🌀

ecs-cliとTerraformを使ってECSを管理すると循環参照を起こす問題について

2022/05/28に公開

ECSを用いたアプリケーションを開発するなかで私はecs-cliを使ってECSのTaskDefinitionやECS serviceをデプロイしていたのですが,オートスケールを設定する場合にこの方法が悩まし問題を生んでしまい困ってしまった,というお話です.

いい筋の解決方法を引き続き模索中です.

アプリケーションの概要

  • 稼働するコンテナイメージはWebサーバーとアプリケーションサーバー程度
  • 開発環境はdocker-compose.ymlで立ち上げている

そもそもecs-cliとは

ecs-cliはECSに特化した拡張的なCLIで,AWS自身が開発・提供しているものです.特にdocker composeをサポートしている点に特徴があります.

Amazon ECS コマンドラインインターフェース (CLI) には、ローカル開発環境からのクラスターおよびタスクの作成、更新、モニタリングを簡素化する高レベルコマンドが用意されています。Amazon ECS CLI では、マルチコンテナアプリケーションの定義と実行によく使用されるオープンソースツール、Docker Compose がサポートされています。(公式ドキュメントより抜粋)

ecs-cliでは,以下の2種類のファイルに構成・設定パラメータを記載します

  • docker-compose.yml:コンテナ構成を定義
  • ecs-params.yml:ECS特有のパラメータ(マシンリソースやrole・ネットワークなど)設定

ローカル環境をdocker composeで構築しているので似たような記述でproduction環境についてもコンテナ構成を書くことができるのが好きです.

ecs-cliを用いたデプロイ方法

単語の使い方がよくないかもしれないのですが,ここでのデプロイとは以下の処理を指すこととします.

  • 新規TaskDefinitionの作成
  • TaskDefinitionの更新(新しいrevisionの作成)
  • Taskの実行
  • ECS serviceの作成・更新(参照するTaskのrevisionを更新する)

ecs-cliで用意されている以下の6つのメソッドから,用途に即したものを選んで実行することでTaskDefinitionの作成や更新・ECS serviceの起動が行えます

command action
ecs-cli compose create TaskDefinitionの作成あるいはrevisionの更新
ecs-cli compose start Taskの実行
ecs-cli compose up create & start
ecs-cli compose service create ecs-cli compose create + service作成(desired countは0)
ecs-cli compose service start desired countを1にする(=serviceの開始)
ecs-cli compose service up create & start

(出所:ecs-cliドキュメント

GithubActionsでecs-cliを利用してデプロイする

具体的にecs-cliをGitHub Actionsに組み込んでこのようなstepでデプロイすることができます

デプロイjob
name: Build and Deploy

on:
  (省略)

env:
  AWS_REGION: ap-northeast-1
  IMAGE_TAG: ${{ github.sha }}

jobs:
  build:
    (省略)

  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: production
    needs: build

    env:
      CLUSTER_NAME: your_awesome_cluster
      SERVICE_NAME: web
      PARAMS_FILE: path_to_your/ecs-params-web.yml
      COMPOSER_FILE: path_to_your/docker-compose.yml

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    # ecs-cliのインストールと初期設定
    - name: Install ECS CLI
      run: |
        curl -Lo /usr/local/bin/ecs-cli https://amazon-ecs-cli.s3.amazonaws.com/ecs-cli-linux-amd64-latest
        chmod +x /usr/local/bin/ecs-cli
        ecs-cli configure profile --access-key $AWS_ACCESS_KEY_ID --secret-key $AWS_SECRET_ACCESS_KEY
        ecs-cli configure --cluster $CLUSTER_NAME --default-launch-type FARGATE --region $AWS_REGION

    # コンテナにログインできるようになる設定を適用
    - name: Enable execute-command on current service
      run: |
        failure_check=$(aws ecs describe-services --cluster $CLUSTER_NAME --service $SERVICE_NAME|grep -E "(INACTIVE|MISSING)"|wc -l)
        if [[ $failure_check == 0 ]]; then
          aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --enable-execute-command
        fi

    # ECS serviceのデプロイ(ecs-cli compose service up)
    # (実際は長いですが改行せず書いてる)
    - name: Deploy Amazon ECS service (Web)
      env:
        DEPLOY_MAX_PERCENT: 200
        DEPLOY_MIN_PERCENT: 100
        TIMEOUT: 10
        CONTAINER_NAME: nginx
        CONTAINER_PORT: 80
        TARGET_GROUP_ARN: ${{ secrets.TARGET_GROUP_ARN }}
      run: |
        TAG=$IMAGE_TAG ecs-cli compose \
          --project-name $SERVICE_NAME_WEB \
          --file $COMPOSER_FILE_WEB \
          --ecs-params $PARAMS_FILE \
          service up \
          --deployment-max-percent $DEPLOY_MAX_PERCENT \
          --deployment-min-healthy-percent $DEPLOY_MIN_PERCENT \
          --create-log-groups --force-deployment --timeout $TIMEOUT \
          --target-group-arn $TARGET_GROUP_ARN \
          --container-name $CONTAINER_NAME \
          --container-port $CONTAINER_PORT

    # Slack通知
    - name: Slack Notification
      uses: rtCamp/action-slack-notify@v2
      if: ${{ always() }}
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_TITLE: your_awesome_service
        SLACK_COLOR: ${{ job.status }}
        SLACK_FOOTER: "Deploy Result"
        MSG_MINIMAL: ref,actions url

補足)イメージビルドjob
jobs:
  build:
    name: Build and Push
    runs-on: ubuntu-latest
    environment: production

    env:
      ECR_NGINX: name_for_nginx
      ECR_APP: name_for_app
      DOCKERFILE_NGINX: path_to_your/nginx-Dockerfile
      DOCKERFILE_APP: path_to_your/app-Dockerfile

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    - name: Set up Docker Buildx
      id: buildx
      uses: docker/setup-buildx-action@master

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build, tag, and push image to Amazon ECR
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
      run: |
        echo "::group::Build app image"
        docker buildx build --target web -t $ECR_REGISTRY/$ECR_APP:$IMAGE_TAG -f $DOCKERFILE_APP  --push path_to_your_app_context_root
        echo "::endgroup::"
        echo "::group::Build nginx image"
        docker buildx build -t $ECR_REGISTRY/$ECR_NGINX:$IMAGE_TAG -f $DOCKERFILE_NGINX  --push path_to_your_nginx_context_root
        echo "::endgroup::"

