プラットフォームグループでインフラエンジニアをしている @laughk です。
少し前の話になりますが、マネージドインスタンスドレインを活用して terraform apply コマンドのみで ECS インスタンスの入れ替えを自動で行えるようにしました。その際、どういった実装をしたのかをまとめます。
動機
コネヒトでは Amazon ECS を活用しており、主要な ECS クラスタのデータプレーンは Fargate を利用していますがそうでないクラスタもあります。
EC2 をデータプレーンとして利用している場合、定期的な ECS インスタンスの入れ替えが必要になります。ですが稼働中の ECS タスクをドレインしつつ入れ替えしていくのは難しい作業ではありませんが手間と労力がかかります。
なんとかしてこの手間を楽にしたい、できれば普段利用している terraform で解決できるとうれしいと思い調査を始めました。
調査・実装
実装方法については最初は検索ではなかなかそれらしき事例は見つけられず、そもそも何をどう実装すればいいかもなかなか掴めずにいましたが Perplexy に聞いてみたところ簡易的なサンプル実装を示されて一気に実装方針が見えてきました。
示されたサンプル実装だけではやりたいことは実現できなかったものの、その内容から大まかには次の二つを terraform 管理できれば ECS インスタンス入れ替えの実現ができそうであることがわかりました。
- オートスケーリンググループ(ASG)と起動テンプレートを管理し、インスタンスリフレッシュの設定をする
- ECS クラスタに Capacity Provider を設定し、ASG を Capacity Provider に紐付ける
EC2 をデータプレーンとする ECS クラスタの Capacity Provider は、簡単にいうならば ECS タスクが必要とするリソース量に応じてオートスケーリングの ECS インスタンスを増減させることが可能なものです。この Capacity Provider ではマネージドインスタンスドレインという機能が利用でき、デフォルトで有効となっています。 1 これを利用することで ECS タスクをドレインしつつ ECS インスタンスの入れ替えを行うことができます。
ここまでの情報をもとに terraform で ECS インスタンスの入れ替えのほとんどを自動化する実装を進めることができました。
管理するべき AWS リソースとサンプルコード
実際に実装したコードの一部を引用しつつ、管理するべき AWS リソースとそのコードを紹介します。 ファイル構成は次の通りです。
|-- dev/ | `-- main.tf |-- prd/ | `-- main.tf `-- module/ |-- main.tf `-- variables.tf
dev, prd と環境ごとにディレクトリを分けていますがこれらは基本的に module を呼び出しているだけです。 実際に ECS インスタンスの入れ替えを行うための実装は module に閉じ込めています。
module/main.tf
の内容は次のとおりです。
resource "aws_ecs_cluster" "my_cluster" { name = var.ecs_cluster_name } resource "aws_ecs_capacity_provider" "my_capacity_provider" { name = var.ecs_cluster_capacity_provider_name auto_scaling_group_provider { auto_scaling_group_arn = aws_autoscaling_group.my_asg.arn managed_termination_protection = "ENABLED" managed_scaling { status = "ENABLED" target_capacity = 100 minimum_scaling_step_size = 1 maximum_scaling_step_size = 10000 } } } resource "aws_ecs_cluster_capacity_providers" "my_cluster_capacity_provider" { cluster_name = aws_ecs_cluster.my_cluster.name capacity_providers = [ aws_ecs_capacity_provider.my_capacity_provider.name ] } resource "aws_launch_template" "my_lt" { name = var.lt_name image_id = var.lt_image_id instance_type = var.lt_instance_type ebs_optimized = var.lt_ebs_optimized key_name = var.lt_key_name # ECS エージェントとDockerデーモンがECSクラスターに参加するように # ユーザーデータを設定します user_data = base64encode(<<-EOF #!/bin/bash echo ECS_CLUSTER=${aws_ecs_cluster.my_cluster.name} >> /etc/ecs/ecs.config EOF ) lifecycle { create_before_destroy = true } block_device_mappings { device_name = "/dev/xvda" ebs { delete_on_termination = "true" encrypted = "false" iops = var.lt_ebs_volume_iops volume_size = 30 volume_type = var.lt_ebs_volume_type snapshot_id = var.lt_ebs_snapshot_id } } iam_instance_profile { name = var.lt_iam_instance_profile } monitoring { enabled = true } network_interfaces { associate_public_ip_address = "false" device_index = 0 ipv4_address_count = 0 ipv4_addresses = [] ipv4_prefix_count = 0 ipv4_prefixes = [] ipv6_address_count = 0 ipv6_addresses = [] ipv6_prefix_count = 0 ipv6_prefixes = [] network_card_index = 0 security_groups = var.lt_security_group_id } } resource "aws_autoscaling_group" "my_asg" { name = var.asg_name min_size = var.asg_min_size max_size = var.asg_max_size desired_capacity = var.asg_desired_capacity vpc_zone_identifier = var.asg_vpc_zone_identifier health_check_type = "EC2" health_check_grace_period = var.asg_health_check_grace_period force_delete = true enabled_metrics = var.asg_enabled_metrics protect_from_scale_in = var.asg_protect_from_scale_in launch_template { id = aws_launch_template.my_lt.id # "$Latest" では Launch Template に更新があっても instance_refresh(インスタンス入れ替え)が発動しないので、直接バージョンを指定する # 反対に発動してほしくない場合は、"$Latest" を指定する # see. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_group version = var.asg_ir_enabled ? aws_launch_template.my_lt.latest_version : "$Latest" } lifecycle { create_before_destroy = true } instance_refresh { strategy = "Rolling" preferences { min_healthy_percentage = 100 max_healthy_percentage = 110 instance_warmup = 300 scale_in_protected_instances = "Refresh" checkpoint_delay = var.asg_ir_checkpoint_delay } triggers = ["launch_template"] } tag { key = "Name" value = var.asg_name propagate_at_launch = true } tag { key = "AmazonECSManaged" value = true propagate_at_launch = true } } resource "aws_autoscaling_lifecycle_hook" "my_asg_ec2_terminating_lifecycle_hook" { name = var.asg_ec2_terminating_lifecycle_hook_name autoscaling_group_name = aws_autoscaling_group.my_asg.name default_result = var.asg_ec2_terminating_lifecycle_hook_default_result heartbeat_timeout = var.asg_ec2_terminating_lifecycle_hook_heartbeat_timeout lifecycle_transition = "autoscaling:EC2_INSTANCE_TERMINATING" }
ポイントは次の通りです。
- Capcity Provider, ASG の本体と ASG で利用する Launch Template はもちろん、ECS クラスタに Capacity Provider を紐付けるためのリソース (
aws_ecs_cluster_capacity_providers
) を忘れずに実装 - ECS クラスタそのものはクラスタ名さえわかれば良いので、管理せずに variable で直接渡せるようにしてしまっても問題なし
- 運用上問題ないところは割り切ってデフォルト値や決め打ちで設定
- ASG の launch_template の version は
"$Latest"
ではなく直接バージョンを指定しないとインスタンスの入れ替えが発動しないので注意。ここではその仕様を利用して ECS インスタンスの入れ替えを実施したくないクラスタもまとめて扱えるようにしている
この module は dev, prd の main.tf で以下のように呼び出します。(locals の内容は割愛)
data "aws_ssm_parameter" "latest_ami_id" { name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id" } data "aws_ami" "latest_ami" { most_recent = true filter { name = "image-id" values = [data.aws_ssm_parameter.latest_ami_id.value] } } module "app1" { source = "../module" ecs_cluster_name = "app1" ecs_cluster_capacity_provider_name = "app1-capacity-provider" asg_name = "EC2ContainerService-app1-EcsInstanceAsg" asg_min_size = 0 asg_vpc_zone_identifier = local.vpc_zone_identifier lt_name = "ec2-container-service-app1" lt_ebs_snapshot_id = [for bdm in data.aws_ami.latest_ami.block_device_mappings : bdm.ebs.snapshot_id].0 lt_image_id = data.aws_ssm_parameter.latest_ami_id.value lt_key_name = local.ssh_key_name lt_security_group_id = local.security_group_id } module "app1_batch" { source = "../module" ecs_cluster_name = "app1-batch-cluster" ecs_cluster_capacity_provider_name = "app1-batch" asg_name = "EC2ContainerService-app1-batch-EcsInstanceAsg" asg_min_size = 2 asg_max_size = 2 asg_vpc_zone_identifier = local.vpc_zone_identifier asg_health_check_grace_period = 300 asg_ir_enabled = false lt_name = "ec2-container-service-app1-batch" lt_image_id = data.aws_ssm_parameter.latest_ami_id.value lt_ebs_snapshot_id = [for bdm in data.aws_ami.latest_ami.block_device_mappings : bdm.ebs.snapshot_id].0 lt_security_group_id = local.security_group_id lt_key_name = local.ssh_key_name }
ポイントは次の通りです。
- コネヒトでは ECS インスタンスをカスタマイズせずに利用しているため、ParameterStore から最新の AMI ID を参照して利用 2
- モジュールの variable.tf である程度デフォルト値を定めているので、呼び出すときは環境ごとの差異を加味しつつも最小限のパラメータで呼び出せるようにしている
このように実装すると、次のように terraform apply
を実行するだけで AMI ID に変更がある場合に ECS インスタンスの入れ替えが始まります。
## dev 環境での例 $ cd dev $ terraform apply
その際は ASG のインスタンスリフレッシュが発動し、マネージドインスタンスドレインによって自動でドレインしながら指定した AMI ID の ECS インスタンスに入れ替わります。
ただし module 呼び出しの際に asg_ir_enabled
を false
にしている場合はインスタンスリフレッシュは発動しません。
ハマりどころや Tips
ECS インスタンス入れ替え時の Terminate 発動までの時間が長い場合の対処
一番最初に module を実装した際、terraform apply コマンドのみで ECS インスタンスの入れ替えはドレインを含め自動化できたものの、退役する古いインスタンスの terminate が発動するまでに1時間近くかかる問題に遭遇しました。
この問題は試行錯誤をした結果、 aws_autoscaling_lifecycle_hook
の heartbeat_timeout
を短めに設定することで解決しています。 module の variables.tf に次のように設定し、デフォルト値を300秒(5分)にしています。
variable "asg_ec2_terminating_lifecycle_hook_heartbeat_timeout" { type = number default = 300 }
ECS サービスではないスタンドアロンなタスクが動く環境では使えない
この記事で紹介している方法では ECS サービスに紐づくタスクに対してのみ利用できます。
ECS Schedule Task などで利用されるようなスタンドアロンな ECS タスクに対しては、ECS タスクの終了を待つような動作はされずに問答無用で ECS インスタンスが terminate されて入れ替わります。(実際に検証を行いましたが、確かにそのような動作をしました)
そのため、スタンドアロンなタスクが動く ECS インスタンスに関しては ASG の Launch Template の更新までを terraform で行うようにし、ECS インスタンスの入れ替えは手動で行うようにしています。( module 呼び出しの際に asg_ir_enabled
を false
にしています)
終わりに
terraform apply で ECS インスタンスの入れ替えを自動で行う方法について紹介しました。
ECS インスタンス入れ替えの自動化は以前は AutoScaling のライフライクルフックを拾う lambda を用意するなど、大掛かりな仕組みを実装する必要がある印象でした。ですが少なくとも最近では、マネージドインスタンスドレインのおかげで terraform で最低限のリソースを用意するだけで実現可能になりました。
コネヒトではデータプレーンが EC2 の ECS クラスタは一つあたりの規模は大きくないものの、小さいものがたくさんある状況なので今回の自動化によって大幅な作業効率化ができました。 今回の事例が ECS on EC2 の運用に関わっている方の参考になれば幸いです。
参考記事
もともとこの記事で紹介した事例は Perplexy から得たヒントをもとに試行錯誤を繰り返して実装した関係で感覚的に理解していたことも多く、記事の執筆にあたっては言葉の整理が必要でした。その際、以下の記事も参考にさせていただきました。
- Amazon ECS がマネージドインスタンスドレインを発表
- ECSのマネージドインスタンスドレインを試したい #AWS - Qiita
- インスタンスを安全にシャットダウンするように Amazon ECS キャパシティープロバイダーを設定する - Amazon Elastic Container Service
- https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/enable-managed-instance-draining.html↩
- モジュールとしては AMI ID は文字列で渡せば良いだけなので、自前で ECS インスタンスを用意している場合はその AMI ID を直接指定すればよいです↩