クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

AtlantisでTerraformのドリフト検出

こんにちは。crowdworks.jp SREチームの@kangaechuです。 先日開催されたイベント HashiTalks: Japanを見ていましたが、Terraformのドリフト検出に関する発表が多く、Terraform運用において重要なポイントなんだなと感じました。 流行りに乗ったわけではないですが、今回はTerraformのドリフト検出をAtlantisで行った方法をご紹介します。

この記事は以下環境で検証しています。

ドリフト検出

Terraformはインフラストラクチャを安全かつ期待通りに構築するために使用する、Infrastructure as Codeのソフトウェアです。 crowdworks.jpではAWSGitHubGCPの管理をTerraformで行っていますが、今回はAWSに絞って話します。

TerraformではリソースをHCLで記述したコードと、前回のapply時の状態が保存されたtfstateファイルと、実際のリソースの3つの要素で管理します。 基本的にこの3つの状態は一致しています。 しかしさまざまな理由により、これらの状態が一致しなくなることがあります。たとえば以下のようなものが挙げられます。

  • Terraform外(AWSマネジメントコンソールなど)での変更
  • Apply のし忘れ
  • Terraformやプロバイダのバージョンアップによる破壊的変更

Terraformのコードの状態と実リソースの状態に差異が発生した場合、できるだけ早期に検出したいですね。 そのために「ドリフト検出」を行います。 ドリフトは「想定とは異なった状態」を表し、ドリフト検出とは「想定と異なった状態を検出する」ことを指します。 この記事ではTerraformで全部のディレクトリにterraform planを行い、plan結果に差分がないことを確認する処理を「ドリフト検出」と呼びます。

crowdworks.jpでの以前のドリフト検出の仕組み

ドリフト検出のため、以前はCircleCIとAWS CodeBuildを使用したシステムを運用していました。

crowdworks.jpでの以前のドリフト検出の仕組み
crowdworks.jpでの以前のドリフト検出の仕組み

  1. CircleCI: スケジュール機能によりGitHubのメインブランチからドリフト検出用のブランチに日次でforce push
  2. CodeBuild: GitHubのWebhook経由で起動し、ドリフト検出用のブランチにpushがあったらドリフト検出用のスクリプトを実行
  3. CodeBuild: ドリフト検出の結果をSlackに通知

このような構成で動いていたのですが、いくつか課題がありました。

課題1. ログの確認にAWSへのログインが必要

CodeBuildからSlackへは以下のように通知されていました。

CodeBuildからSlackへの通知
CodeBuildからSlackへの通知

Slackではドリフト検出の結果がエラーとなったことはわかりますが、詳細情報がわかりませんでした。 エラーのたびにAWSにログインしてCodeBuildのログからエラー部分を探す必要があり、かなりの手間となっていました。 そのため、Slackの通知からエラーメッセージを簡単に確認できることが求められていました。

課題2. CodeBuildがGitHub Appsに対応していないため、Private Access Token(PAT)の廃止ができない

crowdworks.jp ではGitHubのPAT廃止を進めています。 しかし、CodeBuildではソースプロバイダとしてGitHubを使用した場合、PATしか使用できません。

docs.aws.amazon.com

CodeBuildからCodePipelineに移行することでGitHub Appsを使用できます。 しかしCodePipelineは特定のブランチに紐づけることを前提としており、複数のブランチで実行する場合はLambdaとCloudFormationで動的にCodePipelineのワークフローを生成する必要がありました。

aws.amazon.com

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を組み合わせることにより、以下のようなワークフローを運用できます。

  1. ユーザがPull Requestを作成すると、変更のあったディレクトリでPlanを実行し、GitHubにPlan結果をコメント
  2. Pull RequestのコメントからAtlantisにコマンドを実行すると、Applyを実行
  3. Apply後に自動でPull Requestをマージ

Atlantisのフロー
Atlantisのフロー
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があります。

github.com

ドリフト検出機能そのものではないですが、Atlantis v0.19.8から /plan/applyAPIが追加されました。 作り込みを許容するのであれば、「GitHub Actionsなどで全ディレクトリに対しAtlantisのAPIを呼び出し、統合したPlan結果をPRにコメント」のような実装ができそうです(試してないですが)。

