前回の考察をより実現しやすくするために、ECS on EC2 と Fargate でリソース構成にどのような違いがあるのか、をTerraformコードで追っていきます。
1つの設定で気軽に切り替えられる書き方にしていますが、実際にはそんな風にする必要はないので、ポイントを確認する感じで見てもらえればと思います。
構成の概要
ECS on EC2 と Fargate では、細かくは多くの違いがあるのですが、基本的な構成はそう違いはないとも言えます。このどっちつかずの表現は、以降のTerraformコードを見ていただければ、なんとなくわかってもらえると思います。ただ、1つだけ大きめの違いがあり、on EC2 ではネットワークの選択余地があります。それはネットーワークモードが「awsvpc」か「bridge」かの違いです。
Fargate では「awsvpc」固定になり、タスクにPublicIPAddress(任意)もPrivateIPAddressも割り当てられ、そのまま外部との通信が可能になります。
EC2 では、「awsvpc」だとPrivate Networkかつ、外部との通信がNAT Gateway頼りになるので、Public Network に所属させるには「bridge」にする必要があります。ただし、「bridge」の場合はタスクにIPアドレスが付与されないので、HostPort経由の経路になります。
今回は両方ともPublic Networkにする方針で書いていきます。あとはせっかくなので、Graviton2 ARM64対応の部分もおまけする感じでいきましょう。
ALB TargetGroup
わかりやすいかは微妙かもですが、上から順に記述していきます。まずは TargetGroup についてです。ネットワークモード「awsvcp」はタスクにIPアドレスが付与されますが、「bridge」は付与されないので、ターゲットタイプが変わります。
また、ヘルスチェックは同様の理由により、「awsvpc」は直にポートを指定し、「bridge」はインスタンスに自動的に割り当てられるエフェメラルポートとなるよう設定します。
1 2 3 4 5 6 7 8 9 10 |
resource "aws_alb_target_group" "ecs-blue-web" { ... target_type = local.on_ecs_fargate ? "ip" : "instance" ... health_check { ... port = local.on_ecs_fargate ? 80 : "traffic-port" ... } } |
Blue/Green用なら2つ作ることになります。count で1つにまとめることもできますが、私の好みでは別々に分けて記述した方が扱いやすいです。
SecurityGroup
EC2の場合、エフェメラルポートを使うので、aws_launch_template の vpc_security_group_ids で指定するためのセキュリティグループを作成する必要があります。
1 2 3 4 5 6 7 8 9 10 |
resource "aws_security_group" "ephemeral_port_range" { ... ingress { from_port = 32768 to_port = 65535 protocol = "tcp" security_groups = [aws_security_group.web_global[0].id] } ... } |
security_groups には aws_alb の security_groups に指定したものと同一にすることで、TargetGroupからのヘルスチェックのみに限定しています。
LaunchTemplate
EC2の時のみ作成します。私の場合、Scaling用(Ondemand / Spot 混合)と、そうでないStatic用(Ondemand のみ)の2つを作るので、UserData区別のためにLaunchTemplateも2つにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
locals { ecs_ami_id = "ami-04e05d88b2af7bddb" # amzn2-ami-ecs-hvm-2.0.20211115-arm64-ebs } resource "aws_launch_template" "ecs" { count = local.on_ecs && ! local.on_ecs_fargate ? 2 : 0 name_prefix = format( "%s-%s-%s", "ecs", local.ecs_cluster_prefix, count.index == 0 ? "scaling" : "static", ) image_id = local.ecs_ami_id ... vpc_security_group_ids = [ ... aws_security_group.ephemeral_port_range[0].id, ] ... user_data = base64encode(count.index == 0 ? <<EOF #!/bin/bash cat <<'SCRIPT' >> /etc/ecs/ecs.config ... ECS_CONTAINER_INSTANCE_TAGS={"INSTANCE_DISTRIBUTION":"mix","EXAMPLE_BOOLEAN":"false"} ECS_ENABLE_SPOT_INSTANCE_DRAINING=true SCRIPT EOF : <<EOF #!/bin/bash cat <<'SCRIPT' >> /etc/ecs/ecs.config .... ECS_CONTAINER_INSTANCE_TAGS={"INSTANCE_DISTRIBUTION":"ondemand","EXAMPLE_BOOLEAN":"true"} SCRIPT EOF ) ... } |
ARM64用のイメージを指定したり、user_data でECSキャパシティプロバイダーの区別をコンテナ内でつけられるようにしています。
AutoscalingGroup
LaunchTemplateと同じようにEC2の時のみ、scaling / static の役割で2つ作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
resource "aws_autoscaling_group" "ecs" { count = local.on_ecs && ! local.on_ecs_fargate ? 2 : 0 ... mixed_instances_policy { launch_template { launch_template_specification { launch_template_id = aws_launch_template.ecs[count.index].id version = "$Latest" } dynamic "override" { for_each = local.ecs_asg_env["instance_types"] content { instance_type = override.value } } } instances_distribution { on_demand_base_capacity = count.index == 0 ? local.ecs_asg_env["on_demand_base_capacity"] : 0 on_demand_percentage_above_base_capacity = count.index == 0 ? local.ecs_asg_env["on_demand_percentage_above_base_capacity"] : 100 on_demand_allocation_strategy = "prioritized" spot_allocation_strategy = "capacity-optimized-prioritized" spot_instance_pools = 0 } } ... } |
1つめは on_demand_percentage_above_base_capacity でスポット割合を調整し、2つめは100%固定です。
これは設計の好みですが、スケールインに既存タスクが巻き込まれないよう設定できるものの、それ以前にサーバーの役割の特徴ごとに、ASGで区別をつけるほうがスマートだと考えるためです。スポットも使いますしね:-)
ECS CapacityProvider
AutoscalingGroup と同じようにEC2の時のみ、scaling / static の役割で2つ作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
resource "aws_ecs_capacity_provider" "ec2" { count = local.on_ecs && ! local.on_ecs_fargate ? 2 : 0 ... auto_scaling_group_provider { auto_scaling_group_arn = aws_autoscaling_group.ecs[count.index].arn managed_termination_protection = "ENABLED" managed_scaling { status = "ENABLED" maximum_scaling_step_size = local.ecs_asg_env["maximum_scaling_step_size"] minimum_scaling_step_size = local.ecs_asg_env["minimum_scaling_step_size"] target_capacity = local.ecs_asg_env["target_capacity"] } } } |
これで ECS Service がASGのCapacityProvider を選択できるようになります。
ECSからすると、ASG内のインスタンスがオンデマンドかスポットかは隠蔽されているので、Blue/Green+FARGET_SPOT のような制約とは無関係となります。
ECS Cluster
クラスターは1サービスに1つで成り立ちます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
resource "aws_ecs_cluster" "main" { ... capacity_providers = concat( ["FARGATE", "FARGATE_SPOT"], local.on_ecs_fargate ? [] : aws_ecs_capacity_provider.ec2.*.name, ) default_capacity_provider_strategy { capacity_provider = "FARGATE_SPOT" base = 0 weight = 0 } ... } |
capacity_providers で、気持ち無理やり分岐対応しています。
ECS TaskDefinition
template_file で変数を渡しつつ container_definitions の内容を生成します。
1 2 3 4 5 6 7 8 |
data "template_file" "ecs_task_web" { ... vars = { on_ecs_fargate = local.on_ecs_fargate port_nginx = 80 ... } } |
各コンテナの portMappings の hostPort 指定は、awsvpc の場合は containerPort と同じポート数値にしますが、bridge の場合は 0 にしてエフェメラルポートで受けてもらう必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[ { ... "portMappings": [ { "protocol" : "tcp", "hostPort" : ${on_ecs_fargate ? port_nginx : 0}, "containerPort": ${port_nginx} } ], ... }, ... ] |
いざタスク定義で要所を切り替え指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
resource "aws_ecs_task_definition" "web" { ... container_definitions = data.template_file.ecs_task_web[0].rendered requires_compatibilities = [local.on_ecs_fargate ? "FARGATE" : "EC2"] network_mode = local.on_ecs_fargate ? "awsvpc" : "bridge" ... dynamic "runtime_platform" { for_each = local.on_ecs_fargate ? [1] : [] content { operating_system_family = "LINUX" cpu_architecture = "ARM64" } } ... } |
runtime_platform は Fargate でしか有効でないブロックなので、dynamic で丸ごと切り替えています。
ECS Service
サーバーの役割に合わせてキャパシティプロバイダーを割り当てつつ、Service を作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
resource "aws_ecs_service" "web" { ... dynamic "network_configuration" { for_each = local.on_ecs_fargate ? [1] : [] content { subnets = slice(aws_subnet.public.*.id, 0, local.ecs_asg_env["az_num"]) security_groups = [ ... ] assign_public_ip = true } } ... capacity_provider_strategy { capacity_provider = local.on_ecs_fargate ? "FARGATE_SPOT" : aws_ecs_capacity_provider.ec2[0].name base = 0 weight = local.ecs_config["web"]["type"] == "scaling" ? (local.on_ecs_fargate ? 100 - local.ecs_asg_env["on_demand_percentage_above_base_capacity"] : 100) : 0 } capacity_provider_strategy { capacity_provider = local.on_ecs_fargate ? "FARGATE" : aws_ecs_capacity_provider.ec2[1].name base = 0 weight = local.ecs_config["web"]["type"] == "scaling" ? (local.on_ecs_fargate ? local.ecs_asg_env["on_demand_percentage_above_base_capacity"] : 0) : 100 } ... } |
network_configuration が awsvpc の時のみ有効なブロックなので、dynamic しています。
capacity_provider_strategy は scaling / static な種別かと、FARGATE かどうかで、割合にしたり片方に寄せたりしてます。汚いけど、2つ固定にしたので少しマシ…かも。
CodeBuild
切り替えに関係ないけど、ARM で動かすためには、イメージビルドに使うイメージもARMにする必要があるので、指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
locals { ecs_build_type = "ARM_CONTAINER" ecs_build_compute_type = "BUILD_GENERAL1_SMALL" ecs_build_image = "aws/codebuild/amazonlinux2-aarch64-standard:2.0-21.10.15" } resource "aws_codebuild_project" "web-nginx" { ... environment { type = local.ecs_build_type compute_type = local.ecs_build_compute_type image = local.ecs_build_image ... } ... } |
ビルド用イメージと、タスク起動用リソースの、CPUアーキテクチャが異なるとタスク起動時に普通にエラーになります。
おわり。
1つの変数を編集するだけで切り替えられるようにTerraformを書いて、その抜粋で要所を説明してみました。このベースを元に、新規サービスで容易に選択して使ってもらえるようになります。当たり前だけど、運用中に切り替える目的ではないですし、テストでも既存の aws_ecs_service を切り替えようすると編集不可になってエラーになるので、いったん削除~Drainingは待たないといけない、とかその程度の扱いはあります。
言いたいのは、切り替えが便利だよ~ってことじゃなくて、どのような部分が異なるのかってのと、それを元に調べやすくなればイィですねってくらいです。
今回は EC2 の方を bridge として記述しましたが、外部アクセスを必要としないWEB以外の役割がある場合、その service は awsvpc として ServiceDiscovery のAレコードを活用したり、色んな工夫があると思います。
できるだけご時世に柔軟に対応できるように……って思いますけど、どこまで気合い入れて手を抜くかはお好みって感じですね:-)