tk_ch’s blog

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

AWS 同一ECSクラスタ内で、ECSインスタンスを特定の利用者(ECSタスク)に占有させる(Terraform使用)

AWS ECSで、クラスタークエリ言語を使ってECSインスタンスにカスタム属性を付与することで、ECSタスクの実行インスタンスを制御したときのメモ。

環境

・Terraform:1.0.8

やりたいこと

以下を実現したい。

  • ECSタスクを実行するときだけ、必要なECSインスタンスを起動し、ECSタスクを実行したい。
  • とりあえずはECSインスタンスのオートスケールは不要。タスク実行の前後に、必要な台数のECSインスタンスを追加、削除する。
  • 単一のECSクラスタに対して、複数人が同様の作業を行う可能性がある。同時実行された場合に、自分が起動したECSインスタンスは他の人に使われたくない(占有したい)。

実現方法の検討

方針

KubernetesでいうNode Affinity的な機能がECSにもあれば実現出来そうだな、と思って調査したところ、以下ページににそれらしい記述を発見した。 aws.amazon.com

「Constraints」の章の「Member of」の説明に、ECSタスクが配置されるECSインスタンスを、ある属性を持つECSインスタンスに制限する例が載っている。 このページの例だとインスタンスやAZといった属性を使っているが、クラスタークエリ言語というのを使えばカスタム属性を使うこともできそう。

ECSインスタンスに属性を付与する方法

以下に記載がある。 ・既存のECSインスタンスに属性を付与する方法。 docs.aws.amazon.com ・ECS コンテナエージェントの設定で、ECSインスタンスに属性を付与する方法。 docs.aws.amazon.com

タスクの配置先を、特定の属性を持つECSインスタンスに制限する方法

タスク実行(runTask)時に、配置制約を設定すればよい。詳細は以下。 ・配置制約の記載例。 docs.aws.amazon.com ・配置制約内に記載するクラスタークエリ言語の構文。 docs.aws.amazon.com

実現方法まとめ

以下の方法で、やりたいことが実現できそう。
・ECSインスタンスにユーザ単位でユニークなカスタム属性をインスタンスに付与する。
インスタンス作成時に付与したいので、ユーザーデータを使ってECSコンテナエージェントの設定を行う。
・タスク起動時に、配置先を上記カスタム属性を持つECSインスタンスに制限してタスクを起動することにする。

※上記のページ中にタスクグループ(task:groupという属性)を使う例が登場する。
一見これでも実現出来そうだが、これはある既にあるタスクが配置されている場合に、そのタスクと同じECSインスタンスにタスクを配置したいときに使うものであり、既存のタスクが存在しない場合にECSインスタンスの指定ができない。
(ECSインスタンスのAffinityではなく、ECSタスクのAffinity。)
以下にもそのような記載がある。
stackoverflow.com そのため、今回やりたいことには合わない。

動作確認環境の構築

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

  • ECSで使用するVPC、セキュリティグループ、IAMロールの作成
  • ユーザーデータを使って「group:001」というカスタム属性を付与したECSインスタンス(インスタンス名:ec2_001)の作成
  • ユーザーデータを使って「group:002」というカスタム属性を付与したECSインスタンス(インスタンス名:ec2_002)の作成
  • ECSクラスタの作成
  • 動作確認用のECSタスク定義(「sleep 600」を実行するだけのタスク)の作成

※以下のTerraform Moduleを使用している。
- vpc
- security-group
- iam
- ec2-instance
- ecs
- ecs-container-definition

使用した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-hvm-2.0.20220411-x86_64-ebs"]
  }

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

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

# ECSインスタンス(group:001)
module "ec2_001" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "3.2.0"

  name = "ecs-instance-001"

  ami                    = data.aws_ami.amazon_linux_ecs.id
  instance_type          = "t2.micro"
  availability_zone      = element(module.vpc.azs, 0)
  subnet_id              = element(module.vpc.private_subnets, 0)
  vpc_security_group_ids = [module.ecs_instance_sg.security_group_id]
  key_name               = "test-ec2-key"
  iam_instance_profile   = module.iam_assumable_role_ecs.iam_instance_profile_name
  user_data              = data.template_file.user_data_001.rendered

  enable_volume_tags = true
  root_block_device = [
    {
      encrypted   = false
      volume_type = "gp2"
      volume_size = 30
    },
  ]
}

