TerraformのECS定義をecspressoに移行する

Posted on | 1050 words | ~5 mins

Context

  • AWSコンテナ設計・構築入門を読んだ続きとして、
  • ECSリソースについては他AWSリソースとライフサイクルが異なるとして、ECSはTerraformではなくecspressoによる構成管理を行うとよさそう

モチベーション

  • TerraformでECSリソースを管理しようとすると、以下のような課題がある

  • a.) TerraformでECSタスク定義を管理する上での課題

    • aws_ecs_task_definitionを更新すると、タスクリビジョンの積み上げではなく差替(replacement)の差分が生じる
      • リビジョンを戻すということができない
      • rollback相当のことをしたい場合、前回のタスク定義でまたreplacementする差分をapplyする
    • 課題
      • リビジョンを遡ることができない
        • 過去のリビジョンを(gitから)探し出すのが手間
      • 迅速なrollbackができない
        • rollbackは思考を自動化した手順で行いたいが、それを妨げる
  • b.) TerraformでECSサービスを管理する上での課題

    • Auto Scalingによってdesired_countの変更が起き得る
      • aws_ecs_serviceで定義しているdesired_countは、たとえばaws_appautoscaling_target定義によるAuto Scaling発動によって、terraform applyを介さずに状態が変わり得る
      • -> Terraform定義と稼働しているECSサービスとの間に差分が生まれる
    • 課題
      • 定義と実態で差分がある状態でterraform applyが実行されると(定義自体に差分は生じず)、Auto Scalingによるスケールアウトを意図せず打ち消してしまうなどが起きる
        • ignore_changesに含めて意図しない「修正」を行わないようにすることは可能だが、本来のdesired_countを変更したいときなどはignore_changes外す必要があり、トリッキーな対応を要する
        • いずれにせよ、applyが意図した収斂になるか判別できない(tfstateが最新状態を反映しているかが不明である以上は)

ecspressoによるECS管理

  • ecspressoは何を解決するか
    • a.) Terraform ECSタスク定義のリビジョンが残らない/rollback問題
      • タスク定義の登録(register)や実行(run)が個別で行える
      • ECSサービスのデプロイ(deploy)で、“最新リビジョンのタスク定義を参照(新規登録しない)“オプション(--latest-task-definition)の付与が可能
      • -> タスク定義登録前のリビジョンへのロールバックが可能(rollback )
    • b.) ECSサービスのTerraform定義と実態の乖離と誤applyリスク
      • -> 定義と現在のECS(=実際にデプロイされているECS)状態との差分の確認(diff)ができる

ECSリソースのTerraform -> ecspresso移行

TL;DR version

before:TerraformでのECSリソース定義

ecs.tf
## Backend container
resource "aws_ecs_task_definition" "sbcntr_backend" {
  family = local.task_def.backend.name
  container_definitions = templatefile(local.task_def.backend.container_def_file, {
    container_name = local.task_def.backend.container_name
    image          = local.task_def.backend.image_url
    awslogs-group  = local.task_def.backend.cwlogs_group
    awslogs-region = var.aws_region
    awslogs-prefix = local.task_def.backend.cwlogs_prefix
  })

  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"

  network_mode = "awsvpc"

  task_role_arn      = aws_iam_role.sbcntr_task.arn
  execution_role_arn = aws_iam_role.sbcntr_task_exec.arn
}

resource "aws_ecs_cluster" "sbcntr_backend" {
  name = var.cluster_def.backend.name
}


resource "aws_ecs_service" "sbcntr_backend" {
  name                   = local.service_def.backend.name
  cluster                = local.service_def.backend.cluster_name
  task_definition        = aws_ecs_task_definition.sbcntr_backend.arn
  desired_count          = local.service_def.common.desired_count
  launch_type            = "FARGATE"
  enable_execute_command = true

  network_configuration {
    subnets         = var.subnet_ids
    security_groups = local.service_def.backend.security_groups
  }

  load_balancer {
    target_group_arn = local.service_def.backend.lb_target_group_arn
    container_name   = local.service_def.backend.container_name
    container_port   = 80
  }

  deployment_controller {
    type = "CODE_DEPLOY"
  }
}
  • CodeDeployによるBlue/GreenデプロイタイプのECS定義

