📦

Amazon ECSでサービスを使わない場合のタスク定義をどう更新するか

に公開

はじめに

Amazon ECS Fargateでコンテナを動かしたい。Step Functionsのステートマシンで「Amazon ECS: RunTask」を使いたい。この場合、クラスターは作成するが、サービスは利用しない。そうするとタスク定義の更新はどうするの?という課題にぶつかった。

背景

Railsアプリで定期実行の処理をSidekiqで実装していたが、EventBridge SchedulerとStep Functionsの組み合わせで置き換えられることに気付いた。Sidekiqの場合はRedisとWorkerプロセスの常時稼働が必要だが、EventBridge Scheduler + Step Functions + ECS Fargateなら実行時のみ課金され、インフラの管理負荷も軽減できる。CPUやメモリがあまり必要ないメール送信などの軽めのバックグラウンドジョブはSolid Queueで、CPUやメモリが多めに必要な重い処理はEventBridge SchedulerとStep Functionsで実現したい。この構成であればステートマシンの実行単位でCloudWatch logsにログが書き込まれるためログも見やすくなるかと思い、着手しました。

ECSでサービスを使わない場合の課題

Amazon ECSでコンテナを動かす場合、

  • ECSのサービスを使う。常時稼働するWebアプリケーション向け。CodePipelineと連携すれば自動デプロイができる。
  • Step Functionsのステートマシンを使う。定期的に実行するタスク向け。EventBridge SchedulerでStep FunctionsのステートマシンのRunTaskから起動する。

などがあります。前者の場合、CodePipelineの「Amazon ECS 標準デプロイアクション」を使えば、タスク定義の更新からサービスの更新まで自動化されます。

一方、EventBridge Scheduler → Step Functions → Amazon ECS: RunTaskのような構成でバッチ処理を実行している場合、タスク定義の更新は自分で行う必要があります。

┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│   EventBridge    │───▶│  Step Functions  │───▶│    ECS Task      │
│    Scheduler     │    │    (RunTask)     │    │   (バッチ処理)    │
└──────────────────┘    └──────────────────┘    └──────────────────┘

                                │ タスク定義を参照

                        ┌──────────────────┐
                        │ Task Definition  │
                        │ (最新リビジョン)   │
                        └──────────────────┘

Step Functionsのステートマシンでは、タスク定義を指定してECSタスクを起動します。TaskDefinitionにはリビジョン番号を含めずに指定すると、最新のACTIVEリビジョンが自動的に使用されます。そのため、タスク定義を更新すれば、次回のスケジュール実行から新しいイメージが使われます。

ステートマシン定義(ASL)の例:

{
  "Type": "Task",
  "Resource": "arn:aws:states:::ecs:runTask.sync",
  "Parameters": {
    "LaunchType": "FARGATE",
    "Cluster": "arn:aws:ecs:ap-northeast-1:123456789012:cluster/my-cluster",
    "TaskDefinition": "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/my-batch-task"
  }
}

TaskDefinitionはRunTask APIの仕様としてfamily[:revision]またはfull ARNで指定可能です[1]。リビジョンを省略した場合は最新のACTIVEリビジョンが使用されます。本記事ではClusterがARN形式のため統一してARNを使用しています。上記の例ではTaskDefinitionにリビジョン番号を含めていないため(:1:10がない)、常に最新のACTIVEリビジョンが使用されます。


Run Fargate Taskの引数と出力タブの引数。クラスター(Cluster)とタスク定義(TaskDefinition)はあるがサービスの指定はない。

タスク定義内のimageの指定ではコミットハッシュをコンテナのイメージタグとして指定するため、タスク定義を更新する必要があります。コンテナイメージのビルドとしてCodeBuildを利用していたため、方法として思いついたのは、CodeBuildのbuildspec.ymlのpost_buildでコンテナイメージをECRにプッシュした後に既存のタスク定義を取得してimageを差し替える方法です。

最初に試した方法: 既存のタスク定義を取得してimageのイメージタグを差し替える

実装

# buildspec.yml
phases:
  ...(省略)...
  post_build:
    commands:
      - echo Pushing the Docker image...
      - docker push $REPOSITORY_URI:$IMAGE_TAG
      - echo Registering new task definition...
      - TASK_FAMILY=my-batch-task
      - TASK_DEFINITION=$(aws ecs describe-task-definition --task-definition "$TASK_FAMILY")
      - NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq --arg IMAGE "$REPOSITORY_URI:$IMAGE_TAG" \
          '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)')
      - aws ecs register-task-definition --cli-input-json "$NEW_TASK_DEFINITION"

$IMAGE_TAGはpre_build中に取得)

仕組み

  1. describe-task-definitionで現在のタスク定義を取得
  2. jqimageだけを新しいものに差し替え(.containerDefinitions[0].image = $IMAGEの部分)
  3. 不要なフィールド(taskDefinitionArnなど。これらがあるとregister-task-definitionでエラーとなる)を削除
  4. register-task-definitionで新しいリビジョンとして登録

