30 分で Conftest を書き始められる Policy as Code 基盤を作った話
株式会社ナレッジワーク SRE の tapih(@_tapih) です。
本記事では、 2024/11 頃に行った Conftest を用いた Policy as Code 基盤の構築事例をご紹介します。
初学者がすぐにポリシーを実装できるようになることを重視して設計しており、実際に 30 分のモブプロでチームメイトにルールを書き始めてもらうことに成功しました。
背景
弊社はここ一年でエンジニアの数が着実に増加しており、ガバナンスの強化がスケーラビリティの観点で重要となっています。例えば、 Terraform の設定を各エンジニアに行ってもらう際に、 SRE がすべてのレビューを行うのは現実的ではありません。エンジニア側としても、どういう設定をするべきか/するべきでないかを低コストでフィードバックされることが好ましいでしょう。
この問題の対策としては Policy as Code が有効です。 Policy as Code の説明は以下をご参照ください。
Policy as Code の重要性 by Gemini 2.0
Policy as Code がなぜ重要なのか、背景で触れた内容をより具体的にまとめてみましょう。
Policy as Code の重要性は、主に以下の点に集約されます。
- 一貫性と網羅性の向上: 人手によるレビューではどうしても見落としや判断のばらつきが発生しがちです。ポリシーをコードとして定義することで、設定の隅々まで一貫したルールを適用できるようになり、意図しない設定ミスやセキュリティリスクを低減できます。
- 早期のフィードバック: 開発の早い段階でポリシーチェックを行うことで、問題が表面化する前に修正できます。これにより、手戻りコストを削減し、開発サイクル全体の効率を向上させることができます。
-
レビュー負荷の軽減: 自動化されたポリシーチェックによって、手動レビューの負担を大幅に軽減できます。これにより、レビュー担当者はより複雑な問題や設計に集中できるようになります。
監査とコンプライアンスの強化: ポリシーがコードとして管理されるため、誰がいつどのようなルールを適用したのかという履歴を追跡できます。これは、監査やコンプライアンス対応において非常に重要になります。 - スケーラビリティの確保: 組織やシステムが成長するにつれて、管理すべきルールや設定項目も増加していきます。Policy as Code を導入することで、このような変化に柔軟に対応でき、管理コストの増大を抑えることができます。
- 知識の共有と標準化: コード化されたポリシーは、組織内のベストプラクティスやセキュリティ基準を明確化し、チーム間で共有することを容易にします。これにより、組織全体の技術力向上や標準化を促進できます。
要するに、Policy as Code は、人手による管理の限界を克服し、より効率的で安全、かつスケーラブルなシステム運用を実現するための基盤となるのです。特に、エンジニア組織が拡大し、ガバナンスの強化が求められる状況においては、その重要性はますます高まります。
正直なところ、導入時に書きたいルールがたくさんあったわけではなかったのですが、 1 カ月に 1 回くらいの頻度で「Policy as Code できたらなあ」と思う出来事がありました。そのときにシュッとポリシー化できないと徐々に運用が苦しくなっていくことが懸念されたため、若干先回りして仕組みを整えることを決心しました。
方針
まず、 Policy as Code の実装には採用事例の多い Conftest を採用することとしました。
続けて、実装の前に Policy を「使う側」と「作る側」のそれぞれの目線に立ち、どのような基盤にするかを整理しました。
- 使う側
- 実装前に全ての Policy を把握しなくてもよいこと
- 実装時にフィードバックがすぐ得られること
- 実装時に Policy 違反した際にその Policy の WHAT/WHY にすぐたどり着けること
- 新規 Policy が追加された / 既存 Policy が変更された際に、対応コストが膨大にならないこと
- 作る側
- Policy を実装するのに必要十分な OPA/Rego の知識を素早く得られること
- Policy の実装を配置するディレクトリが明確であること
- Policy を実装する際に実装すべき項目が明確であること
- Policy の Level を適切に選択できること
- Policy 自体のテストが Push 時に自動で行われること
- Policy のドキュメントが自動で生成されること
特に重視したポイントは 2 つです。
一つは、 Policy を使う側の認知コストを抑えることです。ポリシーの概要及び詳細をドキュメントにまとめることで、そのドキュメントを見ればどういう設定をすればよいかを確認できる状態を意識しました。
もう一つは、書き始めの学習コストを可能な限り下げることです。 Conftest で利用される OPA/Rego は一般的な言語と比べるととっつきにくさががあると感じています。私の場合、恥ずかしながら数年間で 5 回ほど入門しては忘れるということを繰り返してしまっていました。また、 SRE だけでなくセキュリティエンジニア等の他のエンジニアにもポリシーを書いてもらうことが望ましいという思いもあります。以上から、書き始めの学習コストを可能な限り下げることを意識するようにしました。
実装
Conftest 対象は Terraform と GitHub Actions のみでスタートしています。
ドキュメント生成
Policy の概要と詳細を Rego コードの METADATA
として記述し、 plexsystems/konstraint でドキュメントを生成します。
また、 PR へコードを push すると、 GitHub Actions で konstraint doc
が実行され、差分を push するように整備しました (なお、ドキュメント生成用のコードは自前実装することも考えましたが、 kostraint が継続的にメンテナンスが行われていることを確認したため一旦採用して楽をしています)。
# METADATA
# title: |-
# `permissions` is required
#
# description: |-
# `permissions` に設定された権限が `GITHUB_TOKEN` に付与されます。
# 必要最低限の権限を設定することで、 3rd party action 等により予期せぬ処理が実行されることを防ぎます。
#
# #### Good
# ```yaml
# jobs:
# example:
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# ...
# ```
#
# #### Bad
# ```yaml
# jobs:
# example:
# runs-on: ubuntu-latest
# steps:
# ...
# ```
package permissions
...
Policy 違反時にこのドキュメントへの動線を設けることで、各エンジニアがポリシーの詳細を低コストで理解できることを期待しています。
生成されたドキュメントの例
ディレクトリ構成
Terraform の Policy に関しては以下のルールに従ってファイルを配置しています (GitHub Actions はルール数がそこまで増えない予定なのでよりフラットに配置)。理由は後述します。
- 原則、リソース名と package 名を一致させる
- リソース毎にディレクトリを分ける
- 1 ファイルの場合は
deny.rego
、 2 ファイル以上の場合はdeny_xxx.rego
と命名する
- 1 ファイルの場合は
- 1 ファイルに 1 ルールを書く
├── annotations.rego
├── conftest.yaml
├── google_cloud_run_v2_service_iam_member
│ ├── deny.rego
│ └── deny_test.rego
├── local_exec_provisioner
│ ├── deny.rego
│ └── deny_test.rego
├── policies.md
└── Taskfile.yaml
コード生成
以上を満たすコードをテンプレートから生成します。生成後に TODO の箇所を変更するだけで、ルールの実装~ドキュメント生成までが完了します。簡単なルールの場合は 10 行程度の変更でルールの実装が完了します。 METADATA:ルール=1:1 という形で、 METADATA
の粒度とルールの粒度を揃えることで、ルールの実装を簡単にしつつドキュメントが網羅的になることを期待しています。
なお、この方針だと似たようなコードがたくさんできてしまう点は気になりましたが、このようなフラットなファイルの大量な書き換えは AI に比較的お願いしやすいと考えて許容しました。この仕組みを考えた 2024/11 時点では若干半信半疑なところがありましたが、 2025/04 時点でかなり AI が進歩しており、何とかなりそうな雰囲気を感じています。
# METADATA
# title: |-
# {{ .Env.title }}
#
# description: |-
# TODO: write description here
package {{ .Env.resource }}
import rego.v1
import data.annotations
is_create_or_update(action) if {
action == "create"
}
is_create_or_update(action) if {
action == "update"
}
violation contains msg if {
resource := input.resource_changes[_]
x := resource.address
# type is {{ .Env.resource }}
resource.type == "{{ .Env.resource }}"
# actions has "create" or "update"
change := resource.change
is_create_or_update(change.actions[_])
# TODO: write your condition here
# ...
# fail if all the conditions above are met
title := annotations.title(rego.metadata.chain())
msg := sprintf("`%v`: %v", [x, title])
}
package {{ .Env.resource }}
test_violation {
count(f) == 0
}
f[failed] {
testdata := [
# fail
{
"name": "{{ .Env.resource }} should violate policy on create",
"expected": {"`{{ .Env.resource }}.this`: {{ .Env.title }}"},
"input": {
"resource_changes": [
{
"address": "{{ .Env.resource }}.this",
"type": "{{ .Env.resource }}",
"name": "this",
"change": {
"actions": [
"create"
],
"after": {
# TODO: write JSON input here
# ...
},
},
}
]
}
},
{
"name": "{{ .Env.resource }} should violate policy on update",
"expected": {"`{{ .Env.resource }}.this`: {{ .Env.title }}"},
"input": {
"resource_changes": [
{
"address": "{{ .Env.resource }}.this",
"type": "{{ .Env.resource }}",
"name": "this",
"change": {
"actions": [
"update"
],
"after": {
# TODO: write JSON input here
# ...
},
},
}
]
}
},
# pass
{
"name": "{{ .Env.resource }} should not violate policy on create",
"expected": set(),
"input": {
"resource_changes": [
{
"address": "{{ .Env.resource }}.this",
"type": "{{ .Env.resource }}",
"name": "this",
"change": {
"actions": [
"create"
],
"after": {
# TODO: write JSON input here
# ...
},
},
}
]
}
},
{
"name": "{{ .Env.resource }} should not violate policy on update",
"expected": set(),
"input": {
"resource_changes": [
{
"address": "{{ .Env.resource }}.this",
"type": "{{ .Env.resource }}",
"name": "this",
"change": {
"actions": [
"update"
],
"after": {
# TODO: write JSON input here
# ...
},
},
}
]
}
},
]
t := testdata[_]
result := violation with input as t.input
result != t.expected
failed := sprintf("FAIL `%s`: expected %v, but got %v", [t.name, t.expected, result])
print(failed)
}
title := annotations.title(rego.metadata.chain())
は METADATA
の title
をコードから取得している箇所で、以下の通り実装しています。 title
は msg
を経由して違反時のエラーメッセージに表示されるため、 違反時のエラーを確認 -> ドキュメントに移動 -> ルールの意図を理解
という動線を提供できます。
package annotations
import rego.v1
title(chain) := val if {
val := override(chain, "title", ["package"])
} else := null
override(chain, name, scopes) := val if {
val := [v |
link := chain[_]
link.annotations.scope in scopes
v := link.annotations[name]
][0]
} else := null
オンボーディング
以上の構成によりオンボーディングで扱う項目数を抑えることが可能となりました。以下の 8 個のポイントだけをカバーしています (各項目の詳細は省略)。
- and / or
- deny (violation)
- some / _
- package
- metadata
- Terraform plan JSON schema
- Table driven test
- Conftest CLI
結果
冒頭にも書きましたが、モブプロをして実質 30 分程度でチームメンバーが簡単なルールを書き始めることができました 🎉 もちろん難しいルールはまだ書くことはできませんが、それは (自分も含め) 今後キャッチアップしていけばよいでしょう。
まとめと今後の展望
まだまだ仕組みは荒い状態でスタートしていますが、コア体験に関してはチームメイトの反応は上々でした。今後は社内に浸透させつつ、以下のような点を改良していきます。
- konstraint をフォークして namespace 毎にドキュメントを分割
-
terraform plan
結果を使った e2e 的なテスト方法の確立 -
google_project_iam_member
のような実装が重複しやすいポリシーへの対処方法の決定