こんにちは。crowdworks.jp SREチームの@kangaechuです。 先日開催されたイベント HashiTalks: Japanを見ていましたが、Terraformのドリフト検出に関する発表が多く、Terraform運用において重要なポイントなんだなと感じました。 流行りに乗ったわけではないですが、今回はTerraformのドリフト検出をAtlantisで行った方法をご紹介します。
この記事は以下環境で検証しています。
ドリフト検出
Terraformはインフラストラクチャを安全かつ期待通りに構築するために使用する、Infrastructure as Codeのソフトウェアです。 crowdworks.jpではAWS、GitHub、GCPの管理をTerraformで行っていますが、今回はAWSに絞って話します。
TerraformではリソースをHCLで記述したコードと、前回のapply時の状態が保存されたtfstateファイルと、実際のリソースの3つの要素で管理します。 基本的にこの3つの状態は一致しています。 しかしさまざまな理由により、これらの状態が一致しなくなることがあります。たとえば以下のようなものが挙げられます。
- Terraform外(AWSマネジメントコンソールなど)での変更
- Apply のし忘れ
- Terraformやプロバイダのバージョンアップによる破壊的変更
Terraformのコードの状態と実リソースの状態に差異が発生した場合、できるだけ早期に検出したいですね。 そのために「ドリフト検出」を行います。 ドリフトは「想定とは異なった状態」を表し、ドリフト検出とは「想定と異なった状態を検出する」ことを指します。 この記事ではTerraformで全部のディレクトリにterraform planを行い、plan結果に差分がないことを確認する処理を「ドリフト検出」と呼びます。
crowdworks.jpでの以前のドリフト検出の仕組み
ドリフト検出のため、以前はCircleCIとAWS CodeBuildを使用したシステムを運用していました。
- CircleCI: スケジュール機能によりGitHubのメインブランチからドリフト検出用のブランチに日次でforce push
- CodeBuild: GitHubのWebhook経由で起動し、ドリフト検出用のブランチにpushがあったらドリフト検出用のスクリプトを実行
- CodeBuild: ドリフト検出の結果をSlackに通知
このような構成で動いていたのですが、いくつか課題がありました。
課題1. ログの確認にAWSへのログインが必要
CodeBuildからSlackへは以下のように通知されていました。
Slackではドリフト検出の結果がエラーとなったことはわかりますが、詳細情報がわかりませんでした。 エラーのたびにAWSにログインしてCodeBuildのログからエラー部分を探す必要があり、かなりの手間となっていました。 そのため、Slackの通知からエラーメッセージを簡単に確認できることが求められていました。
課題2. CodeBuildがGitHub Appsに対応していないため、Private Access Token(PAT)の廃止ができない
crowdworks.jp ではGitHubのPAT廃止を進めています。 しかし、CodeBuildではソースプロバイダとしてGitHubを使用した場合、PATしか使用できません。
CodeBuildからCodePipelineに移行することでGitHub Appsを使用できます。 しかしCodePipelineは特定のブランチに紐づけることを前提としており、複数のブランチで実行する場合はLambdaとCloudFormationで動的にCodePipelineのワークフローを生成する必要がありました。
PATを廃止したいだけなのに変更が大掛かりになるので、CodeBuild/CodePipeline以外の方法を採用する必要があります。
課題3. TerraformのPlan/ApplyはAtlantisでも行なっているため、似た権限が複数存在する
2021年のアドベントカレンダー 6日目の記事 tfmigrate + Atlantis でTerraformリファクタリング機能をCI/CDに組み込むでご紹介した通り、crowdworks.jpではTerraformのPlan/ApplyにAtlantisを使用しています。
通常のPlan/ApplyはAtlantis、ドリフト検出はCodeBuildとTerraformのPlanを2か所で行なっている状態でした。 AWSのほぼすべてのリソースに対してPlanができるような、強いAWSの権限が複数箇所に散らばっているのは良くありません。Atlantisに寄せ、構成をシンプルにしたいところです。
Atlantis
Atlantisとは
どのように組み込んだかを紹介する前に、少しだけAtlantisの紹介をします。 AtlantisはTerraformのPlan/ApplyをGitHubやGitLabなどのソースコード管理サービスのPull Requestを組み合わせることにより、以下のようなワークフローを運用できます。
- ユーザがPull Requestを作成すると、変更のあったディレクトリでPlanを実行し、GitHubにPlan結果をコメント
- Pull RequestのコメントからAtlantisにコマンドを実行すると、Applyを実行
- Apply後に自動でPull Requestをマージ
AtlantisはWebサーバとして起動します。GitHubのWebhook先にAtlantisのURLを登録することにより、AtlantisはGitHubのPull Request作成/Push/コメントなどのイベントを取得します。 Atlantisはそれらのイベントに応じて内部でPlan/Applyを実行し、その結果をGitHubのPull Requestにコメントします。
ここではAtlantis自体の構築方法は取り上げません。crowdworks.jpでのAtlantis構築について知りたい方はtfmigrate + Atlantis でTerraformリファクタリング機能をCI/CDに組み込むを読みましょう!
Atlantisのドリフト検出機能
「Atlantisにドリフト検出の仕組みはないの?」はみんな思うところです。残念ながら執筆時点(2022年9月)ではドリフト検出の機能はありません。 GitHubのAtlantisリポジトリを見ると、ドリフト検出の仕組みが欲しいというIssueがあります。
ドリフト検出機能そのものではないですが、Atlantis v0.19.8から /plan
と /apply
のAPIが追加されました。
作り込みを許容するのであれば、「GitHub Actionsなどで全ディレクトリに対しAtlantisのAPIを呼び出し、統合したPlan結果をPRにコメント」のような実装ができそうです(試してないですが)。
その他に、Atlantisにはカスタムワークフロー機能があります。
これはPlan/Applyのフェーズで、デフォルトでは terraform plan
/ terraform apply
のコマンドを実行しますが、これを任意の処理に差し替えることが可能です。
今回はカスタムワークフロー機能を使用してドリフト検出機能を実装します。
Atlantisのカスタムワークフローを理解するため、設定ファイルについてもう少し見ていきましょう。
設定ファイル
Atlantisでは大きく分けて3種類の設定ファイルがあります。 設定ファイル名はリポジトリ設定以外は任意の名前にできますが、ここではドキュメントに記載されたファイル名を記載しています。
- サーバ設定:
config.yaml
- サーバ側のリポジトリ共通設定:
repos.yaml
- リポジトリ設定:
atlantis.yaml
Atlantisの面白いところはリポジトリ設定の atlantis.yaml
を動的に生成することが可能というところです。
カスタムワークフロー
Atlantisにはカスタムワークフローという機能があり、PlanやApplyフェーズの処理をカスタマイズできます。
ワークフローはサーバ側のリポジトリ共通設定(repos.yaml)やリポジトリ設定(atlantis.yaml)で以下のように定義します。
この例では myworkflow
というワークフローを定義し、Planフェーズでは3つのコマンドを実行しています。
workflows: myworkflow: plan: steps: - run: terraform init -input=false - run: pre-plan.sh - run: terraform plan -input=false -refresh -out $PLANFILE apply: steps: - run: terraform apply $PLANFILE
カスタムワークフローの選択
定義したワークフローのうち、どのワークフローを使うかはサーバ側のリポジトリ共通設定(repos.yaml)の pre_workflow_hooks
というパラメータでカスタマイズできます。
pre_workflow_hooksを使ってatlantis.yamlを動的に生成することも可能です。 pre_workflow_hooksとカスタムワークフローを組み合わせることで、特定の条件で発火するカスタマイズ処理を組み込むことができます。
いろいろな言葉が出てきたのでここでまとめてみます。 設定ファイルとカスタムワークフローの関係を図に表すと以下のようになります。
サーバ側のリポジトリ共通設定で、workflows
と pre_workflow_hooks
を定義します。
workflows
: カスタムワークフローで処理するコマンドを指定pre_workflow_hooks
: ワークフロー実行前に実行するコマンド。カスタムワークフローを呼び出す場合は、この処理内で動的にatlantis.yaml
を生成し、プロジェクト定義内で実行するワークフロー名を指定します。
カスタムワークフロー呼び出しの条件に合致しない場合、デフォルトのワークフローが実行されます。
補足: Atlantisのロック
AtlantisのロックはTerraformのロックとは異なるものであり、以下のような違いがあります。
- Atlantisのロック : 複数のPull Request間で同じディレクトリに対して変更が行われないようにロックを取得。ロックの保持期限はPull Requestのマージ・クローズまで
- Terraformのロック : tfstateへのアクセスが重複しないようにロックを取得。ロックの保持期限はplan/applyなどのステート操作中
Atlantisのロックの仕組みについて少し補足します。 AtlantisはPlanしたディレクトリをロックします。(デフォルトのワークフローの場合)。 デフォルトのワークフローを使って全ディレクトリをPlanした場合、AtlantisはPlanしたディレクトリをすべてロックします。 Atlantisは事前にロックされているディレクトリにPlanするとエラーとなるため、全ディレクトリをPlanする場合には事前にすべてのディレクトリをアンロックしておく必要があります。 ドリフト検出のために全部のディレクトリをアンロックする運用は実用的ではないですね。
今回はカスタムワークフローを使いますが、カスタムワークフローではプロジェクト定義で指定した単一のディレクトリをロックします。 指定するディレクトリはPlan対象のディレクトリである必要はありません。 便宜上、あるディレクトリをロック対象として指定することで、ワークフローを実行します。
ロックディレクトリはプロジェクト定義内の dir
パラメータで指定します。
なお、プロジェクトのワークフローを実行する場合、同じロックディレクトリを指定している場合もロックエラーとなります。
その場合は動的に生成するリポジトリ設定の atlantis.yaml
内で別のロックディレクトリを指定することで回避できます。
サンプルのカスタムワークフローを作成
サンプルとして簡単なワークフローを作成しましょう。
ブランチ名が hello-(任意の文字列)
のブランチで、ルートディレクトリに変更があった場合に hello
をコメントするワークフローを作成します。
echo-hello
ワークフローをrepos.yaml
に設定します。
workflows: echo-hello: plan: steps: - run: echo hello
repos.yaml
に pre_workflow_hooks
を設定します。
repos: - shared id: /.*/ pre_workflow_hooks: - run: pre_workflow_hooks.sh
そして、 pre_workflow_hooks.sh
で動的にatlantis.yaml
を生成します。
dir
と when_modified
でどのディレクトリのどのファイルが修正されたという条件を指定し、 workflow
で実行するワークフローを指定します。
run
で実行するコマンドに対し、Atlantisはいくつかの環境変数を設定して実行してくれるため、$HEAD_BRANCH_NAME
にはPull Requestのヘッドブランチ名が入ります。
if [[ "$HEAD_BRANCH_NAME" =~ ^hello-.*$ ]] ; then cat << EOF > atlantis.yaml version: 3 projects: - name: echo-hello dir: . autoplan: when_modified: ["*"] enabled: true workflow: echo-hello EOF fi
条件に合致するPull Requestを作成するとカスタムワークフローが実行され、 "hello" をコメントしてくれました。
設計と実装
ドリフト検出を行うため、以下のように設計を行いました。
- GitHub Actions: スケジュール機能により月初にPull Requestを作成、月中はメインブランチをforce push。次月月初に前月のPull Requestをクローズ
- Atlantis: 全ディレクトリをplanするカスタムワークフローを定義。ドリフト検出用のブランチに変更があった場合、全ディレクトリをplanするワークフローを実行、GitHubのPull Requestにコメント
- SlackのGitHub App: ドリフト検出の結果をSlackに通知
構成としてはCircleCI + CodeBuildと大きく違いはありません。
GitHub Actions
CircleCIの場合、Pull Requestの操作やpushに対しGitHubのPATが必要となるため、GitHub Actionsに変更しました。
日次でドリフト検出を行うため、ブランチ名は daily-drift-check/yyyymm
としました。
日次で全ディレクトリのplan結果をGitHubにコメントするため、PRが長大にならないよう、月次でPRを作成し直すようにしました。
また、ロックディレクトリを .atlantis/locks/daily-drift-check/yyyymm/
とし、空の dummy.tf
を作成することで自動的にplanが実行されるようにしました。
.github/workflows/daily-drift-check.yaml
name: daily-drift-check permissions: contents: write pull-requests: write on: workflow_dispatch: schedule: # 毎日JST 4:00 (UTC 19:00)に起動 - cron: '00 19 * * *' concurrency: drift-check jobs: drift-check: runs-on: ubuntu-latest timeout-minutes: 3 steps: - uses: actions/checkout@v3 - run: "${GITHUB_WORKSPACE}/.github/workflows/daily-drift-check.sh" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/daily-drift-check.sh
#!/usr/bin/env bash set -e TZ="Asia/Tokyo" export TZ # 日次ドリフトチェック CURRENT_YM=$(date "+%Y%m") TARGET_BRANCH="daily-drift-check/${CURRENT_YM}" LOCK_DIR=".atlantis/locks/daily-drift-check/${CURRENT_YM}" LOCK_FILE="${LOCK_DIR}/dummy.tf" git config --global user.name "Drift Check Updater" git config --global user.email "YOUR_EMAIL_ADDRESS" # ブランチを作成 git switch -c "$TARGET_BRANCH" # ロックファイルを作成 mkdir -p "$LOCK_DIR" touch "$LOCK_FILE" # commit & force push git add "$LOCK_FILE" git commit -m "add dummy file to run daily drift check" git push -f origin "${TARGET_BRANCH}" # Pull Requestがない場合は作成 PR_COUNT=$(gh pr list -H "$TARGET_BRANCH" --json url | jq '. | length') if [ "$PR_COUNT" -eq 0 ]; then gh pr create --title "[DO NOT CLOSE/MERGE] daily drift check ${CURRENT_YM}" --body "日次のドリフトチェックです。クローズやマージをしないでください。" fi # 前月分のPull Requestがある場合はclose PREV_YM=$(date --date "1 month ago" "+%Y%m") PREV_BRANCH="daily-drift-check/${PREV_YM}" PR_COUNT=$(gh pr list -H "$PREV_BRANCH" --json url | jq '. | length') if [ "$PR_COUNT" -ne 0 ]; then gh pr close -d "$PREV_BRANCH" fi
Atlantis
サーバ側のリポジトリ共通設定である repos.yaml
です。
pre_workflow_hooks
で pre_workflow_hooks.sh
を指定します。
workflows
に全ディレクトリをplanするワークフロー plan-all
を定義します。
repos.yaml
repos: id: /.*/ pre_workflow_hooks: - run: pre_workflow_hooks.sh workflows: plan-all: plan: steps: - run: cd $(git rev-parse --show-toplevel) && plan_all_no_changes.sh
pre_workflow_hooks.sh ではブランチ名が daily-drift-check/yyyymm
の時に plan-all
ワークフローを実行するように指定します。
また、ロックディレクトリは .atlantis/locks/daily-drift-check/${YYYYMM}/
にしています。
月ごとにPull Requestを作成するため、ロックディレクトリも年月ごとに作成しています。
pre_workflow_hooks.sh
#!/bin/bash set -euo pipefail if [[ "$HEAD_BRANCH_NAME" =~ ^daily-drift-check/([0-9]{6})$ ]] ; then YYYYMM=${BASH_REMATCH[1]} cat << EOF > atlantis.yaml version: 3 projects: - name: plan-all dir: .atlantis/locks/daily-drift-check/${YYYYMM}/ autoplan: when_modified: ["*.tf"] enabled: true workflow: plan-all EOF fi
plan-all
ワークフローで実行するシェルスクリプト plan_all_no_changes.sh
です。
crowdworks.jpのterraform環境では、tfstateが存在するディレクトリにはconfig.tfがあるので、config.tfの存在するディレクトリを全てplanします。
終了ステータスが0以外であれば失敗となるため、ドリフトを検出した場合はステータスコードを0以外にしています。
また、標準出力をPull Requestにコメントするため、必要な情報のみ標準出力に出すように制御しています。
plan_all_no_changes.sh
#!/bin/bash # # tfstateがある全てのディレクトリで terraform planを実行し、プラン差分とwarningを確認する。 # # リポジトリが存在するディレクトリ REPO_DIR=$(pwd) # tfstateファイルが存在するディレクトリ一覧 # config.tfがあることを前提にしている ALL_TFSTATE_DIRS=$(find . -type f -name 'config.tf' -print0 | xargs -I {} dirname {} | sed -e "s/.\///" | sort) # 変更があった場合でもすべてのディレクトリをplanした上で最後にCIを落としたいので、 # なんらかの変更があったディレクトリの一覧を保存する WARN_DIRS=() DRIFTED_DIRS=() # 使用するTerraformのバージョンは.terraform-versionを使用し、全てのディレクトリで使用するバージョンが一致していることを前提としている if [ ! -f .terraform-version ]; then echo "no .terraform-version file found." exit 1 fi TERRAFORM_VERSION=$(cat .terraform-version) TERRAFORM_BIN="/var/lib/atlantis/bin/terraform${TERRAFORM_VERSION}" # Terraformのバイナリがない場合はダウンロードする if [ ! -x "$TERRAFORM_BIN" ]; then echo "Downloading Terraform $TERRAFORM_VERSION" TF_DL_DIR=$(mktemp -d "/tmp/terraform${TERRAFORM_VERSION}.XXXXXX") OS_NAME=linux OS_ARCH=amd64 TF_URL="https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_${OS_NAME}_${OS_ARCH}.zip" curl -sSL -o "${TF_DL_DIR}/terraform.zip" "$TF_URL" unzip "${TF_DL_DIR}/terraform.zip" -d "$TF_DL_DIR" mv "$TF_DL_DIR/terraform" "$TERRAFORM_BIN" rm -rf "$TF_DL_DIR" else echo "Terraform $TERRAFORM_VERSION is already downloaded" fi # Atlantisのプラグインキャッシュディレクトリを使用 export TF_PLUGIN_CACHE_DIR="/var/lib/atlantis/plugin-cache" echo "plan following directories:" for plan_dir in ${ALL_TFSTATE_DIRS}; do # plan差分あり / warningありの時にplanを表示するか display_plan=0 cd "$REPO_DIR/$plan_dir" || exit 1 echo "##### ${plan_dir}" "$TERRAFORM_BIN" init -input=false -no-color >/dev/null "$TERRAFORM_BIN" plan -input=false -no-color -lock-timeout=60s -detailed-exitcode -out=terraform.tfplan >plan.log ret=$? case $ret in 0) ;; 1) echo "failed to plan on $plan_dir" # 戻り値はnumericで返す必要がある # shellcheck disable=SC2086 exit $ret ;; 2) DRIFTED_DIRS=("${DRIFTED_DIRS[@]}" "$plan_dir") echo "plan succeeded, there is a diff on $plan_dir" display_plan=1 ;; *) echo "unexpected return status: $ret on $plan_dir" # 戻り値はnumericで返す必要がある # shellcheck disable=SC2086 exit $ret ;; esac # plan中に出力されたwarningを抽出 # terraform validateはスキーマレベルのdeprecatedなどの警告しか拾えず、coreやproviderのコード内で出力される警告を拾えないため、 # planのwarningをgrepする実装としている。 if grep -i 'warning' plan.log; then WARN_DIRS=("${WARN_DIRS[@]}" "$plan_dir") echo "detect warnings on $plan_dir" display_plan=1 fi if [[ $display_plan == 1 ]]; then cat plan.log fi done # 最終的に CHANGED or WARNING が0以外の場合はCIで落とす if [[ "${#DRIFTED_DIRS[@]}" -ne 0 || "${#WARN_DIRS[@]}" -ne 0 ]]; then echo "" echo "Directories that have drifts: ${DRIFTED_DIRS[*]}" echo "Directories that have warnings: ${WARN_DIRS[*]}" exit 1 fi
実行結果
ドリフトがない場合は、Slackに以下のように通知されます。
ドリフトがある場合はPlanが失敗するので、Slackにも失敗とわかるよう出力されます。
Show Output
内には差分となったディレクトリとplan結果が出力されます。
AWSにログインしなくてもどのディレクトリでドリフトしているのかを確認できるのはありがたいです。
##### dir2
plan succeeded, there is a diff on dir2
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# null_resource.foo3 will be created
+ resource "null_resource" "foo3" {
+ id = (known after apply)
}
# null_resource.foo4 will be created
+ resource "null_resource" "foo4" {
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
まとめ
AtlantisでTerraformのドリフト検出をする方法を紹介しました。 他にもAtlantisでTerraformの全ディレクトリのplanをする必要があるものとして以下が挙げられます。
crowdworks.jp ではこれらについてもカスタムワークフローでの処理を行なっていますが、長くなってきたのでまたどこかで紹介したいと思います。
クラウドワークスでは全方位での採用を行なっています。 興味のある方はこちらから応募ください。