tk_ch’s blog

インフラエンジニアのブログ

AWS ECSクラスタをTerraformで構築する(EC2、Auto Scaling、GPU利用)

AWS ECS クラスター Auto Scaling (CAS)を活用したECSクラスタ(GPUインスタンス利用)を、Terraformを使って構築した。

環境

・Terraform:1.0.8

やりたいこと

以下を実現したい。

  • イベントを契機にある処理をしたい。
  • イベントの発生頻度、量は予想が困難。
  • イベントが大量に発生した場合も、
    処理を並列同時実行することでスループットを維持したい。
  • 出来るだけコストを抑えたい。
  • 「ある処理」にはGPUの利用が必要。
  • 「ある処理」には15分以上かかる見込み。

必要な分だけスケール・課金ということなので、LambdaやECS(Fargate)を使いたくなるが、以下に記載した通り、今回の要件は実現できない。
ECS(EC2起動タイプ)であれば実現できそう。

  • Lambda
    →15分以上かかる処理には使用できず、GPUも利用できないため、不採用。
  • ECS(Fargate起動タイプ) ※イベントを検知してECSタスクを起動する部分はLambdaでやる
    GPUを利用できないため、不採用。
  • ECS(EC2起動タイプ) ※イベントを検知してECSタスクを起動する部分はLambdaでやる
    →処理時間制限がなく、GPUも利用可能。
     ECS クラスター Auto Scaling (CAS)を使えば、0台からのスケールも可能。

構築

以下を参考にECS Cluster Auto Scaling機能を試して概要を理解した。

その後、以下を実行するTerrformコードを作成し、構築を行った。

  • ECSで使用するVPC、セキュリティグループ、IAMロールの作成
  • ECS クラスター Auto Scaling (CAS)により0台からスケールし、かつGPUインスタンス(g4dn.xlarge)を使用するECSクラスタの作成
  • 動作確認用のECSタスク定義の作成

※以下のTerraform Moduleを使用している。

使用したTerraformコード

# VPCとサブネットの作成
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.7.0"

  name = "test-vpc"    # VPC名
  cidr = "10.0.0.0/16" # VPCのCIDR

  azs             = ["ap-northeast-1a", "ap-northeast-1c"] # サブネットを作成するAZ
  private_subnets = ["10.0.32.0/24", "10.0.33.0/24"]       # プライベートサブネットのCIDR
  public_subnets  = ["10.0.16.0/24", "10.0.17.0/24"]       # パブリックサブネットのCIDR

  enable_nat_gateway   = true  # NATゲートウェイを作成する
  enable_vpn_gateway   = false # VPNゲートウェイを作成しない
  enable_dns_hostnames = true  # DNSホスト名を有効にする
}

# ECSインスタンス用セキュリティグループの作成
module "ecs_instance_sg" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "4.3.0"

  name        = "test-sg-ecs"     # セキュリティグループ名
  description = "test-sg-ecs"     # セキュリティグループ説明
  vpc_id      = module.vpc.vpc_id # セキュリティグループを作成するVPC

  egress_with_cidr_blocks = [ # アウトバウンドルール
    {
      rule        = "all-all"
      cidr_blocks = "0.0.0.0/0"
    },
  ]
  ingress_with_self = [ # インバウンドルール
    {
      rule = "all-all"
    },
  ]
}

# ECSインスタンス用IAMロールの作成
module "iam_assumable_role_ecs" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role"
  version = "4.6.0"

  trusted_role_services = [
    "ec2.amazonaws.com" # 信頼されたエンティティにEC2を設定
  ]

  create_role             = true # IAMロールを作成する
  create_instance_profile = true # IAMロールのインスタンスプロファイルを作成する

  role_name         = "test-role-ecs-instance" # IAMロール名
  role_requires_mfa = false                    # MFA必須にしない

  custom_role_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" # ECSインスタンス用に用意されているIAMポリシーを設定
  ]
}

# ECSタスク用IAMロールの作成
module "iam_assumable_role_ecstask" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role"
  version = "4.6.0"

  trusted_role_services = [
    "ecs-tasks.amazonaws.com" # 信頼されたエンティティにECSタスクを設定
  ]

  create_role = true # IAMロールを作成する

  role_name         = "test-role-ecs-task" # IAMロール名
  role_requires_mfa = false                # MFA必須にしない

  custom_role_policy_arns = [
    "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" # X-RAYへ情報を送信するためのIAMポリシーを設定
  ]
}

