Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
name: CI

# terraform-only 변경은 이 파이프라인을 태우지 않는다 — `terraform.yml`이 대신 돈다.
# `pull_request`와 `push`는 별개 이벤트라 양쪽에 모두 필요하다.
#
# ⚠ main에 branch protection이나 ruleset을 걸 때 이 job을 required check로
# 지정하면, skip된 실행이 Pending으로 남아 terraform-only PR이 영구히 막힌다.
# 그때는 required check를 두지 말고 별도 gate job을 쓴다.
on:
pull_request:
paths-ignore:
- 'infra/terraform/**'
push:
branches: [main]
paths-ignore:
- 'infra/terraform/**'

permissions:
contents: read
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# terraform 전용 검사. `ci.yml`에 job으로 얹지 않고 파일을 분리한 이유는
# 이 구성이 결국 자기 저장소로 나가기 때문이다 — 그때 이 파일이 함께 이동한다.
#
# 자격증명이 필요 없는 검사만 여기서 돈다. backend 블록이 생긴 뒤로
# `plan`은 backend 초기화를 요구하므로 `-backend=false`로 끊는다.
# plan/apply는 OIDC role이 붙은 별도 워크플로의 몫이며 아직 없다.
name: Terraform

on:
pull_request:
paths:
- 'infra/terraform/**'
- '.github/workflows/terraform.yml'
push:
branches: [main]
paths:
- 'infra/terraform/**'
- '.github/workflows/terraform.yml'

permissions:
contents: read

defaults:
run:
working-directory: infra/terraform

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.16.0
terraform_wrapper: false

- name: fmt
run: terraform fmt -check -recursive -diff

# provider 스키마만 받고 backend는 건드리지 않는다.
# `.terraform.lock.hcl`이 커밋돼 있으므로 provider 버전이 고정된다.
- name: init
run: terraform init -backend=false -input=false

- name: validate
run: terraform validate
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,27 @@ Matt Pocock의 안정 `engineering`/`productivity` 스킬을 두 에이전트에

## AWS Terraform

`infra/terraform`은 ECS Fargate, ALB, RDS(PostgreSQL/MySQL/MariaDB), 선택형 ElastiCache Valkey,
SQS/DLQ의 시작 구성입니다. MongoDB는 DocumentDB로 가정하지 않고 외부 관리형 Mongo URI를 Secrets
Manager로 주입할 수 있게 둡니다.
`infra/terraform`은 ECS Fargate, ALB, RDS(PostgreSQL/MySQL/MariaDB), 선택형 ElastiCache Valkey의
시작 구성입니다. MongoDB는 DocumentDB로 가정하지 않고 외부 관리형 Mongo URI를 Secrets Manager로
주입할 수 있게 둡니다. SQS/DLQ는 `enable_sqs`로 끌 수 있으며 기본은 꺼짐입니다 — 이 앱의 messaging
composition root는 BullMQ/Redis입니다.

```bash
cd infra/terraform
cp terraform.tfvars.example terraform.tfvars
cp terraform.tfvars.example terraform.tfvars # cors_origins는 필수입니다
terraform init -backend-config=backend.hcl
terraform plan
```

실제 backend와 secret 값은 커밋하지 않습니다. 처음에는 `backend.hcl.example`을 환경별 파일로
복사하고, state bucket/versioning/lock 설정을 먼저 준비합니다.

이 구성은 [별도 저장소로 분리해 버전 모듈로 참조](./docs/terraform-repo-boundary.md)하는 방향으로
갑니다. 그래서 파생 프로젝트가 고쳤을 값은 사본을 편집하는 대신 input으로 노출합니다.

terraform-only 변경은 앱 CI를 태우지 않고 `.github/workflows/terraform.yml`이 fmt/init/validate를
돌립니다. `plan`/`apply`는 아직 CI에 없습니다.

## 검증