この方法でも特に問題なくタスクが更新されてコンテナタスクは動作していたのですが、タスク定義をコード管理していませんでした。そこで他のリソースとともにTerraformでタスク定義を管理しようと思ったのですが断念しました。

採用した方法: タスク定義をテンプレートにしてbuildspec.ymlと一緒にGit管理し、imageのイメージタグを差し替える

ここまでの構成でCodeBuildがイメージのビルドとタスク定義の登録を行うためシンプルなフローになっています。タスク定義の更新を行っているのはbuildspec.ymlなのでTerraformが管理するインフラ側ではなく、アプリ側のコードベースでタスク定義を管理することにしました。

タスク定義をJSONファイルとしてリポジトリに含め、ビルド時にイメージタグを埋め込む方式です。

全体の流れ

┌─────────────────────────────────────────────────┐
│                Git Repository                   │
│     ┌────────────────┐  ┌────────────────┐      │
│     │  taskdef.json  │  │  buildspec.yml │      │
│     │  (テンプレート)  │  │                │      │
│     └───────┬────────┘  └────────┬───────┘      │
└─────────────┼────────────────────┼──────────────┘
              │                    │
              ▼                    ▼
┌────────────────────────────────────────────────┐
│                 CodeBuild                      │
│    1. taskdef.jsonのプレースホルダーをsedで置換    │
│    2. aws ecs register-task-definitionで登録    │
└────────────────────────────────────────────────┘


┌────────────────────────────────────────────────┐
│             ECS Task Definition                │
│           新しいリビジョンが登録される              │
└────────────────────────────────────────────────┘

実装

taskdef.json(タスク定義テンプレート)

{
  "containerDefinitions": [
    {
      "name": "my-app",
      "image": "${REPOSITORY_URI}:${IMAGE_TAG}",
      "cpu": 0,
      "essential": true,
      "environment": [],
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "/my-app/db/password"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/my-app/batch",
          "awslogs-create-group": "true",
          "awslogs-region": "${AWS_DEFAULT_REGION}",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ],
  "family": "my-batch-task",
  "executionRoleArn": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/ECS-TaskExecutionRole",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "2048"
}

上記テンプレートでは${REPOSITORY_URI}${IMAGE_TAG}${AWS_DEFAULT_REGION}${AWS_ACCOUNT_ID}をプレースホルダーとして使用しています。これらはCodeBuildの環境変数から注入されるため、環境ごとにtaskdef.jsonを変更する必要がありません。

buildspec.yml

version: 0.2

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws --version
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - IMAGE_TAG=${COMMIT_HASH:=latest}
  build:
    commands:
      - echo Building the Docker image...
      - docker build -t $REPOSITORY_URI:$IMAGE_TAG .
  post_build:
    commands:
      - echo Pushing the Docker image...
      - docker push $REPOSITORY_URI:$IMAGE_TAG
      - echo Registering new task definition...
      - |
        sed -e "s#\${REPOSITORY_URI}#$REPOSITORY_URI#g" \
            -e "s#\${IMAGE_TAG}#$IMAGE_TAG#g" \
            -e "s#\${AWS_DEFAULT_REGION}#$AWS_DEFAULT_REGION#g" \
            -e "s#\${AWS_ACCOUNT_ID}#$AWS_ACCOUNT_ID#g" \
            taskdef.json > taskdef_rendered.json
      - aws ecs register-task-definition --region "$AWS_DEFAULT_REGION" --cli-input-json file://taskdef_rendered.json
      - echo "Registered task definition:"
      - aws ecs describe-task-definition --task-definition my-batch-task --query 'taskDefinition.taskDefinitionArn' --output text
      - echo Writing image definitions file...
      - printf '[{"name":"my-app","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
artifacts:
  files: imagedefinitions.json

まとめ

  • ECSサービスがないとCodePipelineの標準アクションが使えないため、タスク定義の更新は自前で行う
  • タスク定義のTerraformでの管理はCI/CDとの相性が悪い(イメージタグが動的に決まるため)
  • taskdef.jsonをGit管理 + sedでの置換がシンプルで実用的

CodePipelineおよびCodeBuild自体の構成管理はECSクラスターやIAMロール、ネットワークなどと一緒にTerraform側で、buildspec.ymlとtaskdef.jsonはアプリ側で管理することで役割が分かれました。

例えば、パラメータストアに新しいシークレットを追加する場合、シークレットを使うようにアプリの改修もあるため、

  1. アプリ側のリリース前にTerraform側でaws_ssm_parameterリソースを追加してapply
  2. アプリ側でtaskdef.jsonsecretsに項目を追加してコミット
  3. CodePipelineが実行され、新しいタスク定義が登録される

このように、インフラの変更とアプリの変更が明確に分離されます。シンプルな環境ではこれでいいのではないかと思います。

脚注
  1. https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_RunTask.html ↩︎

  2. https://docs.aws.amazon.com/ja_jp/linux/al2023/release-notes/all-packages-AL2023.9.html ↩︎

Discussion