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