# ECS GPU-optimized AMI
data "aws_ami" "amazon_linux_ecs" {
  most_recent = true

  owners = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-ecs-gpu-hvm-2.0.20210708-x86_64-ebs"]
  }

  filter {
    name   = "owner-alias"
    values = ["amazon"]
  }
}

# ECSインスタンス用ユーザーデータ
data "template_file" "user_data" {
  template = file("./templates/user-data.sh")
  vars = {
    cluster_name = "test-ecs-cluster"
  }
}

# 起動設定とAuto Scalingグループの作成
module "asg_g4dn" {
  source  = "terraform-aws-modules/autoscaling/aws"
  version = "4.6.0"

  # 起動設定
  lc_name   = "test-aslc-g4dn" # 起動設定の名前
  use_lc    = true             # 起動設定を利用する
  create_lc = true             # 起動設定を作成する

  image_id                  = data.aws_ami.amazon_linux_ecs.id                       # 「ECS GPU-optimized AMI」を使用する
  instance_type             = "g4dn.xlarge"                                          # インスタンスタイプ
  security_groups           = [module.ecs_instance_sg.security_group_id]             # セキュリティグループ
  iam_instance_profile_name = module.iam_assumable_role_ecs.iam_instance_profile_arn # インスタンスにアタッチするIAMロール
  user_data                 = data.template_file.user_data.rendered                  # ユーザーデータ
  ebs_optimized             = true                                                   # EBS最適化を有効

  root_block_device = [ # ストレージ設定
    {
      delete_on_termination = true
      encrypted             = false
      volume_size           = "30"
      volume_type           = "gp2"
    },
  ]

  # Auto Scalingグループ
  name                = "test-asg-g4dn"            # Auto Scalingグループの名前
  vpc_zone_identifier = module.vpc.private_subnets # 起動するサブネット
  health_check_type   = "EC2"                      # ヘルスチェックのタイプ
  min_size            = 0                          # 最小キャパシティ
  max_size            = 100                        # 最大キャパシティ
  wait_for_capacity_timeout = 0  # TerraformがAuto Scalingグループのインスタンスの作成を待機しないようにする
  protect_from_scale_in     = true # インスタンスのスケールイン保護を有効にする
}

# キャパシティプロバイダの作成
resource "aws_ecs_capacity_provider" "prov" {
  name = "test-ecs-cp"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = module.asg_g4dn.autoscaling_group_arn # 使用するAuto ScalingグループのARN
    managed_termination_protection = "ENABLED"                             # マネージドターミネーション保護を有効にする

    managed_scaling {
      maximum_scaling_step_size = 10000     # 1回のスケールアウトごとに起動される最大の台数
      minimum_scaling_step_size = 1         # 1回のスケールアウトごとに起動される最小の台数
      status                    = "ENABLED" #マネージドスケーリングを有効にする 
      target_capacity           = 100       # ターゲットキャパシティ
      instance_warmup_period    = 300       # インスタンスのウォームアップ期間
    }
  }
}

# ECSクラスタの作成
module "ecs" {
  source  = "terraform-aws-modules/ecs/aws"
  version = "3.4.0"

  name               = "test-ecs-cluster" # ECSクラスタ名
  container_insights = true               # Container Insightsを有効にする

  capacity_providers = [aws_ecs_capacity_provider.prov.name] # 作成したキャパシティプロバイダを指定

  default_capacity_provider_strategy = [{
    capacity_provider = aws_ecs_capacity_provider.prov.name # デフォルトも作成したキャパシティプロバイダを指定
  }]
}

# リージョン名
data "aws_region" "now" {}

#ECSタスク用ロググループの作成
resource "aws_cloudwatch_log_group" "log_ecstask_test" {
  name              = "/ecs/test-ecstask" # ロググループ名
  retention_in_days = 1                   # 保存期間(日)
}

## ECSタスク定義に使うコンテナ定義JSONの生成
### APコンテナ
module "test_ap_container" {
  source  = "cloudposse/ecs-container-definition/aws"
  version = "0.58.1"

  container_name  = "ap"            # コンテナ名
  container_image = "amazonlinux:2" # コンテナイメージ