# ECSインスタンス(group:002)
module "ec2_002" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "3.2.0"

  name = "ecs-instance-002"

  ami                    = data.aws_ami.amazon_linux_ecs.id
  instance_type          = "t2.micro"
  availability_zone      = element(module.vpc.azs, 0)
  subnet_id              = element(module.vpc.private_subnets, 0)
  vpc_security_group_ids = [module.ecs_instance_sg.security_group_id]
  key_name               = "test-ec2-key"
  iam_instance_profile   = module.iam_assumable_role_ecs.iam_instance_profile_name
  user_data              = data.template_file.user_data_002.rendered

  enable_volume_tags = true
  root_block_device = [
    {
      encrypted   = false
      volume_type = "gp2"
      volume_size = 30
    },
  ]
}

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

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

# リージョン名
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             = 982                       # ハードメモリ制限(MiB)
  container_memory_reservation = 982                       # ソフトメモリ制限(MiB)
  container_cpu                = 1024                      # 予約するCPUユニット数
  essential                    = true                      # 基本パラメータ。trueの場合は、このコンテナの失敗によりタスクが停止する。

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

## 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
  ])
  task_role_arn            = module.iam_assumable_role_ecstask.iam_role_arn # ECSタスクロールを指定
  cpu                      = 1024                                           # タスクCPUユニット数
  memory                   = 982                                            # タスクメモリ(MiB)
  requires_compatibilities = ["EC2"]                                        # 互換性が必要な起動タイプ
  network_mode             = "bridge"                                       # ネットワークモード
}

「./templates/user-data.sh」の中身は以下。

