GitHub Actions上でTerraform CI/CD環境を構築する

# はじめに
Github Actionsの正式版がリリースされて1ヶ月ほど経ちました。中々さわれてなかったのですが、TerraformのCI/CD環境構築の調査で動かしたのでその時のまとめです。

# サンプルプロジェクト
とりあえず試すだけであれば、Terraform公式が用意している[Terraform GitHub Actions](https://github.com/hashicorp/terraform-github-actions)を使えばすぐに終わってしまいます。
それだけだと面白くないのでなるべく実際の運用を想定して動かしていきます。とはいえあらゆるパターンを試すのは難しいので以下のようなサンプルプロジェクトを仮定します。

- providerはAWS
- moduleを使う
- workspaceを使う
- リポジトリ内に複数のmain.tfがある

ディレクトリ構成は以下。（ファイルの中身は本題ではないので割愛）

```
├── modules
│   ├── ec2
│   │   ├── main.tf
│   │   └── variables.tf
│   └── iam-role
│       ├── main.tf
│       └── variables.tf
├── service1
│   ├── ec2
│   │   ├── main.tf
│   │   └── variable.tf
│   └── iam-role
│       ├── main.tf
│       └── variable.tf
└── service2
    ├── ec2
    │   ├── main.tf
    │   └── variable.tf
    └── iam-role
        ├── main.tf
        └── variable.tf
```

# ゴール
上に書いた構成のサンプルに対して以下の1〜4を行う。

1. masterブランチへのプルリクエスト作成をトリガーに以下の3つ（以降、自動テストと呼ぶ）を実行する。
    - terraform fmt
    - terraform validate
    - terraform plan
- 一度のプルリクエストで複数のworkspaceに対してまとめて自動テストを実行する。
- masterブランチへのプッシュ（マージ）をトリガーに自動テストと`terraform apply`を実行する。自動テストのwrokflowが成功した場合のみapplyを実行する。
- 自動テストおよびapplyは毎回リポジトリ内の全てのmain.tfに対して実行するのではなく、変更があったファイルに関係するmain.tfに対して実行する。

# 1. masterブランチへのPR作成をトリガーに自動テストを実行

## workflowの作成
早速workflowを作成していきますが、Terraformの公式が用意してくれている[Terraform GitHub Actions](https://www.terraform.io/docs/github-actions/index.html)を使っていきます。
以下の公式のサンプルを修正していく形で進めます。

```yml:.guthub/workflows/terraform.yml
name: 'Terraform GitHub Actions'
on:
  - pull_request
jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    steps:
      - name: 'Checkout'
        uses: actions/checkout@master
      - name: 'Terraform Format'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'fmt'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: 'Terraform Init'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'init'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: 'Terraform Validate'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'validate'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: 'Terraform Plan'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'plan'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

## AWSのcredential
AWSのリソースを扱うためのcredentialをsecretsから取得するようにします。`secrets.xxx` でGitHub上で設定したSecretsの情報できます。
GitHub側の設定はリポジトリの Settings > Secrets で設定してあげればOKです。
<img width="500" alt="スクリーンショット 2019-12-15 11.39.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132008/91125285-85e7-275a-3dd4-ea2e61510f87.png">

stepsの中にあったenvを一つ上の階層に移動させ、そこにAWSのクレデンシャルの情報を設定しています。Checkoutのstepでは使用しませんが、何度も書くのも冗長なので上に持ってきています。

```yml:.guthub/workflows/terraform.yml
name: 'Terraform GitHub Actions'
on:
  - pull_request
jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    env: # 追加
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 移動
      AWS_ACCESS_KEY_ID:  ${{ secrets.AWS_ACCESS_KEY_ID }} # 追加
      AWS_SECRET_ACCESS_KEY:  ${{ secrets.AWS_SECRET_ACCESS_KEY }} # 追加
    steps:
      - name: 'Checkout'
        uses: actions/checkout@master
      - name: 'Terraform Format'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'fmt'
      - name: 'Terraform Init'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'init'
      - name: 'Terraform Validate'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'validate'
      - name: 'Terraform Plan'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'plan'
```

## workspaceを指定する
まずはdevというworkspaceで動くようにしてみます。 [Terraform GitHub Actions](https://github.com/hashicorp/terraform-github-actions)では`TF_WORKSPACE`という環境変数でworspaceを指定できるのでenvに追加します。

``````yml:.guthub/workflows/terraform.yml（一部抜粋）

    env:
      TF_WORKSPACE: dev # 追加
```

## 実行するディレクトリを指定する
main.tfが複数に別れているので、どのmain.tfがあるディレクトリで実行するかを指定します。
Github Actionsに準備されているstrategy.matrixを指定してあげれば簡単に複数の設定に対してjobを実行することができます。
今回のサンプルだと4つmain.tfがあるのでそれぞれ`strategy.matrix`を指定してそれを`tf_actions_working_dir`で渡します。

```yml:.guthub/workflows/terraform.yml（一部抜粋）
jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    strategy: # 追加 strategyのmatrixでworkspace設定。この組み合わせの数だけjobが実行される
      matrix:
        workdir: [./service1/ec2, ./service2/ec2, ./service1/iam-role, ./service2/iam-role]
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      AWS_ACCESS_KEY_ID:  ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY:  ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      TF_WORKSPACE: dev
    steps:
      - name: 'Checkout'
        uses: actions/checkout@master
      - name: 'Terraform Format'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'fmt'
          tf_actions_working_dir: ${{ matrix.workdir }} # 追加
      - name: 'Terraform Init'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'init'
          tf_actions_working_dir: ${{ matrix.workdir }} # 追加
      - name: 'Terraform Validate'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'validate'
          tf_actions_working_dir: ${{ matrix.workdir }} # 追加
      - name: 'Terraform Plan'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'plan'
          tf_actions_working_dir: ${{ matrix.workdir }} # 追加
```

## masterへのプルリクエストをトリガーにする
以下のようにbranchesのフィルターを設定すればOK。（以下の設定の場合はPR作成時以外にもプルリクエストを作成したブランチにプッシュした場合などいくつかのイベントでトリガーされます。）

```yml:.guthub/workflows/terraform.yml（一部抜粋）
name: 'Terraform GitHub Actions'
on:
  pull_request:
    branches:
      - master
```

## 動作確認
[ここまでの設定](https://gist.github.com/yamashun/dd190697fb22e519c18f8cdf0f2be1c2)で動作を確認してみます。masterブランチに対してプルリクエストを作成すると、無事にActionsが実行されるはずです。`strategy.matrix`で指定したディレクトリの数だけjobが実行されているのがわかります。

<img width="1077" alt="スクリーンショット 2019-12-15 12.23.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132008/6a3bb283-cab3-f3dd-7c60-6ce0a35e59dc.png">


# 2. 一度のプルリクエストで複数のworkspaceに対してまとめて自動テストを実行する
実行ディレクトリを指定した時と同じように`strategy.matrix`を使えば簡単に実現できます。
devとprodの二つのworkspaceがあると想定して以下のように設定します。

``````yml:.guthub/workflows/terraform.yml(一部抜粋)
terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    strategy:
      matrix: 
        env: [dev, prod] # 追加
        workdir: [./service1/ec2, ./service2/ec2, ./service1/iam-role, ./service2/iam-role]
    env:
      TF_WORKSPACE: ${{ matrix.env }} # matrixから値を取得するように修正
```

## 動作確認
[ここまでの設定](https://gist.github.com/yamashun/0bb9e2423b3e1aadaa24af2270f8d330)で動作確認します。masterブランチに対してプルリクエストを作成すると今度は `4(workdir) × 2(env) = 8`のjobが動きます。

<img width="800" alt="スクリーンショット 2019-12-15 14.33.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132008/ac2e7ea8-1478-63ff-22cb-4c017da35230.png">



# 3. masterブランチへのプッシュ(マージ)をトリガーに自動テストとapplyを実行する

## masterブランチへのプッシュ(マージ)をトリガーに実行
pull_requestと同じ階層にpushを追加してbranchesにmasterブランチを指定します。
これにより**masterブランチへのプルリクエスト or masterブランチへのプッシュ**の条件でjobが実行されるようになります。

```yml:.guthub/workflows/terraform.yml(一部抜粋)
name: 'Terraform GitHub Actions'
on:
  pull_request:
    branches:
      - master
  push: #　追加
    branches:
      - master
```

今回は一つのymlで定義しましたが、プルリクエストの場合とプッシュの場合でymlを複数に分けてしまうのもありだと思います。


## apply用のstepを追加
applyも他と同様に[Terraform GitHub Actions](https://www.terraform.io/docs/github-actions/index.html)に用意されているのでそれを使います。
ただしapplyはmasterへ修正が反映された時のみ動いてほしいので `if` で条件を追加しています。[github context](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/contexts-and-expression-syntax-for-github-actions#github-context)でjobを実行のトリガーとなったイベント情報などを取得できるので、masterブランチへのイベントの場合のみ実行されるようにします。

```yml:.guthub/workflows/terraform.yml(一部抜粋)
      - name: 'Terraform Plan'
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'plan'
          tf_actions_working_dir: ${{ matrix.workdir }}
      - name: 'Terraform Apply' # ここから下を追加
        if: github.ref == 'refs/heads/master'  # プルリクエストの作成では動かないように
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.17
          tf_actions_subcommand: 'apply'
          tf_actions_working_dir: ${{ matrix.workdir }}
```

## planまでのstepが成功した場合のみapplyを実行
デフォルトで前のstepが失敗したは次のstepは実行されないため特に設定しなくてもplanが成功した時のみapplyが実行されます。

## 動作確認
[ここまでの設定](https://gist.github.com/yamashun/564ff4e5032d828527c51c194171ba65)で動作確認します。プルリクエストを作成してマージするとapplyまで実行されています。
<img width="649" alt="スクリーンショット 2019-12-15 15.45.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132008/901d9996-1fd5-1269-edbb-1a43abb6491a.png">

# 4. ディレクトリ内のファイルが修正されたmain.tfに対してのみjobを実行する
ここまででプルエスト作成でplanを実行してmasterブランチをマージしてapplyするという流れができましたが、実際の運用を想定した時に以下のような課題で出てきそうです。

- main.tfの数やworkspaceのが増えると一度の修正で実行されるjobの数が増え、jobの並列実行数が増加し続けGitHub Actionsの上限までいってしまう。
- 修正と関係ないmain.tfに対してもplan、applyが実行されるので、修正内容に関わらず一番実行時間が長いjobの分だけ時間がかかってしまう。

これらを解決するためにディレクトリ内の修正されたファイルに関連するjobのみ実行するような設定をしていきます。

## pathsフィルタの設定
pathsフィルタを使うことで特定のディレクトリやファイルに変更が合った場合にjobを実行することができます。例えば以下のように設定すると**masterブランチへのプルリクエスト かつ service1ディレクトリとmodulesディレクトリ内に変更が合った場合**にjobが実行されます。

```yml:.guthub/workflows/terraform.yml(一部抜粋)
name: 'Terraform GitHub Actions'
on:
  pull_request:
    branches:
      - master
    paths: # 追加
      - service1/**
      - modules/**
```

ちなみに差分をどのように検知しているかは、プルリクエストやすでにあるブランチへのプッシュ、新規ブランチへのプッシュによって以下のように動作するようです。

[on](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#on)

> Pull requests: Three-dot diffs are a comparison between the most recent version of the topic branch and the commit where the topic branch was last synced with the base branch.
Pushes to existing branches: A two-dot diff compares the head and base SHAs directly with each other.
Pushes to new branches: A two-dot diff against the parent of the ancestor of the deepest commit pushed.

pathsフィルタを利用して

- `./service1`ディレクトリ以下のファイルと`./modules`ディレクトリ以下のファイルに変更がある場合、`./service1`以下のmain.tfを実行する
- `./service2`ディレクトリ以下のファイルと`./modules`ディレクトリ以下のファイルに変更がある場合、`./service2`以下のmain.tfを実行する

のように動作させるため元のterrafrom.ymlを以下のように二つに分割していきます。

```yml:.github/workflows/service1.yml
name: 'Terraform Service1'
on:
  pull_request:
    branches:
      - master
    paths: # service1に関係する変更のみを検知
      - service1/**
      - modules/**
  push:
    branches:
      - master
    paths: # service1に関係する変更のみを検知
      - service1/**
      - modules/**
jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [dev, prod]
        workdir: [./service1/ec2, ./service1/iam-role] # service1のディレクトリのみを指定
    # env以下は元のterraform.ymlと同じ
```


```yml:.github/workflows/service２.yml
name: 'Terraform Service2'
on:
  pull_request:
    branches:
      - master
    paths: # service2に関係する変更のみを検知
      - service2/**
      - modules/**
  push:
    branches:
      - master
    paths: # service2に関係する変更のみを検知
      - service1/**
      - modules/**
jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [dev, prod]
        workdir: [./service2/ec2, ./service2/iam-role] # service1のディレクトリのみを指定
    # env以下は元のterraform.ymlと同じ
```

各main.tfの依存関係などを考慮し出すと複雑になりそうなど課題はありますが、pathsフィルタを使えば修正ファイルに応じたjobを実行できそうです。

## 動作確認
修正後の状態([service1.yml](https://gist.github.com/yamashun/1a6ad1a4841a7ca469a431545311a03e)、[service2.yml](https://gist.github.com/yamashun/13f5650974ecd54a3b58153cd434a72c))で試しに`service1/ec2/variable.tf`のみ修正してプルリクエストを作成すると`./service1`ディレクトリ内のmain.tfに関するjobが実行されることが確認できました。

<img width="913" alt="スクリーンショット 2019-12-15 17.31.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132008/2f06e942-7181-6a6c-aa86-3adf48ee0edf.png">



# まとめ
簡単なTerraformリポジトリを想定してGitHub Actions上でCI/CDを作り、どんな設定ができるか試していきました。
初めてGitHub Actionsを使いましたがドキュメントもしっかり書かれていてつまることも少なく進めらかなり使いやすいと感じました。他にも以下のあたりがいい感じでした。

- GitHub上のサービスだけあって、GitHubのコンテキストの情報を簡単に取得できる
- 無料で並列実行までできるのすごい
- maxrixによる並列実行がかなり便利
- pathsフィルタがかなり便利

個人的にはapplyする時はCircle CIで用意されている[Manual Approval](https://circleci.com/docs/2.0/workflows/#holding-a-workflow-for-a-manual-approval)のような手動で実行を承認する機能が欲しいところですが、[要望](https://github.community/t5/GitHub-Actions/GitHub-Actions-Manual-Trigger-Approvals/m-p/31517#M813)も多そうなのでそのうち追加されるのではと思っています。

今回想定したTerraformリポジトリはシンプルなので、実運用にそのまま使えるかという難しいかもしれませんが、どんなことができそうかイメージをつけられたのがよかったです。

# 参考
[GitHub Actionsドキュメント](https://help.github.com/en/actions)
[terraform-github-actionsドキュメント](https://www.terraform.io/docs/github-actions/getting-started.html)
[terraform-github-actions](https://github.com/hashicorp/terraform-github-actions)