Pull Request の terraform plan の実行結果を S3 に保存して安全に apply

こんにちは。 SRE の @suzuki-shunsuke です。

Pull Request (以下PR) の CI の terraform plan (以下 plan) の実行結果(以下 plan file)を S3 に保存して、安全に terraform apply (以下 apply) 出来るようにしたとともに、 GitHub リポジトリRequire branches to be up to date before merging の設定を無効化することで Experience を向上した話を紹介します。

課題

Terraform の CI/CD を CodeBuild に移行した話 でも紹介したように、弊社では Terraform の CI/CD を CodeBuild で行っています。 GitHub Flow を採用しており、 PR では plan を実行し、 default branch に PR がマージされたら apply を実行しています。 Monorepo になっており、一つのリポジトリで 70 個以上の state が管理されていますが、 CI では PR で変更されたファイルに関連する state に対してのみ plan や apply が実行されるようになっています。 この GitHub リポジトリでは Require branches to be up to date before merging *1が有効化されていました。

image

これが有効化されていると、ある PR がマージされたら他の PR は rebase ないし merge をして default branch の変更を取り込まないとマージできません。

Update branch

逐一マージして CI の結果を待つのは面倒なのですが、 そうしないと PR の plan の結果と異なる結果が apply される可能性がありました。 apply は最悪取り返しのつかない破壊的変更が行われる可能性があるため、多少利便性を損なっても安全な設定がされていました *2

しかし、最近 state の数が増えてきた(70個以上)ことや Renovate によって頻繁に AWS provider が update されるようになったこともあり、 default branch の更新頻度が増え、 update branch しないと merge 出来ない頻度が増え、 Developer Experience を大きく損なってしまっていました。

そこで安全性を担保しつつ Require branches to be up to date before merging を無効化する方法を考えました。

解決策

CI に以下の変更を入れました。

  • PR の CI で plan の実行結果のファイルを S3 s3://<bucket name>/tfplan/<pr number>/<state path>/tfplan.binary に upload
  • apply では upload した plan file をダウンロードし、それを使う
  • apply 実行後、同じ state に関する PR の CI を実行し、 plan file を更新
    • GitHub API で PR のリストを取得し、 tfcmt で付与された PR Label を元に、対象の PR のリストを判別
    • AWS CLI で実行 aws codebuild start-build --source-version "pr/<pr number>"
  • Require branches to be up to date before merging を無効化

plan の -out option で実行結果をファイルに出力し、 S3 に upload します。 S3 の object のパスには PR 番号と state を識別するための path を含めています。 default branch の CI では plan は実行せずに、 S3 から plan file をダウンロードし、それを指定して apply を実行します。 PR の CI で生成された plan file を使うことで PR の plan の結果と同じ結果が apply されるため、想定外の破壊的変更は起こらなくなります。

plan 実行時点より後に state が更新されていたら apply は失敗します。

$ terraform apply tfplan.binary 

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

apply が実行されたら state は更新されるので、既存の PR の CI を実行し、 plan file を更新する必要があります。 apply が失敗しても state は更新されることがあるので処理を終了せずに続行します(ただし最終的には exit 1 で CI を失敗させます)。 とはいえ、弊社は Monorepo になっているので、実行する必要があるのは、同じ State に対する PR だけです。 弊社では tfcmt を使っており、 PR label に対象の state の path が含まれています(例: service-1/staging/no-change)。 そこで GitHub API で PR のリストを取得し、 AWS CLI で PR の CI を実行します。 CodeBuild だと --source-version に pr/<PR 番号> を指定すれば良いので簡単です。

get_prs() {
  # open な PR が 100 個以上ない前提で pagination は考慮していない
  # shellcheck disable=SC2086
  curl \
    -H "Authorization: token $GITHUB_TOKEN" \
    -H "Accept: application/vnd.github.v3+json" \
    "https://api.github.com/repos/<owner>/<repo>/pulls?per_page=100" |
    jq '.[] | select(.labels[].name | startswith("'$TARGET'/")) | .number'
}

while read -r pr_number; do
  echo "===> Start build pr/$pr_number" >&2
  aws codebuild start-build \
    --project-name "$PROJECT_NAME" \
    --source-version "pr/$pr_number"
done < <(get_prs)

CI を rerun する際には、対象の PR には次のようなコメントを post するようにしています。

ci is rerun

弊社の @chaspytweet も御覧ください。

なお、このやり方だと、同じ PR の CI が複数回実行される可能性があります。 例えば merge された PR A とオープンな PR B が両方とも service 1 と service 2 の state に対する PR な場合です。 default branch の CI で service 1 の build と service 2 の build が並列で実行されます。 そうすると service 1 の build と service 2 の build の両方で PR B の CI が実行されることになります。 これは無駄ではありますが、さほど実害はないので気にしないことにしています。

CI では pull/<pr number>/merge を checkout することで default branch の更新を反映させた状態で plan などを実行します。 pull/<pr number>/mergeGitHub で PR が作られたら自動で作られるリモートブランチです。 PR がコンフリクトしている場合はこのブランチは作られないので失敗します。そのときはコンフリクトを解消して CI を実行します。

git fetch --depth 1 origin "pull/$PR_NUMBER/merge:pr/$PR_NUMBER/merge"
git checkout "pr/$PR_NUMBER/merge"

結果

安全性を担保しつつ Require branches to be up to date before merging を無効化する事ができました。 安全性に関しては、単に Require branches to be up to date before merging を有効化しているだけよりもむしろ安全になりました。 というのも Require branches to be up to date before merging を有効にしたところで、PR の terraform plan の結果と異なる結果が apply される可能性は 0 ではないからです。

また、古い CI をうっかり rerun して古い設定が apply されそうになっても、 plan file が古ければ apply に失敗するという意味でも安全になりました。 これに関しては元々 default branch の CI に関しては最新の revision じゃないと CI が失敗するようにすることで対処していたのですが、今回の変更に伴いその制約はなくしました。 各 state の version によって保護することで default branch の CI が失敗したときにより柔軟に rerun できるようになりましたし、自分たちで保護する仕組みを独自に実装しなくてもよくなりました。

*1:GitHubBranch Protection Rule の1つ

*2:なお、本番データベースのように本当に消えたら大変なものには Terraform の prevent_destroyAWS の削除保護設定もされています