ecs_deploy 0.2.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,16 @@ module EcsDeploy
6
6
  :secret_access_key,
7
7
  :default_region,
8
8
  :deploy_wait_timeout,
9
- :ecs_service_role
9
+ :ecs_service_role,
10
+ :ecs_wait_until_services_stable_max_attempts,
11
+ :ecs_wait_until_services_stable_delay
10
12
 
11
13
  def initialize
12
14
  @log_level = :info
13
15
  @deploy_wait_timeout = 300
14
- @ecs_service_role = "ecsServiceRole"
16
+ # The following values are the default values of Aws::ECS::Waiters::ServicesStable
17
+ @ecs_wait_until_services_stable_max_attempts = 40
18
+ @ecs_wait_until_services_stable_delay = 15
15
19
  end
16
20
  end
17
21
  end
@@ -0,0 +1,195 @@
1
+ require "aws-sdk-autoscaling"
2
+ require "aws-sdk-ec2"
3
+ require "aws-sdk-ecs"
4
+
5
+ module EcsDeploy
6
+ class InstanceFluctuationManager
7
+ attr_reader :logger
8
+
9
+ MAX_UPDATABLE_ECS_CONTAINER_COUNT = 10
10
+ MAX_DETACHEABLE_EC2_INSTACE_COUNT = 20
11
+ MAX_DESCRIBABLE_ECS_TASK_COUNT = 100
12
+
13
+ def initialize(region:, cluster:, auto_scaling_group_name:, desired_capacity:, logger:)
14
+ @region = region
15
+ @cluster = cluster
16
+ @auto_scaling_group_name = auto_scaling_group_name
17
+ @desired_capacity = desired_capacity
18
+ @logger = logger
19
+ end
20
+
21
+ def increase
22
+ asg = fetch_auto_scaling_group
23
+
24
+ @logger.info("Increase desired capacity of #{@auto_scaling_group_name}: #{asg.desired_capacity} => #{asg.max_size}")
25
+ as_client.update_auto_scaling_group(auto_scaling_group_name: @auto_scaling_group_name, desired_capacity: asg.max_size)
26
+
27
+ # Run in background because increasing instances may take time
28
+ Thread.new do
29
+ loop do
30
+ cluster = ecs_client.describe_clusters(clusters: [@cluster]).clusters.first
31
+ instance_count = cluster.registered_container_instances_count
32
+ if instance_count == asg.max_size
33
+ @logger.info("Succeeded in increasing instances!")
34
+ break
35
+ end
36
+ @logger.info("Current registered instance count: #{instance_count}")
37
+ sleep 5
38
+ end
39
+ end
40
+ end
41
+
42
+ def decrease
43
+ asg = fetch_auto_scaling_group
44
+
45
+ decrease_count = asg.desired_capacity - @desired_capacity
46
+ if decrease_count <= 0
47
+ @logger.info("The capacity is already #{asg.desired_capacity}")
48
+ return
49
+ end
50
+ @logger.info("Decrease desired capacity of #{@auto_scaling_group_name}: #{asg.desired_capacity} => #{@desired_capacity}")
51
+
52
+ container_instances = ecs_client.list_container_instances(cluster: @cluster).flat_map do |resp|
53
+ ecs_client.describe_container_instances(
54
+ cluster: @cluster,
55
+ container_instances: resp.container_instance_arns
56
+ ).container_instances
57
+ end
58
+
59
+ az_to_container_instances = container_instances.sort_by {|ci| - ci.running_tasks_count }.group_by do |ci|
60
+ ci.attributes.find {|attribute| attribute.name == "ecs.availability-zone" }.value
61
+ end
62
+ if az_to_container_instances.empty?
63
+ @logger.info("There are no instances to terminate.")
64
+ return
65
+ end
66
+
67
+ target_container_instances = extract_target_container_instances(decrease_count, az_to_container_instances)
68
+
69
+ @logger.info("running tasks: #{ecs_client.list_tasks(cluster: @cluster).task_arns.size}")
70
+ all_running_task_arns = []
71
+ target_container_instances.map(&:container_instance_arn).each_slice(MAX_UPDATABLE_ECS_CONTAINER_COUNT) do |arns|
72
+ @logger.info(arns)
73
+ ecs_client.update_container_instances_state(
74
+ cluster: @cluster,
75
+ container_instances: arns,
76
+ status: "DRAINING"
77
+ )
78
+ arns.each do |arn|
79
+ all_running_task_arns.concat(list_running_task_arns(arn))
80
+ end
81
+ end
82
+
83
+ stop_tasks_not_belonging_service(all_running_task_arns)
84
+ wait_until_tasks_stopped(all_running_task_arns)
85
+
86
+ instance_ids = target_container_instances.map(&:ec2_instance_id)
87
+ terminate_instances(instance_ids)
88
+ @logger.info("Succeeded in decreasing instances!")
89
+ end
90
+
91
+ private
92
+
93
+ def aws_params
94
+ {
95
+ access_key_id: EcsDeploy.config.access_key_id,
96
+ secret_access_key: EcsDeploy.config.secret_access_key,
97
+ region: @region,
98
+ logger: @logger
99
+ }.reject do |_key, value|
100
+ value.nil?
101
+ end
102
+ end
103
+
104
+ def as_client
105
+ @as_client ||= Aws::AutoScaling::Client.new(aws_params)
106
+ end
107
+
108
+ def ec2_client
109
+ @ec2_client ||= Aws::EC2::Client.new(aws_params)
110
+ end
111
+
112
+ def ecs_client
113
+ @ecs_client ||= Aws::ECS::Client.new(aws_params)
114
+ end
115
+
116
+ def fetch_auto_scaling_group
117
+ as_client.describe_auto_scaling_groups(auto_scaling_group_names: [@auto_scaling_group_name]).auto_scaling_groups.first
118
+ end
119
+
120
+ # Extract container instances to terminate considering AZ balance
121
+ def extract_target_container_instances(decrease_count, az_to_container_instances)
122
+ target_container_instances = []
123
+ decrease_count.times do
124
+ @logger.debug do
125
+ "AZ balance: #{az_to_container_instances.sort_by {|az, _| az }.map {|az, instances| [az, instances.size] }.to_h}"
126
+ end
127
+ az = az_to_container_instances.max_by {|_az, instances| instances.size }.first
128
+ target_container_instances << az_to_container_instances[az].pop
129
+ end
130
+ @logger.info do
131
+ "AZ balance: #{az_to_container_instances.sort_by {|az, _| az }.map {|az, instances| [az, instances.size] }.to_h}"
132
+ end
133
+
134
+ target_container_instances
135
+ end
136
+
137
+ # list tasks whose desired_status is "RUNNING" or
138
+ # whoose desired_status is "STOPPED" but last_status is "RUNNING" on the ECS container
139
+ def list_running_task_arns(container_instance_arn)
140
+ running_tasks_arn = ecs_client.list_tasks(cluster: @cluster, container_instance: container_instance_arn).flat_map(&:task_arns)
141
+ stopped_tasks_arn = ecs_client.list_tasks(cluster: @cluster, container_instance: container_instance_arn, desired_status: "STOPPED").flat_map(&:task_arns)
142
+ stopped_running_task_arns = stopped_tasks_arn.each_slice(MAX_DESCRIBABLE_ECS_TASK_COUNT).flat_map do |arns|
143
+ ecs_client.describe_tasks(cluster: @cluster, tasks: arns).tasks.select do |task|
144
+ task.desired_status == "STOPPED" && task.last_status == "RUNNING"
145
+ end
146
+ end.map(&:task_arn)
147
+ running_tasks_arn + stopped_running_task_arns
148
+ end
149
+
150
+ def wait_until_tasks_stopped(task_arns)
151
+ @logger.info("All old tasks: #{task_arns.size}")
152
+ task_arns.each_slice(MAX_DESCRIBABLE_ECS_TASK_COUNT).each do |arns|
153
+ ecs_client.wait_until(:tasks_stopped, cluster: @cluster, tasks: arns)
154
+ end
155
+ @logger.info("All old tasks are stopped")
156
+ end
157
+
158
+ def stop_tasks_not_belonging_service(running_task_arns)
159
+ @logger.info("Running tasks: #{running_task_arns.size}")
160
+ unless running_task_arns.empty?
161
+ running_task_arns.each_slice(MAX_DESCRIBABLE_ECS_TASK_COUNT).each do |arns|
162
+ ecs_client.describe_tasks(cluster: @cluster, tasks: arns).tasks.each do |task|
163
+ ecs_client.stop_task(cluster: @cluster, task: task.task_arn) if task.group.start_with?("family:")
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ def terminate_instances(instance_ids)
170
+ if instance_ids.empty?
171
+ @logger.info("There are no instances to terminate.")
172
+ return
173
+ end
174
+ instance_ids.each_slice(MAX_DETACHEABLE_EC2_INSTACE_COUNT) do |ids|
175
+ as_client.detach_instances(
176
+ auto_scaling_group_name: @auto_scaling_group_name,
177
+ instance_ids: ids,
178
+ should_decrement_desired_capacity: true
179
+ )
180
+ end
181
+
182
+ ec2_client.terminate_instances(instance_ids: instance_ids)
183
+
184
+ ec2_client.wait_until(:instance_terminated, instance_ids: instance_ids) do |w|
185
+ w.before_wait do |attempts, response|
186
+ @logger.info("Waiting for stopping all instances...#{attempts}")
187
+ instances = response.reservations.flat_map(&:instances)
188
+ instances.sort_by(&:instance_id).each do |instance|
189
+ @logger.info("#{instance.instance_id}\t#{instance.state.name}")
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,101 @@
1
+ require 'aws-sdk-cloudwatchevents'
2
+ require 'timeout'
3
+
4
+ module EcsDeploy
5
+ class ScheduledTask
6
+ class PutTargetsFailure < StandardError; end
7
+
8
+ attr_reader :cluster, :region, :schedule_rule_name
9
+
10
+ def initialize(
11
+ cluster:, rule_name:, schedule_expression:, enabled: true, description: nil, target_id: nil,
12
+ task_definition_name:, revision: nil, task_count: nil, role_arn:, network_configuration: nil, launch_type: nil, platform_version: nil, group: nil,
13
+ region: nil, container_overrides: nil
14
+ )
15
+ @cluster = cluster
16
+ @rule_name = rule_name
17
+ @schedule_expression = schedule_expression
18
+ @enabled = enabled
19
+ @description = description
20
+ @target_id = target_id || task_definition_name
21
+ @task_definition_name = task_definition_name
22
+ @task_count = task_count || 1
23
+ @revision = revision
24
+ @role_arn = role_arn
25
+ @network_configuration = network_configuration
26
+ @launch_type = launch_type || "EC2"
27
+ @platform_version = platform_version
28
+ @group = group
29
+ region ||= EcsDeploy.config.default_region
30
+ @container_overrides = container_overrides
31
+
32
+ @client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
33
+ @region = @client.config.region
34
+ @cloud_watch_events = Aws::CloudWatchEvents::Client.new(region: @region)
35
+ end
36
+
37
+ def deploy
38
+ put_rule
39
+ put_targets
40
+ end
41
+
42
+ private
43
+
44
+ def cluster_arn
45
+ cl = @client.describe_clusters(clusters: [@cluster]).clusters[0]
46
+ if cl
47
+ cl.cluster_arn
48
+ end
49
+ end
50
+
51
+ def task_definition_arn
52
+ suffix = @revision ? ":#{@revision}" : ""
53
+ name = "#{@task_definition_name}#{suffix}"
54
+ @client.describe_task_definition(task_definition: name).task_definition.task_definition_arn
55
+ end
56
+
57
+ def put_rule
58
+ res = @cloud_watch_events.put_rule(
59
+ name: @rule_name,
60
+ schedule_expression: @schedule_expression,
61
+ state: @enabled ? "ENABLED" : "DISABLED",
62
+ description: @description,
63
+ )
64
+ EcsDeploy.logger.info "create cloudwatch event rule [#{res.rule_arn}] [#{@region}] [#{Paint['OK', :green]}]"
65
+ end
66
+
67
+ def put_targets
68
+ target = {
69
+ id: @target_id,
70
+ arn: cluster_arn,
71
+ role_arn: @role_arn,
72
+ ecs_parameters: {
73
+ task_definition_arn: task_definition_arn,
74
+ task_count: @task_count,
75
+ network_configuration: @network_configuration,
76
+ launch_type: @launch_type,
77
+ platform_version: @platform_version,
78
+ group: @group,
79
+ },
80
+ }
81
+ target[:ecs_parameters].compact!
82
+
83
+ if @container_overrides
84
+ target.merge!(input: { containerOverrides: @container_overrides }.to_json)
85
+ end
86
+
87
+ res = @cloud_watch_events.put_targets(
88
+ rule: @rule_name,
89
+ targets: [target]
90
+ )
91
+ if res.failed_entry_count.zero?
92
+ EcsDeploy.logger.info "create cloudwatch event target [#{@target_id}] [#{@region}] [#{Paint['OK', :green]}]"
93
+ else
94
+ res.failed_entries.each do |entry|
95
+ EcsDeploy.logger.error "failed to create cloudwatch event target [#{@region}] target_id=#{entry.target_id} error_code=#{entry.error_code} error_message=#{entry.error_message}"
96
+ end
97
+ raise PutTargetsFailure
98
+ end
99
+ end
100
+ end
101
+ end
@@ -3,13 +3,27 @@ require 'timeout'
3
3
  module EcsDeploy
