CircleCIでTerraformする時のベストプラクティス

# イントロ

Terraformの実行は手動で行うことが多いと思いますが、これだと色々と問題があります。

* コードをマージしたけどTerraformの実行を忘れる
* 毎回手動でやるのがめんどくさい
* それぞれの環境にTerraoformの実行環境を作る必要がある

こうゆう手動作業にまつわる問題を解決するために、CircleCIで自動化しよう！っとなるわけですが実際にやってみると色々な問題に遭遇します。この記事ではCircleCIを使ってTerraformの自動化をする際のベストプラクティスについて解説します。

# Terraformジョブの共通化

Terraformはディレクトリ単位でインフラを管理するのが通常なので各ディレクトリでTerraformコマンドを叩きます。forループで全ディレクトリに一つずつ `cd` して実行する方法もありますが宣言的に書きたいのでCircleCIのParameterized Jobを使って共通化します。

## Terraform Jobの定義

```yaml
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を使って共通化します。主要なところだけ解説します。

```yaml
    executor: terraform
```

Terraformのバイナリやその他のツールが入ったイメージを `executors` で定義してそれを使っています。

```yaml
    parameters:
      tf_path:
        type: string
```

`tf_path` でTerraformを実行するディレクトリパスを指定します。ジョブの中ではこのディレクトリに `cd` してコマンドを実行します。

```
command: ./scripts/run-terraform.sh
```

このスクリプトの中で `terraform plan` や `terraform apply` を実行します。直接 `.circleci/config.yml` に書いてもいいですがポータビリティーとテストのしやすさからシェルスクリプトにしています。

## Terraform Jobを使う

```yaml
      - 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](https://github.com/mercari/tfnotify) を使いましょう。使い方は２パターンあります。

## トピックブランチの時

トピックブランチで実行される時は実行結果をPRレビューに含めたいので `tfnotify` がPRに実行結果をコメントで入れるようにします。以下はtfnotifyがPRへ通知する時の設定例です。

```yaml
---
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に通知します。

```yaml
---
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**

```bash
tfnotify --config ~/src/.tfnotify/github.yml plan --title "$(tfnotify_title)" <"$TFNOTIFY_LOG" >/dev/null || true
```

**Slack**

```bash
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実行時のログを流しています。

```bash
terraform apply -auto-approve -no-color $PLAN_FILE | tee "$TFNOTIFY_LOG"
```

これでTerraformの実行結果をPRレビュー、適用されたらSlackで確認、ということができるようになりました。

<img width="644" alt="Screen Shot 2019-12-21 at 9.29.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16437/ce158809-f78c-19d5-8a70-224888454b69.png">

# `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](https://circleci.com/orbs/registry/orb/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ジョブを実行するとどうなるでしょうか？過去のコミットでジョブは実行されるので、最新のインフラに古い変更を適用することになりかねないので危険です。これを防ぐためにチェックを入れます。

```bash
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を実行します。

```shell
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
}
```

```shell
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にはないので自分たちで実装しています。