From 71ddf464568ebd70ff5d8349ee1b88f091606f49 Mon Sep 17 00:00:00 2001 From: ifLov Date: Tue, 1 Sep 2026 10:42:47 +0900 Subject: [PATCH] refactor(terraform): draw the foundation/service seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary doc assumed one terraform copy per consumer. That premise is wrong: frontend and other services share this infrastructure. With more than one consumer, resources split into two layers with very different change rates and blast radius, and the code mixed them. Sharing cost is not one number. In ap-northeast-2 at 730h, VPC/subnets/ ECS cluster are free, ALB is $16.43, Valkey $14.02 and RDS $18.25 per month. Complexity differs by an order of magnitude in the other direction: a shared RDS with least-privilege users needs a SQL provider that must reach a private-subnet instance, so it buys a permanent privileged runner inside the VPC to save $18/month. The data plane stays per-service; the foundation shares what is free plus the ALB. - name axis: local.name was the name of everything from the VPC to the target group, so two services would collide on the ECS cluster name alone. Split into local.foundation_name and local.service_name, with a new service_name input. - security groups: inline ingress/egress blocks are authoritative, so once the layers split a foundation apply would delete rules a service attached to a shared group. Moved to aws_vpc_security_group_*_rule. - outputs: none of the identifiers a service root needs existed. Added vpc_id, subnet ids, ALB security group id, listener arn, cluster arn and db subnet group name as the foundation contract. - Redis namespaces: the app already has REDIS_DB, REDIS_KEY_PREFIX and BULLMQ_PREFIX; terraform never passed them, so two services on one Valkey would both use 'app:' on db 0. Derived from service_name. - names silently truncated at 32 characters via substr(); two long service names could collide and only show it at apply. Replaced with lifecycle preconditions that fail during plan. - backend.hcl.example documents the foundation/service key convention. State boundaries come from the backend key, not from the repository. - removed the unused local.redis_on. Verified against real AWS: terraform plan reports 33 to add, 0 to change, 0 to destroy — the first successful plan of this config. Not applied, so task startup, health checks and the env contract remain unverified. ALB routing is still undecided. The listener default action currently forwards straight to this service's target group, which stops working with a second service. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DrsDBRXt7sYCYxt5gmFMVy --- docs/terraform-repo-boundary.md | 132 +++++++++++++++++++++ infra/terraform/README.md | 31 +++++ infra/terraform/backend.hcl.example | 26 ++++- infra/terraform/compute.tf | 41 +++++-- infra/terraform/data.tf | 15 +-- infra/terraform/network.tf | 139 +++++++++++++++-------- infra/terraform/outputs.tf | 64 +++++++++++ infra/terraform/terraform.tfvars.example | 9 ++ infra/terraform/variables.tf | 34 ++++++ 9 files changed, 423 insertions(+), 68 deletions(-) diff --git a/docs/terraform-repo-boundary.md b/docs/terraform-repo-boundary.md index 9df2451..ab4d537 100644 --- a/docs/terraform-repo-boundary.md +++ b/docs/terraform-repo-boundary.md @@ -4,6 +4,10 @@ > `infra/terraform`을 앱 저장소 밖으로 뺄 것인가. 3라운드 교차검증 결과와 내린 결정. > 조사 2026-08-31 · 결정 2026-09-01 > +> **⚠ 아래 "추천과 그 근거"는 2026-09-01에 폐기됐다.** 그 추천은 "terraform 사본 하나당 +> 소비자 하나"를 전제했는데, 사용자가 그 전제가 틀렸다고 정정했다 — frontend와 다른 +> 서비스가 **같은 인프라를 나눠 쓴다.** 갱신된 판단은 아래 "2026-09-01 재검토"에 있다. +> > **결정: 모델 B — 파생 프로젝트는 버전 모듈을 참조한다.** 따라서 별도 repo가 최종 목적지다. > 다만 아래 "추천과 그 근거"의 순서 제약은 그대로 유효해서, 지금 옮기지 않는다 — > 모듈 인터페이스는 한 번 apply해서 무엇이 입력이고 무엇이 프로젝트별인지 드러난 뒤에 정한다. @@ -92,6 +96,134 @@ Deployment/Service/Ingress까지 붙어 2~4배로 추정된다. 전부 미검증이다. EKS profile의 실제 줄 수 배수도 endpoint·node model·ingress 결정 전에는 주장할 수 없다. +## 2026-09-01 재검토 — 소비자가 여럿이라는 정정 + +사용자가 문서의 전제를 정정했다. + +> frontend 혹은 다른 서비스의 인프라도 다 이 프로젝트에 종속되고 프로젝트별로 +> 나눠져 있지 않기 때문에 이걸 나누는 비용이 발생한다 + +인프라의 소비자가 처음부터 여러 개다. 그러면 "독립 app 소비자 수 ≥ 2"라는 이 문서의 +분리 신호가 **이미 켜져 있었다.** in-tree 유지 추천은 근거가 사라졌다. + +Claude와 Codex(orca 워크트리 `infra-seam-audit`, gpt-5.6-terra high)가 2라운드 +교차검증했다. 읽기 전용, 코드 변경 없음. + +### 저장소 경계는 state 경계가 아니다 — Claude 정정됨 + +Claude가 "저장소만 옮기면 frontend service apply가 backend RDS와 같은 state를 건드려 +지금보다 나빠진다"고 했는데 틀렸다. **state 경계는 backend key가 정한다.** 저장소를 +옮겨도 key가 같으면 같은 state이고, 저장소를 안 옮겨도 key를 나누면 state는 나뉜다. +저장소 분리는 state 분리의 필요조건도 충분조건도 아니다. + +### 모델 B는 두 가지로 갈린다 + +| | B1 — 모듈 라이브러리 | B2 — 공유 환경 | +| -------------------- | -------------------------------------- | ---------------------------- | +| infra repo가 갖는 것 | 재사용 **모듈**. 버전 태그 | 실제로 도는 **리소스** | +| 각 프로젝트 | 자기 root에서 인스턴스화 | 그 환경에 자기 서비스를 등록 | +| 결과 | **프로젝트마다 VPC·RDS가 따로 생긴다** | 하나를 나눠 쓴다 | + +이 문서가 말한 B(`source = "git::…?ref=v1.2"`)는 **B1**이다. git `ref`는 소스 버전을 +고를 뿐 이미 만들어진 VPC를 가리키지 않으므로 B1은 공유를 만들지 않는다. 사용자의 +제약은 **B2**를 가리킨다. 최종 형태는 혼합이다 — foundation은 B2(live), service는 +B1(버전 모듈)이고 각 앱 저장소가 그것을 참조한다. + +### 공유 비용은 하나가 아니라 셋이고 복잡도가 다르다 + +AWS 공식 Price List, `ap-northeast-2`, 730h, storage·LCU 제외: + +| 자원 | 월 | 공유하려면 치를 값 | +| --------------------------- | ---------: | --------------------------------------------- | +| VPC·subnet·IGW·ECS 클러스터 | $0 | 없음 | +| ALB | $16.43 | listener rule priority 거버넌스 | +| Valkey `cache.t4g.micro` | $14.02 | prefix 주입 + ACL/TLS — **앱 코드 변경 필요** | +| RDS `db.t4g.micro` | $18.25 | 별도 SQL provider + **VPC 안 특권 runner** | +| 합계 | **$48.69** | | + +### 공유 RDS는 config 선택이 아니라 운영 약속이다 + +`hashicorp/aws`만으로 공유 RDS의 최소권한을 만들 수 없다. `CREATE DATABASE`/`CREATE ROLE`/ +`GRANT`는 SQL catalog 작업이라 `cyrilgdn/postgresql` 같은 provider가 필요하고, 그 +provider는 RDS에 TCP로 붙어야 한다. RDS는 `publicly_accessible = false`(`data.tf:18`)에 +private subnet이고 data SG는 app SG만 허용한다 — GitHub 호스티드 runner는 닿지 못한다. +따라서 VPC 안에서 도는 특권 실행 환경(CodeBuild VPC / self-hosted runner / bastion)과 +DB-admin SG를 새로 만들어야 한다. $18.25/월을 아끼려고 **영구적인 특권 CI 표면**을 +추가하는 거래다. + +그걸 안 하면 대안은 지금 구조 그대로 — 모든 서비스에 master credential 배포다 +(`data.tf:15` `manage_master_user_password = true` → `compute.tf:37`,`:103`). +한 서비스가 뚫리면 전부 뚫린다. + +### 공유 Redis는 앱에 축은 있는데 인증이 없다 + +앱은 `REDIS_DB`·`REDIS_KEY_PREFIX`·`BULLMQ_PREFIX`를 이미 갖고 있다 +(`env.validation.ts:85-89`, `cache.config.ts:29`, `redis.config.ts:29`). terraform이 +안 넘겼을 뿐이고 이건 세 줄로 고쳐진다. 하지만 prefix는 이름 구분이지 권한 경계가 +아니다. 진짜 격리(ElastiCache RBAC)는 username과 transit encryption을 요구하는데 +`buildRedisOptions`(`redis.config.ts:20-32`)는 password만 받는다 — **앱 코드 변경이 +선행**이다. + +### 결정 (2026-09-01) + +**공유의 손익비가 자원마다 달라 한 덩어리로 결정하지 않는다.** + +```text +foundation (공유) VPC, subnet, IGW, route table, ECS 클러스터, ALB + listener, ALB SG +service (서비스별) RDS, Valkey, task def, target group, listener rule, SG, IAM, SQS +``` + +$32.27/월은 VPC 안 특권 CI 표면보다 싸다. 그리고 사용자가 말한 통증("인프라가 이 +프로젝트에 종속된다")은 **공짜인 것들**(VPC·subnet·ECS 클러스터)을 공유하는 것만으로 +대부분 해소된다. RDS 공유는 그 통증과 거의 무관하다. + +**미결정:** ALB 라우팅이 host-based냐 path-based냐. rule priority는 listener 안에서 +유일해야 하고(중복 시 `PriorityInUse`) 여러 root가 각자 번호를 고르는 구조는 성립하지 +않으므로, 대역 배정 규약이 함께 정해져야 한다. + +### 이 재검토로 실제로 한 것 + +무후회 작업 — 라우팅을 뭘 고르든, 데이터를 공유하든 안 하든 필요한 것들: + +1. 이름 축 분리 (`local.foundation_name` / `local.service_name`, `var.service_name`). + 이전에는 `local.name` 하나가 VPC부터 target group까지 전부의 이름이라 서비스 둘이면 충돌했다. +2. `outputs.tf`에 foundation 계약 추가 — VPC ID, subnet IDs, ALB SG ID, listener ARN, + cluster ARN, DB subnet group. 이전엔 하나도 없었다. +3. inline `ingress`/`egress` → `aws_vpc_security_group_*_rule`. inline은 authoritative라 + 층이 갈리면 foundation apply가 service의 rule을 지운다. +4. `REDIS_DB`·`REDIS_KEY_PREFIX`·`BULLMQ_PREFIX` 주입 (service name에서 유도). +5. `backend.hcl.example`에 foundation/service key 규약. +6. ALB·target group 이름의 `substr(..., 0, 32)` 조용한 절단을 `lifecycle.precondition`으로. + 이름이 긴 서비스 둘이 같은 32자로 잘리는 사고가 apply 시점에야 드러났다. +7. 죽은 `local.redis_on` 제거. + +### 검증 — 처음으로 plan이 통과했다 + +이 문서가 "한 번도 apply된 적이 없어 실제로 뜨는지 미검증"이라고 적은 것 중 일부가 +풀렸다. 실제 AWS 대상으로: + +```text +Plan: 33 to add, 0 to change, 0 to destroy. +``` + +`enable_sqs = false`가 기본이라 SQS/DLQ/IAM 정책은 계획에 없다. 분리된 SG rule 6개 +(`aws_vpc_security_group_ingress_rule` 4 + `egress_rule` 2)가 정상 계획된다. +**apply는 하지 않았다** — plan 통과는 "AWS가 이 요청을 받아들일 것 같다"이지 +"앱이 부팅한다"가 아니다. task 기동, health check 통과, env 계약 정합은 여전히 미검증이다. + +validation과 precondition은 실제로 발화한다: + +| 입력 | 결과 | +| ----------------------------------------------------------------- | ------------------------------------------------------------- | +| `service_name=API` | `service_name must be lowercase alphanumeric with hyphens...` | +| `redis_db=16` | `redis_db must be an integer between 0 and 15.` | +| `project_name=verylongprojectnamehere` + `environment=production` | `ALB name '...' exceeds 32 characters` (precondition) | +| `service_name=averyveryverylongservicename` | `Target group name '...' exceeds 32` (precondition) | + +마지막 둘이 이전에는 `substr(..., 0, 32)`로 조용히 잘렸다. + +--- + ## 분리 여부와 무관하게 고쳐야 하는 결함 검증 중에 드러난 것들이다. **같은 저장소에 있는데도 이 계약들이 이미 어긋나 있다**는 게 diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 2dab829..012a672 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -11,6 +11,31 @@ terraform plan terraform apply ``` +## foundation과 service + +한 root 안에 두 층이 있고 이름 축이 갈려 있습니다. 아직 state는 하나지만, 층 구분은 +코드에 이미 반영돼 있습니다. + +| 층 | 이름 | 무엇 | +| -------------- | -------------------------------- | ----------------------------------------------------------------------------------- | +| **foundation** | `project_name`-`environment` | VPC, subnet, route table, ALB + listener, ALB SG, ECS 클러스터, DB subnet group | +| **service** | foundation 이름 + `service_name` | task definition, ECS service, target group, task SG, data SG, IAM, RDS, Valkey, SQS | + +foundation은 여러 서비스가 나눠 쓰도록 설계했습니다. **데이터 플레인(RDS/Valkey)은 +서비스별입니다** — 공유하려면 `hashicorp/aws`만으로는 안 되고 별도 SQL provider와 +VPC 안에서 도는 특권 runner가 필요한데, 서울 리전 `db.t4g.micro` + `cache.t4g.micro`가 +월 $32 남짓이라 그 운영 표면을 사는 거래가 성립하지 않습니다. 자세한 근거는 +[결정 문서](../../docs/terraform-repo-boundary.md)에 있습니다. + +`outputs.tf`의 foundation 계약(VPC ID, subnet IDs, ALB SG ID, listener ARN, +cluster ARN)은 지금 아무도 쓰지 않지만, 층이 갈릴 때 service root가 읽어야 하는 +값들입니다. 전달은 `terraform_remote_state`가 아니라 SSM Parameter Store를 씁니다 — +전자는 output만 읽는 것처럼 보여도 state 스냅샷 전체에 대한 읽기 권한을 요구합니다. + +보안 그룹 rule은 inline `ingress`가 아니라 별도 `aws_vpc_security_group_*_rule` +리소스입니다. inline 블록은 authoritative라 층이 갈리면 foundation apply가 +service가 붙인 rule을 지웁니다. + ## 소비자가 반드시 넘겨야 하는 값 | 변수 | 기본값 | 왜 여기 있는가 | @@ -22,6 +47,7 @@ terraform apply | 변수 | 기본값 | 근거 | | ------------------ | ------- | ------------------------------------------------------------------------------------------------------- | +| `service_name` | `api` | foundation 안에서의 정체성. 리소스 이름과 Redis 네임스페이스가 여기서 유도됩니다. | | `trust_proxy_hops` | `1` | 이 구성은 ALB 하나입니다. CloudFront를 얹으면 2. 실제보다 크게 잡으면 XFF 위조로 rate limit이 뚫립니다. | | `enable_redis` | `true` | BullMQ와 캐시의 backend입니다. | | `enable_sqs` | `false` | 앱에 `MESSAGE_QUEUE_URL` 소비자가 없습니다. 켜려면 `MessageQueuePort`의 SQS adapter를 먼저 만듭니다. | @@ -47,6 +73,11 @@ backend **type**은 구성 안에서 정해야 하고, 없으면 `-backend-confi ## 아직 없는 것 +- **listener rule이 없습니다.** 지금은 listener의 default action이 이 서비스의 + target group을 직접 forward합니다. 서비스가 둘이 되면 default는 고정 404가 되고 + 각 service가 host/path 기반 `aws_lb_listener_rule`을 소유해야 합니다. rule + priority는 listener 안에서 유일해야 하므로(중복 시 `PriorityInUse`) 대역 배정 + 규약이 함께 필요합니다. host-based냐 path-based냐는 미결정입니다. - **한 번도 apply된 적이 없습니다.** 이 구성이 실제로 뜨는지 미검증입니다. - ECR build/push와 이미지 태그 전달 경로. `Dockerfile`의 `EXPOSE 3000`도 `var.container_port`와 무관하게 고정입니다. diff --git a/infra/terraform/backend.hcl.example b/infra/terraform/backend.hcl.example index 58dc8c8..6f9b076 100644 --- a/infra/terraform/backend.hcl.example +++ b/infra/terraform/backend.hcl.example @@ -1,5 +1,23 @@ -bucket = "replace-with-terraform-state-bucket" -key = "barebones/dev/terraform.tfstate" -region = "ap-northeast-2" -encrypt = true +# 이 파일은 커밋하지 않는다. `backend.hcl`로 복사해 실제 값을 채운다. + +bucket = "replace-with-terraform-state-bucket" +region = "ap-northeast-2" + +# key 규약 — foundation과 service를 **처음부터 다른 key로** 둔다. +# +# //foundation/terraform.tfstate +# //service//terraform.tfstate +# +# 지금은 한 root라 key도 하나지만, 이 규약을 지금 정해두는 이유는 state 경계가 +# 저장소가 아니라 **backend key**로 정해지기 때문이다. 저장소를 옮겨도 key가 같으면 +# 같은 state이고, 저장소를 안 옮겨도 key를 나누면 state는 나뉜다. +# +# key를 나누면 apply role도 key 단위로 자를 수 있다 — service role에는 자기 +# key의 GetObject/PutObject와 lockfile 권한만 준다. foundation state에는 RDS +# master secret ARN과 IAM 정책이 들어 있으므로, service가 그걸 읽을 이유가 없다. +key = "barebones/dev/service/api/terraform.tfstate" + +encrypt = true + +# S3 native locking (Terraform 1.10+). DynamoDB 테이블이 필요 없다. use_lockfile = true diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf index af1e5cf..df13628 100644 --- a/infra/terraform/compute.tf +++ b/infra/terraform/compute.tf @@ -1,14 +1,15 @@ resource "aws_cloudwatch_log_group" "app" { - name = "/ecs/${local.name}" + name = "/ecs/${local.service_name}" retention_in_days = var.environment == "prod" ? 30 : 7 } +# 클러스터는 foundation이다. 여러 서비스가 같은 클러스터에 뜬다. resource "aws_ecs_cluster" "this" { - name = local.name + name = local.foundation_name } resource "aws_iam_role" "ecs_execution" { - name = "${local.name}-ecs-execution" + name = "${local.service_name}-ecs-execution" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -40,7 +41,7 @@ resource "aws_iam_role_policy" "ecs_secrets" { } resource "aws_iam_role" "ecs_task" { - name = "${local.name}-ecs-task" + name = "${local.service_name}-ecs-task" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -88,6 +89,13 @@ locals { { name = "REDIS_ENABLED", value = tostring(var.enable_redis) }, { name = "REDIS_HOST", value = var.enable_redis ? aws_elasticache_replication_group.this[0].primary_endpoint_address : "localhost" }, { name = "BULLMQ_ENABLED", value = tostring(var.enable_redis) }, + # 앱은 이 세 축을 이미 갖고 있는데 terraform이 넘기지 않았다. 기본값이 + # cache `app:` / BullMQ `app` / db 0이라 서비스 둘이 같은 Valkey를 쓰면 + # 그대로 충돌한다. 서비스 이름에서 유도해 그 사고를 없앤다. + # > src/config/env.validation.ts:85-89 · cache.config.ts:29 · redis.config.ts:29 + { name = "REDIS_DB", value = tostring(var.redis_db) }, + { name = "REDIS_KEY_PREFIX", value = "${var.service_name}:" }, + { name = "BULLMQ_PREFIX", value = var.service_name }, { name = "MONGODB_ENABLED", value = tostring(local.mongodb_on) }, { name = "PROMETHEUS_ENABLED", value = "true" }, { name = "CORS_ORIGINS", value = join(",", var.cors_origins) } @@ -110,7 +118,7 @@ locals { } resource "aws_ecs_task_definition" "app" { - family = local.name + family = local.service_name cpu = 512 memory = 1024 network_mode = "awsvpc" @@ -136,16 +144,26 @@ resource "aws_ecs_task_definition" "app" { }]) } +# ALB는 foundation이다. 여러 서비스가 listener rule로 나눠 쓴다. resource "aws_lb" "this" { - name = substr(local.name, 0, 32) + name = local.foundation_name internal = false load_balancer_type = "application" security_groups = [aws_security_group.alb.id] subnets = aws_subnet.public[*].id + + # 이전에는 `substr(..., 0, 32)`로 **조용히 잘랐다.** 이름이 긴 서비스 둘이 + # 같은 32자로 잘리면 이름이 충돌하는데, 그 사실이 apply 시점에야 드러난다. + lifecycle { + precondition { + condition = length(local.foundation_name) <= 32 + error_message = "ALB name '${local.foundation_name}' exceeds 32 characters. Shorten project_name or environment." + } + } } resource "aws_lb_target_group" "app" { - name = substr("${local.name}-app", 0, 32) + name = local.service_name port = var.container_port protocol = "HTTP" target_type = "ip" @@ -157,6 +175,13 @@ resource "aws_lb_target_group" "app" { path = "/v1/system/health" unhealthy_threshold = 3 } + + lifecycle { + precondition { + condition = length(local.service_name) <= 32 + error_message = "Target group name '${local.service_name}' exceeds 32 characters. Shorten project_name, environment, or service_name." + } + } } resource "aws_lb_listener" "http" { @@ -171,7 +196,7 @@ resource "aws_lb_listener" "http" { } resource "aws_ecs_service" "app" { - name = local.name + name = local.service_name cluster = aws_ecs_cluster.this.id task_definition = aws_ecs_task_definition.app.arn desired_count = var.desired_count diff --git a/infra/terraform/data.tf b/infra/terraform/data.tf index 459fc95..067cd20 100644 --- a/infra/terraform/data.tf +++ b/infra/terraform/data.tf @@ -1,5 +1,6 @@ +# subnet group은 "DB가 들어갈 subnet 목록"일 뿐이라 foundation이다. resource "aws_db_subnet_group" "this" { - name = local.name + name = local.foundation_name subnet_ids = aws_subnet.private[*].id } @@ -10,7 +11,7 @@ resource "aws_db_instance" "this" { db_subnet_group_name = aws_db_subnet_group.this.name deletion_protection = var.environment == "prod" engine = var.db_engine - identifier = local.name + identifier = local.service_name instance_class = var.db_instance_class manage_master_user_password = true multi_az = var.environment == "prod" @@ -25,7 +26,7 @@ resource "aws_db_instance" "this" { resource "aws_elasticache_subnet_group" "this" { count = var.enable_redis ? 1 : 0 - name = local.name + name = local.foundation_name subnet_ids = aws_subnet.private[*].id } @@ -33,12 +34,12 @@ resource "aws_elasticache_replication_group" "this" { count = var.enable_redis ? 1 : 0 at_rest_encryption_enabled = true - description = "${local.name} cache and BullMQ backend" + description = "${local.service_name} cache and BullMQ backend" engine = "valkey" node_type = var.redis_node_type num_cache_clusters = var.environment == "prod" ? 2 : 1 port = 6379 - replication_group_id = local.name + replication_group_id = local.service_name security_group_ids = [aws_security_group.data.id] subnet_group_name = aws_elasticache_subnet_group.this[0].name } @@ -46,14 +47,14 @@ resource "aws_elasticache_replication_group" "this" { resource "aws_sqs_queue" "dead_letter" { count = var.enable_sqs ? 1 : 0 - name = "${local.name}-dlq" + name = "${local.service_name}-dlq" message_retention_seconds = 1209600 } resource "aws_sqs_queue" "messages" { count = var.enable_sqs ? 1 : 0 - name = "${local.name}-messages" + name = "${local.service_name}-messages" visibility_timeout_seconds = 60 redrive_policy = jsonencode({ diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf index 30341e3..13d5288 100644 --- a/infra/terraform/network.tf +++ b/infra/terraform/network.tf @@ -3,12 +3,16 @@ data "aws_availability_zones" "available" { } locals { - name = "${var.project_name}-${var.environment}" + # foundation — 여러 서비스가 나눠 쓴다. VPC, subnet, ECS 클러스터, ALB. + foundation_name = "${var.project_name}-${var.environment}" + + # service — 이 서비스만의 것. task definition, target group, RDS, 큐. + service_name = "${local.foundation_name}-${var.service_name}" + selected_azs = slice(data.aws_availability_zones.available.names, 0, 2) db_port = var.db_engine == "postgres" ? 5432 : 3306 db_driver = var.db_engine mongodb_on = var.mongodb_secret_arn != null - redis_on = var.enable_redis public_cidrs = ["10.20.0.0/24", "10.20.1.0/24"] private_cidrs = ["10.20.10.0/24", "10.20.11.0/24"] } @@ -18,12 +22,12 @@ resource "aws_vpc" "this" { enable_dns_hostnames = true enable_dns_support = true - tags = { Name = local.name } + tags = { Name = local.foundation_name } } resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id - tags = { Name = local.name } + tags = { Name = local.foundation_name } } resource "aws_subnet" "public" { @@ -34,7 +38,7 @@ resource "aws_subnet" "public" { map_public_ip_on_launch = true vpc_id = aws_vpc.this.id - tags = { Name = "${local.name}-public-${count.index + 1}" } + tags = { Name = "${local.foundation_name}-public-${count.index + 1}" } } resource "aws_subnet" "private" { @@ -44,7 +48,7 @@ resource "aws_subnet" "private" { cidr_block = local.private_cidrs[count.index] vpc_id = aws_vpc.this.id - tags = { Name = "${local.name}-private-${count.index + 1}" } + tags = { Name = "${local.foundation_name}-private-${count.index + 1}" } } resource "aws_route_table" "public" { @@ -55,7 +59,7 @@ resource "aws_route_table" "public" { gateway_id = aws_internet_gateway.this.id } - tags = { Name = "${local.name}-public" } + tags = { Name = "${local.foundation_name}-public" } } resource "aws_route_table_association" "public" { @@ -65,62 +69,99 @@ resource "aws_route_table_association" "public" { subnet_id = aws_subnet.public[count.index].id } +# ──────────────────────────────────────────────────────────────────────────── +# 보안 그룹 +# +# rule을 `aws_security_group`의 inline `ingress`/`egress`로 쓰지 않는다. +# inline 블록은 **authoritative**라 terraform이 구성에 없는 rule을 지운다. +# 그러면 foundation과 service가 갈렸을 때, service root가 공유 SG에 붙인 rule을 +# foundation apply가 조용히 삭제한다. 별도 rule 리소스는 소유자를 rule 단위로 +# 나눌 수 있어서, 각 root가 자기가 만든 rule만 관리한다. +# ──────────────────────────────────────────────────────────────────────────── + +# foundation. 여러 서비스의 task SG가 이걸 source로 참조한다. resource "aws_security_group" "alb" { - name_prefix = "${local.name}-alb-" + name_prefix = "${local.foundation_name}-alb-" vpc_id = aws_vpc.this.id - ingress { - from_port = 80 - protocol = "tcp" - to_port = 80 - cidr_blocks = ["0.0.0.0/0"] - } + tags = { Name = "${local.foundation_name}-alb" } +} - egress { - from_port = 0 - protocol = "-1" - to_port = 0 - cidr_blocks = ["0.0.0.0/0"] - } +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + description = "Public HTTP" + security_group_id = aws_security_group.alb.id + + cidr_ipv4 = "0.0.0.0/0" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" } +resource "aws_vpc_security_group_egress_rule" "alb_all" { + description = "ALB to targets" + security_group_id = aws_security_group.alb.id + + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +# service. 이 서비스의 task만 담는다. resource "aws_security_group" "app" { - name_prefix = "${local.name}-app-" + name_prefix = "${local.service_name}-app-" vpc_id = aws_vpc.this.id - ingress { - from_port = var.container_port - protocol = "tcp" - to_port = var.container_port - security_groups = [aws_security_group.alb.id] - } + tags = { Name = "${local.service_name}-app" } +} - egress { - from_port = 0 - protocol = "-1" - to_port = 0 - cidr_blocks = ["0.0.0.0/0"] - } +resource "aws_vpc_security_group_ingress_rule" "app_from_alb" { + description = "ALB to this service" + security_group_id = aws_security_group.app.id + + referenced_security_group_id = aws_security_group.alb.id + from_port = var.container_port + to_port = var.container_port + ip_protocol = "tcp" } +resource "aws_vpc_security_group_egress_rule" "app_all" { + description = "Task egress" + security_group_id = aws_security_group.app.id + + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +# service. 이 서비스의 RDS/Valkey를 담는다. +# 데이터 플레인을 서비스끼리 공유하게 되면 이 SG는 foundation으로 올라가고, +# 아래 rule들은 각 service root가 자기 것을 만들게 된다 — 그때 inline이 아닌 +# 것이 결정적으로 중요해진다. resource "aws_security_group" "data" { - name_prefix = "${local.name}-data-" + name_prefix = "${local.service_name}-data-" vpc_id = aws_vpc.this.id - ingress { - from_port = local.db_port - protocol = "tcp" - to_port = local.db_port - security_groups = [aws_security_group.app.id] - } + tags = { Name = "${local.service_name}-data" } +} - dynamic "ingress" { - for_each = var.enable_redis ? [1] : [] - content { - from_port = 6379 - protocol = "tcp" - to_port = 6379 - security_groups = [aws_security_group.app.id] - } - } +resource "aws_vpc_security_group_ingress_rule" "data_from_app" { + description = "Service tasks to RDB" + security_group_id = aws_security_group.data.id + + referenced_security_group_id = aws_security_group.app.id + from_port = local.db_port + to_port = local.db_port + ip_protocol = "tcp" +} + +resource "aws_vpc_security_group_ingress_rule" "data_redis_from_app" { + count = var.enable_redis ? 1 : 0 + + description = "Service tasks to Valkey" + security_group_id = aws_security_group.data.id + + referenced_security_group_id = aws_security_group.app.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" } + +# data SG에 egress rule은 없다. RDS와 ElastiCache는 연결을 시작하지 않는다. diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 400c903..b381f50 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -1,3 +1,64 @@ +# ──────────────────────────────────────────────────────────────────────────── +# foundation 계약 +# +# 이 값들은 이 root가 foundation과 service로 갈릴 때 **service root가 읽어야 하는 +# 것들**이다. 지금은 한 root라 아무도 안 쓰지만, 계약을 지금 고정해두면 분리할 때 +# "무엇이 공개 인터페이스였나"를 다시 발굴하지 않아도 된다. +# +# 분리 후 전달은 SSM Parameter Store를 쓴다. `terraform_remote_state`는 output만 +# 읽는 것처럼 보여도 state 스냅샷 전체에 대한 읽기 권한을 요구하고, 그 안에는 +# RDS master secret ARN과 IAM 정책이 함께 들어 있다. `sensitive = true`는 출력 +# 표시를 가릴 뿐 권한 경계가 아니다. +# ──────────────────────────────────────────────────────────────────────────── + +output "vpc_id" { + description = "Foundation VPC" + value = aws_vpc.this.id +} + +output "public_subnet_ids" { + description = "Foundation public subnets (ALB, and currently ECS tasks)" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "Foundation private subnets. No route table or NAT is attached yet." + value = aws_subnet.private[*].id +} + +output "alb_security_group_id" { + description = "Foundation ALB security group. Service task SGs allow ingress from this." + value = aws_security_group.alb.id +} + +output "alb_listener_arn" { + description = "Foundation HTTP listener. Services attach their own aws_lb_listener_rule here." + value = aws_lb_listener.http.arn +} + +output "alb_dns_name" { + description = "Foundation ALB DNS name, for the DNS records services point at" + value = aws_lb.this.dns_name +} + +output "ecs_cluster_arn" { + description = "Foundation ECS cluster that service tasks run in" + value = aws_ecs_cluster.this.arn +} + +output "ecs_cluster_name" { + value = aws_ecs_cluster.this.name +} + +output "db_subnet_group_name" { + description = "Foundation DB subnet group. Per-service RDS instances place themselves here." + value = aws_db_subnet_group.this.name +} + +# ──────────────────────────────────────────────────────────────────────────── +# service 출력 +# ──────────────────────────────────────────────────────────────────────────── + output "application_url" { value = "http://${aws_lb.this.dns_name}" } @@ -6,6 +67,9 @@ output "rds_endpoint" { value = aws_db_instance.this.endpoint } +# ⚠ 이것은 **master** credential이다. 지금은 task가 직접 쓴다(compute.tf). +# 데이터 플레인을 서비스끼리 공유하게 되면 이 값을 앱에 주는 것은 성립하지 않는다 — +# 서비스별 database/user/grant가 먼저 필요하고, 그건 hashicorp/aws만으로는 안 된다. output "rds_master_secret_arn" { value = aws_db_instance.this.master_user_secret[0].secret_arn sensitive = true diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 33b4d6b..6a3fd0e 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -1,6 +1,12 @@ aws_region = "ap-northeast-2" project_name = "barebones" environment = "dev" + +# foundation 안에서 이 서비스의 정체성. task definition, target group, RDS, +# 큐 이름과 Redis 네임스페이스가 전부 여기서 유도된다. +# `project_name`-`environment`가 foundation 이름(VPC, ECS 클러스터, ALB)이고 +# 여기에 이 값을 붙인 것이 service 이름이다. +service_name = "api" container_image = "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/barebones:latest" db_engine = "postgres" @@ -18,5 +24,8 @@ cors_origins = ["https://app.example.com"] # 켜려면 MessageQueuePort의 SQS adapter를 먼저 만든다. # enable_sqs = false +# Redis logical database. 데이터 플레인이 서비스별이면 0으로 둔다. +# redis_db = 0 + # MongoDB Atlas 등 외부 MongoDB URI를 담은 Secrets Manager ARN. 사용하지 않으면 생략한다. # mongodb_secret_arn = "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:mongodb-uri" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 81b1e53..0f55e14 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -16,6 +16,23 @@ variable "environment" { default = "dev" } +# 이름 축이 둘로 갈린다. `project_name`-`environment`가 **foundation** 이름이고 +# (VPC, ECS 클러스터, ALB — 여러 서비스가 나눠 쓴다), 여기에 `service_name`을 +# 붙인 것이 **service** 이름이다(task definition, target group, RDS). +# +# 이전에는 `local.name` 하나가 VPC부터 target group까지 전부의 이름이었다. +# 서비스가 둘이면 ECS 클러스터 이름부터 충돌한다. +variable "service_name" { + description = "Service identity within the foundation (e.g. api, web, worker)" + type = string + default = "api" + + validation { + condition = can(regex("^[a-z][a-z0-9-]*[a-z0-9]$", var.service_name)) + error_message = "service_name must be lowercase alphanumeric with hyphens, starting with a letter." + } +} + variable "container_image" { description = "Deployable application image URI" type = string @@ -127,6 +144,23 @@ variable "redis_node_type" { default = "cache.t4g.micro" } +# Redis logical database 번호. 데이터 플레인이 서비스별이면 0으로 두면 된다. +# Valkey 하나를 여러 서비스가 나눠 쓰게 되면 여기서 갈라야 한다 — 단 번호는 16개뿐이고 +# key prefix와 달리 권한 경계도 아니다. 진짜 격리는 ElastiCache RBAC인데, 그건 +# transit encryption과 앱의 username/TLS 지원을 함께 요구한다. 지금 앱의 +# `buildRedisOptions`는 password만 받는다. +# > src/config/redis.config.ts:20-32 +variable "redis_db" { + description = "Redis logical database index for this service" + type = number + default = 0 + + validation { + condition = var.redis_db >= 0 && var.redis_db <= 15 && floor(var.redis_db) == var.redis_db + error_message = "redis_db must be an integer between 0 and 15." + } +} + variable "mongodb_secret_arn" { description = "Optional Secrets Manager ARN whose value is the MongoDB URI" type = string