resource "aws_codedeploy_app" "sbcntr_backend" {
  name             = "sbcntr-ecs-backend-codedeploy"
  compute_platform = "ECS"
}

resource "aws_codedeploy_deployment_group" "sbcntr" {
  app_name               = aws_codedeploy_app.sbcntr_backend.name
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
  deployment_group_name  = "sbcntr-ecs-backend-blue-green-deployment-group"
  service_role_arn       = aws_iam_role.ecs_codedeploy.arn

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  blue_green_deployment_config {
    deployment_ready_option {
      //action_on_timeout = "CONTINUE_DEPLOYMENT"
      action_on_timeout    = "STOP_DEPLOYMENT"
      wait_time_in_minutes = 5
    }

    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 0
    }
  }

  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  ecs_service {
    cluster_name = aws_ecs_cluster.sbcntr_backend.name
    service_name = aws_ecs_service.sbcntr_backend.name
  }

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [
          aws_lb_listener.internal_blue.arn
        ]
      }

      test_traffic_route {
        listener_arns = [
          aws_lb_listener.internal_green.arn
        ]
      }

      target_group {
        name = aws_lb_target_group.internal_blue.name
      }

      target_group {
        name = aws_lb_target_group.internal_green.name
      }
    }
  }
}
codedeploy.tf

after:ecspresso移行後のTerraform定義

ecs.tf
resource "aws_ecs_cluster" "sbcntr_backend" {
  name = local.backend_def.cluster_name
}

resource "null_resource" "ecspresso_backend" {
  triggers = {
    cluster          = aws_ecs_cluster.sbcntr_backend.name
    task_exec_role   = aws_iam_role.sbcntr_task_exec.name
    task_role        = aws_iam_role.sbcntr_task.name
    alb_backend      = aws_lb.internal.arn
    alb_target_group = aws_lb_target_group.internal_blue.arn
  }

  provisioner "local-exec" {
    command     = "ecspresso deploy --no-wait"
    working_dir = "./ecspresso/backend-app"
    environment = {
      # Common
      AWS_ACCOUNT_ID = var.aws_account_id,
      ECS_CLUSTER    = local.backend_def.cluster_name,
      ECS_SERVICE    = local.backend_def.service_name,
      # Task
      CW_LOG_GROUP_ECS_TASK_BACKEND = local.backend_def.cwlogs_group,
      ECS_TASK_EXEC_ROLE_ARN        = aws_iam_role.sbcntr_task_exec.arn,
      ECS_TASK_ROLE_ARN             = aws_iam_role.sbcntr_task.arn,
      DB_HOST                       = var.db_host,
      DB_NAME                       = var.db_name,
      # Service
      LB_TG_INTERNAL_BLUE = aws_lb_target_group.internal_blue.arn,
      SG_BACKEND_APP      = aws_security_group.sbcntr_sg_container.id,
      VPC_SUBNET_APP_AZ_A = var.subnet_ids[0],
      VPC_SUBNET_APP_AZ_C = var.subnet_ids[1]
      # CodeDeploy
      CODEDEPLOY_APP_NAME   = local.backend_def.codedeploy_app_name,
      CODEDEPLOY_GROUP_NAME = local.backend_def.codedeploy_group_name,
    }
  }

  provisioner "local-exec" {
    when        = destroy
    command     = "aws ecs delete-service --cluster $ECS_CLUSTER --service $ECS_SERVICE --force"
    working_dir = "."
    environment = {
      ECS_CLUSTER = "sbcntr-ecs-backend-cluster",
      ECS_SERVICE = "sbcntr-ecs-backend-service"
    }
  }
}