  command                      = ["sh", "-c", "sleep 600"] # 実行コマンド
  container_memory             = 15488                     # ハードメモリ制限(MiB)
  container_memory_reservation = 15488                     # ソフトメモリ制限(MiB)
  container_cpu                = 4064                      # 予約するCPUユニット数
  essential                    = true                      # 基本パラメータ。trueの場合は、このコンテナの失敗によりタスクが停止する。
  links                        = ["xray-daemon"]           # リンク設定

  log_configuration = { # ログ出力設定
    logDriver = "awslogs"
    options = {
      "awslogs-group"         = "/ecs/test-ecstask"
      "awslogs-region"        = data.aws_region.now.name
      "awslogs-stream-prefix" = "ecs"
    }
    secretOptions = null
  }

  map_environment = { # 環境変数設定
    "AWS_XRAY_CONTEXT_MISSING" = "LOG_ERROR"
    "AWS_XRAY_DAEMON_ADDRESS"  = "xray-daemon:2000"
    "AWS_XRAY_SDK_ENABLED"     = true
  }

  resource_requirements = [ # GPUを使用するコンテナの場合は設定する
    {
      type  = "GPU"
      value = "1"
    }
  ]
}

### xray-daemonコンテナ
module "test_xray_container" {
  source  = "cloudposse/ecs-container-definition/aws"
  version = "0.58.1"

  container_name  = "xray-daemon"                  # コンテナ名
  container_image = "amazon/aws-xray-daemon:3.3.2" # コンテナイメージ

  container_memory             = 256   # ハードメモリ制限(MiB)
  container_memory_reservation = 256   # ソフトメモリ制限(MiB)
  container_cpu                = 32    # 予約するCPUユニット数
  essential                    = false # 基本パラメータ

  log_configuration = { # ログ出力設定
    logDriver = "awslogs"
    options = {
      "awslogs-group"         = "/ecs/test-ecstask"
      "awslogs-region"        = data.aws_region.now.name
      "awslogs-stream-prefix" = "ecs"
    }
    secretOptions = null
  }

  port_mappings = [ # ポートマッピング設定
    {
      containerPort = 2000
      hostPort      = 0
      protocol      = "udp"
    }
  ]
}

## ECSタスク定義
resource "aws_ecs_task_definition" "test" {
  # 注意:applyを実行するたびに、常に新しいリビジョンに更新してしまうため、lifecycleで更新されないようにしている
  # このタスク定義を更新したい場合のみ、lifecycle部分をコメントアウトすること
  # https://github.com/hashicorp/terraform-provider-aws/issues/258
  lifecycle {
    ignore_changes = all
  }

  family = "test-ecstask"              # ECSタスク名
  container_definitions = jsonencode([ # ECSコンテナ定義JSONを設定
    module.test_ap_container.json_map_object,
    module.test_xray_container.json_map_object
  ])
  task_role_arn            = module.iam_assumable_role_ecstask.iam_role_arn # ECSタスクロールを指定
  cpu                      = 4096                                           # タスクCPUユニット数
  memory                   = 15744                                          # タスクメモリ(MiB)
  requires_compatibilities = ["EC2"]                                        # 互換性が必要な起動タイプ
  network_mode             = "bridge"                                       # ネットワークモード
}

templates/user-data.shの内容は以下。

#!/bin/bash
echo ECS_CLUSTER=${cluster_name} >> /etc/ecs/ecs.config

上記コードでTerraformを実行すると、登録インスタンスが0台のECSクラスタができる。

作成したECSクラスタ
作成したECSクラスタ

※注意:初めてECSを利用するAWSアカウントでTerraformを実行すると、以下のエラーが発生する。

│ Error: error creating ECS Capacity Provider (test-ecs-cp): ClientException: ECS Service Linked Role does not exist. Please create a Service linked role for ECS and try again.

→ECSのサービスリンクロールが無いことが原因なので、ロールを作成してTerraformを再実行すればよい。
作成方法はAmazon ECS のサービスにリンクされたロールに記載されている。
もしTerraformでサービスリンクロールを作成したい場合は、以下で作成できる。
(参考:ECS初回構築時に自動作成されるIAMロール「AWSServiceRoleForECS」とTerraformでの予期せぬ挙動)

resource "aws_iam_service_linked_role" "ecs" {
  aws_service_name = "ecs.amazonaws.com"
}