4
4
  class Service
5
5
  CHECK_INTERVAL = 5
6
- attr_reader :cluster, :region, :service_name
6
+ MAX_DESCRIBE_SERVICES = 10
7
+
8
+ class TooManyAttemptsError < StandardError; end
9
+
10
+ attr_reader :cluster, :region, :service_name, :delete
7
11
 
8
12
  def initialize(
9
13
  cluster:, service_name:, task_definition_name: nil, revision: nil,
10
14
  load_balancers: nil,
11
15
  desired_count: nil, deployment_configuration: {maximum_percent: 200, minimum_healthy_percent: 100},
12
- region: nil
16
+ launch_type: nil,
17
+ placement_constraints: [],
18
+ placement_strategy: [],
19
+ network_configuration: nil,
20
+ health_check_grace_period_seconds: nil,
21
+ scheduling_strategy: 'REPLICA',
22
+ enable_ecs_managed_tags: nil,
23
+ tags: nil,
24
+ propagate_tags: nil,
25
+ region: nil,
26
+ delete: false
13
27
  )
14
28
  @cluster = cluster
15
29
  @service_name = service_name
@@ -17,11 +31,24 @@ module EcsDeploy
17
31
  @load_balancers = load_balancers
18
32
  @desired_count = desired_count
19
33
  @deployment_configuration = deployment_configuration
