インフラの変更を、コードのレビューと同じにする — Terraform × GitHub Actions
インフラの変更には、独特の緊張感があります。この緊張感は、仕組みで消せます。しかも特別な仕組みではなく、今では標準的なやり方です。本記事では、その仕組みが「なぜ効くのか」を、インフラにそれほど詳しくない人に向けて書きます。
GUIでポチポチするのは緊張感がある
AWS のコンソールを開いて、設定を変える。セキュリティグループにルールを一つ足す、それだけの操作でも、手は妙に慎重になります。間違えれば、その瞬間に本番が変わるからです。
だから、画面を共有して、誰かに横で見てもらっていました。「ここを、こう変えます」と声に出して、ポチッと押す。その瞬間を一緒に確認してもらう。安全のためですが、自分の時間も、相手の時間も使います。
この緊張感を、注意力の問題だと思っていました。でも、違いました。手段の問題でした。
緊張の正体は「一発勝負」
コンソールでの変更には、3つの弱点があります。何を変えたかが記録に残らない。第三者が事前にレビューできない。同じ状態を後から再現できない。
つまり、一回一回がその場限りの一発勝負です。やり直しも見直しも効かないから、操作そのものに神経を使うしかありません。画面共有でのダブルチェックは、この一発勝負を人力で補うための、苦肉の策でした。
GUI が悪いわけではありません。学習にも、一度きりの作業にも向いています。ただ、本番を継続的に変更し続ける運用には向いていない。それだけのことです。
PR に乗せると、別世界になる
同じ変更を Terraform で書いて、プルリクエストに乗せる。すると、さっきの3つがそのままひっくり返ります。
変更がコードの差分として見える。適用前に Plan で結果が分かる。マージの履歴に残る。レビューは、横で画面を覗き込む代わりに、PR 上で非同期にできます。声に出す必要も、相手を拘束する必要もありません。普段コードでやっているレビューと、まったく同じです。
一発勝負が、見直しと記録のあるフローに変わる。神経を使わなくなったのは、使わなくていいように仕組みが変わったからです。
仕組み:PR で Plan、マージで Apply
やり方そのものは、特別なものではありません。Terraform を CI/CD に乗せるときの、ほぼ定番の構成です。Atlantis や HCP Terraform といったツールも、考え方は同じです。枯れていて、標準的。だからこそ安心して乗れます。
まず、言葉だけ説明します。terraform plan は「何が変わるかを確認する」コマンドです。まだ何も変えません。terraform apply が「実際に変える」コマンドです。この2つを、PR とマージに割り当てます。
flowchart TD
pr["① PR を出す(人)"]
plan["② plan を実行して PR にコメント(自動)"]
review["③ 差分をレビュー(人)"]
merge["④ main にマージ(人)"]
apply["⑤ apply を実行(自動)"]
pr --> plan --> review --> merge --> apply
変更を出すと、何が変わるかが自動で PR に並ぶ。それを確認してからマージし、マージされたものだけが本番に適用される。コードの変更フローと、まったく同じです。
設定は1ファイルで完結する
.github/workflows/terraform.yml 一つで足ります。認証や初期化まわりの定型は省いて、肝になる部分だけ抜き出します。
まず、いつ動かすか。PR が出たときと、main に push(=マージ)されたときの2つです。
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
infra/ を変更したときだけ走らせています。この「PR」と「main への push」という2つの入口が、そのまま「plan するとき」と「apply するとき」に対応します。
次に plan。PR でもマージ後でも、まず必ず plan が走ります。結果はファイル(tfplan)に書き出しておきます。
- name: terraform plan
id: plan
run: terraform plan -out=tfplan
そして apply。ここがいちばん大事です。
- name: terraform apply
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: terraform apply -auto-approve tfplan
apply のステップには if が付いています。「main への push のときだけ実行する」という条件です。これがあるおかげで、PR の段階では plan までで止まり、apply されません。実際に本番が変わるのは、マージされて main に入ったあとだけ。plan と apply を、PR とマージに振り分けている本体が、この一行です。
このほかに、plan の結果を PR にコメントする処理も入れていますが、本筋ではないので、全文を末尾に置いておきます。
安全の本体は、このファイルの外にある
ここまで読んで「この YAML を置けば安全だ」と思うかもしれません。半分は正しくて、半分は間違っています。
このワークフローは、PR を経由すれば必ずレビューが挟まる仕組みを提供しているだけです。誰かが main に直接 push してしまえば、PR もレビューも通らずに apply まで走ります。
だから、本当の安全装置はリポジトリ側の設定にあります。main への直接 push を禁止して、変更は必ず PR 経由でしか入れられないようにする。ブランチ保護、あるいは Ruleset です。これを設定して初めて、「変更には必ずレビューが挟まる」が保証されます。
レビューを善意やチームの慣習に頼るのではなく、ルールとして強制する。安全は YAML の中ではなく、このゲートが担保しています。
変更が、コードと同じ重さになった
インフラの変更が、特別な慎重さを要する作業ではなくなりました。普段のコードレビューと同じ、淡々としたフローの一部です。画面を共有してダブルチェックする時間も、もう要りません。
緊張感は、気合いで乗り切るものだと思っていました。でも、仕組みで消せるものでした。書く場所と同じように、インフラも、レビューを通ってから本番に入る。当たり前のことを、当たり前にやる。だからこの構成は、長く標準であり続けています。
なお、Plan の厳密な一致や、もっと踏み込んだ運用の話は、別の記事で書きます。
当社では、こうした「手作業で慎重に回している運用を、仕組みで安全にする」開発のお手伝いもしています。今回のような小さな自動化から、業務システムの開発・保守まで、長期運用を前提とした設計でご支援します。お問い合わせフォーム、または LINE 公式アカウントからお気軽にどうぞ。
ワークフロー全文
name: Terraform
on:
pull_request:
paths:
- "infra/**"
- ".github/workflows/terraform.yml"
push:
branches: [main]
paths:
- "infra/**"
- ".github/workflows/terraform.yml"
concurrency:
group: terraform
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.10"
- name: terraform fmt
run: terraform fmt -check -recursive
- name: terraform init
run: terraform init -input=false
- name: terraform validate
run: terraform validate
- name: terraform plan
id: plan
run: terraform plan -input=false -no-color -out=tfplan
continue-on-error: true
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
PLAN_STDOUT: ${{ steps.plan.outputs.stdout }}
PLAN_STDERR: ${{ steps.plan.outputs.stderr }}
PLAN_OUTCOME: ${{ steps.plan.outcome }}
with:
script: |
const marker = '<!-- terraform-plan -->'
const MAX = 60000
let out = process.env.PLAN_STDOUT || process.env.PLAN_STDERR || '(no output)'
if (out.length > MAX) out = out.slice(0, MAX) + '\n... (truncated)'
const body = [
marker,
`### Terraform Plan \`${process.env.PLAN_OUTCOME}\``,
'',
'<details><summary>plan 結果を表示</summary>',
'',
'```hcl',
out,
'```',
'',
'</details>',
].join('\n')
const { owner, repo } = context.repo
const issue_number = context.issue.number
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number,
})
const existing = comments.find((c) => c.body && c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body,
})
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body,
})
}
- name: Fail if plan failed
if: steps.plan.outcome == 'failure'
run: exit 1
- name: terraform apply
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: terraform apply -input=false -auto-approve tfplan