ecs-cliとTerraformを使ってECSを管理すると循環参照を起こす問題について
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セット構築したい場合にも
- terraform apply
- 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として定義していないんです!
そっかー困ったなと思いつつも,以下のような方法が検討できます.
- ECS service名称はベタ書きする
- ECS serviceを
data
で取ってくる - やっぱりECS serviceもTerraformで
resource
として管理する - そもそもオートスケールの設定をTerraformで管理しない
そして本当に困ったのが,3とか4の方法は現実的に選択したくないと思う一方で,1と2の方法はワークしなかったんです...
1. ECS service名称はベタ書きする → Applyエラー
ECS serviceの名称さえ自分たちのチームなりに命名規則を持っておけば,そこまでベタ書き(実際には var.service_name
とか)で書いても悪くはないと思うのですが,AWSのApplication Auto Scalingが存在しないリソースに対してオートスケール設定を作成できないことから,一番最初にTerraformをApplyするときに「そのようなECS serviceはない」というエラーでApplyが成功しません.(本質的には次と同じある種の循環参照問題)
data
で取ってくる → 循環参照問題
2. ECS serviceを以下のようにすれば,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でも,以下のような順序でコードを書いたり実行したりすれば最終的にエラーの起きない状態にはなります:
- まずはTerraformでオートスケール設定を除いたリソースを定義してApply
- CI/CDを回してecs-cliがECS TaskDefinition/serviceを作成
- その後,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には空を許容する機構はありませんでした.全く同じようなことを検討している方もいて,ユースケースとしても筋が通るんじゃないかとは思ったんですが,ないので諦めます.
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