※補足:ECSタスク定義の設定内容について

  • ECSタスクは、動作確認用に「sleep 600」を実行するだけのコンテナで構成されている。
  • ECSタスク内処理のトレーシング情報をAWS X-RAYに送る検証にも使用する予定のため、「Amazon ECS での X-Ray デーモンの実行」を参考にxray-daemonを実行するコンテナもタスクに含めているが、クラスタのスケーリングとは無関係なので気にしなくて良い。
  • Amazon ECS での GPU の使用」によると、
    ECSタスクでGPUを利用するときは、タスク定義にGPU数を設定する必要がある。
    g4dn.xlargeのGPU数は1なので、1を設定した。
  • ECSインスタンスは、ECS最適化AMIから起動する。今回はGPUを使うので、「Amazon ECS に最適化された AMI」記載の「Amazon Linux 2 (GPU)」のAMIイメージを指定する。
  • ECSタスク定義に1タスクが使用するメモリ・CPUを設定することができる。
    この値によって、1台のECSインスタンスでいくつのタスクが起動できるかが決まる。
    今回はスケールの挙動がわかりやすいように、1タスクが1インスタンスを占有させたい。
    そのため、g4dn.xlarge(4vcpu・16GBメモリ)1台の使用できるリソースぎりぎりを設定する。
  • ただし、システム用にメモリはある程度残しておく必要がある。タスクに割り当てられるリソースは、コンテナインスタンス メモリの表示のように確認できる。
    g4dn.xlargeでECSインスタンスを作成して確認すると、メモリ16038MB・CPU4096ユニットを利用できることが確認できたため、これをタスクに設定することにした。
  • また、今回は1タスクにメイン処理のコンテナ(ap)とX-RAY連携用のコンテナ(xray-daemon)があるため、それぞれが使用するCPU・メモリについても設定している。
    xray-daemonコンテナに設定例にあるメモリ256MB・CPU32ユニットを割り当てて、それを差し引いたメモリ15488MB、CPU4064ユニットをapコンテナに設定した。
  • タスクが利用するメモリ量は、タスク単位の設定とコンテナ単位の設定がある。
    コンテナ単位で指定した場合はタスク単位の設定は必須ではないが、今回はタスク単位もコンテナ単位も設定した。
    また、コンテナ単位の設定にはソフト制限とハード制限がある。ソフト制限の分がECSインスタンスに最低限確保され、必要に応じてハード制限に達するまでバーストする。 今回はソフト制限もハード制限も割り当てられる最大の値にした。
  • CPUにもタスク単位指定とコンテナ単位指定がある。
    タスク単位指定はタスクが使用するvcpu数を指定し、コンテナ単位の指定は複数コンテナがCPU利用で競合した場合の使用比率を指定するという違いがある。

動作確認

タスクの起動

作成したECSクラスタで、動作確認用タスクを複数起動してみて、クラスタがどのようにスケールするか確認する。
期待する動作は、タスクが同時起動されると必要な分のECSインスタンス数にスケールアウトし、タスクが終わったら0台にスケールインすること。

タスクは以下の内容で起動した。

  • タスク起動時の設定
    • キャパシティープロバイダー戦略:クラスターのデフォルト戦略
    • タスク定義:test-ecstask
    • クラスター:test-eccs-cluster
    • タスクの数:8
    • 配置テンプレート:AZバランスビンパック

結果

最初はECSインスタンスが1台も存在しないので、全てのタスクがPROVISIONING状態となる。

タスク実行直後
タスク実行直後

少し待っていると、キャパシティプロバイダの「希望するサイズ」が0から2に変わり、「現在のサイズ」も0から2に変わった。

キャパシティプロバイダの希望するサイズが2
キャパシティプロバイダの希望するサイズが2

クラスタインスタンスも2台にスケールアウトしている。

ECSインスタンスが2台
ECSインスタンスが2台

ECSインスタンスが2台になったため、2つのタスクの状態がPROVISIONING→PENDING→RUNNINGと遷移し、処理が開始した。

2個のタスクがRUNNING状態
2個のタスクがRUNNING状態

その後、キャパシティプロバイダの「希望するサイズ」、「現在のサイズ」が2から5に変わり、5つのタスクがRUNNING状態となった。

5個のタスクがRUNNING状態
5個のタスクがRUNNING状態

次に、キャパシティプロバイダの「希望するサイズ」、「現在のサイズ」が5から8に変わり、8つのタスクがRUNNING状態となった。

8個のタスクがRUNNING状態
8個のタスクがRUNNING状態

