今回、既存のECSデプロイ方法をTerraformベースに整理する中で、かなりいろいろハマりました。
最初は単純に「GitHub ActionsでDockerイメージをビルドしてECSにデプロイすればいいだろう」と考えていたのですが、実際にやってみると次のような概念が一気に絡んできました。
GitHub Actions
Docker build
Amazon ECR
Amazon ECS
Terraform
Cloudflare Provider
AWS Secrets Manager
GitHub Actions 環境変数
IAM 権限
YAML のインデント
一つひとつを見るとそこまで難しくないのですが、これらがまとめて絡むとかなり混乱します。
特に terraform.tfvars、Cloudflare API Token、Secrets Manager、GitHub Actionsの環境変数名の変換あたりでかなり時間を使いました。
この記事は、自分が理解できずにハマった部分を最初から最後まで整理した記録です。
1. 既存の方法: task-definition.json ベースのECSデプロイ
最初のGitHub Actionsは、おおよそ次のような構成でした。
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: example
image: ${{ steps.login-ecr.outputs.registry }}/teamspace:${{ github.sha }}
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: example1-service-049qfqjg
cluster: favorable-hippopotamus-akjx0r22
wait-for-service-stability: true
この方法は、リポジトリのルートにある task-definition.json をもとにECS Task Definitionをレンダリングし、そのままデプロイする構成です。
流れとしては次のようになります。
task-definition.json
→ GitHub Actionsでimageの値を差し替え
→ ECS Task Definitionを登録
→ ECS Serviceへデプロイ
この方法自体が間違っているわけではありません。
ただし、ECSをTerraformで管理することにした場合は話が変わります。
2. Terraformに移行すると task-definition.json は基本的に不要になる
TerraformでECS Task Definitionを次のように管理している場合を考えます。
resource "aws_ecs_task_definition" "app" {
family = "teamspace"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
container_definitions = jsonencode([
{
name = "app"
image = var.container_image
}
])
}
この場合、ECS Task Definitionの元になる定義は task-definition.json ではなくTerraformコードです。
つまり、管理主体が変わります。
以前はこうでした。
task-definition.json が Task Definition の元定義
Terraform移行後はこうなります。
Terraformコードが Task Definition の元定義
そのため、GitHub Actionsで次のActionを使う必要はなくなります。
aws-actions/amazon-ecs-render-task-definition
aws-actions/amazon-ecs-deploy-task-definition
代わりにGitHub Actionsでは、DockerイメージをビルドしてECRにpushし、新しいイメージURIだけをTerraformに渡す形にします。
3. 最終的なデプロイの流れ
Terraformベースのデプロイフローは次のようになります。
GitHubにpush
→ GitHub Actionsが実行される
→ Dockerイメージをビルド
→ Amazon ECRにpush
→ 新しいイメージURIを生成
→ terraform applyを実行
→ ECS Task Definitionの新しいrevisionを作成
→ ECS Serviceが新しいTask Definitionでデプロイされる
つまり、GitHub ActionsがECSへ直接デプロイするのではなく、Terraformを実行してECSの変更を反映する形になります。
4. container_image 変数の役割
最初はTerraform側にイメージURIを直接書いていました。
variable "container_image" {
description = "ECR image URI"
type = string
default = "990678687582.dkr.ecr.ap-northeast-2.amazonaws.com/teamspace:77f22fd..."
}
このように書いてしまうと、イメージが変わるたびにTerraformコードを直接修正する必要があります。
これではCI/CDを使う意味が薄くなってしまいます。
そこで default を削除しました。
variable "container_image" {
description = "ECR image URI"
type = string
}
そしてECS Task Definitionではこの変数を使います。
container_definitions = jsonencode([
{
name = "app"
image = var.container_image
}
])
これでGitHub Actionsから、毎回新しいイメージURIをTerraformに渡せるようになります。
terraform apply -auto-approve \
-var="container_image=$IMAGE_URI"
こうすることで、コミットごとに新しいイメージタグが作られ、そのイメージがECSに反映されます。
5. task_definition = aws_ecs_task_definition.app.arn の意味が分かりにくかった
ECS Serviceには次のようなコードがあります。
resource "aws_ecs_service" "app" {
task_definition = aws_ecs_task_definition.app.arn
}
最初はこれが何を意味しているのか、少し分かりにくく感じました。
整理すると次のようになります。
aws_ecs_task_definition.app
= Terraformで作成したECS Task Definitionリソース
.arn
= そのTask DefinitionのAWS ARN
ECSではTask Definitionをrevision単位で管理します。
例えば次のような形です。
teamspace:17
teamspace:18
teamspace:19
イメージが変わると、Terraformは新しいTask Definition revisionを作成します。
そしてECS Serviceが次の値を参照している場合、
task_definition = aws_ecs_task_definition.app.arn
新しいrevisionを使ってサービスが再デプロイされます。
つまり、このコードは次の意味になります。
ECS Serviceが、Terraformで作成した最新のTask Definition revisionを使うように紐づける。
6. GitHub Actionsの id: login-ecr も分かりにくかった
GitHub Actionsには次のようなコードがあります。
- name: Log in to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
ここでの id: login-ecr は、このstepにラベルを付けるためのものです。
このラベルがあることで、後続のstepからそのstepのoutputを参照できます。
${{ steps.login-ecr.outputs.registry }}
この値はECRのレジストリURLです。
例えば次のような値になります。
990678687582.dkr.ecr.ap-northeast-2.amazonaws.com
そのため、Dockerイメージを作成するときに次のように使えます。
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
最終的なイメージURIは次のような形になります。
990678687582.dkr.ecr.ap-northeast-2.amazonaws.com/teamspace:コミットSHA
7. env はstepごとにスコープが異なる
最初に混乱したポイントの一つがこれです。
- name: Build, tag, and push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: teamspace
IMAGE_TAG: ${{ github.sha }}
ここで定義した env は、そのstepの中でのみ有効です。
つまり、次のstepでは使えません。
そのため、Terraformを実行するstepでも同じ値を再度定義する必要がありました。
これを減らすには、共通の値をjobレベルの env に上げるのが良いです。
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: teamspace
IMAGE_TAG: ${{ github.sha }}
こうすると、すべてのstepから参照できます。
ただし、ECR_REGISTRY は login-ecr stepが実行された後に得られる値なので、stepレベルの env に置くのが自然です。
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
8. terraform apply -auto-approve の意味
GitHub ActionsでTerraformを実行する場合、よく次のように書きます。
terraform apply -auto-approve \
-var="container_image=$IMAGE_URI"
ここでの -auto-approve は、Terraformが途中で求める確認を省略するオプションです。
ローカルで terraform apply を実行すると、通常は次のように確認されます。
Do you want to perform these actions?
Only 'yes' will be accepted to approve.
Enter a value:
ローカルなら人間が yes と入力できますが、GitHub Actionsでは人が入力できません。
そのためCI/CD環境では -auto-approve を付けて、自動で承認するようにします。
9. terraform.tfvars をGitHubに上げてはいけない理由
Terraformをローカルで使う場合、一般的には terraform.tfvars に値を書きます。
例えば次のような内容です。
cloudflare_api_token = "実際のトークン"
db_password = "実際のDBパスワード"
しかし、このようなファイルをGitHubに上げてはいけません。
特に次のような値は、絶対にGitへ上げないようにする必要があります。
AWS Secret Access Key
Cloudflare API Token
DB Password
JWT Secret
Private Key
そのため通常は、
terraform.tfvars
を .gitignore に追加し、GitHubには上げません。
代わりに、サンプルファイルだけを置きます。
terraform.tfvars.example
サンプルは次のようにしておけます。
cloudflare_zone_id = "CHANGE_ME"
ただし今回は、そもそもCI/CDで tfvars を意識しなくてよい構成にしたいと考えました。
10. Cloudflare API Tokenをどこに置くか
最初はCloudflare API TokenをTerraform variableとして受け取る構成でした。
variable "cloudflare_api_token" {
type = string
sensitive = true
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
この方法でも動きます。
GitHub Actionsでは次のように渡せます。
env:
TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Terraformは TF_VAR_変数名 という形式の環境変数を、自動的にTerraform variableとして認識します。
つまり次のような対応になります。
TF_VAR_cloudflare_api_token
→ var.cloudflare_api_token
ただし、Cloudflare Providerは専用の環境変数を直接読み取ることができます。
Cloudflare Providerが期待する環境変数名は次のとおりです。
CLOUDFLARE_API_TOKEN
そのため、Terraformコードは次のようにシンプルにできます。
provider "cloudflare" {}
この場合、Terraform variableも不要です。
最終的には次の構成を目指しました。
GitHub Actionsの実行環境に CLOUDFLARE_API_TOKEN が存在していれば、
Cloudflare Providerが自動的に読み取る。
11. Cloudflare API Token は必ず大文字の環境変数名にする
重要なのは、環境変数名を正確に合わせることです。
正しい名前は次です。
CLOUDFLARE_API_TOKEN
正しくない例は次です。
cloudflare_api_token
Cloudflare Providerが自動で探す名前は、正確に CLOUDFLARE_API_TOKEN です。
そのため、Secrets ManagerやGitHub Actions側で、最終的にこの名前の環境変数が作られる必要があります。
12. AWS Secrets ManagerにCloudflare Tokenを保存する
GitHub SecretsにCloudflare Tokenを入れる代わりに、AWS Secrets Managerへ保存することにしました。
例えばSecret名は次のように作成しました。
terraSecret
そしてSecretの値はJSON形式で保存しました。
最初は次のような形でした。
{
"CLOUDFLARE_API_TOKEN": "実際のトークン",
"SECRETS_ARN": "arn:aws:secretsmanager:..."
}
次に、GitHub ActionsからAWS Secrets Managerの値を読み取る必要がありました。
13. GitHub ActionsでSecrets Managerを読む2つの方法
方法1. AWS CLIで直接読む
最初は次のような方法を検討しました。
- name: Get secrets from AWS Secrets Manager
run: |
SECRET_JSON=$(aws secretsmanager get-secret-value \
--secret-id terraSecret \
--query SecretString \
--output text)
echo "CLOUDFLARE_API_TOKEN=$(echo "$SECRET_JSON" | jq -r '.CLOUDFLARE_API_TOKEN')" >> $GITHUB_ENV
この方法でも動作します。
ただし、初めて見ると次のような構文が少し分かりにくいです。
SECRET_JSON=$(...)
jq -r
>> $GITHUB_ENV
それぞれの意味は次のとおりです。
SECRET_JSON=$(...)
= コマンドの実行結果をSECRET_JSON変数に保存する
jq -r '.CLOUDFLARE_API_TOKEN'
= JSONからCLOUDFLARE_API_TOKENの値だけを取り出す
>> $GITHUB_ENV
= 後続のGitHub Actions stepでも使えるように環境変数として保存する
動きはしますが、やや読みづらいと感じました。
方法2. 公式GitHub Actionを使う
そこで、よりシンプルな方法として公式Actionを使いました。
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
terraSecret
parse-json-secrets: true
ここでの with は、そのActionに渡すオプションです。
with:
secret-ids: |
terraSecret
parse-json-secrets: true
それぞれの意味は次のとおりです。
secret-ids
= どのSecrets Manager secretを読むかを指定する
parse-json-secrets: true
= Secretの値がJSONの場合、keyを環境変数として自動変換する
例えばSecrets Managerの値が次のようなJSONだった場合、
{
"CLOUDFLARE_API_TOKEN": "xxxxx"
}
GitHub Actionsの環境変数として自動的に作成されます。
14. IAM権限が原因でAccessDeniedが発生した
最初にSecrets Managerを読み取ろうとしたとき、次のようなエラーが出ました。
AccessDeniedException:
User: arn:aws:iam::990678687582:user/github-actions-user
is not authorized to perform: secretsmanager:GetSecretValue
原因は明確でした。
GitHub Actionsで使っているIAM Userである github-actions-user に、Secrets Managerを読み取る権限がありませんでした。
解決するには、IAM Userに次の権限を追加する必要があります。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:ap-northeast-2:990678687582:secret:terraSecret-*"
}
]
}
ここで重要なのは、Resource の末尾に * を付けることです。
Secrets ManagerのARNには、実際には次のようなランダムなsuffixが付きます。
terraSecret-AbCdEf
そのため、ポリシーでは次のように指定しておくのが安全です。
terraSecret-*
15. Create inline policy が見つからず、また迷った
AWSコンソールでIAM Userに権限を追加しようとしたところ、Create inline policy ボタンが見つかりませんでした。
この場合は、先にポリシーを作成してからユーザーにアタッチすれば問題ありません。
流れは次のとおりです。
IAM
→ Policies
→ Create policy
→ JSONにポリシーを入力
→ ポリシーを作成
→ Users
→ github-actions-user
→ Permissions
→ Add permissions
→ Attach policies directly
→ 作成したポリシーを選択
つまり、必ずしもinline policyである必要はありません。
別途IAM Policyを作成し、それをIAM Userにattachしても問題ありません。
16. Secrets Managerの値は読めたが、環境変数名が想定と違っていた
権限問題を解決して再実行すると、次のような値が出力されました。
TERRASECRET_CLOUDFLARE_API_TOKEN=***
TERRASECRET_SECRETS_ARN=***
最初は成功したように見えましたが、Terraformは依然としてCloudflare tokenを見つけられませんでした。
理由は単純です。
Cloudflare Providerが探している名前は次です。
CLOUDFLARE_API_TOKEN
しかし、実際に作られた名前は次でした。
TERRASECRET_CLOUDFLARE_API_TOKEN
つまり値自体は存在していましたが、名前が違うため見つけられなかったのです。
17. なぜ TERRASECRET_ というprefixが付いたのか
aws-actions/aws-secretsmanager-get-secrets@v2 Actionは、環境変数名の衝突を避けるため、デフォルトでsecret名をprefixとして付けることがあります。
つまり、
Secret名: terraSecret
Key名: CLOUDFLARE_API_TOKEN
の場合、結果が次のようになることがあります。
TERRASECRET_CLOUDFLARE_API_TOKEN
この挙動をデバッグしていなければ、かなり長くハマっていたと思います。
18. 環境変数のデバッグ方法
確認のために、次のstepを追加しました。
- name: Debug secrets
run: |
env | grep CLOUDFLARE
env は現在の環境変数をすべて出力します。
env
grep CLOUDFLARE は、その中から CLOUDFLARE を含む行だけを絞り込みます。
grep CLOUDFLARE
つまり、全体としては次の意味になります。
env | grep CLOUDFLARE
現在の環境変数のうち、CLOUDFLAREを含むものだけを表示する。
結果は次のようになりました。
TERRASECRET_CLOUDFLARE_API_TOKEN=***
このデバッグのおかげで、なぜTerraformがtokenを見つけられないのか分かりました。
ただし、実際のsecret値をログに出すのは危険です。
本番では、次のように名前だけを確認する方法のほうが安全です。
- name: Check Cloudflare env name
run: |
env | grep CLOUDFLARE | cut -d= -f1
19. 期待する環境変数名で作成する
Cloudflare Providerに自動認識させるには、最終的な環境変数名を必ず次にする必要があります。
CLOUDFLARE_API_TOKEN
そのために、aws-secretsmanager-get-secrets Actionでaliasを使います。
例えばGitHub Actionsを次のように書きます。
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
CLOUDFLARE,terraSecret
parse-json-secrets: true
そしてSecrets Managerの値は次のようにします。
{
"API_TOKEN": "実際のトークン"
}
すると、環境変数は次の名前で作成されます。
CLOUDFLARE_API_TOKEN
構造としては次のようになります。
Alias: CLOUDFLARE
Secret JSON Key: API_TOKEN
結果:
CLOUDFLARE_API_TOKEN
最初のようにSecret JSON keyを CLOUDFLARE_API_TOKEN にしていると、結果が次のようになる可能性があります。
CLOUDFLARE_CLOUDFLARE_API_TOKEN
そのため、aliasを使う場合はSecret JSON keyを API_TOKEN にしておくのがすっきりします。
20. secrets_arn は秘密情報ではない
Terraformには次のような値もありました。
secrets_arn = "arn:aws:secretsmanager:ap-northeast-2:990678687582:secret:teamspace-AMbgCS"
この値も秘密情報として管理すべきか少し迷いました。
結論として、Secret ARNは通常は秘密情報ではありません。
Secret ARNは「秘密の場所」を表すものです。
実際の秘密情報そのものではありません。
例えるなら次のような関係です。
Secret ARN
= 金庫の住所
Secret値
= 金庫の中身
住所を知っていても、権限がなければ中身は読めません。
そのため、TerraformコードにSecret ARNを書くこと自体は一般的には問題ありません。
ただしpublic repositoryの場合、AWS Account IDやリソース名の構造が見える可能性はあるため注意は必要です。
21. Cloudflare zone_id も通常は秘密情報ではない
Cloudflare DNS recordをTerraformで作成するとき、次のようなコードがありました。
resource "cloudflare_record" "app" {
zone_id = var.cloudflare_zone_id
name = "@"
type = "CNAME"
content = aws_lb.main.dns_name
proxied = true
ttl = 1
}
ここでの zone_id も秘密情報として扱うべきか悩みました。
結論として、zone_id は通常は秘密情報ではありません。
秘密情報として扱うべきなのはAPI Tokenです。
Cloudflare API Token
= 非常に重要
Cloudflare Zone ID
= 識別子
そのため、zone_id はTerraform variableのdefaultとして置いても問題ないことが多いです。
variable "cloudflare_zone_id" {
type = string
default = "実際の_zone_id"
}
環境ごとにzoneが異なる場合は、TF_VAR_cloudflare_zone_id として注入してもよいです。
ただし単一環境だけで使うなら、コードに置いたほうがシンプルです。
22. Terraformで var.cloudflare_api_token の入力待ちになった
Terraform実行中に、次のように止まってしまいました。
var.cloudflare_api_token
これは、Terraformが cloudflare_api_token 変数の値を要求しているという意味です。
つまり、Terraformコードのどこかにまだ次のコードが残っていたということです。
variable "cloudflare_api_token" {
type = string
sensitive = true
}
または、providerが次のようになっていた可能性が高いです。
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
しかし今回は、Cloudflare Providerに環境変数 CLOUDFLARE_API_TOKEN を直接読ませる構成にすることにしました。
そのため、Terraformコードは次のように変更する必要があります。
provider "cloudflare" {}
そして、もう使わない変数定義は削除します。
variable "cloudflare_api_token" {
type = string
sensitive = true
}
最終的な構成は次のようになります。
GitHub Actionsの環境変数:
CLOUDFLARE_API_TOKEN
Terraform:
provider "cloudflare" {}
こうすることで、Terraformが変数入力を求めて止まることはなくなります。
23. YAMLのインデントがかなり厳しかった
GitHub ActionsはYAMLベースなので、インデントにとても敏感です。
特に分かりにくかったのは次の構造です。
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
CLOUDFLARE,terraSecret
parse-json-secrets: true
この構造では、それぞれの階層は次のようになります。
- name
uses
with
secret-ids
CLOUDFLARE,terraSecret
parse-json-secrets
注意点は次のとおりです。
タブは使わない
スペースを使う
基本は2スペースのインデント
withはusesと同じ階層
secret-idsはwithの中
複数行の値はsecret-idsよりさらに1段深くインデントする
YAMLはインデントが一つずれるだけで、次のようなエラーになります。
You have an error in your yaml syntax
しかも、どこが間違っているのかを親切に教えてくれないことが多いです。
24. 最終的なGitHub Actions構成
整理すると、最終的なGitHub Actionsはおおよそ次のような形になります。
name: Deploy to ECS
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: teamspace
IMAGE_TAG: ${{ github.sha }}
steps:
# 1. GitHubリポジトリのコードを取得
- name: Checkout code
uses: actions/checkout@v4
# 2. AWS認証情報を設定
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
# 3. Amazon ECRにログイン
- name: Log in to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
# 4. DockerイメージをビルドしてECRへpush
- name: Build, tag, and push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "Build image: $IMAGE_URI"
docker build -t $IMAGE_URI .
docker push $IMAGE_URI
# 5. AWS Secrets ManagerからCloudflare tokenを取得
# terraSecretの中身は { "API_TOKEN": "..." } の形式で保存しておく
# 最終的な環境変数名は CLOUDFLARE_API_TOKEN になる
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
CLOUDFLARE,terraSecret
parse-json-secrets: true
# 6. デバッグ用: 環境変数名だけを確認
# デプロイが安定したら削除してもよい
- name: Check Cloudflare env
run: |
env | grep CLOUDFLARE | cut -d= -f1
# 7. Terraformをセットアップ
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
# 8. Terraformを実行
- name: Terraform Apply
working-directory: ./infra
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "Deploy image: $IMAGE_URI"
terraform init
terraform apply -auto-approve \
-var="container_image=$IMAGE_URI"