Terraformとの棲み分け

上記のようなCI/CDパイプラインを作った場合,ECSのTaskDefinitinとserviceはCI/CD側で更新をかけていくことになります.そこで,TerraformでのIaCとは次のような棲み分けが必要になります.

Terraformで管理:

  • ECR
  • ECS cluster
  • IAM role (Taskに付与するもの・Task実行時のもの)
  • セキュリティグループ(ECS serviceに付与するもの)
  • ロードバランサー・ターゲットグループ関連

ecs-cliが更新:

  • ECS TaskDefinition
  • ECS service

言うなれば,Terraformでは本当に箱だけを用意し,中身(コンテナ)はTerraformでは関与しないという思想です.

もし,TaskDefinitionとserviceもTerraformのresourceで定義してしまうと,CI/CDによってデプロイが走るたびにterraform側もドリフトしないよう修正を加える必要があり非常に手間です.

新規の環境構築もサクッと行える

逆に言えば上記のように管理を分けておけば,新しい環境を1セット構築したい場合にも

  1. terraform apply
  2. CI/CDを回す

という順序で何の問題もなく新規環境がサクッと作れます.実際に半日程度で本当にサクッと新しい検証環境を構築できました.この体験はとても気持ち良いですよね.

オートスケール設定を加えたい...

ここまでは大きな問題なく構成管理できるのですが,オートスケール設定をECS serviceに加えたいという場合に,悩ましい問題が発生してしまいました.

オートスケールは,ECS serviceに対して付与される設定です.

Automatic scaling is the ability to increase or decrease the desired count of tasks in your Amazon ECS service automatically. Amazon ECS leverages the Application Auto Scaling service to provide this functionality.
AWS docs

オートスケールの設定はTerraformに記述するのが好ましいかと思いますが,その定義は以下のようなもので,resource_idにECS service名称を記述する必要があります.