今回実行した処理は「sleep 600」を実行するものなので、600秒経過したタスクから終了していく。
タスクが終了しても、ECSインスタンスはすぐにはスケールインせず、以下のように残っている。 (「実行中のタスク」が0となっているインスタンス。)

5タスク終了した時点のECSインスタンス
5タスク終了した時点のECSインスタンス

その後も様子を見ていると、ECSインスタンスはタスク終了から約15分後に削除された。
最終的に、ECSクラスタインスタンス0台にスケールインした。

時間と併せてまとめると、以下のようになる。
0台→8台と一気にスケールアウトするのではなく、段階的にスケールアウトした。
タスクの起動から実行開始までにはある程度のリードタイムがあることが分かる。

①タスクを8件同時起動
②ECSインスタンスが2台にスケールアウト(①から約3分後)
③ECSインスタンスが5台にスケールアウト(①から約5分後)
④ECSインスタンスが8台にスケールアウト(①から約12分後)
⑤各ECSインスタンスがスケールイン(各タスク終了から約15分後)

何が起きていたのか

前提知識

何が起きたか把握するためには、ECS クラスター Auto Scaling (CAS)の仕組みを理解する必要がある。
Amazon ECS クラスターの Auto Scaling を深く探る 」によると、以下のような仕組みとなっている。

<ECS クラスター Auto Scaling (CAS)の仕組み>

  • ECS クラスター Auto Scaling (CAS)は、EC2 Auto Scaling グループ (ASG) のインスタンス必要数を調整することで、ECSクラスタをスケールアウト/インする。
  • CASは、CapacityProviderReservationというメトリクスが、キャパシティープロバイダのキャパシティターゲットの値に近づくよう、ASGのインスタンス必要数を調整する。
  • CapacityProviderReservation メトリクスは以下の公式で算出される。
    CapacityProviderReservation = M / N × 100
    基本的には、Mは 「プロビジョニング中を含むタスクを起動するために、必要なコンテナインスタンスの数」、
    Nは「現在起動中のコンテナインスタンスの数」である。
  • そのため、キャパシティターゲットの値によって、CASは以下の挙動となる。
    • キャパシティターゲットが100
      →M = Nであることが必要なので100になるため、CASはタスク数に丁度必要なECSインスタンス数にしようとする。
    • キャパシティターゲットが100より大きい
      →M > Nであることが必要なので、CASはタスク数に対して不足するECSインスタンス数にしようとする。
    • キャパシティターゲットが100より小さい
      →M < Nであることが必要なので、CASタスク数に対して余裕のあるECSインスタンス数にしようとする。
  • ただし、この公式が当てはまらない特殊なケースがある。以下の2つ。
    • M = 0 かつ N = 0 の場合(つまり、インスタンスが0台でタスクが起動されていない場合) →「CapacityProviderReservation = 100」とする。
    • M > 0 かつ N = 0 の場合(つまり、インスタンスが0台のときにタスクが起動された場合) →「CapacityProviderReservation = 200」とする。 この場合M の値は 200 / 100 / 1 = 2 となるため、初回起動の際のコンテナインスタンス数は、実際の必要数とは無関係に2と設定される。 (ターゲットトラッキングスケーリングには、ゼロキャパシティからのスケーリングを行う際に用いる特殊例があり、N=1で計算をする)
考察

上記の仕組みを踏まえると、今回行った動作確認の場合は、以下のような動きになると想像できる。

インスタンス数が0台でタスク数が8個の場合、特殊ケースである「M > 0 かつ N = 0 の場合」に該当する。
 そのため「CapacityProviderReservation = 200」となり、インスタンス必要数は2台が設定され、2台にスケールアウトする。
インスタンス数が2台、タスク数が8個となり、「CapacityProviderReservation = 8 / 2 × 100 = 400」となる。
 ターゲットキャパシティは100にしているので、CapacityProviderReservationを100に下げるためには、インスタンスは8台必要になる。
 そのため、インスタンス必要数が8台に設定され、8台にスケールアウトする。
インスタンス数が8台、タスク数が8個となり、「CapacityProviderReservation = 8 / 8 × 100 = 100」となる。
 ターゲットキャパシティの値(100)と一致するので、スケールアウト/インは行われない。
④終了するタスクが出てくると、インスタンスが余る状態となるため、CapacityProviderReservationの値が100を下回るようになる。
 そのため、インスタンス必要数が減少し、スケールインが行われる。
 