#!/bin/bash
echo ECS_CLUSTER=${cluster_name} >> /etc/ecs/ecs.config
echo ECS_INSTANCE_ATTRIBUTES={\"group_id\": \"${group_id}\"} >> /etc/ecs/ecs.config

動作確認

ECSインスタンスの属性を確認する

何らかの方法で作成したECSインスタンスにログインして、ECSコンテナエージェントのconfigを確認する。 ※今回は、ECSインスタンスに接続できる踏み台EC2インスタンスを作成し、ECSインスタンスsshして確認した。 - ec2_001のconfig

# cat /etc/ecs/ecs.config
ECS_CLUSTER=test-ecs-cluster
ECS_INSTANCE_ATTRIBUTES={"group_id": "001"}
  • ec2_002のconfig
# cat /etc/ecs/ecs.config
ECS_CLUSTER=test-ecs-cluster
ECS_INSTANCE_ATTRIBUTES={"group_id": "002"}

→それぞれ想定の属性が設定されている。

また、マネジメントコンソールのECSインスタンス表示画面から、インスタンスに属性が付与されていることを確認できる。

属性の表示例
属性の表示例

※カスタム属性はデフォルトでは表示されていない。
 上記ページの歯車マークをクリックすると、以下のように表示する内容を変更できる。 属性の表示方法

属性の表示方法

配置制約を指定してタスクを実行する

AWSCLIを使って、「group_idが001」という配置制約でタスクを1つ実行してみる。

$ ECS_CLUSTER="ECSクラスタのARN"
$ TASK="test-ecstask:1"
$ COUNT=1
$ PLACEMENT_CONSTRAINTS="type=memberOf,expression=attribute:group_id == 001"
$ aws --profile stg-user ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${TASK} --count ${COUNT} --placement-constraints "${PLACEMENT_CONSTRAINTS}"

想定通り、「group_idが001」であるec2_001インスタンスで起動した。

配置制約が「group_idが001」のとき
配置制約が「group_idが001」のとき

AWSCLIを使って、「group_idが002」という配置制約でタスクを1つ実行してみる。

$ ECS_CLUSTER="ECSクラスタのARN"
$ TASK="test-ecstask:1"
$ COUNT=1
$ PLACEMENT_CONSTRAINTS="type=memberOf,expression=attribute:group_id == 002"
$ aws --profile stg-user ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${TASK} --count ${COUNT} --placement-constraints "${PLACEMENT_CONSTRAINTS}"

想定通り、「group_idが002」であるec2_002インスタンスで起動した。

配置制約が「group_idが002」のとき
配置制約が「group_idが002」のとき

AWSCLIを使って、「group_idが003」という配置制約でタスクを1つ実行してみる。

$ ECS_CLUSTER="ECSクラスタのARN"
$ TASK="test-ecstask:1"
$ COUNT=1
$ PLACEMENT_CONSTRAINTS="type=memberOf,expression=attribute:group_id == 003"
$ aws --profile stg-user ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${TASK} --count ${COUNT} --placement-constraints "${PLACEMENT_CONSTRAINTS}"

「group_idが003」という制約に該当するECSインスタンスは存在しないため、以下のように「MemberOf placement constraint unsatisfied」というエラーとなった。

{
    "tasks": [],
    "failures": [
        {
            "arn": "arn:aws:ecs:ap-northeast-1:900501247601:container-instance/1d44d64291814d1ab4997169b4172d7c",
            "reason": "MemberOf placement constraint unsatisfied."
        },
        {
            "arn": "arn:aws:ecs:ap-northeast-1:900501247601:container-instance/530839e48ef84fdead06b208d91946a2",
            "reason": "MemberOf placement constraint unsatisfied."
        }
    ]
}

ちなみに、以下のように配置制約を指定しないで2タスク実行すると、ec2_001とec2_002両方で実行される。 ECSインスタンスの属性はあくまで配置制約を指定したときに使われるだけなので、「配置制約でgroup_idを指定していないタスクは配置されない」といったことにはならない。

$ ECS_CLUSTER="ECSクラスタのARN"
$ TASK="test-ecstask:1"
$ COUNT=2
$ aws --profile stg-user ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${TASK} --count ${COUNT}

まとめ

  • カスタム属性と配置制約を活用することで、タスクの配置先として特定のECSインスタンスを指定できる。
  • これによって、同一のECSクラスタ内を複数人で利用する際に、自分が作成したECSインスタンスを占有することが可能。
     (配置制約を使わずにタスクを起動されると占有できないので、必ず配置制約を使用するという運用ルールは必要。)

おまけ:ECSクラスタのスケーリングについて

以前の記事で検証したオートスケールECSクラスタと組み合わせて、配置制約に当てはまるECSインスタンスの台数をスケールできないか。
→配置制約に当てはまるECSインスタンスのみ追加するといった制御はできない。
利用者の数だけAuto Scalingグループとキャパシティプロバイダを事前に定義しておき、ECSクラスタに設定すれば、各配置制約を満たすECSインスタンスを自動的に追加させることはできる。
しかし、スケールの基準に用いられる指標はあくまでタスク数であり、そのタスクがどのような配置制約を指定しているかは考慮されない。

以前の記事で作成したECSクラスタでは、インスタンスに空きが無い状態でタスクを実行すると、タスクの状態はpendingになり、インスタンス台数がスケールした後に処理されていた。
今回作成したECSクラスタでは、インスタンスに空きが無い状態でタスクを実行すると、タスクの状態はpendingにならずに即座に「"reason": "RESOURCE:CPU"」などリソース不足が理由でエラーになる。
この挙動の違いはどこの設定に依存するか気になったので、AWSサポートに問合せたところ、以下の回答だった。

  • ECS タスクの起動にキャパシティープロバイダー戦略が選択されるか、起動タイプが選択されるかによって、タスクを起動可能なコンテナインスタンスが存在しない場合の挙動が変わる。
  • ECSタスクの起動(RunTask API実行)時に、"capacityProviderStrategy" のプロパティを明示的に指定した場合、または "capacityProviderStrategy" および "launchType" の双方を指定せずデフォルトキャパシティープロバイダー戦略が自動的に使用される場合、起動されるタスクは PROVISIONING 状態となり、 Auto Scaling などにより使用可能なコンテナインスタンスが用意されるのを待つ動きとなる。
  • ECSタスクの起動(RunTask API実行)時に、起動タイプ(EC2など)を指定した場合には、"reason": "RESOURCE:CPU" などのメッセージにて API の呼び出し自体に失敗する挙動となる。
  • ただし、キャパシティプロバイダを設定したECSクラスタに対して明示的に起動タイプ に "EC2" を指定して RunTask API を呼び出すことは、以下ページに「When you use cluster auto scaling, you must specify capacityProviderStrategy and not launchType.」と記載されているように非推奨。 docs.aws.amazon.com