resource "aws_appautoscaling_target" "ecs_target" {
  max_capacity       = 4
  min_capacity       = 1
  resource_id        = "service/${aws_ecs_cluster.example.name}/${aws_ecs_service.example.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

そうなんです!前述の通りECS serviceはTerraformではresourceとして定義していないんです!

そっかー困ったなと思いつつも,以下のような方法が検討できます.

  1. ECS service名称はベタ書きする
  2. ECS serviceをdataで取ってくる
  3. やっぱりECS serviceもTerraformでresourceとして管理する
  4. そもそもオートスケールの設定をTerraformで管理しない

そして本当に困ったのが,3とか4の方法は現実的に選択したくないと思う一方で,1と2の方法はワークしなかったんです...

1. ECS service名称はベタ書きする → Applyエラー

ECS serviceの名称さえ自分たちのチームなりに命名規則を持っておけば,そこまでベタ書き(実際には var.service_nameとか)で書いても悪くはないと思うのですが,AWSのApplication Auto Scalingが存在しないリソースに対してオートスケール設定を作成できないことから,一番最初にTerraformをApplyするときに「そのようなECS serviceはない」というエラーでApplyが成功しません.(本質的には次と同じある種の循環参照問題)

2. ECS serviceをdataで取ってくる → 循環参照問題

以下のようにすれば,Terraformは矛盾なくPlan/Applyできるかとも思ったのですが

data "aws_ecs_service" "example" {
  service_name = "example"
  cluster_arn  = aws_ecs_cluster.example.arn
}

resource "aws_appautoscaling_target" "ecs_target" {
  max_capacity       = 4
  min_capacity       = 1
  resource_id        = "service/${aws_ecs_cluster.example.name}/${data.aws_ecs_service.example.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

こうすると,TerraformとCI/CD側で循環参照が生じてしまい,構築できないのです:

  • 初回のterraform plan時にすでにECS serviceが存在していないといけない
  • 初回のCI/CDを回す時にはECS clusterやTarget groupなどが存在していないといけない

→ どちらから始めても最初の実行が成功しない

もちろん,初回だけ目をつぶることはできる

方法1でも方法2でも,以下のような順序でコードを書いたり実行したりすれば最終的にエラーの起きない状態にはなります:

  1. まずはTerraformでオートスケール設定を除いたリソースを定義してApply
  2. CI/CDを回してecs-cliがECS TaskDefinition/serviceを作成
  3. その後,Terraform側のコードで方法1ないし2の実装を加える

これでもダメだとは言わないです.ただ,これだと例えば新しい検証環境を構築しようとした場合にこれらのコードを利用しても構築できないので,得てして後任の誰かがその罠にハマり困ってしまいます.

どうする

最初に考えたのは,方法2の「ECS serviceをdataで取ってくる」について,"data sourceで参照するリソースがなかったら,オートスケールを設定しない"というconditionalな記述ができないかという点でした.

そもそもTerraformは素直なif文は存在せずcountを用いてifを実現するというワークアラウンドを活用することになるのですが,いずれにしても以下のように書けないかと考えました.

data "aws_ecs_service" "example" {
  service_name = "example"
  cluster_arn  = aws_ecs_cluster.example.arn
}

# やりたかったこと
resource "aws_appautoscaling_target" "ecs_target" {
  # dataで指定したリソースが存在するなら1(このリソースも作る)
  count = data.aws_ecs_service.example.some_method_of_existence ? 1 : 0

  max_capacity       = 4
  min_capacity       = 1
  resource_id        = "service/${aws_ecs_cluster.example.name}/${data.aws_ecs_service.example.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

ですが,結論としてdata sourceには空を許容する機構はありませんでした.全く同じようなことを検討している方もいて,ユースケースとしても筋が通るんじゃないかとは思ったんですが,ないので諦めます.

https://github.com/hashicorp/terraform/issues/16380#issuecomment-355846367

Now, I could use a data block to pull in the latest version of my task definition. Then I can do what the ecs_task_definition docs says and use ${max(... to get the latest revision.
However, if I am creating the environment for the first time this is not possible. This does not work if you are creating the environment for the first time, but it will on subsequent runs: ...
It would appear that data blocks are only useful if the referenced resource was created outside the environment manually or by another Terraform configuration. I understand the logic of throwing an error if the resource does not exist as a default but I feel we need an option to allow it to return a null resource. A warning in the output seems like an acceptable middle ground instead.
Issueより抜粋

どうする?

これ,どうするのがいいのでしょうか...

もはやCI/CDとTerraformの棲み分けから見直して,CI(テストからイメージビルドとpushまで)をアプリケーション側で,CDやTerraformをプラットフォームエンジニアやSREといったチームのコードで管理するのがよかったのかもしれないと思いつつ,

ここまで来てしまったというサンクコストに負けてこのまま進めるために無理やり思いついたワークアラウンドを最後に書いて終わりにしたいと思います.

”まだECS servicerが作られていないなら”という条件をvarでセットする

さっきは「data sourceが空ならオートスケール設定は作らない」と意気込んでましたが,もうその条件部分を自分でvariableとして持ってしまおうという考えです.

# main.tf

variable "ecs_service" {
  type = object({
    already_created_via_ecs_cli = bool
    name                        = string
  })
}

# ----------------------------------------------------------------------
# Resources to be created after application repository created ecs service
# - Auto scaling

module "exclude_for_initial_creation" {
  count = var.ecs_service.already_created_via_ecs_cli ? 1 : 0
  source = "../shared/exclude-for-initial-creation"
  project_name = var.project_name
  environment = var.environment
  resource_id = "service/${aws_ecs_cluster.ecs_cluster.name}/${var.ecs_service.name}"
}

そして実装者がセットする変数を初回のTerraform plan時とECS serviceが出来上がったのちで以下のように変更させると

# terraform.auto.tfvars

# 一番最初のplan/apply時
ecs_service = {
  already_created_via_ecs_cli = false
  name                        = "_anything"
}

# ↓↓↓

# CI/CDでECS service作られたあと
ecs_service = {
  already_created_via_ecs_cli = true
  name                        = "your_service_name"
}

初回の構築も以後のインフラ管理も,一応行うことができます.

おわりに

もしより良い方法があったら,コメントいただけると嬉しいです

Discussion