diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1632012..e264546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,19 +1,9 @@ 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 diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml deleted file mode 100644 index 8165b7f..0000000 --- a/.github/workflows/terraform.yml +++ /dev/null @@ -1,46 +0,0 @@ -# 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 diff --git a/.gitignore b/.gitignore index d6de416..bb97f57 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,6 @@ coverage .env.local .DS_Store pnpm-debug.log -*.tfstate -*.tfstate.* -.terraform/ -infra/terraform/backend.hcl -infra/terraform/terraform.tfvars # Agent runtime state는 제외하고, 프로젝트 스킬은 Claude/Codex 각각 추적한다. /.claude/* diff --git a/AGENTS.md b/AGENTS.md index 571e9b3..9620168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ adapters/in ## Infrastructure - 로컬 의존성은 Docker Compose에서 실행한다. sql.js 같은 인메모리 RDB 대체물을 사용하지 않는다. -- AWS Terraform은 기본 배포 시작점이며 state backend와 secret 값은 환경별로 분리한다. +- 배포 인프라는 이 저장소에 없다. 컨테이너 경계(`Dockerfile`, Compose)까지가 이 저장소의 범위다. - app 시작 순서에는 Docker healthcheck를 사용한다. - 선택된 필수 의존성이 실행 중 실패하면 readiness는 `503`이다. diff --git a/README.md b/README.md index 6a3bc61..c568da8 100644 --- a/README.md +++ b/README.md @@ -107,28 +107,16 @@ Matt Pocock의 안정 `engineering`/`productivity` 스킬을 두 에이전트에 두 디렉터리는 symlink로 공유하지 않습니다. 각 에이전트가 해당 디렉터리만 읽습니다. 스킬은 사용자가 이름을 명시했을 때만 실행합니다. -## AWS Terraform +## 배포 -`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입니다. +이 저장소의 범위는 **컨테이너 경계까지**입니다 — `Dockerfile`과 Compose가 "이 서비스를 +어떻게 돌리나"를 정의하고, `.env.example`과 `src/config/env.validation.ts`가 "앱이 무슨 +설정을 요구하나"를 정의합니다. 둘 다 클라우드에 무관합니다. -```bash -cd infra/terraform -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에 없습니다. +AWS 인프라는 [iflov/barebones-infra](https://github.com/iflov/barebones-infra)에 있습니다. +2026-09-01에 분리했습니다 — VPC·ALB·ECS 클러스터는 환경당 정확히 하나뿐인 **인스턴스**라 +스캐폴드가 실을 수 없고, 그 소비자에는 이 스캐폴드에서 나오지 않은 저장소도 포함됩니다. +근거와 검증 과정은 [결정 문서](./docs/terraform-repo-boundary.md)에 있습니다. ## 검증 diff --git a/docs/terraform-repo-boundary.md b/docs/terraform-repo-boundary.md index ab4d537..d4ffd53 100644 --- a/docs/terraform-repo-boundary.md +++ b/docs/terraform-repo-boundary.md @@ -4,6 +4,23 @@ > `infra/terraform`을 앱 저장소 밖으로 뺄 것인가. 3라운드 교차검증 결과와 내린 결정. > 조사 2026-08-31 · 결정 2026-09-01 > +> **✅ 종결 (2026-09-01): `infra/terraform`은 [iflov/barebones-infra](https://github.com/iflov/barebones-infra)로 +> 나갔다.** 이 문서는 그 결정의 기록으로 barebones에 남는다. +> +> 최종 근거는 저장소 경계도 CI 비용도 아니었다 — **barebones는 스캐폴드이고 스캐폴드는 +> 템플릿만 실을 수 있는데, foundation은 환경당 하나뿐인 인스턴스다.** VPC·ALB·ECS +> 클러스터는 복제하면 사고이지 기능이 아니고, 그 소비자에는 이 스캐폴드에서 나오지 +> 않은 저장소(frontend 등)도 포함된다. 아래 "2026-09-01 재검토"에서 변경 빈도와 +> blast radius로 그은 foundation/service 선이, 재사용 가능성으로 그으면 나오는 +> "인스턴스 / 템플릿" 선과 같았다. +> +> 남은 배포 경계는 컨테이너까지다 — `Dockerfile`, Compose, `.env.example`, +> `src/config/env.validation.ts`. 전부 클라우드에 무관하다. +> +> 이전은 `git subtree split`으로 했고 이 구성을 만든 커밋 3개가 함께 갔다. +> +> --- +> > **⚠ 아래 "추천과 그 근거"는 2026-09-01에 폐기됐다.** 그 추천은 "terraform 사본 하나당 > 소비자 하나"를 전제했는데, 사용자가 그 전제가 틀렸다고 정정했다 — frontend와 다른 > 서비스가 **같은 인프라를 나눠 쓴다.** 갱신된 판단은 아래 "2026-09-01 재검토"에 있다. diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore deleted file mode 100644 index 545c206..0000000 --- a/infra/terraform/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# 이 디렉터리는 앱 저장소 밖으로 나갈 수 있으므로 무시 규칙을 자기 안에 둔다. -# 저장소 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 diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl deleted file mode 100644 index 308cc90..0000000 --- a/infra/terraform/.terraform.lock.hcl +++ /dev/null @@ -1,26 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.60.0" - constraints = "~> 6.0" - hashes = [ - "h1:BDSQlnJzI6J7VA7JmDk1gfgwVlEBWhTSVljOsxmEpOo=", - "zh:1a29660d9038c38e9e306194bd3e7de7017148f3491af7fdfa434d7d8c82b3d4", - "zh:268d711a0a9c18459e09a837405244e5d459bb4ebb5d34a91c4fcbabe3e25d26", - "zh:2d7058c285484e7cccf4f5b1973c10b404ae77532f77ea95115915dd341fbf5b", - "zh:2e1cddc362497b569ba528f013d0cf770465047b8185e8180f4ccabafa082577", - "zh:5210c99d5946dd4985a6cff7460422e257037679b35df0a54982e3f675d1a8d5", - "zh:60703aa30b23ed5e5cfabf2614aeb465cf2aa4b848eb8afb2723b6b9d358ccd0", - "zh:904f0651e9bfbf08b16f4d13c85e7841c2d1b5a670087cb11f8d4ad06249cf0c", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b4268c98208f725ea28afb38dde6cb4b7536de577c36ee919fa44c3d8d5faf70", - "zh:caf78d1713cc323383facae063c61584758ea697428f328600227902f3a9980f", - "zh:cb349f8a43653bfc78867909d6342c56073f9805bdbd9dc32a8e1d18302257d4", - "zh:e087e5e6e3130980838552022e022a62a52a851cf93a0029cb5d410a34f6106b", - "zh:e94318f9ebf29d06aa821da73a7fc7a637d7d13e535e40585c6242cea4477262", - "zh:effc513566df2f5b5921a5df884817b1de57790db1c485eece566c5d4c55c4ee", - "zh:f2be12b976d10a91289835056ff962721e65f47f8e52b368211c4bd3ce9e20e6", - "zh:fb7ef7e405b05789cec32c8ea06a353789c6e3a336b876508cf2ac98984f0558", - ] -} diff --git a/infra/terraform/README.md b/infra/terraform/README.md deleted file mode 100644 index 012a672..0000000 --- a/infra/terraform/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# AWS Terraform starter - -기본 프로필은 2개 AZ VPC, ALB, ECS Fargate, RDS, 선택적 ElastiCache Valkey를 만듭니다. -MongoDB는 DocumentDB를 동일 제품처럼 취급하지 않으며, Atlas 등 실제 MongoDB URI를 AWS Secrets -Manager ARN으로 주입합니다. SQS/DLQ는 `enable_sqs`로 켜며 기본은 꺼짐입니다. - -```bash -cp terraform.tfvars.example terraform.tfvars -terraform init -backend-config=backend.hcl -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을 지웁니다. - -## 소비자가 반드시 넘겨야 하는 값 - -| 변수 | 기본값 | 왜 여기 있는가 | -| ----------------- | ------ | -------------------------------------------------------------------------------------------- | -| `container_image` | 없음 | 배포할 이미지 URI. ECR build/push 경로는 아직 CI에 없습니다. | -| `cors_origins` | 없음 | 브라우저 Origin 목록. 스킴 필수, `*` 금지 — 앱의 production 검증을 plan 시점으로 당겼습니다. | - -## 토폴로지가 정하는 값 - -| 변수 | 기본값 | 근거 | -| ------------------ | ------- | ------------------------------------------------------------------------------------------------------- | -| `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를 먼저 만듭니다. | - -## 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`을 사용합니다. - -`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과 승인 경로가 정해지지 않았습니다. - -## 아직 없는 것 - -- **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`와 무관하게 고정입니다. -- 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을 -사용합니다. diff --git a/infra/terraform/backend.hcl.example b/infra/terraform/backend.hcl.example deleted file mode 100644 index 6f9b076..0000000 --- a/infra/terraform/backend.hcl.example +++ /dev/null @@ -1,23 +0,0 @@ -# 이 파일은 커밋하지 않는다. `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/backend.tf b/infra/terraform/backend.tf deleted file mode 100644 index 4f04361..0000000 --- a/infra/terraform/backend.tf +++ /dev/null @@ -1,10 +0,0 @@ -# 이 블록은 **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" {} -} diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf deleted file mode 100644 index df13628..0000000 --- a/infra/terraform/compute.tf +++ /dev/null @@ -1,218 +0,0 @@ -resource "aws_cloudwatch_log_group" "app" { - name = "/ecs/${local.service_name}" - retention_in_days = var.environment == "prod" ? 30 : 7 -} - -# 클러스터는 foundation이다. 여러 서비스가 같은 클러스터에 뜬다. -resource "aws_ecs_cluster" "this" { - name = local.foundation_name -} - -resource "aws_iam_role" "ecs_execution" { - name = "${local.service_name}-ecs-execution" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { Service = "ecs-tasks.amazonaws.com" } - }] - }) -} - -resource "aws_iam_role_policy_attachment" "ecs_execution" { - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" - role = aws_iam_role.ecs_execution.name -} - -resource "aws_iam_role_policy" "ecs_secrets" { - name = "secrets" - role = aws_iam_role.ecs_execution.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = ["secretsmanager:GetSecretValue"] - Effect = "Allow" - Resource = compact([aws_db_instance.this.master_user_secret[0].secret_arn, var.mongodb_secret_arn]) - }] - }) -} - -resource "aws_iam_role" "ecs_task" { - name = "${local.service_name}-ecs-task" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { Service = "ecs-tasks.amazonaws.com" } - }] - }) -} - -resource "aws_iam_role_policy" "messages" { - count = var.enable_sqs ? 1 : 0 - - name = "messages" - role = aws_iam_role.ecs_task.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = [ - "sqs:ChangeMessageVisibility", - "sqs:DeleteMessage", - "sqs:GetQueueAttributes", - "sqs:GetQueueUrl", - "sqs:ReceiveMessage", - "sqs:SendMessage" - ] - Effect = "Allow" - Resource = aws_sqs_queue.messages[0].arn - }] - }) -} - -locals { - 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) }, - { name = "DB_DATABASE", value = var.db_name }, - { name = "DB_USERNAME", value = var.db_username }, - { 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) } - ], - # 앱은 allowUnknown이라 이 변수가 남아도 부팅은 되지만, 큐가 없으면 - # 가리킬 URL도 없다. 켰을 때만 주입한다. - var.enable_sqs ? [{ name = "MESSAGE_QUEUE_URL", value = aws_sqs_queue.messages[0].url }] : [] - ) - - app_secrets = concat( - [{ - name = "DB_PASSWORD" - valueFrom = "${aws_db_instance.this.master_user_secret[0].secret_arn}:password::" - }], - var.mongodb_secret_arn == null ? [] : [{ - name = "MONGODB_URI" - valueFrom = var.mongodb_secret_arn - }] - ) -} - -resource "aws_ecs_task_definition" "app" { - family = local.service_name - cpu = 512 - memory = 1024 - network_mode = "awsvpc" - requires_compatibilities = ["FARGATE"] - execution_role_arn = aws_iam_role.ecs_execution.arn - task_role_arn = aws_iam_role.ecs_task.arn - - container_definitions = jsonencode([{ - name = "app" - image = var.container_image - essential = true - environment = local.app_environment - secrets = local.app_secrets - portMappings = [{ containerPort = var.container_port, protocol = "tcp" }] - logConfiguration = { - logDriver = "awslogs" - options = { - awslogs-group = aws_cloudwatch_log_group.app.name - awslogs-region = var.aws_region - awslogs-stream-prefix = "app" - } - } - }]) -} - -# ALB는 foundation이다. 여러 서비스가 listener rule로 나눠 쓴다. -resource "aws_lb" "this" { - 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 = local.service_name - port = var.container_port - protocol = "HTTP" - target_type = "ip" - vpc_id = aws_vpc.this.id - - health_check { - healthy_threshold = 2 - interval = 30 - 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" { - load_balancer_arn = aws_lb.this.arn - port = 80 - protocol = "HTTP" - - default_action { - target_group_arn = aws_lb_target_group.app.arn - type = "forward" - } -} - -resource "aws_ecs_service" "app" { - name = local.service_name - cluster = aws_ecs_cluster.this.id - task_definition = aws_ecs_task_definition.app.arn - desired_count = var.desired_count - launch_type = "FARGATE" - - network_configuration { - assign_public_ip = true - security_groups = [aws_security_group.app.id] - subnets = aws_subnet.public[*].id - } - - load_balancer { - container_name = "app" - container_port = var.container_port - target_group_arn = aws_lb_target_group.app.arn - } - - depends_on = [aws_lb_listener.http] -} diff --git a/infra/terraform/data.tf b/infra/terraform/data.tf deleted file mode 100644 index 067cd20..0000000 --- a/infra/terraform/data.tf +++ /dev/null @@ -1,64 +0,0 @@ -# subnet group은 "DB가 들어갈 subnet 목록"일 뿐이라 foundation이다. -resource "aws_db_subnet_group" "this" { - name = local.foundation_name - subnet_ids = aws_subnet.private[*].id -} - -resource "aws_db_instance" "this" { - allocated_storage = 20 - backup_retention_period = var.environment == "prod" ? 7 : 1 - db_name = var.db_name - db_subnet_group_name = aws_db_subnet_group.this.name - deletion_protection = var.environment == "prod" - engine = var.db_engine - identifier = local.service_name - instance_class = var.db_instance_class - manage_master_user_password = true - multi_az = var.environment == "prod" - port = local.db_port - publicly_accessible = false - skip_final_snapshot = var.environment != "prod" - storage_encrypted = true - username = var.db_username - vpc_security_group_ids = [aws_security_group.data.id] -} - -resource "aws_elasticache_subnet_group" "this" { - count = var.enable_redis ? 1 : 0 - - name = local.foundation_name - subnet_ids = aws_subnet.private[*].id -} - -resource "aws_elasticache_replication_group" "this" { - count = var.enable_redis ? 1 : 0 - - at_rest_encryption_enabled = true - 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.service_name - security_group_ids = [aws_security_group.data.id] - subnet_group_name = aws_elasticache_subnet_group.this[0].name -} - -resource "aws_sqs_queue" "dead_letter" { - count = var.enable_sqs ? 1 : 0 - - name = "${local.service_name}-dlq" - message_retention_seconds = 1209600 -} - -resource "aws_sqs_queue" "messages" { - count = var.enable_sqs ? 1 : 0 - - name = "${local.service_name}-messages" - visibility_timeout_seconds = 60 - - redrive_policy = jsonencode({ - deadLetterTargetArn = aws_sqs_queue.dead_letter[0].arn - maxReceiveCount = 5 - }) -} diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf deleted file mode 100644 index 13d5288..0000000 --- a/infra/terraform/network.tf +++ /dev/null @@ -1,167 +0,0 @@ -data "aws_availability_zones" "available" { - state = "available" -} - -locals { - # 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 - public_cidrs = ["10.20.0.0/24", "10.20.1.0/24"] - private_cidrs = ["10.20.10.0/24", "10.20.11.0/24"] -} - -resource "aws_vpc" "this" { - cidr_block = "10.20.0.0/16" - enable_dns_hostnames = true - enable_dns_support = true - - tags = { Name = local.foundation_name } -} - -resource "aws_internet_gateway" "this" { - vpc_id = aws_vpc.this.id - tags = { Name = local.foundation_name } -} - -resource "aws_subnet" "public" { - count = 2 - - availability_zone = local.selected_azs[count.index] - cidr_block = local.public_cidrs[count.index] - map_public_ip_on_launch = true - vpc_id = aws_vpc.this.id - - tags = { Name = "${local.foundation_name}-public-${count.index + 1}" } -} - -resource "aws_subnet" "private" { - count = 2 - - availability_zone = local.selected_azs[count.index] - cidr_block = local.private_cidrs[count.index] - vpc_id = aws_vpc.this.id - - tags = { Name = "${local.foundation_name}-private-${count.index + 1}" } -} - -resource "aws_route_table" "public" { - vpc_id = aws_vpc.this.id - - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.this.id - } - - tags = { Name = "${local.foundation_name}-public" } -} - -resource "aws_route_table_association" "public" { - count = 2 - - route_table_id = aws_route_table.public.id - 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.foundation_name}-alb-" - vpc_id = aws_vpc.this.id - - tags = { Name = "${local.foundation_name}-alb" } -} - -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.service_name}-app-" - vpc_id = aws_vpc.this.id - - tags = { Name = "${local.service_name}-app" } -} - -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.service_name}-data-" - vpc_id = aws_vpc.this.id - - tags = { Name = "${local.service_name}-data" } -} - -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 deleted file mode 100644 index b381f50..0000000 --- a/infra/terraform/outputs.tf +++ /dev/null @@ -1,84 +0,0 @@ -# ──────────────────────────────────────────────────────────────────────────── -# 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}" -} - -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 -} - -output "message_queue_url" { - value = var.enable_sqs ? aws_sqs_queue.messages[0].url : null -} - -output "redis_endpoint" { - value = var.enable_redis ? aws_elasticache_replication_group.this[0].primary_endpoint_address : null -} diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example deleted file mode 100644 index 6a3fd0e..0000000 --- a/infra/terraform/terraform.tfvars.example +++ /dev/null @@ -1,31 +0,0 @@ -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" - -# 필수. 기본값이 없다 — 브라우저가 보내는 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 - -# 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 deleted file mode 100644 index 0f55e14..0000000 --- a/infra/terraform/variables.tf +++ /dev/null @@ -1,169 +0,0 @@ -variable "aws_region" { - description = "AWS region" - type = string - default = "ap-northeast-2" -} - -variable "project_name" { - description = "Resource name prefix" - type = string - default = "barebones" -} - -variable "environment" { - description = "Environment name" - type = string - 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 -} - -# 기본값이 없는 것은 의도다. 이전에는 `compute.tf`에 "https://replace-me.example"이 -# 박혀 있었고, 앱의 production 검증(스킴 있음·와일드카드 아님)을 **통과**하기 때문에 -# task는 멀쩡히 뜨고 브라우저 요청만 조용히 막혔다. 복제 모델에서는 사본을 고치면 -# 됐지만 참조 모델에서는 고칠 방법이 없으므로 소비자가 반드시 넘겨야 한다. -# -# 아래 validation은 앱의 규칙을 plan 시점으로 당긴 것이다 — 같은 실패를 -# ECS task 기동이 아니라 `terraform plan`에서 본다. -# > src/config/env.validation.ts:136 · src/config/cors.config.ts:14 -variable "cors_origins" { - description = "Allowed browser origins, each with a scheme (e.g. https://app.example.com)" - type = list(string) - - validation { - condition = length(var.cors_origins) > 0 - error_message = "cors_origins must list at least one origin." - } - - validation { - condition = !contains(var.cors_origins, "*") - error_message = "cors_origins must not contain \"*\" - the app rejects it in production." - } - - validation { - condition = alltrue([for o in var.cors_origins : can(regex("^[a-z][a-z0-9+.-]*://", o))]) - error_message = "Each cors_origins entry must include a scheme. Browsers send Origin as scheme://host[:port]." - } -} - -variable "container_port" { - type = number - default = 3000 -} - -variable "desired_count" { - type = number - default = 1 -} - -# 앱은 이 값으로 Express `trust proxy`를 설정한다. 0은 "프록시 없음"이라 -# `req.ip`가 ALB IP가 되고 ThrottlerGuard가 전 사용자를 한 버킷에 넣는다. -# 이 구성은 ALB 하나를 세우므로 기본값이 1이다. CloudFront를 앞에 얹는 -# 소비자는 2로 올린다. `true`(전부 신뢰)는 XFF 위조로 throttle을 우회시키므로 -# 앱이 숫자만 받는다. -# -# 상한 10은 앱의 Joi 스키마와 같은 값이다 — 여기서 넘기면 task가 부팅에 실패한다. -# > src/config/env.validation.ts:64 · src/app.setup.ts:75 -variable "trust_proxy_hops" { - description = "Number of proxy hops the app should trust for X-Forwarded-For" - type = number - default = 1 - - validation { - condition = var.trust_proxy_hops >= 0 && var.trust_proxy_hops <= 10 && floor(var.trust_proxy_hops) == var.trust_proxy_hops - error_message = "trust_proxy_hops must be an integer between 0 and 10." - } -} - -variable "db_engine" { - description = "Materialized RDB engine" - type = string - default = "postgres" - - validation { - condition = contains(["postgres", "mysql", "mariadb"], var.db_engine) - error_message = "db_engine must be postgres, mysql, or mariadb." - } -} - -variable "db_name" { - type = string - default = "app" -} - -variable "db_username" { - type = string - default = "app" -} - -variable "db_instance_class" { - type = string - default = "db.t4g.micro" -} - -variable "enable_redis" { - type = bool - default = true -} - -# 기본값 false다. 이 앱의 messaging composition root는 BullMQ/Redis이고 -# (src/infra/queue/queue.module.ts) `MESSAGE_QUEUE_URL`을 읽는 코드가 없다. -# 켜면 소비자 없는 큐·DLQ·IAM 권한이 그대로 생기므로 기본은 끈다. -# -# 지우지 않고 input으로 남긴 이유: 모델 B에서 소비 프로젝트는 이 구성을 -# 참조만 하고 고칠 수 없다. SQS가 필요한 소비자에게 남기는 유일한 문이다. -# 켜는 쪽은 앱에 `MessageQueuePort`의 SQS adapter를 함께 만들어야 한다. -variable "enable_sqs" { - description = "Create the SQS main/dead-letter queues and grant the task access" - type = bool - default = false -} - -variable "redis_node_type" { - type = string - 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 - default = null - nullable = true -} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf deleted file mode 100644 index e5f9e5b..0000000 --- a/infra/terraform/versions.tf +++ /dev/null @@ -1,22 +0,0 @@ -terraform { - required_version = ">= 1.10.0" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 6.0" - } - } -} - -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Environment = var.environment - ManagedBy = "terraform" - Project = var.project_name - } - } -}