data "aws_ecs_service" "sbcntr_backend" {
  cluster_arn  = aws_ecs_cluster.sbcntr_backend.arn
  service_name = local.backend_def.service_name

  depends_on = [
    null_resource.ecspresso_backend,
  ]
}
  • null_resourceのlocal-exec provisionerでecspresso deployコマンドを実行
  • aws_ecs_service data sourceで実際にデプロイされたECSサービスのリソースを参照

codedeploy.tf
resource "aws_codedeploy_app" "sbcntr_backend" {
  name             = local.backend_def.codedeploy_app_name
  compute_platform = "ECS"
}

resource "aws_codedeploy_deployment_group" "sbcntr" {
  app_name               = aws_codedeploy_app.sbcntr_backend.name
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
  deployment_group_name  = local.backend_def.codedeploy_group_name
  service_role_arn       = aws_iam_role.ecs_codedeploy.arn

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  blue_green_deployment_config {
    deployment_ready_option {
      //action_on_timeout = "CONTINUE_DEPLOYMENT"
      action_on_timeout    = "STOP_DEPLOYMENT"
      wait_time_in_minutes = 5
    }

    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 0
    }
  }

  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  ecs_service {
    cluster_name = local.backend_def.cluster_name
    service_name = data.aws_ecs_service.sbcntr_backend.service_name
  }

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [
          aws_lb_listener.internal_blue.arn
        ]
      }

      test_traffic_route {
        listener_arns = [
          aws_lb_listener.internal_green.arn
        ]
      }

      target_group {
        name = aws_lb_target_group.internal_blue.name
      }

      target_group {
        name = aws_lb_target_group.internal_green.name
      }
    }
  }
}
  • aws_ecs_service data sourceで実際にデプロイされたECSサービスのリソースを参照して、CodeDeploy deployment groupに紐づくECSサービスを指定
    • CodeDeploy -> ECSサービス の依存関係を記述

after:ecspressoの定義

実際にデプロイされているECS構成のecspresso定義を生成する

  • ecspresso initで生成できる
