イントロ
Terraformの実行は手動で行うことが多いと思いますが、これだと色々と問題があります。
- コードをマージしたけどTerraformの実行を忘れる
- 毎回手動でやるのがめんどくさい
- それぞれの環境にTerraoformの実行環境を作る必要がある
こうゆう手動作業にまつわる問題を解決するために、CircleCIで自動化しよう!っとなるわけですが実際にやってみると色々な問題に遭遇します。この記事ではCircleCIを使ってTerraformの自動化をする際のベストプラクティスについて解説します。
Terraformジョブの共通化
Terraformはディレクトリ単位でインフラを管理するのが通常なので各ディレクトリでTerraformコマンドを叩きます。forループで全ディレクトリに一つずつ cd
して実行する方法もありますが宣言的に書きたいのでCircleCIのParameterized Jobを使って共通化します。
Terraform Jobの定義
executors:
terraform:
docker:
- image: my-org/my-terraform
working_directory: ~/src
...
terraform:
resource_class: medium
parameters:
tf_path:
type: string
executor: terraform
working_directory: ~/src
shell: /bin/bash -eo pipefail -o nounset
steps:
- checkout
- run:
name: Running Terraform
environment:
TFNOTIFY_LOG: /tmp/tfnotify.log
PARTITION: << parameters.tf_path >>
command: ./scripts/run-terraform.sh
CircleCI 2.1のParameterized Jobを使って共通化します。主要なところだけ解説します。
executor: terraform
Terraformのバイナリやその他のツールが入ったイメージを executors
で定義してそれを使っています。
parameters:
tf_path:
type: string
tf_path
でTerraformを実行するディレクトリパスを指定します。ジョブの中ではこのディレクトリに cd
してコマンドを実行します。
command: ./scripts/run-terraform.sh
このスクリプトの中で terraform plan
や terraform apply
を実行します。直接 .circleci/config.yml
に書いてもいいですがポータビリティーとテストのしやすさからシェルスクリプトにしています。
Terraform Jobを使う
- terraform:
name: "elasticache"
tf_path: "terraform/elasticache"
context: terraform
requires:
- test
定義したTerraform Jobを実際に使います。tf_path
で実行するディレクトリを指定するのでどのディレクトリで実行するかを宣言的に書けるようになっています。
ポイントは name
を指定している点です。これがないとCircleCIのワークフロー画面で見たときに全部のジョブが terraform
と表示されて区別がつきません。
Terraform実行の通知
手動で運用していると、terraform plan
を実行してログを確認してから apply
ということが普通かと思います。これをCI/CDで自動化するために tfnotify を使いましょう。使い方は2パターンあります。
トピックブランチの時
トピックブランチで実行される時は実行結果をPRレビューに含めたいので tfnotify
がPRに実行結果をコメントで入れるようにします。以下はtfnotifyがPRへ通知する時の設定例です。
---
ci: circleci
notifier:
github:
token: $GITHUB_TOKEN
repository:
owner: <my-org>
name: <my-repo>
terraform:
fmt:
template: |
{{ .Title }}
{{ .Message }}
{{ .Result }}
{{ .Body }}
plan:
template: |
{{ .Title }} <sup>[CI link]( {{ .Link }} )</sup>
{{ .Message }}
{{if .Result}}
<pre><code> {{ .Result }}
</pre></code>
{{end}}
<details><summary>Details (Click me)</summary>
<pre><code> {{ .Body }}
</pre></code></details>
$GITHUB_TOKEN
、 <my-org>
、<my-repo>
は適宜変更してください。
masterブランチの時
後述しますが、masterブランチではapplyを実行するので、実行後に問題がないかすぐに確認したいのでSlackに通知します。
---
ci: circleci
notifier:
slack:
token: $SLACK_TOKEN
channel: <my-channel>
bot: <bot-name>
terraform:
apply:
template: |
{{ .Message }}
{{if .Result}}
``` {{ .Result }} ```
{{end}}
$SLACK_TOKEN
、<my-channel>
、<bot-name>
は置き換えてください。
tfnotifyの実行
GitHubとSlackへの通知設定ができたので実際に通知する方法を見てみましょう。
GitHub
tfnotify --config ~/src/.tfnotify/github.yml plan --title "$(tfnotify_title)" <"$TFNOTIFY_LOG" >/dev/null || true
Slack
tfnotify --config ~/src/.tfnotify/slack.yml apply --title "Changes were successfully applied to $PARTITION" --message "See details at $CIRCLE_BUILD_URL" <"$TFNOTIFY_LOG" >/dev/null || true
$TFNOTIFY
にはTerraform実行時のログを流しています。
terraform apply -auto-approve -no-color $PLAN_FILE | tee "$TFNOTIFY_LOG"
これでTerraformの実行結果をPRレビュー、適用されたらSlackで確認、ということができるようになりました。
apply
する場所の判断
Terraformでは terraform apply
コマンドを実行してTerraformで記述したインフラのあるべき状態をリアルなインフラに適用します。手動で実行しているうちはとてもシンプルなオペレーションなのですが、CI/CDで自動化しよとすると色々と考えるべき問題があることに気付きます。
一番大きな問題は、いつ apply
を実行するかということです。Terraformでは各モジュールをディレクトリごとに管理していて、そこのディレクトリ直下でapplyすると変更が反映されます。
人間は自分の変更した箇所をわかっているので、そのディレクトリに移動して terraform apply
とやればいいので簡単です。
しかし、CircleCIで自動化すると、どのディレクトリで terraform apply
するか判断しないといけません。なぜなら、CircleCIはコミットのプッシュをトリガーにして実行されるので、その都度どのディレクトリが変更されたのかを判断しないと正しい場所でapplyできません。
前置きが長くなりましたが、これが apply
する場所の判断の問題です。
まずは直感的だけど間違っている方法を解説して、その後正しい方法を紹介します。
直感的だけど間違っている方法
直感的には各コミットの変更箇所を特定して、変更のあった部分でだけ terraform apply
するのがよさそうです。また apply
はTerraformが実際のインフラに変更をかける部分でもあるので、変更された箇所でしか実行したくありません。このことから 変更された場所のみで apply
というアプローチは正しそうです。
これをCircleCIで実現するために、各ディレクトリで変更があったら terraform apply
してなければスキップを実装しました。
また、apply
は一発本番のコマンドなので master
ブランチでしか実行したくありません。
つまりやりたいことはこうゆうことです。
- コミット内容から
apply
するディレクトリを特定 - (トピックブランチ):
terraform plan
を実行 - (masterブランチ):
terraform apply
を実行
詳しい実装方法はここでは書きませんが dnephin/multirepo を使って変更がなければジョブを途中止めるということをやりました。
このやり方でしばらく運用してみると以下の問題に遭遇しました。
問題1: masterブランチで変更箇所の特定が難しい
トピックブランチでは git diff master my-branch
で変更箇所が簡単にわかります。しかし、master
ブランチではくらべる先がないのでこの方法は使えません (自分自身がmasterなので)。方法としてはマージされたPRのマージコミットから変更箇所を推測する方法です。でも、これだとGitHubでSquash Mergeされると破綻します。
この問題のワークアラウンドとして、CircleCIでmasterブランチのワークフローの最後でHEAD SHAをs3に保存して、次回のワークフローでダウンロードして現在のmasterと比べることをやりました。
この方法でしばらく運用してみるも、すぐに問題に気付きます。SHAのアップロードはすべてのジョブが成功した最後に実行したいですが、途中のジョブがなんらかの理由で失敗すると保存がされません。この間に別の人がmasterにプッシュして別のジョブが走ると不整合が起こってしまいます。
結局、ワークフロー全体がs3に依存することになり、この方法はよくないことがわかりました。
追記
これはいわゆるmono repo問題というやつでCircleCIががんばってくれれば解決できる問題ですが、今のところCircleCIはmono repoのサポートは弱いのでうまく扱うことができません。
問題2: 依存する変更箇所の検知
Terraformではモジュールで共有コードを管理して、実際のインフラは実装部分に書くということをよくします。モジュールを変更した時はそれを実際に使う実装側のディレクトリでapplyしないと反映されません。しかし、変更箇所だけapplyするやり方だとモジュールが変更された時は実装部分にはコードの変更がないのでapplyされません。
Terraformの data
などを使って他のインフラの状態を参照している場合も問題があります。参照されているインフラが変更されたら、参照している側でもapplyしないといけませんが、参照している側にはコード変更はないのでapplyされません。
まとめると、変更部分だけapplyだとTerraformでの依存性をうまく扱えないということがわかりました。
正しい方法
ここまで見てきたように、変更箇所を特定してapplyするアプローチは直感的ですが必要以上にワークフローを複雑するBad Practiceということがわかりました。
最終的にたどりついたのは、常にapply するというアプローチです。この方法ではmasterブランチではTerraformの全ディレクトリで変更があろうがなかろうがapplyします。運用してみて現時点ではこの方法がベストだと思っているので、導入してみてよかった点を紹介します。
外部依存 (s3) をなくす
この方法では変更箇所を検知する必要がないのでmasterブランチでdiffをとれない問題もなくなります。これにより、s3にSHAを保存するという必要もなくなりました。
依存するモジュールのアップデート
常にapplyしているので、モジュールだけ変更されて実装は変更なし、という場合も必ず実装側でapplyを実行するので対応できるようになりました。
Configuration Drift (意図しない設定の差異) をなくす
Terraformで運用しているインフラに意図せず誰かが手動で変更してしまうことはよくあります。常にapplyするアプローチではCircleCIのmasterのワークフローが走ったときに自動でTerraformをapplyするので、手動での変更をTerraformのあるべき状態に戻してくれます。これにより、Terraformと実際のインフラが常に同期された状態を保つことができます。
常にapplyって怖くない?
運用する前は、変更していないところでもapplyを実行するというのは怖くない?とチームで話していましたがやってみると全然大丈夫でした。これは前述したtfnotifyによる通知で変更箇所が見える化されているので、意図しない変更がapplyされてもすぐに気付けることが大きいと思います。
その他Tips
以上がCircleCIでTerraformを実行する時のポイントになりますが、いくつか便利なTipsも紹介します。
WorkflowのRerunを防ぐ
CircleCIでは過去のジョブをあとから再実行することができます。もし、過去のTerraformジョブを実行するとどうなるでしょうか?過去のコミットでジョブは実行されるので、最新のインフラに古い変更を適用することになりかねないので危険です。これを防ぐためにチェックを入れます。
function check_branch_update_to_date() {
# check remote branch exist or not
if git ls-remote --exit-code --heads origin "$CIRCLE_BRANCH"; then
# remote branch is existed
LATEST_SHA1_IN_CURRENT_REMOTE_BRANCH=$(git rev-parse "origin/$CIRCLE_BRANCH")
if [[ "${CIRCLE_SHA1}" == "${LATEST_SHA1_IN_CURRENT_REMOTE_BRANCH}" ]]; then
echo "Found current branch $CIRCLE_BRANCH is update to date, ready to run terraform."
else
echo "Skip to run terraform on old commit ${CIRCLE_SHA1} in current branch $CIRCLE_BRANCH."
exit 1
fi
else
# remote branch is not existed, like you have deleted the remote branch after you merge to master
echo "Remote branch $CIRCLE_BRANCH is not existed, skip to run terraform."
exit 1
fi
}
このシェルスクリプトはTerraformを実行しようとしているブランチのSHAが最新かどうかチェックして古ければ実行しないというスクリプトです。このチェックは完璧ではないですが、間違って古いmasterのワークフローを再実行してインフラを壊してしまうことを不正でくれます。
masterにrebaseしてからTerraformを実行
PRのブランチからTerraformを実行したとき、他の人がmasterブランチにプッシュしてインフラを更新してしまうとPRブランチで実行したときにすでにmasterで適用されている変更が再びdiffとして出ることがあります。
これを防ぐために、最新のmasterをrebaseしてからTerraformを実行します。
function merge_master() {
cat <<EOF
Merging master to the feature branch so that Terraform doesn't show rollbacks of
changes that have been applied from master since the PR was branched.
EOF
git config --global user.name "$(git show -s --format='%an' "${CIRCLE_SHA1}")"
git config --global user.email "$(git show -s --format='%ae' "${CIRCLE_SHA1}")"
git checkout --detach
git pull origin master --no-edit
git --no-pager diff --check
}
function restore_branch() {
cat <<EOF
Restoring the feature branch, without the merge from master, so that tfnotify
can identify the right commit.
EOF
git reset --hard
git checkout "$CIRCLE_BRANCH"
}
Terraformの実行をmergeとrestoreで挟んでやると常に最新のmasterとのdiffのみでTerraformを実行できるので正しい差分を得ることができます。
追記
この機能はTravis CIには標準機能としてありますが、CircleCIにはないので自分たちで実装しています。