以下の内容はhttps://tech.connehito.com/entry/2024/10/10/174600より取得しました。


マネージドインスタンスドレインを活用して ECS インスタンス入れ替えを terraform apply 一発で終わるようにした話

プラットフォームグループでインフラエンジニアをしている @laughk です。

少し前の話になりますが、マネージドインスタンスドレインを活用して terraform apply コマンドのみで ECS インスタンスの入れ替えを自動で行えるようにしました。その際、どういった実装をしたのかをまとめます。

動機

コネヒトでは Amazon ECS を活用しており、主要な ECS クラスタのデータプレーンは Fargate を利用していますがそうでないクラスタもあります。

EC2 をデータプレーンとして利用している場合、定期的な ECS インスタンスの入れ替えが必要になります。ですが稼働中の ECS タスクをドレインしつつ入れ替えしていくのは難しい作業ではありませんが手間と労力がかかります。

なんとかしてこの手間を楽にしたい、できれば普段利用している terraform で解決できるとうれしいと思い調査を始めました。

調査・実装

実装方法については最初は検索ではなかなかそれらしき事例は見つけられず、そもそも何をどう実装すればいいかもなかなか掴めずにいましたが Perplexy に聞いてみたところ簡易的なサンプル実装を示されて一気に実装方針が見えてきました。

perplexyに聞いている様子

示されたサンプル実装だけではやりたいことは実現できなかったものの、その内容から大まかには次の二つを terraform 管理できれば ECS インスタンス入れ替えの実現ができそうであることがわかりました。

  1. オートスケーリンググループ(ASG)と起動テンプレートを管理し、インスタンスリフレッシュの設定をする
  2. 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_enabledfalse にしている場合はインスタンスリフレッシュは発動しません。

ハマりどころや Tips

ECS インスタンス入れ替え時の Terminate 発動までの時間が長い場合の対処

一番最初に module を実装した際、terraform apply コマンドのみで ECS インスタンスの入れ替えはドレインを含め自動化できたものの、退役する古いインスタンスの terminate が発動するまでに1時間近くかかる問題に遭遇しました。

この問題は試行錯誤をした結果、 aws_autoscaling_lifecycle_hookheartbeat_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_enabledfalse にしています)

終わりに

terraform apply で ECS インスタンスの入れ替えを自動で行う方法について紹介しました。

ECS インスタンス入れ替えの自動化は以前は AutoScaling のライフライクルフックを拾う lambda を用意するなど、大掛かりな仕組みを実装する必要がある印象でした。ですが少なくとも最近では、マネージドインスタンスドレインのおかげで terraform で最低限のリソースを用意するだけで実現可能になりました。

コネヒトではデータプレーンが EC2 の ECS クラスタは一つあたりの規模は大きくないものの、小さいものがたくさんある状況なので今回の自動化によって大幅な作業効率化ができました。 今回の事例が ECS on EC2 の運用に関わっている方の参考になれば幸いです。

参考記事

もともとこの記事で紹介した事例は Perplexy から得たヒントをもとに試行錯誤を繰り返して実装した関係で感覚的に理解していたことも多く、記事の執筆にあたっては言葉の整理が必要でした。その際、以下の記事も参考にさせていただきました。


  1. https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/enable-managed-instance-draining.html
  2. モジュールとしては AMI ID は文字列で渡せば良いだけなので、自前で ECS インスタンスを用意している場合はその AMI ID を直接指定すればよいです



以上の内容はhttps://tech.connehito.com/entry/2024/10/10/174600より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14