Terraform + Conftest で「許可されていないEC2はデプロイさせない」を実現する
はじめに
ある日、AWSの請求画面を開いて冷や汗をかいた。誰かが別リージョンにGPUインスタンスを立てていた。Terraformのコードレビューはしていたはずなのに、リージョンとインスタンスタイプの組み合わせまでは目が届いていなかった。
人の目に頼るガードレールは、いつか破れる。ならばコードで止めるしかない。Terraform の plan 出力を Conftest で機械的に検査すれば、ルール違反のデプロイはapplyの前に弾ける。実際にやってみたら、ポリシーのコードは30行に満たなかった。
やりたいこと
ルールは2つだけ。
- リージョンは
ap-northeast-1だけ許可する - インスタンスタイプは
t3.microとt3.smallだけ許可する
これに引っかかる Terraform コードは、plan の時点でブロックする。実際にAWSリソースを作るわけではないので、安全に試せる。
ディレクトリ構成
├─ terraform/
│ ├─ valid/main.tf # ルールに沿った正常系
│ └─ invalid/main.tf # わざとルールを破る異常系
├─ policy/
│ ├─ cost_guardrail.rego # ポリシー本体
│ └─ cost_guardrail_test.rego # ポリシーのテスト
Terraform コード
正常系。東京リージョンに t3.micro を置くだけの最小構成にした。
# terraform/valid/main.tf
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_instance" "example" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
}
異常系。バージニアリージョンに GPU インスタンスを置く。明らかに違反する。
# terraform/invalid/main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "example" {
ami = "ami-0123456789abcdef0"
instance_type = "p3.2xlarge"
}
ポリシーを Rego で書く
Conftest は package main の中で deny というルールを定義すると、そこに入ったメッセージを違反として扱ってくれる。メッセージが1件でもあれば exit code 1 で落ちるので、CI に組み込みやすい。
# policy/cost_guardrail.rego
package main
import rego.v1
allowed_region := "ap-northeast-1"
allowed_instance_types := {"t3.micro", "t3.small"}
# リージョンが東京以外なら拒否
deny contains msg if {
region := input.configuration.provider_config.aws.expressions.region.constant_value
region != allowed_region
msg := sprintf("Region '%s' is not allowed. Allowed region: %s", [region, allowed_region])
}
# インスタンスタイプが許可リスト外なら拒否
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_instance"
some action in rc.change.actions
action in {"create", "update"}
instance_type := rc.change.after.instance_type
not instance_type in allowed_instance_types
msg := sprintf("Instance type '%s' is not allowed. Allowed types: t3.micro, t3.small",
[instance_type])
}
ポリシー自体は30行に満たない。許可値を変数に切り出しておけば、あとから t3.medium を追加したくなったときもすぐ対応できる。
テストを先に書く
ポリシーが正しく動くかどうかは、OPA の組み込みテスト機能で確認できる。test_ で始まるルールを定義し、with input as でモックデータを差し込む。
# policy/cost_guardrail_test.rego(抜粋)
package main
import rego.v1
# バージニアリージョンは拒否される
test_deny_invalid_region if {
result := deny with input as {
"configuration": {"provider_config": {"aws": {"expressions": {
"region": {"constant_value": "us-east-1"}}}}},
"resource_changes": [{
"type": "aws_instance",
"address": "aws_instance.example",
"change": {"actions": ["create"],
"after": {"instance_type": "t3.micro"}},
}],
}
count(result) > 0
some msg in result
contains(msg, "us-east-1")
}
# 東京リージョン + t3.micro なら何も引っかからない
test_allow_valid_region if {
result := deny with input as {
"configuration": {"provider_config": {"aws": {"expressions": {
"region": {"constant_value": "ap-northeast-1"}}}}},
"resource_changes": [{
"type": "aws_instance",
"address": "aws_instance.example",
"change": {"actions": ["create"],
"after": {"instance_type": "t3.micro"}},
}],
}
count(result) == 0
}
テストケースは全部で9つ。リージョン違反、インスタンスタイプ違反、両方同時の違反、deleteアクションのスキップ、aws_instance 以外のリソースの無視、複数インスタンスの個別評価をカバーしている。
$ opa test -v policy/
PASS: 9/9
動かしてみる
正常系
cd terraform/valid
terraform init
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p ../../policy/
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
exit code は 0。何も引っかからず通過した。
異常系
cd terraform/invalid
terraform init
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p ../../policy/
FAIL - tfplan.json - main - Instance type 'p3.2xlarge' is not allowed. Allowed types: t3.micro, t3.small
FAIL - tfplan.json - main - Region 'us-east-1' is not allowed. Allowed region: ap-northeast-1
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
exit code は 1。リージョンとインスタンスタイプの両方で弾かれた。
なぜ素の OPA ではなく Conftest を使うのか
OPA 単体でも同じことはできる。
opa eval -d policy/ -i tfplan.json "data.main.deny"
ただ、この場合は結果がJSON で返ってくるだけで、deny があっても exit code は 0 のまま。CI で落とすには自分で結果をパースして判定するスクリプトが要る。
Conftest はそのあたりを全部やってくれる。deny にメッセージが入っていれば FAIL 表示で exit code 1。入っていなければ PASS で exit code 0。Rego の書き方は変わらないのに、実行と判定の手間がなくなる。
ハマりどころ
delete アクションで落ちる
Terraform Plan JSON の resource_changes には、作成だけでなく削除も含まれる。削除対象のリソースは change.after が null になるので、そこから instance_type を取ろうとすると Rego 上で undefined になって挙動がおかしくなる。
対策は単純で、actions に create か update が含まれるリソースだけを見ればいい。
some action in rc.change.actions
action in {"create", "update"}
この1行がないだけで、リソース削除時にポリシーが誤作動する。地味だけど忘れると厄介なポイントだった。
リージョンはリソースの中にない
最初、resource_changes の中にリージョン情報があると思って探したが見つからなかった。Terraform Plan JSON ではリージョンはリソースの属性ではなく、Provider の設定に格納されている。
input.configuration.provider_config.aws.expressions.region.constant_value
このパスを知らないと、リージョンチェックの実装で止まる。terraform show -json の出力を一度じっくり眺めておくと、構造が掴めて楽になる。
replace の扱い
インスタンスタイプを変えるような更新では、Terraform は actions を ["update"] ではなく ["delete", "create"] にすることがある。リソースを作り直すためだ。今回は create と update の両方をチェック対象にしているので、replace のケースも create 側で拾える。ここは意識しておかないと抜ける。
使ったツール
| ツール | バージョン |
|---|---|
| Terraform | v1.5.7 |
| Conftest | 0.66.0 |
| OPA | v1.14.0 |
macOS なら brew install terraform conftest opa で揃う。
検証結果
| 項目 | 結果 |
|---|---|
| OPA 単体テスト | 9/9 PASS |
| 正常系の Conftest 評価 | deny なし |
| 異常系の Conftest 評価 | deny 2件 |
Rego のポリシーは30行弱、テストを含めても170行ほど。このくらいの分量で、リージョンとインスタンスタイプの制限が terraform plan の段階で効くようになる。CI/CD パイプラインの中に conftest test を1行入れるだけで、ルール違反のデプロイは自動的に止まる。
さいごに
やってみて感じたのは、Policy as Code は思ったより敷居が低いということだった。Rego の文法に慣れるまで少し戸惑ったものの、ポリシー自体は短く書ける。Conftest のおかげで実行まわりの面倒も省ける。
今回はリージョンとインスタンスタイプの2ルールだけだったが、同じ仕組みでタグの強制やストレージサイズの上限チェックも書ける。ルールを足すたびに .rego ファイルが少し伸びるだけで、テストも同じ要領で追加できる。
コードレビューで「ここ、リージョン合ってる?」と聞く手間がなくなるのは、地味だけど確実に楽になる。