しかし、実際には2台→5台→8台と段階的にスケールアウトが行われた。
また、スケールアウトが行われた時間帯のCapacityProviderReservationメトリクスをCloudWatchで確認すると、こちらも想定の動き(100→200→400→100)とは異なる。

CloudWatchで確認したCapacityProviderReservationメトリクス
CloudWatchで確認したCapacityProviderReservationメトリクス

この点について、AWSサポートに問合せたところ、以下の回答だった。

  • CapacityProviderReservation メトリクスにおける指標は、あくまでも ASG のトリガーとして機能する。
  • ECS が必要なコンテナインスタンス数として計算を行った設定値がそのまま ASG のスケールアウト時の起動数として反映されるわけではない。
    ASG 内における必要なインスタンス数は、ターゲット追跡スケーリングポリシーにより、別途算出される。
  • そのため、キャパシティプロバイダが必要数として計算している数と、1 回のスケールアウトにて起動される数は必ずしも一致しない。

→CapacityProviderReservation はASGによるスケールアウト/インの実行トリガーとなるだけで、実際の起動数は別の方法で算出されている。
 そのためスケールアウト/インの台数を正確に予測することは難しいが、大まかな推測は可能。

タスク開始までのリードタイムを短縮する

ECSインスタンスの起動にある程度時間がかかること、スケールアウトが段階的に行われることから、
ECSタスクが全てRUNNING状態となるまでには、ある程度のリードタイムが発生する。
ECSインスタンスはタスク終了後も15分程度は残っているため、その間にプロビジョニングされたタスクはほぼリードタイム無く実行される。
しかし、断続的にタスクがプロビジョニングされる場合は、ECSインスタンスが0台に戻っているため、リードタイムが発生する。
このリードタイムは、以下の設定値を調整することである程度短縮することができる。

キャパシティプロバイダの「minimumScalingStepSize」

説明

1 回のスケールアウトごとに起動される最小の値。初期値は1。
例えば10に設定しておくと、タスクが1~10個起動された場合は10台のインスタンスが起動する。
その状態で11個目のタスクが起動されると、さらに10台のインスタンスが追加され、合計20台となる。
デフォルトの1だとスケール速度がタスク数増加に追いつかない場合に、数を増やすことを検討するとよい。
一方で、インスタンスが必要以上に起動し、無駄なコストが発生しがちになる。

Terraformでの設定箇所

aws_ecs_capacity_provider resourceの「minimum_scaling_step_size」の値。

※手動で設定する場合の注意
 マネジメントコンソールからキャパシティプロバイダを作成するときには指定できない設定値のため、AWS CLIを使用して作成する。
 minimumScalingStepSizeが10のキャパシティプロバイダを作成する場合の例は以下。

$ aws ecs create-capacity-provider --name test-cp --auto-scaling-group-provider autoScalingGroupArn="使用するASGのARN",managedScaling=\{status='ENABLED',targetCapacity=100,minimumScalingStepSize=10,maximumScalingStepSize=100\},managedTerminationProtection="ENABLED"

ASGの「最小キャパシティ」

説明

ASGが管理するインスタンスの最小台数(=常時起動させておくECSインスタンス台数)。 ここを0ではなく1以上にすることで、常に待機しているECSインスタンスを指定の台数用意することができる。 このECSインスタンスのリソースが空いていれば、起動されたタスクはほぼリードタイムなく開始される。
ECSインスタンスのリソースが埋まっている場合には、スケールアウトが発生し、やはりリードタイムが発生する。 常時起動するインスタンスがある分コストがかかるが、常時一定数のECSタスクが起動される見込みであれば、0台からスケールする場合と変わらない可能性もある。

Terraformでの設定箇所

autoscaling moduleの「min_size」の値。

キャパシティプロバイダの「ターゲットキャパシティ」

説明

前述したとおり、CASはCapacityProviderReservationの値を、この値に近づけようとする。
そのため100より小さい値にすれば、タスク数に対して余裕のあるインスタンス数が起動されることになる。
例えば、ターゲットキャパシティを50として8個のタスクを起動すると、
必要数の8台の2倍余裕がある16台のインスタンスが起動してくる。
常にECSインスタンス台数に余裕があるため、急激にタスク数が増えない限りは、ほぼリードタイムが発生しない。
しかし、インスタンス費用も多くかかることになるので、要件に合わせて値を調整する必要がある。
例えば、「常に1台だけ余分に起動していればよい」ということであれば、99にしておけば実現できる。

