Terraform/Terragrunt の自動 plan・apply ができるまで ③ 〜apply 編〜
本記事は SimpleForm Advent Calendar 2024 の 23 日目の記事です。
... とするつもりでしたが年末でボケッとしていて遅くなりました、すみません 😓
はじめに
こんにちは、シンプルフォームでインフラエンジニアをやっている入江 純 (@jirtosterone) です。
Terraform/Terragrunt の CI/CD 実現を目的とした自動 plan・apply ができるまで 三部作の最終章になります。お待ちになっていただいていた方がいるかは甚だ怪しいですが、ここに完結します!今回は「自動 apply
の実現について」です。
前回までの記事については以下をご覧ください。
対象読者
- Terraform/Terragrunt でインフラ管理されている方
- Terragrunt の CI/CD を運用・検討されている方
結論
時間が無い方のために結論から。
- GitHub のリリース作成時に自動
apply
を行うようにした。 - 意図せぬ
apply
防止を目的として、前回バージョンタグからの差分のみをapply
対象とした。 - 依存先から順番に
apply
するため、NetworkX
を利用して依存関係順にソートした上でapply
する ようにした。
apply
の仕組み
自動 GitHub Actions (GHA) で実現します。
- Git でバージョンタグを作成して (
git tag vx.x.x
) push する。 - GitHub でのリリース作成時 (
gh release create
) に GitHub Actions を実行する。 - 最新のタグと前回のタグとの差分を取得して、
apply
対象を決定する。 -
apply
対象をソートして 1 つずつapply
する。 -
apply
結果を GHA 実行結果画面に出力する。
課題と対策
自動 apply
を実装する時の最大の課題は、依存関係を考慮して順番に apply
していくことです。
どういうことかと言うと、例えば VPC 上に Lambda 関数をデプロイすることを考えます。その Lambda 関数はある IAM ロールを引き受け、Secrets Manager のあるパラメータを参照するとします。IAM ロールはこの Secrets Manager へのアクセス権が必要です。また、この Lambda 関数は ALB 経由でリクエストを受け付けます。
これらのリソースを Terraform で実現すると以下のような依存関係になります。
デプロイする時は最も依存されているリソース、この場合だと VPC、Secrets Manager を先にデプロイしていく必要があります。
解決策として、apply
対象を抽出した後 NetworkX
を利用したグラフ化を行います。
その後 apply
対象を最も依存するリソース順にソートして、1 つずつ順番に apply
することとしました。
GitHub Actions の全体像
.github
├── workflows
│ ├── terragrunt-apply-diff-prod.yml # 1. prod 環境に対する自動 apply
│ ├── terragrunt-apply-diff-stg.yml # 2. stg 環境に対する自動 apply
│ └── _apply_terragrunt_diff.yml # 3. 自動 apply 処理の本体となるワークフロー
└── actions
└── terragrunt
├── apply # 4. terragrunt apply を実行する action
│ └── action.yml
│── sort # 5.1 apply 対象をソートする action
│ └── action.yml
└── scripts # ソート処理を行うスクリプト (Poetry でパッケージ管理)
├── poetry.lock
├── poetry.toml
├── pyproject.toml
└── src
└── sort.py # 5.2 sort の実態
apply
1. prod 環境に対する自動 name: "[PROD] Terragrunt apply"
on:
release:
types: [published]
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
apply:
needs:
- checkout
uses: ./.github/workflows/_apply_terragrunt_diff.yml
with:
env: "prod"
diff_style: "tag"
secrets: inherit
apply
2. stg 環境に対する自動 name: "[STG] Terragrunt apply"
on:
push:
branches:
- "main"
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
apply:
needs:
- checkout
uses: ./.github/workflows/_apply_terragrunt_diff.yml
with:
env: "prod"
diff_style: "branch"
secrets: inherit
apply
処理の本体となるワークフロー
3. 自動 name: Apply terragrunt with diff files (Reuseable workflow)
on:
workflow_call:
inputs:
env:
required: true
type: string
# prod と stg とでデプロイ方法が異なるため、inputs で分岐させる
diff_style:
required: false
type: string
default: "branch"
description: |
Specify the diff style: 'branch' or 'tag'.
- branch: Compare the diff between the current branch and the `main` branch.
- tag: Compare the diff between the current tag and the previous tag.
secrets:
# Slack 通知するための BOT トークンを secrets として受け取る
SLACK_BOT_TOKEN:
required: true
env:
TF_VERSION: "1.x.x"
TG_VERSION: "0.xx.xx"
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
jobs:
vars:
runs-on: ubuntu-latest
outputs:
envs_dir: ${{ steps.set_values.outputs.envs_dir }}
aws_role_arn: ${{ steps.set_values.outputs.aws_role_arn }}
dynamodb_table: ${{ steps.set_values.outputs.dynamodb_table }} # Terraform ファイルと terragrunt.hcl とのマッピングを保存している DynamoDB テーブル
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# AWS 環境の情報をセットする (詳細は割愛)
- id: account_config
uses: ./.github/actions/common/get_account_config
with:
env: ${{ inputs.env }}
- id: set_values
run: |
echo "envs_dir=${{ inputs.env }}" >> "$GITHUB_OUTPUT"
echo "aws_role_arn=arn:aws:iam::${{ steps.account_config.outputs.account_id }}:role/github-actions-terraform-apply-${{ inputs.env }}-role" >> "$GITHUB_OUTPUT"
echo "dynamodb_table=${{ inputs.env }}-infra-tfmappings" >> "$GITHUB_OUTPUT"
shell: bash
# diff_style = 'branch' の場合: main ブランチと現在ブランチとの差分を取得する
diff_branch:
needs:
- vars
if: ${{ inputs.diff_style == 'branch' }}
runs-on: ubuntu-latest
outputs:
diff_envs: ${{ steps.align_diffs.outputs.diff_envs }}
diff_modules: ${{ steps.get_hcl_by_modules.outputs.found_paths }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: get_diffs
uses: dorny/paths-filter@v3
with:
base: "main"
ref: ${{ github.ref }}
list-files: "shell"
filters: |
env:
- "envs/${{ needs.vars.outputs.envs_dir }}/**/terragrunt.hcl"
module:
- "modules/**/*.tf"
# 後続の処理で扱いやすいように出力を整形する
- id: align_diffs
run: |
diff_envs=$(echo "${{ steps.get_diffs.outputs.env_files }}" | sed "s/\/terragrunt.hcl//g" | jq -R 'split(" ")' | jq -c)
diff_modules=$(echo "${{ steps.get_diffs.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: ${{ needs.vars.outputs.aws_role_arn }}
module_paths: ${{ steps.get_diffs.outputs.module_files }}
dynamodb_table_name: ${{ needs.vars.outputs.dynamodb_table }}
# diff_style = 'tag' の場合: 最新のタグと 1 つ前のタグとの差分を取得する
diff_tag:
needs:
- vars
if: ${{ inputs.diff_style == 'tag' }}
runs-on: ubuntu-latest
outputs:
diff_envs: ${{ steps.align_diffs.outputs.diff_envs }}
diff_modules: ${{ steps.get_hcl_by_modules.outputs.found_paths }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 最新のタグ名、1 つ前のタグ名を取得する
- id: get_tags
run: |
previous=$(git tag --sort=-creatordate | head -2 | sed -n 2p)
current=$(git tag --sort=-creatordate | head -2 | sed -n 1p)
echo "previous=${previous}" >> "$GITHUB_OUTPUT"
echo "current=${current}" >> "$GITHUB_OUTPUT"
# 2 つのタグを指定して git diff コマンドにて差分を抽出する
- id: get_diffs
run: |
env_files=$(git diff --name-only ${{ steps.get_tags.outputs.previous }} ${{ steps.get_tags.outputs.current }} -- "envs/${{ needs.vars.outputs.envs_dir }}/**/terragrunt.hcl")
modules_files=$(git diff --name-only ${{ steps.get_tags.outputs.previous }} ${{ steps.get_tags.outputs.current }} -- "modules/**/*.tf")
echo "Diff files (envs): ${env_files}"
echo "Diff files (modules): ${modules_files}"
echo "env_files=$(echo ${env_files} | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
echo "module_files=$(echo ${modules_files} | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
# 後続の処理で扱いやすいように出力を整形する
- id: align_diffs
run: |
diff_envs=$(echo "${{ steps.get_diffs.outputs.env_files }}" | sed "s/\/terragrunt.hcl//g" | jq -R 'split(" ")' | jq -c)
diff_modules=$(echo "${{ steps.get_diffs.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: ${{ needs.vars.outputs.aws_role_arn }}
module_paths: ${{ steps.get_diffs.outputs.module_files }}
dynamodb_table_name: ${{ needs.vars.outputs.dynamodb_table }}
# apply 対象の terragrunt.hcl をソート・重複排除する
sort_apply_targets:
needs:
- vars
- diff_branch
- diff_tag
# diff_branch, diff_tag のどちらかが必ず skip されるため、`always()` で必ず実行されるようにする
if: ${{ always() }}
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.sort_hcl_paths.outputs.sorted_apply_targets }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# diff_branch, diff_tag のどちらが実行されていても 1 つの変数で参照できるようにする
- id: diff_files
run: |
if [ "${{ needs.diff_branch.result }}" = "success" ]; then
echo 'diff_envs=${{ needs.diff_branch.outputs.diff_envs }}' >> "$GITHUB_OUTPUT"
echo 'diff_modules=${{ needs.diff_branch.outputs.diff_modules }}' >> "$GITHUB_OUTPUT"
elif [ "${{ needs.diff_tag.result }}" = "success" ]; then
echo 'diff_envs=${{ needs.diff_tag.outputs.diff_envs }}' >> "$GITHUB_OUTPUT"
echo 'diff_modules=${{ needs.diff_tag.outputs.diff_modules }}' >> "$GITHUB_OUTPUT"
else
echo "diff の取得でエラーが発生しました"
echo "diff_style: ${{ inputs.diff_style }}"
echo "diff_branch result: ${{ needs.diff_branch.result }}"
echo "diff_tag result: ${{ needs.diff_tag.result }}"
exit 1
fi
# envs 由来の修正と module 由来の修正を 1 つにまとめる
- id: combine_paths
run: |
all_paths=$(echo '${{ steps.diff_files.outputs.diff_envs }}' | jq '. + ${{ steps.diff_files.outputs.diff_modules }}' | jq -c 'unique' | jq -r 'join(" ")')
echo "${all_paths}"
echo "all_paths=${all_paths}" >> "$GITHUB_OUTPUT"
# 修正対象が複数ある場合、依存関係を考慮したソートを行い apply 順序を決定する
- id: sort_hcl_paths
uses: ./.github/actions/tf_module_mapper/sort # この action については後述します
with:
aws_role_arn: ${{ needs.vars.outputs.aws_role_arn }}
tg_paths: ${{ steps.combine_paths.outputs.all_paths }}
apply_terragrunt:
needs:
- vars
- sort_apply_targets
# diff_branch, diff_tag のどちらかが必ず skip されるため、`! cancelled() && ! failure()` で強制 skip されないようにする
if: ${{ ! cancelled() && ! failure() && needs.sort_apply_targets.outputs.targets != '' && toJson(fromJson(needs.sort_apply_targets.outputs.targets)) != '[]' }}
outputs:
exit_code: ${{ steps.apply.outputs.exit_code }}
output_message: ${{ steps.apply.outputs.output_message }}
strategy:
fail-fast: true
max-parallel: 1 # シリアル実行する
matrix:
tg_dir: ${{ fromJson(needs.sort_apply_targets.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Apply
id: apply
uses: ./.github/actions/terragrunt/apply
env:
TF_VERSION: ${{ env.TF_VERSION }}
TG_VERSION: ${{ env.TG_VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 冒頭で secrets に指定していなくても実行元から自動で受け取ることができる
with:
aws_role_arn: ${{ needs.vars.outputs.aws_role_arn }}
tg_dir: ${{ matrix.tg_dir }}
slack_notification:
needs:
- apply_terragrunt
# diff_branch, diff_tag のどちらかが必ず skip されるため、`always()` で強制 skip されないようにする
if: ${{ always() && needs.apply_terragrunt.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
... (本記事とは直接関係ないため割愛します) ...
terragrunt apply
を実行する action
4. name: Apply terragrunt
inputs:
aws_role_arn:
required: true
type: string
tg_dir:
required: true
type: string
outputs:
exit_code:
value: ${{ steps.apply.outputs.tg_action_exit_code }}
output_message:
value: ${{ steps.apply.outputs.tg_action_output }}
permissions:
id-token: write
contents: read
actions: read
pull-requests: write
runs:
using: "composite"
steps:
# apply 前にフォーマットチェック
- 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 を利用して apply を実行
- id: apply
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: "apply -auto-approve --terragrunt-non-interactive --terragrunt-forward-tf-stdout"
# -auto-approve ... apply 前の承認インタラクティブをスキップする
# --terragrunt-non-interactive ... プロンプトを表示しない
# --terragrunt-forward-tf-stdout ... Terragrunt のログプレフィックスを付けない
continue-on-error: true # エラーが発生しても続行する
# apply 結果を GHA 実行結果画面に投稿する
- uses: actions/github-script@v7
with:
github-token: ${{ env.GITHUB_TOKEN }}
script: |
const result = (${{ steps.apply.outputs.tg_action_exit_code }} === 0) ? 'success 🎉' : 'failure 😢';
let applyLog = '${{ steps.apply.outputs.tg_action_output }}';
applyLog = applyLog.replace(/%0A/g, '\n');
const output = `\n#### Apply target: \`${{ inputs.tg_dir }}\`\n
#### Apply result: \`${result}\`\n
#### Exit code: \`${{ steps.apply.outputs.tg_action_exit_code }}\`\n
<details><summary>Apply ログ</summary>\n
\`\`\`terraform
${applyLog}
\`\`\`\n
</details>\n
*Pushed by: @${{ github.actor }}*`;
await core.summary
.addHeading('🤖 Terraform Apply Report')
.addRaw(output)
.write()
- uses: actions/github-script@v7
with:
github-token: ${{ env.GITHUB_TOKEN }}
script: |
if (${{ steps.apply.outputs.tg_action_exit_code }} === 0) {
console.log('terragrunt apply に成功しました 🎉');
} else {
core.setFailed('terragrunt apply に失敗しました 😢');
}
apply
対象のソート
5. 今回の工夫点である apply
対象をソートする action および Python スクリプトです。
apply
対象をソートする action
5.1 action ではセットアップと Python スクリプトの実行を行います。
name: Terragrunt sort by dependency
inputs:
# Terragrunt コマンドを実行するための IAM ロール
aws_role_arn:
required: true
type: string
# ソートする対象の terragrunt.hcl パスのリスト
tg_paths:
required: true
type: string
outputs:
# ソート後のパスのリスト
sorted_apply_targets:
description: HCL paths sorted by dependencies
value: ${{ steps.sort.outputs.apply_targets }}
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ap-northeast-1
role-to-assume: ${{ inputs.aws_role_arn }}
# Terraform のセットアップ
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.x.x"
terraform_wrapper: false
# Terragrunt のセットアップ
- uses: autero1/action-terragrunt@v3
with:
terragrunt-version: "0.xx.xx"
# Poetry のセットアップ
- run: pipx install poetry
shell: bash
# Python のセットアップ
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.xx"
cache: "poetry"
# 依存パッケージのインストール
- run: poetry install
shell: bash
working-directory: ${{ github.workspace }}/.github/actions/terragrunt/scripts
# sort スクリプトの実行
- id: sort
run: |
sorted_apply_targets=$(poetry run python src/sort.py --apply_target_dirs ${{ inputs.tg_paths }} --root_dir ${{ github.workspace }})
# 出力用
output_apply_targets=$(echo ${sorted_apply_targets} | jq -R 'split(" ")') && echo "sorted_apply_targets: ${output_apply_targets}"
# output 用
apply_targets=$(echo "${output_apply_targets}" | jq -c)
echo "apply_targets=${apply_targets}" >> $GITHUB_OUTPUT
shell: bash
env:
PYTHONUNBUFFERED: "1"
working-directory: ${{ github.workspace }}/.github/actions/terragrunt/scripts
5.2 sort の実態 (Python スクリプト)
次に処理の実態となる Python スクリプトのポイントを説明します。
from os import path
from networkx.drawing.nx_pydot import from_pydot
from pydot import graph_from_dot_data
# 探索対象のルートパスを設定して絶対パスを取得
apply_target_abs_paths = [path.abspath(path.join(root_dir, d)) for d in apply_target_dirs]
# `terragrunt graph-dependencies` コマンドで dot データに変換 (関数の内容詳細は後述)
dot_data = make_terragrunt_dependencies_graph(target_abs_path)
dot_graphs = graph_from_dot_data(dot_data)
for dot_graph in dot_graphs:
nx_graph = from_pydot(dot_graph)
sub_graphs.append(nx_graph)
import networkx as nx
from networkx.drawing.nx_pydot import to_pydot
# 複数得られたグラフを 1 つに結合
graph = nx.compose_all(sub_graphs)
dot_graph = to_pydot(graph)
# 依存度に応じてソートして apply ターゲットのみを抽出 (有向グラフのトポロジカルソートを利用)
sorted_paths = [node for node in reversed(list(nx.topological_sort(graph)))]
sort.py の全容
import argparse
from os import path
import networkx as nx
from networkx.drawing.nx_pydot import from_pydot, to_pydot
from pydot import graph_from_dot_data
from src.utils.logger import get_logger
from src.utils.path_handler import format_path
from src.utils.terragrunt_handler import make_terragrunt_dependencies_graph
logger = get_logger(__name__)
def main(apply_target_dirs: list[str], root_dir: str = ".") -> list[str]:
# 探索対象のルートパスを設定して絶対パスを取得
apply_target_abs_paths = [path.abspath(path.join(root_dir, d)) for d in apply_target_dirs]
logger.info(f"Apply target directories (Absolute path): {apply_target_abs_paths}")
if not apply_target_abs_paths:
logger.warning("No apply target directories specified")
return []
# apply ターゲットの依存先を取得してグラフに変換
sub_graphs = []
for target_abs_path in apply_target_abs_paths:
dot_data = make_terragrunt_dependencies_graph(target_abs_path)
dot_graphs = graph_from_dot_data(dot_data)
for dot_graph in dot_graphs:
nx_graph = from_pydot(dot_graph)
sub_graphs.append(nx_graph)
# 複数得られたグラフを 1 つに結合
graph = nx.compose_all(sub_graphs)
dot_graph = to_pydot(graph)
logger.info(f"Graph: {dot_graph.to_string()}")
# 依存度に応じてソートして apply ターゲットのみを抽出
sorted_paths = [node for node in reversed(list(nx.topological_sort(graph)))]
sorted_apply_targets = [format_path(p, "envs/") for p in sorted_paths if p in apply_target_abs_paths]
logger.info(f"Sorted apply targets: {sorted_apply_targets}")
return sorted_apply_targets
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--apply_target_dirs", required=True, nargs="*", type=str, help="Target directories to apply changes"
)
parser.add_argument("--root_dir", type=str, default=".", help="Root directory of Terraform project")
args = parser.parse_args()
sorted_apply_targets = main(args.apply_target_dirs, args.root_dir)
print(" ".join(sorted_apply_targets))
def format_path(path: str, remove_position_str: str) -> str:
"""path 文字列を整形する"""
# 不要な prefix を削除
result = path[path.index(remove_position_str) :]
# '//' を '/' に変換
result = result.replace("//", "/")
# 最後の '/' を削除
if result.endswith("/"):
result = result[:-1]
return result
import subprocess
from os import path
from src.utils.logger import get_logger
logger = get_logger(__name__)
def make_terragrunt_dependencies_graph(tg_path: str) -> str:
"""terragrunt.hcl ファイルのグラフ依存関係を取得する"""
if tg_path.endswith("terragrunt.hcl"):
tg_dir_path = path.dirname(tg_path)
else:
tg_dir_path = tg_path
ret = subprocess.run(
["terragrunt", "graph-dependencies", "--terragrunt-non-interactive"], cwd=tg_dir_path, capture_output=True
)
if ret.returncode != 0:
raise RuntimeError(ret.stderr.decode("utf-8"))
dependencies_graph = ret.stdout.decode("utf-8")
logger.info(dependencies_graph)
return dependencies_graph
実行結果
apply
結果は以下のように GHA 実行結果画面上に投稿されます。
さいごに
今回 Terragrunt の CI/CD を実現してみて、正直もの凄く大変でした、というのが率直な感想です (記事も 3 部に分かれるほど)!構成から見直せばもう少し楽にはなったかもしれませんが、ベストな構成は要件や状況によって変わると思いますので一概には言えません。
Terragrunt (または Terraform) のフレームワークがあれば、ここまで考える必要は無かったのかもしれません。それは次なるチャレンジにでも... 💪

リアルタイム法人調査システム「SimpleCheck」を開発・運営するシンプルフォーム株式会社の開発チームのメンバーが、日々の開発で得た知見や試してみた技術などについて発信していきます。 Publication 運用への移行前の記事は zenn.dev/simpleform からご覧ください。
Discussion