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インスタンスで起動した。
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インスタンスで起動した。
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