TerraformでAWSのセキュリティグループの循環参照で詰まったけど、設計を変えて解消した話
目次
1. はじめに
AWS上でTerraformのモジュール分割を進めていた頃、こんなエラーに出会いました。
│ Error: Cycle: module.app.aws_security_group.this, module.db.aws_security_group.this
アプリケーションサーバーからデータベースに通信させるため、 双方のSG(セキュリティグループ)を互いに参照しようとしたときのことでした。
アプリのSGからDBへのインバウンドを許可したい。 そのためにはアプリのSG IDが必要です。 でもDBモジュールはアプリモジュールに依存することになります。 逆にアプリのSGにDBからのルールを書けば、今度はアプリモジュールがDBモジュールに依存します。
どちらから書いても、もう片方を先に作る必要が生まれます。 まさに「卵が先か、鶏が先か」でした。
この記事では、その循環参照(Cycle)に詰まった経験から辿り着いた設計パターン、 「Security GroupとSecurity Group Ruleを分離する」 アプローチを紹介します。
2. なぜSGで循環参照が起きるのか
2.1 前提:source_security_group_id という参照
AWSのSGインバウンドルールは、送信元としてCIDRブロックだけでなく別のSGを指定できます。
resource "aws_vpc_security_group_ingress_rule" "allow_from_lb" {
from_port = 80
to_port = 80
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.lb.id # LBのSGを参照
security_group_id = aws_security_group.app.id
}
「特定のSGに属するリソースからの通信のみ許可する」という仕組みで、IPアドレスが動的なコンテナ環境では特に重宝します。 ただしこの参照が、モジュール分割と組み合わさったときに問題になります。
2.2 モジュール分割した途端に起きること
lb / app / db をそれぞれ別モジュールに切り出した構成を考えます。 「LB → アプリ → DB」という通信を許可したい場合、直感的には「通信先のモジュールにルールを書けばいい」と思うでしょう。
- DBへの通信ルール → DBモジュールに書く → アプリのSG IDが必要 → DBモジュールがアプリモジュールに依存
- アプリへの通信ルール → アプリモジュールに書く → LBのSG IDが必要 → アプリモジュールがLBモジュールに依存
ここまではまだ一方向です。 問題は、システムが複雑になるにつれて 「DBからアプリへの折り返し通信」や 「複数サービスの相互参照」が生まれたときです。 ある時点でモジュールAがBに依存し、BがAに依存するという閉路が完成します。
Terraformはリソース間の依存グラフを解析して作成順序を決定するため、 閉路があるとどちらから作ればいいかが決められません。これが循環参照の正体です。
3. 最初に書いていたコード——SGとルールをセットで定義する
モジュール分割を始めた当初の書き方は、各モジュールの中でSGとルールをまとめて定義するスタイルでした。
# modules/db/main.tf(当初の書き方)
resource "aws_security_group" "this" {
name = "${var.app_name}-${var.env}-sg-db"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "allow_from_app" {
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
referenced_security_group_id = var.app_sg_id # アプリのSG IDをinputで受け取る想定
security_group_id = aws_security_group.this.id
}
このスタイル自体は間違いではありません。単一モジュールで通信が完結している間は問題なく動きます。 しかし、モジュールが増えてモジュール間で source_security_group_id の参照が双方向になると、 呼び出し元でモジュール同士の依存が閉じ、循環参照が発生します。
4. 「器」と「ルール」を分離するパターン
4.1 基本方針
辿り着いた方針はシンプルでした。
aws_security_group (器) -> 各モジュールで作る
aws_vpc_security_group_ingress/egress_rule (中身) -> 専用モジュールで一括定義する
ディレクトリ構成としては次のようになります。
.
├── main.tf
└── modules/
├── app/
│ ├── main.tf
│ └── outputs.tf
├── db/
│ ├── main.tf
│ └── outputs.tf
└── security-group-rules/
├── main.tf
└── variables.tf
各モジュールはSGの「存在(器)」を作る責任を持ちます。 通信を許可するという「ふるまい(ルール)」は、全SGのIDを受け取る専用モジュールが一括して定義します。
4.2 各モジュールのSG定義(器だけ)
db モジュールを例にすると、SGリソースはこれだけになります。
# modules/db/main.tf
resource "aws_security_group" "this" {
name = "${var.app_name}-${var.env}-sg-db"
description = "Security group for database"
vpc_id = var.vpc_id
}
ルールは一切書きません。outputs.tf でSG IDを外部に公開するだけです。 lb / app / bastion など、他の全モジュールも同じ方針をとります。
4.3 security-group-rules モジュール(ルールをまとめる)
全モジュールのSG IDをinputとして受け取り、全ルールをここで定義します。
# modules/security-group-rules/main.tf
# アプリ -> DB(他のルールも同様に定義する)
resource "aws_vpc_security_group_ingress_rule" "allow_from_app_to_db" {
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
referenced_security_group_id = var.app_sg_id
security_group_id = var.db_sg_id
}
このモジュール自身はSGを作りません。全モジュールからSG IDを受け取り、ルールを貼るだけです。
4.4 呼び出し元での組み立て
# main.tf(抜粋)
module "app" { source = "./modules/app" ... }
module "db" { source = "./modules/db" ... }
# 他のモジュールも同様
module "security_group_rules" {
source = "./modules/security-group-rules"
app_sg_id = module.app.sg_id
db_sg_id = module.db.sg_id
# ...
}
各モジュールは互いを参照しません。全員が security_group_rules へSG IDを渡すだけで、依存の矢印は一方向に整理されます。
module.app -->+
module.db -->+--> module.security_group_rules
module.(...) ->+
5. この設計の何が嬉しいか
5.1 循環参照が構造的に消える
security_group_rules モジュール自身はSGを作らないため、このモジュールへの依存は常に一方向です。 各モジュールは互いを知りません。「卵が先か鶏が先か」のジレンマは、ルールの定義を第三のモジュールに委ねることで解消されます。
5.2 ライフサイクルが「モジュールの生死」と一致する
これが個人的に最も重要だと感じているメリットです。
各モジュールがSGを自分で持つということは、そのモジュールを削除すれば、SGも一緒に消えます。 module.app をコードから取り除いて terraform apply すれば、アプリのSGは自動的に破棄されます。 アプリというリソースとそのSGのライフサイクルが、コードの構造として一致しているわけです。
これを「SGを専用モジュールに集約する」パターンで考えてみます。 アプリの撤退に際して module.app は消しても、SG専用モジュールの中にあるアプリ用のSGはそのまま残ります。 誰も使わなくなった空のSGが、共通基盤側に残り続けることになります。 こうした「本体だけ消えて取り残された孤立リソース」は、規模が大きくなるほど発見が難しくなります。
「器(SG)を自分で持つ」という設計は、孤立リソースが構造的に生まれにくい設計でもあります。
6. 別の流派との比較
前述の「SGを専用モジュールに集約する」パターンとの違いを整理します。
| SGを専用モジュールに集約 | 器は各モジュール、ルールだけ分離(本記事) | |
|---|---|---|
| SGの所有権 | 中央集権 | 各モジュール側に残る |
| モジュール削除時 | 不要なSGが残存するリスクがある | SGも一緒に消える |
| ライフサイクルの一致 | ✗(本体とSGが別管理) | ◎(本体とSGが同一モジュール内) |
| 新モジュール追加時 | SGモジュールに手を入れる | 新モジュールにSG追加、ルールモジュールにルール追加 |
どちらが正解というわけではありません。 ただ「器を自分で持つ」ことには、循環参照の解消だけでなく、不要なリソースを残さないという構造的な健全性があります。
7. まとめ
aws_security_group(器)と aws_vpc_security_group_ingress/egress_rule(ルール)を分離します。 器は各モジュールが所有し、ルールは専用モジュールが一括で定義します。
この構造によって得られるのは2つです。
- 循環参照の解消 — 依存の矢印が一方向になり、循環参照が構造的に生まれなくなります
- ライフサイクルの健全性 — モジュールを消せばSGも消え、不要なリソースが残りません
「どこにルールを書くか」を明示的に決める設計にすることで、循環参照は構造的に避けられます。
最後までお読みいただき、ありがとうございました。
動作環境
- クラウド: AWS
- Terraform:
~> 1.12 - AWS Provider:
~> 6.8.0
アジアクエスト株式会社では一緒に働いていただける方を募集しています。
興味のある方は以下のURLを御覧ください。