34
+ @launch_type = launch_type
35
+ @placement_constraints = placement_constraints
36
+ @placement_strategy = placement_strategy
37
+ @network_configuration = network_configuration
38
+ @health_check_grace_period_seconds = health_check_grace_period_seconds
39
+ @scheduling_strategy = scheduling_strategy
20
40
  @revision = revision
21
- @region = region || EcsDeploy.config.default_region || ENV["AWS_DEFAULT_REGION"]
41
+ @enable_ecs_managed_tags = enable_ecs_managed_tags
42
+ @tags = tags
43
+ @propagate_tags = propagate_tags
44
+
22
45
  @response = nil
23
46
 
24
- @client = Aws::ECS::Client.new(region: @region)
47
+ region ||= EcsDeploy.config.default_region
48
+ @client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
49
+ @region = @client.config.region
50
+
51
+ @delete = delete
25
52
  end
26
53
 
27
54
  def current_task_definition_arn
@@ -35,52 +62,104 @@ module EcsDeploy
35
62
  cluster: @cluster,
36
63
  task_definition: task_definition_name_with_revision,
37
64
  deployment_configuration: @deployment_configuration,
65
+ network_configuration: @network_configuration,
66
+ health_check_grace_period_seconds: @health_check_grace_period_seconds,
38
67
  }
