ドリフト検知
現環境のAWSリソースとコードとの差分を定期的にTerraform planで差分確認し、差分が出れば通知する機能。
コードは最後に全文書いてます。
基本アクションのコード解説
ヘッダー
name: Terraform Drift Detection (Daily)
- ワークフロー名。
- Actions の一覧にこの名前で表示される。
トリガー
on:
schedule:
- cron: '0 0 * * *' # runs daily at 00:00 UTC
workflow_dispatch:
-
schedule: 毎日 00:00 UTCに自動実行(JST 09:00)。
- POSIX cronの5フィールドで時刻表現をする。
https://docs.github.com/ja/actions/reference/workflows-and-actions/events-that-trigger-workflows
- POSIX cronの5フィールドで時刻表現をする。
分 時 日 月 曜日
毎日 00:00 UTC(= JST 09:00)
0 0 * * *
平日だけ 00:00 UTC
0 0 * * 1-5
15分ごと
*/15 * * * *
毎時 15分・45分
15,45 * * * *
毎月1日 00:00 UTC
0 0 1 * *
日曜の 00:00 / 12:00 UTC
0 0,12 * * 0
- workflow_dispatch:手動実行ボタン。
https://docs.github.com/ja/actions/how-tos/manage-workflow-runs/manually-run-a-workflow- この記述をすることによって手動で実行出来るようになる。
ポイント
今回は定期実行のドリフト検知のため、自動実行を決定
権限
permissions:
contents: read
id-token: write
- contents: read: GitHub Actionsがリポジトリの中身を読み取れる権限
- id-token: write: OIDCでAWSにAssume Roleする発行権限
ポイント
GitHub Actionsがリポジトリを読み取る権限は基本的に必要で、ドリフトしてるかを確認するためにOIDCの権限が必要
環境変数
env:
TF_PLUGIN_CACHE_DIR: /home/runner/.terraform.d/plugin-cache
AWS_REGION: ap-northeast-1
HUB_ACCOUNT_ID: "318574063927"
WORKDIR_DEV: "infra/static-website/environments/dev"
WORKDIR_STG: "infra/static-website/environments/stg"
WORKDIR_PRD: "infra/static-website/environments/prd"
- 関数内で使う変数を定義
ポイント
使い回す値などを変数としとくと便利。
同時実行制御
concurrency:
group: drift-detection
cancel-in-progress: false
- concurrency: 同時実行制御の定義
- group: drift-detection: 同じグループ名を持つ実行は同時に走らさないようにする
-
cancel-in-progress: false: 今走っている実行をキャンセルしないと言う意味。
- 現在の実行が終わるまで新しい実行は待つ、順番待ちの動きとなる。
ポイント
同時実行制御をしとかないと、plan途中でキャンセルになったりする。
定期実行なので競合することは恐らくない。
ジョブ本体
jobs:
drift:
name: Check drift (dev/stg/prd)
runs-on: ubuntu-latest
-
jobs:drift:: ジョブID
- 依存を書く時はこのIDを使用する。
- ステップはジョブの中の処理の区切り。
- 色々出来ることが増えるらしい。
-
name: Check drift (dev/stg/prd): ジョブname
- 人間向けのラベル。
- Actions内での参照には使えない
- 人間向けのラベル。
- runs-on: ubuntu-latest: Ubuntu ランナーで実行する
ポイント
ジョブIDや、ジョブnameを記載
ランナーの指定は色々出来たはずで標準はUbuntu。
チェックアウト
- name: Checkout
uses: actions/checkout@v4
-
actions/checkout@v4: 公式のチェックアウトアクション
- リポジトリをランナー上の『$GITHUB_WORKSPACE』に展開する
ポイント
リポジトリ内の操作をするために、リポジトリをランナー上へ展開する。
Terraform のセットアップ
- name: Setup Terraform 1.9.2
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.2
-
hashicorp/setup-terraform@v3: 指定のTerraformをインストール。
https://github.com/hashicorp/setup-terraform- バージョン指定も可能。
ポイント
terraformを利用するのでインストールが必要。
GitHubActionsでは基本的に毎回必要なものをインストールする。
jq インストール
- name: Install jq
run: |
sudo apt-get update
sudo apt-get install -y jq
- jq: JSONを集計、抽出、整形するのに使う
-
apt-get update: 最新のパッケージの索引を更新するコマンド
- ランナーは使い捨てVMなので起動時点で古いことがある
- apt-get install jq: その索引を使ってjqをインストールする
ポイント
TerraformのログがJSONなので、それを集計したりするために使う。
ぶっちゃけあんまりjqを分かってない。
プラグインキャッシュの準備 & 復元
- name: Prepare plugin cache
run: mkdir -p "${{ env.TF_PLUGIN_CACHE_DIR }}"
- name: Cache Terraform plugins
uses: actions/cache@v4
with:
path: ${{ env.TF_PLUGIN_CACHE_DIR }}
key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: ${{ runner.os }}-tfplugins-
- run: mkdir -p "${{ env.TF_PLUGIN_CACHE_DIR }}": プラグインキャッシュするためのディレクトリを作成する
-
actions/cache@v4: Terraformのプロバイダを再ダウンロードせずに使い回して高速化するためにキャッシュする
- どこに保存されるんだろう。。。
ポイント
Teeraformを毎回インストールするのは時間がかかるので、キャッシュとして残しておける。
キャッシュの期間は14日間とかだった気がする。
ハブアカウントへの Assume(OIDC)
- name: Assume Hub (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ env.HUB_ACCOUNT_ID }}:role/GitHubActionsHubRole
role-session-name: gha-drift-${{ github.run_id }}
aws-region: ${{ env.AWS_REGION }}
-
aws-actions/configure-aws-credentials@v4: 指定のロールへAssumeロールする
- 指定のロールの信頼されたエンティティはGitHub
- 許可ポリシーは他アカウントへのAssumeロール
- 他アカウントの信頼されたエンティティは指定のロール
- 許可ポリシーは役割にあった適切な許可ポリシー
ポイント
事前に設定したOIDCによって、Asuumeロール出来る。
そのロールからTerraformでリソースを操作可能に出来る。
ドリフトチェック(3環境ループ)
ヘッダ&シェル設定
- name: Drift check (all environments)
shell: bash
run: |
set -uo pipefail
- shell: bash: bashで実行
-
set -uo pipefail: パイプライン内の未定義変数を参照すると即エラー、1つでも失敗したら全体を失敗させる
- -u: 未定義の変数の参照で即エラー
- -o pipefail: パイプラインの途中失敗も失敗扱いにする
ポイント
パイプラインを厳密に定義するために、失敗扱いにする。
ジョブサマリの見出し
echo "## 🔍 Terraform Drift Detection (Daily)" >> "$GITHUB_STEP_SUMMARY"
echo "- Commit: \`${GITHUB_SHA::7}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Actor: @${GITHUB_ACTOR}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
-
$GITHUB_STEP_SUMMARY: GitHubActionsのサマリ表示する箇所の環境変数。
- ここに追記することで、サマリに表示出来る。
- >>: は追記。『>』は上書き。
- ${GITHUB_SHA::7}: コミットSHA先頭の7文字
-
${GITHUB_ACTOR}: 実行ユーザー名
https://docs.github.com/ja/actions/reference/workflows-and-actions/variables
ポイント
サマリが見やすいように表示。
環境ループ開始(dev/stg/prd)
for ENV in dev stg prd; do
echo "::group::[${ENV}] terraform init/plan"
UPPER_ENV=$(echo "$ENV" | tr '[:lower:]' '[:upper:]')
WORKDIR_VAR="WORKDIR_${UPPER_ENV}"
WORKDIR="${!WORKDIR_VAR}"
-
【for ENV in dev stg prd; do】 Bashのfor構文で、ENVにdev、stg、prdを1つずつ代入し、ブロック内を順番に実行していく
- doからdone内をループする
-
【echo "::group::[${ENV}] terraform init/plan"】 グループのタイトルを出力してる
- ::group::はログを折りたたみグループにして、、展開出来るGitHubActionsの機能
https://docs.github.com/de/actions/reference/workflows-and-actions/workflow-commands
- ::group::はログを折りたたみグループにして、、展開出来るGitHubActionsの機能
-
【UPPER_ENV=$(echo "$ENV" | tr '[:lower:]' '[:upper:]')】 ENVに入ってる文字を大文字に変換して出力するコマンドをUPPER_ENVへ格納する
- $( ... ): 中のコマンドを標準出力として取り出す
- echo "$ENV": このENVはdev,stg,prdを格納してる変数
- tr(translate)コマンド : 入力を読み、文字集合A→文字集合Bに1文字ずつ変換する
- '[:lower:]': 小文字の集合
- '[:upper:]': 大文字の集合
- WORKDIR_VAR="WORKDIR_${UPPER_ENV}": WORKDIR_DEVなどをWORKDIR_VARへ格納する
-
WORKDIR="${!WORKDIR_VAR}": 『!』は間接展開の意味。
- WORKDIR_VARの中身は→WORKDIR_DEV
- WORKDIR_DEVの中身は→envで指定している『"infra/static-website/environments/dev"』
- 上記をWORKDIRへ格納している。
初期値の用意
creates="N/A"; updates="N/A"; deletes="N/A"
drift="N/A"
outcome="failure"
- 初期値の用意: set -uを使ってるので、変数が未定義だと即エラーになるのでその対策
作業ディレクトリの存在チェック→入る/出る
if [ -d "$WORKDIR" ]; then
pushd "$WORKDIR" > /dev/null
...
popd > /dev/null
else
echo "Directory not found: $WORKDIR" >&2
drift=-1
outcome="failure"
fi
- [ -d "$WORKDIR" ]: test[]コマンドの条件式で、パスが存在していて尚且つディレクトリなら真
- pushd: 指定ディレクトリへcdしつつ、popdすればそのディレクトリに戻れる
-
> /dev/null: 標準出力をデータの捨て場に捨てる
- ディレクトリスタックが表示されるので、その出力を捨てる
-
then: ifの真の時に実行するブロックの開始を示すもの
- 偽の時はelse
-
>&2: 標準エラーにリダイレクト
- 機械に必要ないものは標準エラーにリダイレクトする方が良いらしい。。。
- ジョブサマリに書く時はそれ用の変数があるし、後段の処理で使わなくて、尚且つGitHubActionsのログには出力したい時は標準エラーにリダイレクトが良いらしい
- drift=-1: 後の評価で-1は失敗を意味する
- outcome="failure": 未使用のフラグとなっている
- fi: ifの終わりを示す
terraform initとplanの実行と終了コードの正確取得
terraform init -input=false -lockfile=readonly -upgrade=false
set +e
terraform plan -no-color -input=false -lock-timeout=60s -detailed-exitcode -out=tf.plan 2>&1 | tee plan.txt
plan_exit=${PIPESTATUS[0]}
set -e
- input=false: 対話プロンプト無し
- -lockfile=readonly: ロックファイルを読み取り専用で扱って、変更があっても書き換えないようにする。
-
set +e: 終了コード0以外の時は即終了するようになっていて、terraform planの差分ありは0以外で出力される
- そのため終了コード0以外で終了しないようにする
-
-detailed-exitcode: 終了コードを返すコマンド
https://developer.hashicorp.com/terraform/cli/commands/plan - -out=tf.plan: プランをバイナリのplanファイルに保存する
-
2>&1:
- パイプに流れるのはstdoutだけ
- terraform planのエラーは標準エラー(stderr)
- 2 … ファイル記述子(FD)番号 2 = 標準エラー(stderr)
- > … 出力リダイレクト(行き先を変える)
- & … **「ファイル名ではなく“FD を参照”する」**という合図
- 1 … FD 1 = 標準出力(stdout)
- パイプに流れるのはstdoutだけ
- tee plan.txt: 画面表示しつつファイルに保存
-
plan_exit=${PIPESTATUS[0]}: 直前に実行したパイプラインの先頭コマンドの終了コードを取り出してplan_exitに代入している
- plan結果で、0が差分無し、2が差分無し、1が失敗
-
set -e: コマンドが失敗したらその場で終了させる
- terraform planが必要だったので、set +eから戻す。
ドリフト判定フラグ
if [ $plan_exit -eq 0 ]; then
drift=0; outcome="success"
elif [ $plan_exit -eq 2 ]; then
drift=1; outcome="success"
else
drift=-1; outcome="failure"
fi
- -eq: 数値的に等しいかを見る
-
if [ $plan_exit -eq 0 ]; then:
- if:条件式
- [ $plan_exit -eq 0 ];:plan_exitが0の時
- drift=0; outcome="success": driftに0を代入し、outcomeにsuccessを代入した。
JSON化して件数サマリを計算
if [ -f tf.plan ]; then
terraform show -json tf.plan > tfplan.json || true
if [ -s tfplan.json ]; then
creates=$(jq '[.resource_changes[]? | select(.change.actions | index("create"))] | length' tfplan.json)
replaces=$(jq '[.resource_changes[]? | select(.change.actions | index("replace"))] | length' tfplan.json)
updates=$(jq '[.resource_changes[]? | select(.change.actions | index("update"))] | length' tfplan.json)
deletes=$(jq '[.resource_changes[]? | select(.change.actions | index("delete"))] | length' tfplan.json)
creates=$((creates + replaces))
deletes=$((deletes + replaces))
fi
fi
-
if [ -f tf.plan ]; then:
- -f:通常ファイルならと言う意味
- 通常ファイルにならないのは、ディレクトリやシンボリックリンク、デバイスファイルやパスが存在しないもの
- -f:通常ファイルならと言う意味
- terraform show -json tf.plan: planファイル(バイナリ)をJSON形式で出力するコマンド
- > tfplan.json: その出力をtfplan.jsonへ保存
-
|| true: terraform showが失敗するとtrueが実行されて全体として成功扱いになる
- ||:論理OR。
- A || B:のようなAが失敗したらBを実行
- ||:論理OR。
-
if [ -s tfplan.json ]; then:
- -s:ファイルが存在していて、サイズが0じゃない判定をする
-
.resource_changes[]?: resource_changes内の各要素を1件ずつ取り出す演算子
- forで回すイメージ
-
select(.change.actions | index("create")):
- select文は条件式で配列をまとめなおす
- changeの中のactionsにcreateが含まれてるものを指定
-
length' tfplan.json:
- length:その配列の要素数を数値として出力する。
- tfplan.json:は、このファイルの中身から抽出する
- creates=$((creates + replaces)): リプレイスは削除してから作成するので、リプレイス件数をクリエイト件数にも計上する
サマリへの出力(分岐)
if [ "$drift" = "1" ]; then
# ドリフトあり:件数と plan の先頭400行を折りたたみで添付
elif [ "$drift" = "0" ]; then
# ドリフトなし:✅ No drift
else
# 失敗:❌ Plan failed と先頭200行を添付
fi
-
if [ "$drift" = "1" ]; then:
- drift=1は、terraform planでのplan成功ドリフトありに分類される
- terraform planの成功コード
- 0:drift=0:プラン成功ドリフト無し
- 2:drift=1:プラン成功ドリフトあり
- 1:drift=-1:プラン失敗
name: Terraform Drift Detection (Daily)
on:
schedule:
- cron: '0 0 * * *' # runs daily at 00:00 UTC
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
TF_PLUGIN_CACHE_DIR: /home/runner/.terraform.d/plugin-cache
AWS_REGION: ap-northeast-1
HUB_ACCOUNT_ID: "318574063927"
WORKDIR_DEV: "infra/static-website/environments/dev"
WORKDIR_STG: "infra/static-website/environments/stg"
WORKDIR_PRD: "infra/static-website/environments/prd"
concurrency:
group: drift-detection
cancel-in-progress: false
jobs:
drift:
name: Check drift (dev/stg/prd)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform 1.9.2
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.2
- name: Install jq
run: |
sudo apt-get update
sudo apt-get install -y jq
- name: Prepare plugin cache
run: mkdir -p "${{ env.TF_PLUGIN_CACHE_DIR }}"
- name: Cache Terraform plugins
uses: actions/cache@v4
with:
path: ${{ env.TF_PLUGIN_CACHE_DIR }}
key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: ${{ runner.os }}-tfplugins-
- name: Assume Hub (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ env.HUB_ACCOUNT_ID }}:role/GitHubActionsHubRole
role-session-name: gha-drift-${{ github.run_id }}
aws-region: ${{ env.AWS_REGION }}
- name: Drift check (all environments)
shell: bash
run: |
set -uo pipefail
echo "## 🔍 Terraform Drift Detection (Daily)" >> "$GITHUB_STEP_SUMMARY"
echo "- Commit: \`${GITHUB_SHA::7}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Actor: @${GITHUB_ACTOR}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
for ENV in dev stg prd; do
echo "::group::[${ENV}] terraform init/plan"
UPPER_ENV=$(echo "$ENV" | tr '[:lower:]' '[:upper:]')
WORKDIR_VAR="WORKDIR_${UPPER_ENV}"
WORKDIR="${!WORKDIR_VAR}"
creates="N/A"; updates="N/A"; deletes="N/A"
drift="N/A"
outcome="failure"
if [ -d "$WORKDIR" ]; then
pushd "$WORKDIR" > /dev/null
terraform init -input=false -lockfile=readonly -upgrade=false
set +e
terraform plan -no-color -input=false -lock-timeout=60s -detailed-exitcode -out=tf.plan 2>&1 | tee plan.txt
plan_exit=${PIPESTATUS[0]}
set -e
if [ $plan_exit -eq 0 ]; then
drift=0
outcome="success"
elif [ $plan_exit -eq 2 ]; then
drift=1
outcome="success"
else
drift=-1
outcome="failure"
fi
if [ -f tf.plan ]; then
terraform show -json tf.plan > tfplan.json || true
if [ -s tfplan.json ]; then
creates=$(jq '[.resource_changes[]? | select(.change.actions | index("create"))] | length' tfplan.json)
replaces=$(jq '[.resource_changes[]? | select(.change.actions | index("replace"))] | length' tfplan.json)
updates=$(jq '[.resource_changes[]? | select(.change.actions | index("update"))] | length' tfplan.json)
deletes=$(jq '[.resource_changes[]? | select(.change.actions | index("delete"))] | length' tfplan.json)
creates=$((creates + replaces))
deletes=$((deletes + replaces))
fi
fi
popd > /dev/null
else
echo "Directory not found: $WORKDIR" >&2
drift=-1
outcome="failure"
fi
if [ "$drift" = "1" ]; then
echo "### ${ENV}: ⚠️ Drift detected" >> "$GITHUB_STEP_SUMMARY"
echo "- + Create: ${creates}" >> "$GITHUB_STEP_SUMMARY"
echo "- ~ Update: ${updates}" >> "$GITHUB_STEP_SUMMARY"
echo "- - Delete: ${deletes}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "<details><summary>Show ${ENV} plan</summary>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
sed -n '1,400p' "${WORKDIR}/plan.txt" >> "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "</details>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
elif [ "$drift" = "0" ]; then
echo "### ${ENV}: ✅ No drift" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ${ENV}: ❌ Plan failed" >> "$GITHUB_STEP_SUMMARY"
echo "<details><summary>Show ${ENV} plan output</summary>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
sed -n '1,200p' "${WORKDIR}/plan.txt" >> "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "</details>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
fi
echo "::endgroup::"
done