Terraformのチーム運用にあたり整えたこと

# 概要

この記事ではTerraformのチーム運用にあたり、どのようにTerraformの継続的applyを整えたかをまとめます。
インフラ環境はAWSで、主要な事は[実践Terraform](https://www.amazon.co.jp/dp/B07XT7LJLC/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)の第27章 継続的applyと[Terraform どこで実行していますか？](https://medium.com/mixi-developers/terraform-on-aws-codebuild-44dda951fead/)という記事にまとめられている所とほぼ同じで、実際の環境作成でもこの記事を非常に参考にさせていただいたのですが、一つのサンプルケースとして参考になれば幸いです。

# 前提

利用しているのは実際のサービスで、AWSは本番用と開発用で2アカウント利用しています。本番ではproductionとstagingとという環境が、開発ではdevelopmentという環境が動いています。terraformのソースはアプリケーションのコードと合わせてGitHubで管理しており、GitFlowでの運用を行っています。tfファイルはterraform cloudで管理しており、planやapplyは実質一人がローカルで実施しているという状態でした。

フォルダ構成は以下のようになってます。

```
app/                        ... アプリケーションのコード
terraform/prod/             ... 本番AWSアカウント用のディレクトリ
              /common       ... 本番AWSアカウントで共通に使うもののtfファイル群
              /prod         ... production環境で使うもののtfファイル群
              /stg          ... staging環境で使うもののtfファイル群
         /dev/              ... 開発AWSアカウント用のディレクトリ
             /common        ... 開発AWSアカウントで共通に使うもののtfファイル群
             /dev           ... develop環境で使うもののtfファイル群
```

# 今回達成したい事

- AWS上でplan,applyをさせたい
  - 実行に時間がかかるものをローカルで実行されるのは途中で落ちないか不安
- 複数人運用しても事故が起こらないようにしたい
  - 常に最新の状態でapplyがされるようにしたい
  - terraformの内容をレビューし合えるようしたい
- 本番環境以外はある程度柔軟に操作できるようにしたい 
  - 開発環境ではトライアンドエラーがしやすいようにしたい。

＃ 最終的に実現できたもの

- GitHubでプルリク時にCodeBuild上でplan実行、マージ時にapply実行
- plan,apply実行時にはGitHubのコメントとSlack両方に通知する
  - GitHubだけだとマージ後にapplyを行った際、終わったかを見に行かなければいけないのが手間なので 
- 本番と本番共通のterraformはmasterのマージ時にそれ以外はdevelopのマージ時に行う。 　
- ローカルではapplyとplan両方が実行できてしまうが、applyは実行しないというルールにする
  - planができないのは不便なため。applyができるのは塞ぎたかったがうまくできず今後の検討事項

# 実装の詳細

## 注意事項

説明したコードは、あくまでもお使いの環境や要件に合わせて変更し、動作確認を十分におこなった上でご利用ください。
また、この運用自体も成熟しているとは言い難いので今後修正していく可能性が大いにあります。より良い方法についての指摘がもしあればいただけると非常にありがたいです！

## フォルダ構成
今回の仕組み作るにあたって以下を追加しています。

```
terraform/continuous_apply/script/apply.sh        ... apply用のシェル
                                 /build.sh        ... applyかplanかで振り分ける用のシェル
                                 /install.sh      ... tfnotifyのインストール用
                                 /plan.sh         ... plan用のシェル
                          /_terraformrc           ... terraform cloudの設定ファイル用のテンプレート
                          /buildspec.yml          ... CodeBuild用のyaml
                          /tfnotify_github.yml    ... tfnotifyの設定ファイル、github用
                          /tfnotify_slack.yml     ... tfnotifyの設定ファイル、slack用
```

それぞれのファイルを見ていきます。

## CodeBuildで実行されるシェルスクリプト

install.shではtfnotifyをインストールしています。これが既に組み込まれたコンテナImageがあれば良いので、いずれ対応する予定です。なお、tfnotifyは現状の最新を入れたため、実践Terraformにあったサンプルスクリプトをアレンジしてます。

```install.sh
#!/bin/sh

VERSION="v0.6.1"
BASE_URL=https://github.com/mercari/tfnotify/releases/download
DOWNLOAD_URL="${BASE_URL}/${VERSION}/tfnotify_linux_amd64.tar.gz"
wget ${DOWNLOAD_URL} -P /tmp
mkdir -p /tmp/tfnotify_linux_amd64
tar zxvf /tmp/tfnotify_linux_amd64.tar.gz -C /tmp/tfnotify_linux_amd64
mv /tmp/tfnotify_linux_amd64/tfnotify /usr/local/bin/tfnotify
```

build.shではマージ時とそうでないかの振り分け、さらにマージ時では本番環境とそれ以外をマージ先で切り分けています。
本当はplanも切り分けたかったのですが、現状まだできていません。

```build.sh
#!/bin/sh
set -x

if [[ ${CODEBUILD_WEBHOOK_TRIGGER} = 'branch/master' ]]; then
  ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/scripts/apply.sh master
elif [[ ${CODEBUILD_WEBHOOK_TRIGGER} = 'branch/develop' ]]; then
  ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/scripts/apply.sh develop
else
  ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/scripts/plan.sh
fi
```

planでは変更があったフォルダを取得し、terraform applyを実施します。GithubとSlack両方に通知したいので二回パイプしてます。

```plan.sh
#!/bin/sh

DIRS=$(git --no-pager diff origin/develop..HEAD --name-only | xargs -I {} dirname {} | grep "terraform" | uniq)
if [ -z "$DIRS" ]; then
  echo "No directories for apply."
  exit 0
fi

for dir in $DIRS
do
  if [ ! -e $dir/terraform.tf ]; then
    continue
  fi

  echo $dir
  (cd $dir && terraform init -input=false -no-color)
  (cd $dir && terraform plan -input=false -no-color | tfnotify --config ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/tfnotify_github.yml plan --message "$dir" | tfnotify --config ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/tfnotify_slack.yml plan --message "$dir" )
done
```

apply用のシェルでは基本的に流れはplanと同じですが、本番用かそれ以外かを切り分けています。

``` apply.sh
#!/bin/sh

MODE=$1
PROD_DIRS='terraform/prod/prod|terraform/prod/common'

echo "Mode: ${MODE}"

MESSAGE=$(git log ${CODEBUILD_SOURCE_VERSION} -1 --pretty=format:"%s")
CODEBUILD_SOURCE_VERSION=$(echo ${MESSAGE} | cut -f4 -d' ' | sed 's/#/pr\//')

get_dir_list () {
  if [ $1 = 'master' ]; then
    git --no-pager diff HEAD^..HEAD --name-only | xargs -I {} dirname {} | grep "terraform" | egrep "$2" | uniq
  else
    git --no-pager diff HEAD^..HEAD --name-only | xargs -I {} dirname {} | grep "terraform" | egrep -v "$2" | uniq
  fi
}

DIRS=$(get_dir_list $MODE $PROD_DIRS)
echo $DIRS

if [ -z "$DIRS" ]; then
  echo "No directories for apply."
  exit 0
fi

for dir in $DIRS
do
  if [ ! -e $dir/terraform.tf ]; then
    continue
  fi

  echo $dir
  (cd $dir && terraform init -input=false -no-color)
  (cd $dir && terraform apply -input=false -no-color -auto-approve | tfnotify --config ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/tfnotify_github.yml apply --message "$dir" | tfnotify --config ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/tfnotify_slack.yml apply --message "$dir" )
done
```

## buildspec.yml、tfnotifyの設定

以下がCodeBuildで実装する用のbuilspecです。
ポイントとしては、install時にtfnotifyを入れているのと、terraform cloudの設定ファイルをおくための処理を入れてます。

```buildspec.yml
version: 0.2

env:
  parameter-store:
    GITHUB_TOKEN: "/continuous_apply/github_token"
    TERRAFROM_CLOUD_TOKEN: "/continuous_apply/terraform_cloud_token"
    SLACK_TOKEN: "/continuous_apply/slack_token"

phases:
  install:
    commands:
      - ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/scripts/install.sh
      - sed -e "s/_TOKEN_/${TERRAFROM_CLOUD_TOKEN}/" ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/_terraformrc > ~/.terraformrc

  build:
    commands:
      - ${CODEBUILD_SRC_DIR}/terraform/continuous_apply/scripts/build.sh
```

```_terraformrc
credentials "app.terraform.io" {
  token = "_TOKEN_"
}
```

tfnotitfyの設定は以下のようになってます。読みやすくするためGitHubとSlackでフォーマットを変えてます。

```tfnotify_githhub.yml
ci: codebuild
notifier:
  github:
    token: $GITHUB_TOKEN
    repository:
      owner: "(__secret__)"
      name: "(__secret__)"
terraform:
  plan:
    template: |
      {{ .Title }}
      {{ .Message }}
      {{if .Result}}<pre><code> {{ .Result }} </pre></code>{{end}}
      <details><summary>Details (Click me)</summary>
      <pre><code> {{ .Body }} </pre></code></details>
  apply:
    template: |
      {{ .Title }}
      {{ .Message }}
      {{if .Result}}<pre><code> {{ .Result }} </pre></code>{{end}}
      <details><summary>Details (Click me)</summary>
      <pre><code> {{ .Body }} </pre></code></details>
```

```tfnotify_slack.yml
ci: codebuild
notifier:
  slack:
    token: $SLACK_TOKEN
    channel: "(__secret__)"
    bot: "(__secret__)"
terraform:
  plan:
    template: |
      {{ .Title }}
      {{ .Message }}
      {{if .Result}}
      ```
      {{ .Result }}
      ```
      {{end}}
      ```
      {{ .Body }}
      ```
  apply:
    template: |
      {{ .Title }}
      {{ .Message }}
      {{if .Result}}
      ```
      {{ .Result }}
      ```
      {{end}}
      ```
      {{ .Body }}
      ```
```

## AWSの設定

terraformで書いています。実際には本番用と開発用が必要で、以下は開発環境用です。
不要にビルドしないようにここでも変更フォルダでフィルタリングしています。

```aws_codebuild_project.tf
resource "aws_codebuild_project" "continuous_apply" {
  name         = "${var.project}-common-continuous-apply"
  service_role = module.continuous_apply_codebuild_role.iam_role_arn

  source {
    type      = "GITHUB"
    location  = "(_secret_)"
    buildspec = "terraform/continuous_apply/buildspec.yml"
  }

  artifacts {
    type = "NO_ARTIFACTS"
  }

  environment {
    type            = "LINUX_CONTAINER"
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "hashicorp/terraform:0.12.25"
    privileged_mode = false
  }

  provisioner "local-exec" {
    command = <<-EOT
      aws codebuild import-source-credentials \
        --server-type GITHUB \
        --auth-type PERSONAL_ACCESS_TOKEN \
        --token $GITHUB_TOKEN
    EOT

    environment = {
      GITHUB_TOKEN = data.aws_ssm_parameter.github_token.value
    }
  }
}
```

```aws_codebuild_project.tf
resource "aws_codebuild_webhook" "continuous_apply" {
  project_name = aws_codebuild_project.continuous_apply.name

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PULL_REQUEST_CREATED"
    }

    filter {
      exclude_matched_pattern = false
      pattern                 = "^terraform/dev/"
      type                    = "FILE_PATH"
    }
  }

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PULL_REQUEST_UPDATED"
    }

    filter {
      exclude_matched_pattern = false
      pattern                 = "^terraform/dev/"
      type                    = "FILE_PATH"
    }
  }

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PULL_REQUEST_REOPENED"
    }

    filter {
      exclude_matched_pattern = false
      pattern                 = "^terraform/dev/"
      type                    = "FILE_PATH"
    }
  }

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PUSH"
    }

    filter {
      type    = "HEAD_REF"
      pattern = "develop"
    }

    filter {
      exclude_matched_pattern = false
      pattern                 = "^terraform/dev/"
      type                    = "FILE_PATH"
    }
  }
}
```

## GitHubの設定

細かな説明は省きますが、ブランチに対して、事故防止のため以下の設定を入れています。

- Require pull request reviews before merging
- Require branches to be up to date before merging

# まとめ

以上で継続的applyの仕組みを整えることができました。完全にガッチリとした運用、仕組みとは言い難いのですが、一人でしか扱えない状態に比べては格段に運用しやすくなったかと思います。参考にしつつ仕組みを組んでいただければ幸いです。

# 参考

冒頭に述べたものとほぼ同じですですが、改めてまとめます。これらの内容は非常に参考になりました。ありがとうございます！

- [実践Terraform](https://www.amazon.co.jp/dp/B07XT7LJLC/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1) 第27章 継続的apply
- [Terraform どこで実行していますか？](https://medium.com/mixi-developers/terraform-on-aws-codebuild-44dda951fead/)
- [メルカリ Microservices Team による Terraform 運用とその中で開発したOSSの紹介](https://tech.mercari.com/entry/2018/04/09/110000)
- [github.com/mercari/tfnotify](https://github.com/mercari/tfnotify)