0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TerraformとGitHubActionsでAWSリソースドリフト検知

0
Posted at

ドリフト検知

現環境のAWSリソースとコードとの差分を定期的にTerraform planで差分確認し、差分が出れば通知する機能。
コードは最後に全文書いてます。

基本アクションのコード解説

ヘッダー

name: Terraform Drift Detection (Daily)
  • ワークフロー名。
    • Actions の一覧にこの名前で表示される。

トリガー

on:
  schedule:
    - cron: '0 0 * * *' # runs daily at 00:00 UTC
  workflow_dispatch:

分 時 日 月 曜日
毎日 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

ポイント
今回は定期実行のドリフト検知のため、自動実行を決定

権限

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

ポイント
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"

ポイント
サマリが見やすいように表示。

環境ループ開始(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"】 グループのタイトルを出力してる
  • 【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)
  • 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:通常ファイルならと言う意味
      • 通常ファイルにならないのは、ディレクトリやシンボリックリンク、デバイスファイルやパスが存在しないもの
  • terraform show -json tf.plan: planファイル(バイナリ)をJSON形式で出力するコマンド
  • > tfplan.json: その出力をtfplan.jsonへ保存
  • || true: terraform showが失敗するとtrueが実行されて全体として成功扱いになる
    • ||:論理OR。
      • A || B:のようなAが失敗したらBを実行
  • 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


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?