```bash
Expand Down
36 changes: 31 additions & 5 deletions docs/terraform-repo-boundary.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Terraform 저장소 경계

> **상태: 미결정 — 내일 이어서**
> `infra/terraform`을 앱 저장소 밖으로 뺄 것인가. 3라운드 교차검증 결과와 아직 내리지 않은 결정.
> 2026-08-31
> **상태: 모델 B 선택됨 (2026-09-01). 분리는 아직 실행하지 않았다.**
> `infra/terraform`을 앱 저장소 밖으로 뺄 것인가. 3라운드 교차검증 결과와 내린 결정.
> 조사 2026-08-31 · 결정 2026-09-01
>
> **결정: 모델 B — 파생 프로젝트는 버전 모듈을 참조한다.** 따라서 별도 repo가 최종 목적지다.
> 다만 아래 "추천과 그 근거"의 순서 제약은 그대로 유효해서, 지금 옮기지 않는다 —
> 모듈 인터페이스는 한 번 apply해서 무엇이 입력이고 무엇이 프로젝트별인지 드러난 뒤에 정한다.
> B는 대신 그 사이 작업의 **내용**을 바꿨다: 파생 프로젝트가 사본에서 고쳤을 값이
> 전부 input이 되어야 한다. 참조 모듈은 편집할 수 없기 때문이다.

## 먼저 정할 것은 repo가 아니다

Expand Down Expand Up @@ -91,6 +97,26 @@ Deployment/Service/Ingress까지 붙어 2~4배로 추정된다.
검증 중에 드러난 것들이다. **같은 저장소에 있는데도 이 계약들이 이미 어긋나 있다**는 게
요점 — 별도 repo로 옮긴다고 정렬되지 않고, 발견만 늦어진다.

> **2026-09-01 현재 상태.** 아래 4개 중 3개를 고쳤다. 남은 하나(이미지 전달 경로)는
> AWS 계정 쪽 결정(OIDC role, apply 승인 주체)이 먼저다.
>
> | 결함 | 상태 | 어떻게 |
> | ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
> | 소비자 없는 SQS | 고침 | 지우지 않고 `var.enable_sqs`(기본 `false`)로 선택지화. 모델 B에서 소비자가 고칠 수 없으므로 문을 남겼다. |
> | `TRUST_PROXY_HOPS` | 고침 | `var.trust_proxy_hops`(기본 `1` = ALB 하나), 0–10 정수 validation이 앱 Joi와 같은 범위. |
> | backend 블록 부재 | 고침 | `backend.tf`에 `backend "s3" {}`. 별도 파일인 이유는 backend가 하위 모듈에 존재할 수 없어서 — 모듈화 시 소비 root로 이동한다. |
> | 이미지 전달 경로 | **남음** | ECR build/push, 태그 전달, apply 승인 주체 미정. |
>
> 함께 한 것: `CORS_ORIGINS`가 `"https://replace-me.example"` 하드코딩이었는데 앱의 production
> 검증을 **통과**해서(스킴 있음·와일드카드 아님) task는 뜨고 브라우저만 조용히 막혔다.
> 기본값 없는 `var.cors_origins`로 올리고 앱의 규칙을 plan 시점 validation으로 당겼다.
> `infra/terraform/.gitignore`도 추가했다 — root의 `infra/terraform/...` 규칙은 경로가
> root 기준이라 디렉터리가 나가면 따라가지 못한다.
>
> CI: `.github/workflows/terraform.yml`이 `fmt -check`/`init -backend=false`/`validate`를 돌리고,
> `ci.yml`에 `paths-ignore`를 넣어 terraform-only 변경이 앱 파이프라인을 태우지 않게 했다.
> 순서는 아래 4번대로 CI를 먼저 만든 뒤 끊었다.

### 소비자 없는 SQS

terraform이 SQS + DLQ + IAM을 만들고 `MESSAGE_QUEUE_URL`을 ECS task에 주입하는데,
Expand Down Expand Up @@ -152,8 +178,8 @@ apply하지 않는다 — apply하는 건 파생된 각 프로젝트가 자기

## 내일 시작점

1. **모델 A냐 B냐를 먼저 정한다.**
복제냐 버전 모듈 참조냐. 이게 저장소 경계를 결정한다. 나머지 단계는 여기에 종속된다.
1. ~~**모델 A냐 B냐를 먼저 정한다.**~~ → **B(버전 모듈 참조)로 정했다 (2026-09-01).**
저장소 경계는 별도 repo로 결정됐다. 다만 실행은 3번 이후다.

2. **ECS starter를 실행 가능한 단위로 완성한다.**
`backend "s3"` 블록 추가 + `infra/terraform/.gitignore`, `TRUST_PROXY_HOPS` 주입,
Expand Down
22 changes: 22 additions & 0 deletions infra/terraform/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 이 디렉터리는 앱 저장소 밖으로 나갈 수 있으므로 무시 규칙을 자기 안에 둔다.
# 저장소 root의 `infra/terraform/...` 규칙은 경로가 root 기준이라 함께 따라가지 못한다.

# provider 바이너리와 backend 메타데이터. `.terraform.lock.hcl`은 반대로 **커밋한다**.
.terraform/

# state는 backend에만 둔다. local backend로 잘못 init하면 여기 생긴다.
*.tfstate
*.tfstate.*

# 실제 값. `*.example`만 커밋한다.
backend.hcl
terraform.tfvars
*.auto.tfvars
*.auto.tfvars.json

crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
47 changes: 43 additions & 4 deletions infra/terraform/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# AWS Terraform starter

기본 프로필은 2개 AZ VPC, ALB, ECS Fargate, RDS, 선택적 ElastiCache Valkey, SQS와 DLQ를 만듭니다.
기본 프로필은 2개 AZ VPC, ALB, ECS Fargate, RDS, 선택적 ElastiCache Valkey를 만듭니다.
MongoDB는 DocumentDB를 동일 제품처럼 취급하지 않으며, Atlas 등 실제 MongoDB URI를 AWS Secrets
Manager ARN으로 주입합니다.
Manager ARN으로 주입합니다. SQS/DLQ는 `enable_sqs`로 켜며 기본은 꺼짐입니다.

```bash
cp terraform.tfvars.example terraform.tfvars
Expand All @@ -11,9 +11,48 @@ terraform plan
terraform apply
```

## 소비자가 반드시 넘겨야 하는 값

| 변수 | 기본값 | 왜 여기 있는가 |
| ----------------- | ------ | -------------------------------------------------------------------------------------------- |
| `container_image` | 없음 | 배포할 이미지 URI. ECR build/push 경로는 아직 CI에 없습니다. |
| `cors_origins` | 없음 | 브라우저 Origin 목록. 스킴 필수, `*` 금지 — 앱의 production 검증을 plan 시점으로 당겼습니다. |

## 토폴로지가 정하는 값

| 변수 | 기본값 | 근거 |
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
| `trust_proxy_hops` | `1` | 이 구성은 ALB 하나입니다. CloudFront를 얹으면 2. 실제보다 크게 잡으면 XFF 위조로 rate limit이 뚫립니다. |
| `enable_redis` | `true` | BullMQ와 캐시의 backend입니다. |
| `enable_sqs` | `false` | 앱에 `MESSAGE_QUEUE_URL` 소비자가 없습니다. 켜려면 `MessageQueuePort`의 SQS adapter를 먼저 만듭니다. |

## backend

`backend.tf`의 `backend "s3" {}`는 빈 블록이지만 지우면 안 됩니다. partial configuration도
backend **type**은 구성 안에서 정해야 하고, 없으면 `-backend-config=backend.hcl`이
`Warning: Missing backend configuration`을 내고 local backend로 초기화됩니다.

상태 버킷은 이 구성과 별도 bootstrap에서 만들고 versioning을 켜십시오. `backend.hcl`은 커밋하지
않으며 예제의 S3 `use_lockfile`을 사용합니다. 운영에서는 HTTPS listener/ACM, WAF, private ECS
subnet+NAT/VPC endpoints, autoscaling과 secret rotation을 환경 요구에 맞게 추가해야 합니다.
않으며 예제의 S3 `use_lockfile`을 사용합니다.

`backend.tf`와 `versions.tf`의 `provider` 블록은 **root 전용**입니다. 이 구성을 재사용 모듈로
빼면 둘 다 소비 프로젝트의 root로 이동합니다 — 하위 모듈은 backend를 가질 수 없고, provider를
가지면 그 모듈에 `count`/`for_each`/`depends_on`을 쓸 수 없습니다.

## CI

`.github/workflows/terraform.yml`이 `infra/terraform/**` 변경에만 반응해
`fmt -check` / `init -backend=false` / `validate`를 돌립니다. 자격증명이 필요 없는 검사만
있으며 `plan`/`apply`는 아직 없습니다 — OIDC role과 승인 경로가 정해지지 않았습니다.

## 아직 없는 것

- **한 번도 apply된 적이 없습니다.** 이 구성이 실제로 뜨는지 미검증입니다.
- ECR build/push와 이미지 태그 전달 경로. `Dockerfile`의 `EXPOSE 3000`도
`var.container_port`와 무관하게 고정입니다.
- HTTPS listener/ACM, WAF, private ECS subnet + NAT/VPC endpoints, autoscaling, secret rotation.
현재 ECS task는 public subnet에 `assign_public_ip = true`로 뜹니다 — private subnet에는
route table도 NAT도 없습니다.

AWS 자격증명을 `.tf`나 `.tfvars`에 넣지 마십시오. AWS CLI profile, 환경변수 또는 CI의 OIDC role을
사용합니다.
10 changes: 10 additions & 0 deletions infra/terraform/backend.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# 이 블록은 **root 전용**이다. backend는 하위 모듈에 존재할 수 없으므로,
# 이 구성을 재사용 모듈로 빼면 이 파일은 소비 프로젝트의 root로 이동한다.
#
# 빈 블록이지만 지워선 안 된다. partial configuration도 backend **type**은
# 구성 안에서 정해야 하고, 이 블록이 없으면 `-backend-config=backend.hcl`은
# `Warning: Missing backend configuration`을 내고 local backend로 초기화된다.
# 나머지 값(bucket/key/region)은 `backend.hcl`이 채운다.
terraform {
backend "s3" {}
}
16 changes: 11 additions & 5 deletions infra/terraform/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ resource "aws_iam_role" "ecs_task" {
}

resource "aws_iam_role_policy" "messages" {
count = var.enable_sqs ? 1 : 0

name = "messages"
role = aws_iam_role.ecs_task.id

Expand All @@ -68,15 +70,16 @@ resource "aws_iam_role_policy" "messages" {
"sqs:SendMessage"
]
Effect = "Allow"
Resource = aws_sqs_queue.messages.arn
Resource = aws_sqs_queue.messages[0].arn
}]
})
}

locals {
app_environment = [
app_environment = concat([
{ name = "NODE_ENV", value = "production" },
{ name = "APP_PORT", value = tostring(var.container_port) },
{ name = "TRUST_PROXY_HOPS", value = tostring(var.trust_proxy_hops) },
{ name = "DB_TYPE", value = local.db_driver },
{ name = "DB_HOST", value = aws_db_instance.this.address },
{ name = "DB_PORT", value = tostring(local.db_port) },
Expand All @@ -86,10 +89,13 @@ locals {
{ 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) },
{ name = "MONGODB_ENABLED", value = tostring(local.mongodb_on) },
{ name = "MESSAGE_QUEUE_URL", value = aws_sqs_queue.messages.url },
{ name = "PROMETHEUS_ENABLED", value = "true" },
{ name = "CORS_ORIGINS", value = "https://replace-me.example" }
]
{ name = "CORS_ORIGINS", value = join(",", var.cors_origins) }
],
# 앱은 allowUnknown이라 이 변수가 남아도 부팅은 되지만, 큐가 없으면
# 가리킬 URL도 없다. 켰을 때만 주입한다.
var.enable_sqs ? [{ name = "MESSAGE_QUEUE_URL", value = aws_sqs_queue.messages[0].url }] : []
)

app_secrets = concat(
[{
Expand Down
6 changes: 5 additions & 1 deletion infra/terraform/data.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,20 @@ resource "aws_elasticache_replication_group" "this" {
}

resource "aws_sqs_queue" "dead_letter" {
count = var.enable_sqs ? 1 : 0

name = "${local.name}-dlq"
message_retention_seconds = 1209600
}

resource "aws_sqs_queue" "messages" {
count = var.enable_sqs ? 1 : 0

name = "${local.name}-messages"
visibility_timeout_seconds = 60

redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
deadLetterTargetArn = aws_sqs_queue.dead_letter[0].arn
maxReceiveCount = 5
})
}
2 changes: 1 addition & 1 deletion infra/terraform/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ output "rds_master_secret_arn" {
}

output "message_queue_url" {
value = aws_sqs_queue.messages.url
value = var.enable_sqs ? aws_sqs_queue.messages[0].url : null
}

output "redis_endpoint" {
Expand Down
14 changes: 14 additions & 0 deletions infra/terraform/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,19 @@ environment = "dev"
container_image = "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/barebones:latest"
db_engine = "postgres"

# 필수. 기본값이 없다 — 브라우저가 보내는 Origin과 정확히 일치해야 하고
# 스킴을 포함해야 한다. production에서 "*"는 앱이 부팅을 거부한다.
cors_origins = ["https://app.example.com"]

# 신뢰할 프록시 홉 수. 이 구성은 ALB 하나이므로 1이 기본값이다.
# CloudFront를 앞에 얹으면 2. 실제 토폴로지보다 크게 잡으면 클라이언트가
# X-Forwarded-For를 위조해 rate limit을 우회할 수 있다.
# trust_proxy_hops = 1

# SQS 메인 큐/DLQ와 task IAM 권한. 기본은 false다 — 이 앱의 messaging root는
# BullMQ/Redis이고 MESSAGE_QUEUE_URL을 읽는 코드가 없다.
# 켜려면 MessageQueuePort의 SQS adapter를 먼저 만든다.
# enable_sqs = false

# MongoDB Atlas 등 외부 MongoDB URI를 담은 Secrets Manager ARN. 사용하지 않으면 생략한다.
# mongodb_secret_arn = "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:mongodb-uri"
Loading
Loading