ecspresso init --cluster <cluster_name> --service <service_name> --jsonnet
  • --jsonnet オプション付与でJsonnet形式でdumpできる

  • ecspresso.yml

    region: ''
    cluster: '{{ must_env `ECS_CLUSTER` }}'
    service: '{{ must_env `ECS_SERVICE` }}'
    service_definition: ecs-service-def.jsonnet
    task_definition: ecs-task-def.jsonnet
    timeout: 10m0s
    plugins:
      - name: tfstate
        config:
          path: terraform.tfstate
      - name: ssm
    
  • ecs-service-def.jsonnet

    {
      deploymentConfiguration: {
        maximumPercent: 200,
        minimumHealthyPercent: 100,
      },
      deploymentController: {
        type: 'CODE_DEPLOY',
      },
      desiredCount: 2,
      enableECSManagedTags: false,
      enableExecuteCommand: true,
      healthCheckGracePeriodSeconds: 0,
      launchType: 'FARGATE',
      loadBalancers: [
        {
          containerName: 'app',
          containerPort: 80,
          targetGroupArn: '{{ or (env `LB_TG_INTERNAL_BLUE` ``) (tfstate `module.ecs.aws_lb_target_group.internal_blue.arn`) }}',
        },
      ],
      networkConfiguration: {
        awsvpcConfiguration: {
          assignPublicIp: 'DISABLED',
          securityGroups: [
            '{{ or (env `SG_BACKEND_APP` ``) (tfstate `module.ecs.aws_security_group.sbcntr_sg_container.id`) }}',
          ],
          subnets: [
            "{{ or (env `VPC_SUBNET_APP_AZ_A` ``) (tfstate `module.network.aws_subnet.sbcntr_subnet_private_container1['a'].id`) }}",
            "{{ or (env `VPC_SUBNET_APP_AZ_C` ``) (tfstate `module.network.aws_subnet.sbcntr_subnet_private_container1['c'].id`) }}",
          ],
        },
      },
      pendingCount: 0,
      platformFamily: 'Linux',
      platformVersion: '1.4.0',
      propagateTags: 'NONE',
      runningCount: 0,
      schedulingStrategy: 'REPLICA',
    }
    
  • ecs-task-def.jsonnet

    {
      containerDefinitions: [
        {
          cpu: 256,
          environment: [
            {
              name: 'DB_NAME',
              value: 'sbcntrapp',
            },
            {
              name: 'DB_HOST',
              value: '{{ or (env `DB_HOST` ``) (tfstate `module.rds.aws_db_instance.sbcntr_db.address`) }}',
            },
            {
              name: 'DB_PASSWORD',
              value: '{{ ssm `/sbcntr/db/DB_PASSWORD` }}',
            },
            {
              name: 'DB_USERNAME',
              value: '{{ ssm `/sbcntr/db/DB_USERNAME` }}',
            },
          ],
          essential: true,
          image: '{{ must_env `AWS_ACCOUNT_ID` }}.dkr.ecr.ap-northeast-1.amazonaws.com/sbcntr-backend:v1',
          logConfiguration: {
            logDriver: 'awslogs',
            options: {
              'awslogs-group': "{{ or (env `CW_LOG_GROUP_ECS_TASK_BACKEND` ``) (tfstate `module.ecs.aws_cloudwatch_log_group.sbcntr['backend_task'].name`) }}",
              'awslogs-region': 'ap-northeast-1',
              'awslogs-stream-prefix': 'ecs',
            },
          },
          memory: 512,
          name: 'app',
          portMappings: [
            {
              appProtocol: '',
              containerPort: 80,
              hostPort: 80,
              protocol: 'tcp',
            },
          ],
        },
      ],
      cpu: '256',
      executionRoleArn: '{{ or (env `ECS_TASK_EXEC_ROLE_ARN` ``) (tfstate `module.ecs.aws_iam_role.sbcntr_task_exec.arn`) }}',
      family: 'sbcntr-backend-def',
      ipcMode: '',
      memory: '512',
      networkMode: 'awsvpc',
      pidMode: '',
      requiresCompatibilities: [
        'FARGATE',
      ],
      taskRoleArn: '{{ or (env `ECS_TASK_EXEC_ROLE_ARN` ``) (tfstate `module.ecs.aws_iam_role.sbcntr_task.arn`) }}',
    }
    
