Terraform の CI/CD を CodeBuild に移行した話

こんにちは。 SRE の @suzuki-shunsuke です。 Terraform の CI/CD を CircleCI から AWS CodeBuild に移行した話を紹介します。

背景

弊社では AWS のリソースを Terraform で管理しており、元々 CircleCI で CI/CD を実行していました。 リポジトリは monorepo になっており、様々なサービスのリソースが同じリポジトリで管理されています。 Terraform の State はサービス・環境ごとに分割されており、現在 70 個以上の State があります。 ちなみに Terraform の Workspace は使っておらず、 State ごとにディレクトリが分けられています。 State ごとに CircleCI の job を分けて terraform plan などを実行していました。

課題

元々幾つかの課題がありました。

  • 非常に強い権限を持った AWS の credential が発行され、それが CircleCI の環境変数として保存されている
  • 毎回の CI で全ての State に対する terraform plan が実行されている

1つ目はセキュリティ的な観点で望ましくありません。 Terraform でリソース管理をする以上、強い権限が必要なのはやむを得ませんが、 AWS の credential は CI の環境変数に設定されるため、不注意な設定によって credential が漏洩したり、本来の目的外に流用されたりするリスクがありました。

2 つ目は毎回の CI で全ての State に対する terraform plan が実行されることで

  • 無駄に時間がかかる(並列実行はしているが、一番遅いものに引っ張られる)
  • Pull Request (以下 PR) と関係ない差分が検出されることがある(master で CI がこけたり、誰かが Remote State を操作していたりすると出る)
  • Rate Limit に引っかからないように -refresh=false を設定する必要がある
    • AWS 上のリソースの手動変更による drift を検知できない
    • Terrafrom AWS Provider のスキーマの変更に伴う差分をいちいち terraform refresh で解決しないといけないことがある

などの問題がありました。

解決策

1つ目の課題は、 CodeBuild 上で CI/CD を実行することで、 AWS 以外にクレデンシャルを保存する必要がなくなります。 また、そもそも IAM Role を assume すれば良いのでクレデンシャルを発行する必要がなく、クレデンシャルが漏洩する心配もありません。 IAM Role を assume する形であればなんの Role を使っているかが IAM User のクレデンシャルを使うより明確であり、 IAM Role と CodeBuild Project を Terraform でまとめて管理すれば使い回しが起こりにくいです。

2つ目の課題は、 PR で変更のあったものだけ terraform plan を実行できるようにすることで上記の問題を解決できます。 CircleCI でもこれは実現可能ですが(CodeBuild への移行に時間がかかったので、暫定処置として実際やりましたが)、 CircleCI では workflow を動的に変えることはできません。 CircleCI の Job の中で terraform plan を実行しないようにすることは可能ですが、 Job 自体をそもそも起動しなくするようなことはできません。 実際に terraform plan を実行する Job は 1 個だけなのに、 70 個の Job が起動したりします。

必要な Job だけ起動するような方法を模索したところ、 2 つの案が思い浮かびました。

  • 失敗案: State ごとに CodeBuild Project を作り、 Webhook Filter で必要な build だけ実行
  • 採用案: CodeBuild の Batch Build で動的な Build Pipeline を実現する

最初に失敗案を思いついて試したものの、上手くいかなかったので別の方法を模索したところ、採用案が思い浮かびました。

失敗案: State ごとに CodeBuild Project を作り、 Webhook Filter で必要な build だけ実行

最初に思いついた案は、 CodeBuild の Webhook Filter を利用することです。

https://docs.aws.amazon.com/codebuild/latest/APIReference/API_WebhookFilter.html

CodeBuild Project は 1 つのリポジトリに対し複数作れます。 Webhook Filter によって特定のファイルが更新された場合のみ Build の実行が可能です。 そこで State ごとに Project を作り Webhook Filter を設定すれば実現できると考えました。

まずは State ごとに CircleCI から CodeBuild に移行していきました。 最初は上手くいっているかに思えましたが、途中で大きな壁にぶつかりました。 CodeBuild の Webhook 設定は Project ごとに作られます。 しかし、 GitHub の制約で、 1 リポジトリあたり 20 個までしか Webhook を作れません。