39
- if res.services.empty?
68
+ if res.services.select{ |s| s.status == 'ACTIVE' }.empty?
69
+ return if @delete
70
+
40
71
  service_options.merge!({
41
72
  service_name: @service_name,
42
73
  desired_count: @desired_count.to_i,
74
+ launch_type: @launch_type,
75
+ placement_constraints: @placement_constraints,
76
+ placement_strategy: @placement_strategy,
77
+ enable_ecs_managed_tags: @enable_ecs_managed_tags,
78
+ tags: @tags,
79
+ propagate_tags: @propagate_tags,
43
80
  })
44
- if @load_balancers
81
+
82
+ if @load_balancers && EcsDeploy.config.ecs_service_role
45
83
  service_options.merge!({
46
84
  role: EcsDeploy.config.ecs_service_role,
85
+ })
86
+ end
87
+
88
+ if @load_balancers
89
+ service_options.merge!({
47
90
  load_balancers: @load_balancers,
48
91
  })
49
92
  end
93
+
94
+ if @scheduling_strategy == 'DAEMON'
95
+ service_options[:scheduling_strategy] = @scheduling_strategy
96
+ service_options.delete(:desired_count)
97
+ end
50
98
  @response = @client.create_service(service_options)
51
99
  EcsDeploy.logger.info "create service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