`{{ or (env `xxx` ``) (tfstate `yyy`) }}`
  • こうすることで、

    • ECSサービス作成時はnull_resource local-exec provisionerからのecspresso deployコマンド実行時に、環境変数としてパラメータを与え、
    • 作成以後はtfstateファイルから作成済リソースの値を読み込む、という区分けができる
  • ポイント

    • CodeDeployによるBlue/Greenデプロイする上で、deployment group作成前に関連するECSサービス作成が先に行われる必要があるので、その依存関係を記述する
      # aws_codedeploy_deployment_group -depends-> aws_ecs_serivce data source
      data "aws_ecs_service" ... {
      
      }
      
      resource "aws_codedeploy_deployment_group" ... {
        ...
        ecs_service {
          ...
          service_name = data.aws_ecs_service.sbcntr_backend.service_name
        }
        ...
      
      • が、ECSサービスのdeployment controller typeがCODE_DEPLOYだと、ecspresso deployの完了待受が正常終了しない挙動をしていた
        • 原因としてはaws-sdk-go側の実装として、ECSサービス作成完了待受を行う実装が、aws-cli ecsのdescribe-services出力結果でいうところのdeploymentsにローリングデプロイ(つまりECS deployment controller type)結果が格納されることをもって完了判定するようになっており、CODE_DEPLOY deployment typeではそもそも検知されないというもの
        • この件についてはecspresso本体にissueを起票させていただいた
        • workaroundとして、ecspresso deployに--no-waitオプションを付与することでECSサービス作成をもって(steady state遷移待たず)リソース作成完了とさせることで後続のCodeDeploy deployment group作成も行えるのでこれで事なきを得ている
    • CodeDeployとECSサービスが紐付いている場合の削除(terraform destroy時)については、ecspresso実行コマンドとしてecspresso scale --tasks 0 && ecspresso delete --forceとすべきところだが、ここはaws-cliで直接ECSサービスの強制削除を行うようにしている
      • 本来であれば、deployment groupに依存するECSサービスを削除した上でdeployment group削除といきたいところだが、
        • 依存関係として、aws_codedeploy_deployment_group -> data.aws_ecs_service -> null_resource(ecspresso provisioner) という依存グラフがある中、
        • terraform destroyを行うと、まず先にdeployment group削除が実行されてしまい、そうなるとecspressoによるECSタスク削除(タスク数0へのロールアウト)実行が阻まれてしまう(これはCODE_DEPLOY deployment controller typeのECSサービスの場合、ecspressoは関連するCodeDeploy deployment groupが存在することを事前条件としている為)
        • ECSサービスが先に削除されるよう、依存グラフをnull_resource(ecspresso provisioner) -> codedeploy_deployment_groupとしたいところだが、そうなると循環依存が発生してしまう(terraform planエラーになる..)
        • workaroundとして、destroy時にはecspressoではなくaws-cliをつかってECSサービスの強制削除を行うようにした(関連するdeployment groupの存在チェックをはさまない)
          provisioner "local-exec" {
            when        = destroy
            command     = "aws ecs delete-service --cluster $ECS_CLUSTER --service $ECS_SERVICE --force"
            working_dir = "."
            environment = {
              ECS_CLUSTER = "***-cluster",
              ECS_SERVICE = "***-service"
            }
          }
          

その他ecspresso利点

  • 設定が柔軟
    • Terraform(.tfstate)とのインテグレーションがサポートされている
      • これがないと始まらなかったりする
      • インフラリソースとECSリソース(アプリケーション)のIaCをそれぞれのライフサイクルで管理できてうれしい
    • Jsonnet形式で設定記述できる(=プログラミングできる)ので、概ねやりたいようにできる自由度があってうれしい
  • 安心安全感
    • 上述のdiffの他verifyコマンドが備わっていて「タスクが立たない」ケースを事前に検知できてうれしい
      ***-service/***-cluster Starting verify
        TaskDefinition
          ExecutionRole[arn:aws:iam::xxx:role/***-task-exec-role]
          --> [OK]
          TaskRole[arn:aws:iam::xxx:role/***-task-role]
          --> [OK]
          ContainerDefinition[app]
            Image[xxx.dkr.ecr.ap-northeast-1.amazonaws.com/***-frontend:2c90e20]
            --> [OK]
            Secret DB_PASSWORD[***]
            --> Secret DB_PASSWORD[***] [NG] failed to get ssm parameter ***: operation error SSM: GetParameter, https response error StatusCode: 400, RequestID: 200bf926-a975-
      4f54-acd1-8abc84a49d98, ParameterNotFound:
          --> ContainerDefinition[app] [NG] verify Secret DB_PASSWORD[***] failed: failed to get ssm parameter ***: operation error SSM: GetParameter, https response error StatusCode: 400, RequestID: 200bf926-a975-4f54-acd1-8abc84a49d98, ParameterNotFound:
      
      • ↑はタスク定義から参照しているssmパラメータストアの参照に失敗している例
      • タスク起動前にこうした検査が行えて便利
    • ecspresso作者(@fujiwaraさん)別作のtracer cliも併用することで、「タスクが立たない」調査について本当にラクになった
  • 実用感
    • run, rollback, scaleなど、ちょっとアレしたいときのユースケースが痒いところに手が届くといった具合でカバーされていてうれしい
    • CodeDeployでのBlue/Greenの場合もサクッとdeployment URL叩ける気遣いがうれしい

参考