https://docs.github.com/en/developers/webhooks-and-events/about-webhooks

You can create up to 20 webhooks for each event on each installation target (specific organization or specific repository).

つまり 20 個までしか CodeBuild Project を作れないので、 70 個を超える State ごとに Project を作ることは無理でした。

加えて、以下のような問題もありました。

  • CodeBuild Project を逐一作るのが面倒
    • 新しいサービスを追加する際にまずは CodeBuild Project を作らないといけない
  • 個々の Project が独立している
    • 管理画面でまとめてみたりとか出来ない
    • まとめて cancel, retry が出来ない

そこで Batch Build を使った方法を考案しました。

採用案: CodeBuild の Batch Build で動的な Build Pipeline を実現する

Batch Build についてはこちらも参照してください。

CodeBuild で面白いのは AWS CLI で build を実行時に buildspec として S3 にあるファイルを指定できるという点です。

https://awscli.amazonaws.com/v2/documentation/api/latest/reference/codebuild/start-build-batch.html

--buildspec-override option で S3 にあるファイルを指定できます。

つまり、 PR で変更のあったものだけ terraform plan が実行されるような buildspec を生成して S3 に upload して Batch Build を起動すればやりたいことが実現します。

architecture.png

CodeBuild の Build を2段階で実行しています。

  1. init build: GitHub から Webhook を受けて buildspsec.yml を生成して S3 に upload して Batch Build を起動
  2. Batch Build: terraform plan, apply などを実行

2段階とも Build Project としては同じです。

buildspec を如何に生成するか

以下の3つのCLIツールを組み合わせました。

  • ci-info: PR の情報を取得
  • matchfile: PR で更新されたファイルのリストから、どの State の CI を実行すればよいか判定
  • gomplate: buildspec.yml のテンプレートから buildspec.yml を生成

buildspec.yml のテンプレートを一部抜粋します。 updated_targets という変数を元に Batch Build の build-graph を生成しています。 TARGET という環境変数によってどの State の CI を実行するか指定しています。

phases:
  build:
    commands:
      - bash ci/entrypoint-batch-build.sh
batch:
  build-graph:
{{- range $_, $state := .updated_targets }}
    # identifier として "/" "-" は使えないので "_" に置換
    - identifier: {{ $state | strings.ReplaceAll "/" "_" | strings.ReplaceAll "-" "_" }}
      env:
        variables:
          TARGET: {{ $state }}
{{- end -}}

updated_targets はどうやって渡すかというと YAML ファイルを生成し、 gomplate の引数として指定しています。

gomplate -f ci/buildspec.template.yaml -c "updated_targets=file://generated-buildspec.yml"

YAML ファイルは、シェルスクリプトで対象の state の一覧を元に生成しています。 YAML の中身はただのリストなので、シェルスクリプトでも簡単に生成できます。 一部の State はローカル Module に依存しており、 Module が更新された場合も State の CI を実行します。 その依存関係も次のような簡単なシェルスクリプトで導出しています。

https://gist.github.com/suzuki-shunsuke/b18753a0fcf3a1005d9713a0f72d2fff

結果

上記の仕組みにより、課題を解決できました。 加えて、新しいサービスを追加する際に逐一 CI の設定を更新する必要がなくなりました。 従来は .circleci/config.yml に新しい job を追加する必要がありましたが、追加し忘れることもままありましたし、 .circleci/config.yml がどんどん肥大化していってました(元々 540 行以上ありました)。

気になる点

移行してから気になる点は、 CodeBuild で build を実行するまでの provisioning などに若干時間が掛かることです。 build を 2 段階で実行しているため、余計に遅く感じてしまいます。 Batch Build の buildspec のダウンロードとかにも 40 秒程度かかっているのも気になります。 それでも State の数が増えても build の時間は変わらないですし、あまり問題にはなりませんが、もう少し早くなると嬉しいなというのが率直な気持ちです。

むすび

以上、 Terraform の CI/CD を CircleCI から CodeBuild に移行した事例を紹介しました。 Batch Build の buildspec を動的に生成するアプローチは、 Terraform に限らず Monorepo の CI/CD では活用できると思います。