設定にあたっての注意

ASGの「最小キャパシティ」を0としているECSクラスタで、ターゲットキャパシティを100以外にすると、以下挙動となるため注意が必要。
 
ターゲットキャパシティに100以外の数字を設定した場合、スケールアウト/インのトリガーとなるCloudWattchアラームが以下のように設定される。

  • スケールアウトのトリガー条件:1 分内の1データポイントのCapacityProviderReservation > ターゲットキャパシティ設定値 →ターゲットキャパシティが50の場合、「CapacityProviderReservation > 50」
  • スケールインのトリガー条件:15 分内の15データポイントのCapacityProviderReservation < ターゲットキャパシティ設定値の9割の値 →ターゲットキャパシティが50の場合、「CapacityProviderReservation < 45」

タスクが0個の場合、ECSクラスタの状態は以下のように遷移する。
①M = 0 かつ N = 0 なので、「CapacityProviderReservation = 100」となる。
②スケールアウトのトリガー条件に引っかかり、ECSインスタンスは2台にスケールアウトする。
③M = 0 、N = 2であるため、「CapacityProviderReservation = 0 / 2 × 100 = 0」となる。
④スケールインのトリガー条件に引っかかり、ECSインスタンスは0台にスケールインする。
⑤M = 0 かつ N = 0 なので、「CapacityProviderReservation = 100」となる。
⑥以下繰り返し。

CapacityProviderReservationの値が0と100を繰り返している
CapacityProviderReservationの値が0と100を繰り返している

→タスクが0個のときは、約15分おきに2台のECSインスタンスの再作成が行われてしまう。
 これにより、タスクが起動された際に、即座に実行できないタイミングが存在することになり、「常時余裕のあるインスタンス台数を維持する」という目的を達成できていない。
 対策としては、ASGの「最小キャパシティ」に1以上の値を設定すればよい。
 この場合以下のような遷移となり、スケールアウト/インがループすることを防止できる。

①M = 0 かつ N = 1 なので、「CapacityProviderReservation = 0 / 1 × 100 = 0」となる。
②スケールインのトリガー条件に引っかかるが、すでに最小台数である1台なので、これ以上スケールインはしない。
⑥②がずっと繰り返され、トリガー条件は満たし続けるが、スケールインしないためECSインスタンス台数は1台のままキープされる。

最小キャパシティを1台にした場合
最小キャパシティを1台にした場合

Terraformでの設定箇所

aws_ecs_capacity_provider resourceの「target_capacity」の値。

各設定値変更のユースケース

  • タスク数のスパイクにスケールアウトが追いついていない場合は、キャパシティプロバイダの「minimumScalingStepSize」を増やすことを検討。
  • ある程度の頻度で一定数のタスク起動が見込まれる場合は、ASGの「最小キャパシティ」を1以上にすることを検討。
  • 常時リードタイム短縮したい場合は、キャパシティプロバイダの「ターゲットキャパシティ」を100未満にすることを検討。 その際はインスタンス再作成のループを防止するため、ASGの「最小キャパシティ」は1以上を設定すること。
  • いずれもECSインスタンスの起動コストがかかる点は留意する必要がある。

まとめ

  • CASを活用することで、0台からスケールアウトするECSクラスタ(EC2)をTerraformを使って構築できることを確認した。
    これにより、LambdaやECS(Fargate)では実行出来なかった処理も、(厳密にはサーバはあるが)サーバレス的な実装ができる。
  • タスク起動から実行までにある程度のリードタイムがある。設定値の調整で、ある程度の短縮は可能。

補足事項

  • G系EC2インスタンスの実行中インスタンスの初期クォータはあまり多くない(自分のアカウントは64vcpuが上限だった)。
    必要に応じて上限緩和申請を行う。
  • プロビジョニング状態(タスクが起動されてからECSインスタンスに割り当てられるまで)でいられるECSタスク数の上限は100件。
    上限緩和は出来ない。101件以降はタスクを起動してもエラーになる。
    また、プロビジョニング状態のまま15 分経過したタスクは、実行されないまま停止されてしまう。
    そのため、タスクの起動元で、タスクを登録する前にプロビジョニングタスク数が一定数以下であることを確認し、一定数を超えている場合は待機するなどの考慮が必要。
    待機が頻発するような規模の場合は、ECSクラスタを複数個用意して、処理を分散させるなどの対応が必要。

参考文献