52
100
  else
101
+ return delete_service if @delete
102
+
53
103
  service_options.merge!({service: @service_name})
54
104
  service_options.merge!({desired_count: @desired_count}) if @desired_count
105
+ update_tags(@service_name, @tags)
55
106
  @response = @client.update_service(service_options)
56
107
  EcsDeploy.logger.info "update service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
57
108
  end
58
109
  end
59
110
 
60
- def wait_running
61
- return if @response.nil?
111
+ def delete_service
112
+ if @scheduling_strategy != 'DAEMON'
113
+ @client.update_service(cluster: @cluster, service: @service_name, desired_count: 0)
114
+ sleep 1
115
+ end
116
+ @client.delete_service(cluster: @cluster, service: @service_name)
117
+ EcsDeploy.logger.info "delete service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
118
+ end
62
119
 
63
- service = @response.service
120
+ def update_tags(service_name, tags)
121
+ service_arn = @client.describe_services(cluster: @cluster, services: [service_name]).services.first.service_arn
122
+ if service_arn.split('/').size == 2
123
+ if tags
124
+ EcsDeploy.logger.warn "#{service_name} doesn't support tagging operations, so tags are ignored. Long arn format must be used for tagging operations."
125
+ end
126
+ return
127
+ end
64
128
 
65
- @client.wait_until(:services_stable, cluster: @cluster, services: [service.service_name]) do |w|
66
- w.delay = 10
129
+ tags ||= []
130
+ current_tag_keys = @client.list_tags_for_resource(resource_arn: service_arn).tags.map(&:key)
131
+ deleted_tag_keys = current_tag_keys - tags.map { |t| t[:key] }
67
132
 
68
- w.before_attempt do
69
- EcsDeploy.logger.info "wait service stable [#{service.service_name}]"
70
- end
133
+ unless deleted_tag_keys.empty?
134
+ @client.untag_resource(resource_arn: service_arn, tag_keys: deleted_tag_keys)
135
+ end
136
+
137
+ unless tags.empty?
138
+ @client.tag_resource(resource_arn: service_arn, tags: tags)
71
139
  end
72
140
  end
73
141
 
74
142
  def self.wait_all_running(services)
75
- services.group_by { |s| [s.cluster, s.region] }.each do |(cl, region), ss|
143
+ services.group_by { |s| [s.cluster, s.region] }.flat_map do |(cl, region), ss|
76
144
  client = Aws::ECS::Client.new(region: region)
77
- service_names = ss.map(&:service_name)
78
- client.wait_until(:services_stable, cluster: cl, services: service_names) do |w|
79
- w.before_attempt do
80
- EcsDeploy.logger.info "wait service stable [#{service_names.join(", ")}]"
145
+ ss.reject(&:delete).map(&:service_name).each_slice(MAX_DESCRIBE_SERVICES).map do |chunked_service_names|
146
+ Thread.new do
147
+ EcsDeploy.config.ecs_wait_until_services_stable_max_attempts.times do
148
+ EcsDeploy.logger.info "wait service stable [#{chunked_service_names.join(", ")}]"
149
+ resp = client.describe_services(cluster: cl, services: chunked_service_names)
150
+ resp.services.each do |s|
151
+ # cf. https://github.com/aws/aws-sdk-ruby/blob/master/gems/aws-sdk-ecs/lib/aws-sdk-ecs/waiters.rb#L91-L96
152
+ if s.deployments.size == 1 && s.running_count == s.desired_count
153
+ chunked_service_names.delete(s.service_name)
154
+ end
155
+ end
156
+ break if chunked_service_names.empty?
157
+ sleep EcsDeploy.config.ecs_wait_until_services_stable_delay
158
+ end
159
+ raise TooManyAttemptsError unless chunked_service_names.empty?
81
160
  end
82
161
  end
83
- end
162
+ end.each(&:join)
84
163
  end
85
164
 
86
165
  private