Terraform/Terragrunt の自動 plan・apply ができるまで ① 〜plan 編〜
本記事は SimpleForm Advent Calendar 2024 の 8 日目の記事です。
はじめに
こんにちは、シンプルフォームでインフラエンジニアをやっている入江 純 (@jirtosterone) です。
シンプルフォームでは、Terraform/Terragrunt を使ってインフラを IaC 化しています。Terragrunt って何ぞ?と言う方は弊社山岸さん (@yamagishihrd) のブログをご参照ください。
IaC 化はできているものの、リソースのデプロイは個人任せになっていました。その状況を改善すべく、自動 apply
を実現する物語の第一章です。
自動 apply
の前に、まずは自動 plan
で差分表示を実現しました。本記事では、その実現までの道のりと苦難をご紹介します。
なお、これら CI/CD は GitHub, GitHub Actions が前提となっています。その他のコードベースは必要に応じて読み替えてください。
対象読者
- Terraform/Terragrunt でインフラ管理されている方
- Terragrunt の CI/CD を運用・検討されている方
結論
時間が無い方のために結論から。
- PR 作成時に修正対象のみ自動
plan
を行うようにした。 - Terraform ファイルのみが更新された時に対応する
terragrunt.hcl
が分かるように、あらかじめマッピングデータを保存しておくようにした。 - 依存先のリソースが
apply
されていない時にエラーが発生するのは仕方ないので、そのエラーは許容するようにした。 - ただし、定義に誤りが無いにも関わらずエラーが発生するのは望ましくないため、必ず正常終了するようにした。
- 正常終了はするものの、
plan
でエラーが発生していたら分かるようにした。
実現方針
シンプルフォームでは、アプリケーションは GitHub Flow により運用されています。そのため、インフラもそれに従おうと以下を実現しようと考えました。
- PR 作成時に自動で
plan
を行い、開発者が差分を確認できるようにする - main ブランチマージ時に staging 環境に自動
apply
してデプロイを行う - リリース時に production 環境に自動
apply
してデプロイを行う
第一段階として、自動 plan
を GitHub Actions にて実装します。
課題と対策
自動 plan
を実装しようとした時、2 つの大きな壁にぶつかりました。その壁 (課題) と対策について示します。
terragrunt.hcl
を plan
すれば良いかが分からない
Terraform ファイルのみが修正された時にどの 何が問題なのか?
Terragrunt にて IaC を実現している方のほとんどが同じだと思いますが、プロジェクトのディレクトリ構造は以下のようになっています (実際にはもう少し複雑ですが簡略化しています)。
project-root
├── terragrunt.hcl # 全体で共通するルート設定
├── envs # 環境ごとの個別パラメータを定義した Terragrunt ファイルを格納
│ ├── prod # 本番環境 (production)
│ │ ├── ecs
│ │ │ └── app1
│ │ │ └── terragrunt.hcl
│ │ ├── rds
│ │ │ └── terragrunt.hcl
│ │ └── alb
│ │ └── terragrunt.hcl
│ └── stg # 検証環境 (staging)
│ ├── ecs
│ │ └── app2
│ │ └── terragrunt.hcl
│ └── rds
│ └── terragrunt.hcl
└── modules # 複数の環境で参照する Terraform ファイルを格納
├── ecs
│ ├── default1
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── default2
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── rds
│ └── default
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── alb
└── default
├── main.tf
├── variables.tf
└── outputs.tf
envs ディレクトリ配下の prod/ecs/app1/terragrunt.hcl
と stg/ecs/app2/terragrunt.hcl
では、以下のように参照しているものとします。
terraform {
source = "${dirname(find_in_parent_folders())}/modules/ecs//default1"
}
terraform {
source = "${dirname(find_in_parent_folders())}/modules/ecs//default2"
}
この状況で例えば modules/ecs/default1/main.tf
を修正した時、どの terragrunt.hcl
に対して plan
を実行すれば良いかが分かリません。
また、 prod
配下には alb
ディレクトリがありますが、 stg
配下にはありません。この時、やはり modules/alb/default/main.tf
を修正した時にどの terragrunt.hcl
に対して plan
を実行すれば良いかが分かりません。
全ての環境で対象であるのが望ましいですが、開発やコストの都合により必ずしもそれができるとは限りません。これが一つ目の問題です。
plan
すれば良いのでは?
全てのディレクトリで この問題解決のため、元々は plan
も apply
も run-all
を指定して全ディレクトリに対して実行すれば良いと考えていました。しかし、その場合修正対象とは全く関係ない箇所で意図しない変更が適用されてしまう可能性がありました。
理想としては、 修正対象のみ plan
, apply
できるようにしてそれ以外には影響のないようにしたかった のです。
どうやって解決したか?
全ての terragrunt.hcl
ファイルと Terraform ファイルとのマッピングを作成しました。作成したマッピングデータは DynamoDB に保存して、 plan
時に参照して対応する terragrunt.hcl
に対して plan
するようにしました。
同時に複数の修正が含まれており、かつ依存関係がある時に、依存している方が必ずエラーになる
何が問題か?
例えば、以下のような定義があったとします。
terraform {
source = "${dirname(find_in_parent_folders())}/modules/ecs//default1"
}
terraform {
source = "${dirname(find_in_parent_folders())}/modules/alb//default"
}
# app1 への依存 (参照)
dependency "app1" {
config_path = "${dirname(find_in_parent_folders())}/envs/prod/ecs/app1"
}
ある PR にて app1 と alb の両方を新規に追加しようとした時、 alb は app1 を参照しているため、 app1 が apply
されていないと alb での plan
は参照するリソースが存在しないとのエラーになってしまいます。
どうやって解決したか?
PR 作成時に apply
するわけにもいかないので、このエラーはどうしようもありません。
依存する terragrunt.hcl
(今回の場合だと alb の terragrunt.hcl
) に mock を定義する等すればエラーは回避できますが、 terragrunt.hcl
に余計な定義は追加したくありませんでしたし、それにより定義漏れに気付けない弊害もあります。
そのため、このエラーについては発生するのを許容して「できれば修正を別 PR に分けてね」というメッセージに留めることとしました。
GitHub Actions の全体像
かなりサラッと書いてますが、実現にはそれなりの実装を行いました。
本記事では、GitHub Actions の構成とワークフローを記載することとし、terragrunt.hcl
ファイルと Terraform ファイルとのマッピングについては別記事にて紹介したいと思います。
.github
├── actions
│ ├── terragrunt
│ │ └── plan
│ │ └── action.yml # 4. terragrunt plan を実行する action
│ └── tf_module_mapper
| ... (本記事では割愛、別記事で紹介します) ...
└── workflows
├── terragrunt-plan-diff-prod.yml # 1. prod 環境に対する自動 plan
├── terragrunt-plan-diff-stg.yml # 2. stg 環境に対する自動 plan
└── _plan_terragrunt_diff.yml # 3. 自動 plan 処理の本体となるワークフロー
plan
1. prod 環境に対する自動 name: "[PROD] Terragrunt plan"
on:
pull_request:
paths:
- "envs/prod/**"
- "modules/**"
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
jobs:
checkout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
plan:
needs:
- checkout
uses: ./.github/workflows/_plan_terragrunt_diff.yml
with:
env: "prod"
plan
2. stg 環境に対する自動 name: "[STG] Terragrunt plan"
on:
pull_request:
paths:
- "envs/stg/**"
- "modules/**"
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
jobs:
checkout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
plan:
needs:
- checkout
uses: ./.github/workflows/_plan_terragrunt_diff.yml
with:
env: "stg"
plan
処理の本体となるワークフロー
3. 自動 name: Plan terragrunt with diff files (Reuseable workflow)
on:
workflow_call:
inputs:
env:
required: true
type: string
env:
TF_VERSION: "1.x.x"
TG_VERSION: "0.xx.xx"
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
jobs:
# plan 前の準備
prepare:
runs-on: ubuntu-latest
outputs:
aws_role_arn: ${{ steps.set_values.outputs.aws_role_arn }}
diff_envs: ${{ steps.align_diffs.outputs.diff_envs }}
diff_modules: ${{ steps.get_hcl_by_modules.outputs.found_paths }}
steps:
# AWS 環境の情報をセットする
- id: set_values
run: |
declare -A account_ids
account_ids=(
["prod"]="123456789012"
["stg"]="112233445566"
)
declare -A account_names
account_names=(
["prod"]="product-prod"
["stg"]="product-stg"
)
echo "envs_dir=${{ inputs.env }}" >> "$GITHUB_OUTPUT"
echo "aws_role_arn=arn:aws:iam::${account_ids['${{ inputs.env }}']}:role/terraform-plan-${{ inputs.env }}-role" >> "$GITHUB_OUTPUT"
echo "dynamodb_table=simplecheck-${{ inputs.env }}-infra-tfmappings" >> "$GITHUB_OUTPUT"
shell: bash
- uses: actions/checkout@v4
with:
fetch-depth: 0
# main ブランチと差分のあるファイルのみを取得する
- id: diff_files
uses: dorny/paths-filter@v3
with:
base: "main"
ref: ${{ github.ref }}
list-files: "shell"
filters: |
env:
- "envs/${{ steps.set_values.outputs.envs_dir }}/**/terragrunt.hcl"
module:
- "modules/**/*.tf"
# 後続の処理で扱いやすいように出力を整形する
- id: align_diffs
run: |
diff_envs=$(echo "${{ steps.diff_files.outputs.env_files }}" | sed "s/\/terragrunt.hcl//g" | jq -R 'split(" ")' | jq -c)
diff_modules=$(echo "${{ steps.diff_files.outputs.module_files }}" | jq -R 'split(" ")' | jq -c)
echo ${diff_envs}
echo ${diff_modules}
echo "diff_envs=${diff_envs}" >> "$GITHUB_OUTPUT"
echo "diff_modules=${diff_modules}" >> "$GITHUB_OUTPUT"
# modules 配下の修正に対して対応する terragrunt.hcl のパスを取得する
- id: get_hcl_by_modules
uses: ./.github/actions/tf_module_mapper/query # この action については別記事にて紹介します
with:
aws_role_arn: ${{ steps.set_values.outputs.aws_role_arn }}
module_paths: ${{ steps.diff_files.outputs.module_files }}
dynamodb_table_name: ${{ steps.set_values.outputs.dynamodb_table }}
# envs 配下のファイルに修正があった場合の plan
plan_terragrunt_envs:
needs:
- prepare
if: ${{ needs.prepare.outputs.diff_envs != '' && toJson(fromJson(needs.prepare.outputs.diff_envs)) != '[]' }} # 差分があった場合にのみ実行
outputs:
exit_code: ${{ steps.plan.outputs.exit_code }}
output_message: ${{ steps.plan.outputs.output_message }}
strategy:
fail-fast: false # 各ジョブでエラーが発生しても続行する
matrix:
tg_dir: ${{ fromJson(needs.prepare.outputs.diff_envs) }} # 修正対象を並列に plan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: plan
uses: ./.github/actions/terragrunt/plan
env:
TF_VERSION: ${{ env.TF_VERSION }}
TG_VERSION: ${{ env.TG_VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
aws_role_arn: ${{ needs.prepare.outputs.aws_role_arn }}
tg_dir: ${{ matrix.tg_dir }}
# modules 配下のファイルに修正があった場合の plan
plan_terragrunt_modules:
needs:
- prepare
if: ${{ needs.prepare.outputs.diff_modules != '' && toJson(fromJson(needs.prepare.outputs.diff_modules)) != '[]' }}
outputs:
exit_code: ${{ steps.plan.outputs.exit_code }}
output_message: ${{ steps.plan.outputs.output_message }}
strategy:
fail-fast: false
matrix:
tg_dir: ${{ fromJson(needs.prepare.outputs.diff_modules) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: plan
uses: ./.github/actions/terragrunt/plan
env:
TF_VERSION: ${{ env.TF_VERSION }}
TG_VERSION: ${{ env.TG_VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
aws_role_arn: ${{ needs.prepare.outputs.aws_role_arn }}
tg_dir: ${{ matrix.tg_dir }}
terragrunt plan
を実行する action
4. name: Plan terragrunt
inputs:
aws_role_arn:
required: true
type: string
tg_dir:
required: true
type: string
outputs:
exit_code:
value: ${{ steps.plan.outputs.tg_action_exit_code }}
output_message:
value: ${{ steps.plan.outputs.tg_action_output }}
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
runs:
using: "composite"
steps:
# plan 前にフォーマットチェック
- uses: gruntwork-io/terragrunt-action@v2
with:
tf_version: ${{ env.TF_VERSION }}
tg_version: ${{ env.TG_VERSION }}
tg_dir: ${{ inputs.tg_dir }}
tg_command: "hclfmt --terragrunt-check --terragrunt-diff"
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ap-northeast-1
role-to-assume: ${{ inputs.aws_role_arn }}
# gruntwork の actions を利用して plan を実行
- id: plan
uses: gruntwork-io/terragrunt-action@v2
env:
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
with:
tf_version: ${{ env.TF_VERSION }}
tg_version: ${{ env.TG_VERSION }}
tg_dir: ${{ inputs.tg_dir }}
tg_command: "plan -lock=false --terragrunt-forward-tf-stdout"
# -lock=false ... plan 時にロックを取得しない
# --terragrunt-forward-tf-stdout ... Terragrunt のログプレフィックスを付けない
continue-on-error: true # エラーが発生しても続行する
# plan 結果を PR コメントとして投稿する
- uses: actions/github-script@v7
with:
github-token: ${{ env.GITHUB_TOKEN }}
script: |
const result = (${{ steps.plan.outputs.tg_action_exit_code }} === 0) ? 'success 🎉' : 'failure 😢';
const notes = (${{ steps.plan.outputs.tg_action_exit_code }} === 0) ? '' : `
> [!NOTE]
> コードに不備がなくても以下ケースで失敗する場合があります
> - \`dependency\` に定義されているリソースが \`apply\` されていない場合 → リソースごとの PR 分割をご検討ください
\n
`;
let planLog = '${{ steps.plan.outputs.tg_action_output }}';
planLog = planLog.replace(/%0A/g, '\n');
const output = `\n### Terragrunt Plan Report\n
#### Plan target: \`${{ inputs.tg_dir }}\`\n
#### Plan result: \`${result}\`\n
#### Exit code: \`${{ steps.plan.outputs.tg_action_exit_code }}\`\n
<details><summary>Plan ログ</summary>\n
\`\`\`terraform
${planLog}
\`\`\`\n
</details>\n
${notes}
*Pushed by: @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
実行結果
plan
結果は以下のように PR 上にコメントとして投稿されます。
plan
が正常に実行できた場合
plan
でエラーが発生した場合
さいごに
Terragrunt の CI/CD をやっている、またはやろうとしている方の参考になれば幸いです。また、他の方法で実現されている方は是非コメントにて教えていただけますと幸いです!
リアルタイム法人調査システム「SimpleCheck」を開発・運営するシンプルフォーム株式会社の開発チームのメンバーが、日々の開発で得た知見や試してみた技術などについて発信していきます。 Publication 運用への移行前の記事は zenn.dev/simpleform からご覧ください。
Discussion