その他に、Atlantisにはカスタムワークフロー機能があります。 これはPlan/Applyのフェーズで、デフォルトでは terraform plan / terraform apply のコマンドを実行しますが、これを任意の処理に差し替えることが可能です。 今回はカスタムワークフロー機能を使用してドリフト検出機能を実装します。 Atlantisのカスタムワークフローを理解するため、設定ファイルについてもう少し見ていきましょう。

設定ファイル

Atlantisでは大きく分けて3種類の設定ファイルがあります。 設定ファイル名はリポジトリ設定以外は任意の名前にできますが、ここではドキュメントに記載されたファイル名を記載しています。

Atlantisの面白いところはリポジトリ設定の atlantis.yaml を動的に生成することが可能というところです。

カスタムワークフロー

Atlantisにはカスタムワークフローという機能があり、PlanやApplyフェーズの処理をカスタマイズできます。

www.runatlantis.io

ワークフローはサーバ側のリポジトリ共通設定(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 というパラメータでカスタマイズできます。

www.runatlantis.io

pre_workflow_hooksを使ってatlantis.yamlを動的に生成することも可能です。 pre_workflow_hooksとカスタムワークフローを組み合わせることで、特定の条件で発火するカスタマイズ処理を組み込むことができます。

いろいろな言葉が出てきたのでここでまとめてみます。 設定ファイルとカスタムワークフローの関係を図に表すと以下のようになります。

設定ファイルとカスタムワークフローの関係
設定ファイルとカスタムワークフローの関係

サーバ側のリポジトリ共通設定で、workflowspre_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 パラメータで指定します。

www.runatlantis.io

なお、プロジェクトのワークフローを実行する場合、同じロックディレクトリを指定している場合もロックエラーとなります。 その場合は動的に生成するリポジトリ設定の atlantis.yaml 内で別のロックディレクトリを指定することで回避できます。

サンプルのカスタムワークフローを作成

サンプルとして簡単なワークフローを作成しましょう。 ブランチ名が hello-(任意の文字列) のブランチで、ルートディレクトリに変更があった場合に hello をコメントするワークフローを作成します。

echo-hello ワークフローをrepos.yaml に設定します。

workflows:
  echo-hello:
    plan:
      steps:
        - run: echo hello

repos.yamlpre_workflow_hooks を設定します。

repos:
- shared
  id: /.*/
  pre_workflow_hooks:
    - run: pre_workflow_hooks.sh

そして、 pre_workflow_hooks.sh で動的にatlantis.yamlを生成します。 dirwhen_modified でどのディレクトリのどのファイルが修正されたという条件を指定し、 workflow で実行するワークフローを指定します。 run で実行するコマンドに対し、Atlantisはいくつかの環境変数を設定して実行してくれるため、$HEAD_BRANCH_NAME にはPull Requestのヘッドブランチ名が入ります。

www.runatlantis.io

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のカスタムワークフロー実行結果。helloが返ってきている
GitHubのカスタムワークフロー実行結果

設計と実装

ドリフト検出を行うため、以下のように設計を行いました。

ドリフト検出 構成
ドリフト検出 構成

  1. GitHub Actions: スケジュール機能により月初にPull Requestを作成、月中はメインブランチをforce push。次月月初に前月のPull Requestをクローズ
  2. Atlantis: 全ディレクトリをplanするカスタムワークフローを定義。ドリフト検出用のブランチに変更があった場合、全ディレクトリをplanするワークフローを実行、GitHubのPull Requestにコメント
  3. 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_hookspre_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に以下のように通知されます。

ドリフトがない状態のSlack
ドリフトがある場合はPlanが失敗するので、Slackにも失敗とわかるよう出力されます。

ドリフトがある状態の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 ではこれらについてもカスタムワークフローでの処理を行なっていますが、長くなってきたのでまたどこかで紹介したいと思います。

クラウドワークスでは全方位での採用を行なっています。 興味のある方はこちらから応募ください。

crowdworks.co.jp

© 2016 CrowdWorks, Inc., All rights reserved.