V2 ECS 기반 전체 아키텍처 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki
- V2는 V1의 Prod·Dev EC2 2대 + Docker Compose 구조를 ECS on EC2 다중 클러스터 구조로 전환한다. 클러스터는 스테이지(dev / stg / prod) 단위로, ECS Service는 애플리케이션(rest / websocket / frontend / fastapi) 단위로 나눈다.
- 네트워크는 V1 VPC를 공유하고 V2 전용 서브넷만 추가한다. 인터넷 진입점인 ALB와 데이터 서브넷만 2개 AZ에 두고, 애플리케이션 워크로드는 AZ-a 단일 배치로 시작한다.
- ALB는 Prod 전용 ALB와 dev·stg가 공유하는 Non-Prod ALB 두 개로 나눈다. Non-Prod ALB는 서브도메인 host-header(
dev.moyeota.com,stg.moyeota.com)로만 스테이지를 구분한다. - 서비스 간 용량·장애 경계는 클러스터가 아니라 같은 클러스터 안의 서비스별 ASG(Capacity Provider) 로 만든다. REST·WebSocket의 기준 용량은 On-Demand에 고정하고, Spot은 REST의 출퇴근·행사 추가분에만 쓴다.
- REST와 WebSocket을 분리하면서 생긴 태스크 간 채팅 전달 문제는 Redis Pub/Sub로 해결한다. 채팅의 원본은 계속 MySQL이며, Prod DB는 RDS for MySQL Single-AZ로 전환한다.
클러스터 분리는 격리의 "경계"를 만드는 것이고, 실제 권한·설정 접근 차단은 스테이지별 IAM Role, SSM 경로, 보안 그룹으로 완성된다. 같은 스테이지 안에서도 DB에 닿을 수 있는 것은 rest·websocket 태스크뿐이다.

V1은 Prod·Dev 각 1대의 EC2 위에 Next.js, Spring Boot, FastAPI, MySQL 컨테이너를 Docker Compose로 띄운 구조다. 빠른 출시와 최소 비용에는 맞았지만, V2 성장 가정(6개 업무지구, DAU 36,000)에서는 아래 한계가 드러난다.
| V1 한계 | 원인 | V2 대응 |
|---|---|---|
| 설정·자격 증명이 환경 간에 섞일 위험 | 같은 계열의 인스턴스 프로파일과 env 파일을 Prod·Dev가 공유 | 스테이지별 클러스터·IAM Role·SSM 경로 분리 |
| 운영과 같은 조건의 사전 검증 단계 부재 | Dev에서 Prod로 바로 배포 | Stg 스테이지 신설, Stg에서 검증된 이미지 digest만 Prod로 승격 |
| REST 지연이 WebSocket 연결 수에 끌려감 | 한 JVM에서 CPU·heap·GC·커넥션 풀 공유 | rest·websocket을 별도 ECS Service와 별도 ASG로 분리 |
| 피크 외 시간의 유휴 용량 | 출퇴근 피크에 맞춘 호스트를 24시간 유지 | On-Demand 기준 용량 + REST Spot 추가 용량 + 예약 스케일링 |
| 호스트 장애 = 서비스 장애 | 모든 컨테이너가 EC2 1대에 존재 | 서비스·태스크 단위 재배치, ALB 헬스체크 기반 교체 |
V1 혼합 부하에서 WebSocket 1,642개 연결과 REST 조회를 함께 걸었을 때 조회 p99는 33.4ms에서 914.8ms로 약 27배, GC pause p95는 3.1ms에서 257ms로 80배 이상 증가했다. 이 측정이 REST와 WebSocket을 분리하는 직접 근거다. 측정 조건은 V2 백엔드 애플리케이션 인프라 설계를 참고한다.
용량 산정은 V2 서비스 확장 가정 및 V1의 한계 지점의 가정을 따른다. DAU 36,000명, 하루 2세션 기준으로 출퇴근 피크 순간 부하는 91 RPS, 피크 외 20시간의 평시 평균은 3.9 RPS로 약 23배 차이가 난다. 행사 트래픽이 겹치면 225.5 RPS와 WebSocket 동시 연결 1,800개를 기준으로 잡는다.
이에 따라 V2의 요구사항을 다음과 같이 정리했다.
- 스테이지별 보안·IAM 권한 경계 분리
- 스테이지별 환경 변수·시크릿 접근 분리
- 스테이지별 배포 전략(CD) 분리
- 운영과 같은 구성(사양만 축소)의 스테이징 환경
- REST·WebSocket 자원 격리와 서로 다른 지표 기반의 확장
V2는 V1 VPC(10.20.0.0/16)와 Internet Gateway를 새로 만들지 않고 참조만 한다. V1 리소스를 V2 Terraform이 교체하거나 소유하지 않게 하기 위해서다. V2는 자기 서브넷과 라우트 테이블만 만들고, CIDR은 V1 서브넷(10.20.10.0/24, 10.20.20.0/24)과 겹치지 않게 잡았다.
| 서브넷 | CIDR | AZ | 계층 | 경로 | 올라가는 것 |
|---|---|---|---|---|---|
| public-a | 10.20.30.0/24 | 2a | public | IGW | Prod ALB, Non-Prod ALB, NAT Gateway |
| public-b | 10.20.31.0/24 | 2c | public | IGW | Prod ALB, Non-Prod ALB |
| app-a | 10.20.40.0/24 | 2a | private-app | NAT | prod ECS 인스턴스·태스크 |
| dev-a | 10.20.41.0/24 | 2a | private-dev | NAT | dev ECS, dev EC2(MySQL·Redis), 현재 stg ECS |
| stg-a (예정) | 10.20.42.0/24 | 2a | private-app | NAT | stg 전용 |
| data-a | 10.20.100.0/24 | 2a | private-data | VPC 로컬만 | RDS, ElastiCache primary |
| data-b | 10.20.101.0/24 | 2c | private-data | VPC 로컬만 | 서브넷 그룹 2번째 AZ, ElastiCache replica |
public / private-app / private-data 3계층을 유지한다. 데이터 서브넷의 라우트 테이블에는 인터넷 경로가 없어, DB와 Redis는 VPC 내부에서만 닿는다.
stg는 지금 dev 서브넷을 함께 쓰고 있다. 그러나 stg를 "Prod와 같은 인프라 구성, 사양만 축소"로 두기로 한 결정과 맞지 않고, dev 실험이 IP를 소진하거나 경로를 바꾸면 stg가 함께 영향을 받는다. 그래서 stg 전용 stg-a 서브넷을 신설하기로 했다.
prod app-a의 IP 예산은 최대 기준 약 41개(인스턴스 ENI 13 + 태스크 ENI 14 + 롤링 배포 여유 14)로, /24 서브넷으로 충분하다.
ALB는 2개 AZ의 public 서브넷이 필수이므로 public-a·b에 걸쳐 두고, 교차 AZ 로드밸런싱을 켠다. 반면 애플리케이션 대상(태스크)은 AZ-a에만 둔다. 워크로드를 2개 AZ로 나누면 호스트·NAT·데이터 전송 비용이 함께 늘어나기 때문이다.
이 구성은 AZ-a 장애 시 앱이 중단되는 위험을 수용한 초기 절충안이다. Multi-AZ 전환을 위한 app-c(10.20.50.0/24), stg-c(10.20.52.0/24) 대역은 미리 예약해 두었다.
private 서브넷의 외부 통신(ECR·SSM API, 외부 AI·교통 API)은 AZ-a의 NAT Gateway 1개로 나간다. ECR 이미지 레이어가 S3에서 내려오므로, NAT 데이터 처리 비용을 줄이기 위해 무료인 S3 Gateway Endpoint를 추가할 계획이다. Interface Endpoint는 시간당 비용이 있어 보류했다.
ECS 태스크 네트워크 모드는 awsvpc로 한다. 태스크마다 ENI와 보안 그룹을 따로 가지므로, 같은 호스트에 올라간 태스크끼리도 서비스 단위로 접근을 나눌 수 있다. bridge 모드처럼 호스트의 동적 포트 범위(32768–65535)를 통째로 열 필요도 없다.
| 보안 그룹 | 붙는 곳 | 인바운드 | 목적 |
|---|---|---|---|
| Prod ALB SG | Prod ALB | 80·443 ← 인터넷 (CloudFront 도입 후 CloudFront prefix list) | 서비스 진입점 |
| Non-Prod ALB SG | Non-Prod ALB | 80·443 ← 운영자·개발자·QA IP | dev·stg는 특정 그룹만 접속 |
| 인스턴스 SG | 스테이지의 모든 ECS 인스턴스 | 없음 | 앱 트래픽은 태스크 SG로, 운영 접근은 SSM |
| rest·websocket 태스크 SG | rest·websocket 태스크 | 8080·8090 ← 해당 ALB SG | 앱 포트 + Actuator 관리 포트 |
| frontend 태스크 SG | Next.js 태스크 | 3000 ← 해당 ALB SG | |
| fastapi 태스크 SG | FastAPI 태스크 | 8000 ← 해당 ALB SG | |
| Redis SG | 스테이지 ElastiCache | 6379 ← 같은 스테이지 rest·websocket 태스크 SG | |
| dev EC2 SG | dev MySQL·Redis 호스트 | 3306·6379 ← dev rest·websocket 태스크 SG |
보안 그룹을 출발지로 참조하면 태스크 IP가 바뀌어도 규칙을 고칠 필요가 없다. 그리고 스테이지를 넘는 규칙은 만들지 않는다. stg 태스크 SG는 prod 데이터 SG의 출발지에 절대 들어가지 않는다. frontend·fastapi 태스크에는 데이터 계층 경로가 없으므로, frontend가 뚫려도 DB에 접근할 수 없다.
EC2 기반 ECS에서는 같은 호스트의 태스크가 IMDS로 인스턴스 Role 자격 증명을 얻어 다른 태스크의 자격 증명까지 가져갈 수 있다(2025년 공개된 ECScape 연구). 이를 막기 위해 모든 인스턴스에 ECS_AWSVPC_BLOCK_IMDS=true와 IMDSv2 필수·hop limit 1을 적용한다.
| 스테이지 | 도메인 | ALB |
|---|---|---|
| prod | moyeota.com | Prod ALB |
| stg | stg.moyeota.com | Non-Prod ALB |
| dev | dev.moyeota.com | Non-Prod ALB |
dev·stg는 서브도메인 DNS로 구분하고, 두 호스트를 담은 ACM 인증서를 Route 53 DNS 검증으로 발급해 Non-Prod ALB의 HTTPS 리스너에 붙인다. V1에서 이미 dev.moyeota.com에 API 서브도메인과 ACM 인증서를 등록해 두었기 때문에, V2 dev는 DNS를 당분간 V1에 두고 V1·V2를 병행한 뒤 전환한다.
dev·stg·prod를 ALB 하나에 몰면 비용은 줄지만, dev·stg 작업으로 리스너 규칙이나 보안 그룹을 바꿀 때마다 Prod 진입점이 함께 영향을 받는다. 반대로 스테이지마다 ALB를 두면 시간당 비용이 세 배가 된다. 그래서 Prod는 전용 ALB, dev·stg는 Non-Prod ALB 하나를 공유하는 절충안을 선택했다.
| 항목 | Prod ALB | Non-Prod ALB |
|---|---|---|
| 대상 스테이지 | prod | dev, stg |
| 스테이지 구분 | 없음 (Prod 전용) | host-header (dev. / stg.) |
| 인바운드 | 인터넷 (CloudFront 도입 후 prefix list) | 운영자·개발자·QA IP만 |
| 기본 동작 | frontend Target Group | 404 고정 응답 |
| idle timeout | 300초 | 300초 |
Non-Prod ALB의 기본 동작을 404로 둔 이유는 host를 잘못 넣은 요청이 다른 스테이지로 새지 않게 하기 위해서다. 헤더(X-Moyeota-Stage)로 스테이지를 고르는 대체 경로는 두지 않았다. Prod에 host 조건을 두지 않은 이유는, 앞단에 CloudFront를 둘 때 Host 헤더 전달 설정에 라우팅이 의존하지 않게 하기 위해서다.
idle timeout은 WebSocket 장기 연결을 위해 300초로 늘렸다. 대신 서버 측 STOMP heart-beat가 꺼져 있으면 유휴 연결이 정확히 5분 만에 끊기므로, 서버 heart-beat를 함께 켠다.
ECS Service마다 전용 ip 타입 Target Group을 하나씩 두고, 서비스끼리 Target Group을 공유하지 않는다. 서비스별 요청 수·지연·헬스 상태를 따로 볼 수 있어야 스파이크 상황에서 어느 서비스가 병목인지 구분할 수 있기 때문이다.
규칙 우선순위는 "스테이지 기준값 + 서비스 순서"로 정한다. 스테이지나 서비스가 늘어도 번호가 충돌하지 않는다.
| 순서 | 경로 | 서비스 | prod | stg | dev |
|---|---|---|---|---|---|
| 0 | /api/wss |
websocket | 1 (컷오버 전 100) | 200 | 300 |
| 1 | /api/* |
rest | 2 (101) | 201 | 301 |
| 2 | /ocr/* |
fastapi | 3 (102) | 202 | 302 |
| 3 | /* |
frontend | 4 (103) | 203 | 303 |
/api/wss는 /api/*보다 먼저 평가해야 한다. 순서가 바뀌면 WebSocket Upgrade 요청이 rest로 간다. SockJS를 쓰지 않아 엔드포인트가 하나뿐이므로 와일드카드도 붙이지 않는다.
Prod는 기존 Compose 호스트에서 ECS로 한 번에 넘어가야 하므로 컷오버 단계를 둔다. 컷오버 전에는 기존 Compose Target Group 규칙(우선순위 10–30)이 실트래픽을 받고, ECS 규칙(100–103)은 X-Moyeota-Stage: prod 헤더가 있는 스모크 테스트 요청만 받는다. 컷오버 시 ECS 규칙을 1–4로 올려 기존 규칙보다 앞서게 하고, 이후 Compose 호스트를 제거한다.
| 헬스체크 | rest · websocket | frontend | fastapi |
|---|---|---|---|
| 포트 | 8090 (Actuator 관리 포트) | 3000 | 8000 |
| 경로 | /actuator/health/readiness |
/ |
/health |
| 정상 코드 | 200-399 | 200-399 | 200-399 |
| deregistration delay | rest 30초 / websocket 120초 | 30초 | 30초 |
Spring 서비스는 Actuator를 앱 포트(8080)와 분리된 관리 포트(8090)에서만 노출한다. 8080의 /actuator/health를 보고 정상 코드를 4xx까지 넓히면 404도 healthy가 되어, 헬스체크가 "프로세스가 떠 있는가"만 보고 기동 완료·DB 연결 같은 readiness는 보지 못한다. 그러면 롤링 배포 중 준비되지 않은 태스크로 트래픽이 들어가고, circuit breaker도 실패를 감지하지 못한다. 그래서 8090의 readiness 경로와 200-399를 유지한다.
deregistration delay는 REST가 Spot 회수 알림(2분) 안에 정리되도록 30초, WebSocket은 연결 정리 시간을 확보하도록 120초로 둔다.
| 계층 | 역할 | V2 단위 |
|---|---|---|
| Cluster | 태스크·서비스를 묶는 논리 그룹이자 용량 제공 단위 | 스테이지당 1개 (moyeota-v2-dev / stg / prod) |
| Capacity Provider · ASG | 같은 스테이지 안에서 서비스별 호스트 풀 | 역할 × 구매 유형별 1개 |
| Service | desired 태스크 수 유지, 배포, ALB 연동, 오토스케일링 | rest / websocket / frontend / fastapi |
| Task Definition | 이미지, CPU·메모리, 포트, 환경 변수, IAM Role | 서비스당 1개 family |
서비스 단위 트래픽 격리를 클러스터 분리로 해결하지 않은 이유는, 클러스터를 서비스마다 나누면 스테이지 × 서비스만큼 클러스터·IAM·네임스페이스가 늘어나는데 정작 자원 경합은 호스트 단위에서 생기기 때문이다. EC2 타입에서는 같은 클러스터 안에서 서비스별 Capacity Provider(별도 ASG) 로 호스트를 나누는 것이 자원 격리의 실제 수단이다.
1 ASG = 1 Launch Template = 1 Capacity Provider = 1 Cluster로 둔다. 같은 스테이지 안에서 아래 중 하나라도 다르면 ASG를 분리한다.
- 장애 영향 범위 (WebSocket 호스트가 죽으면 연결이 끊기고, REST 호스트는 요청만 재시도된다)
- 구매 유형 (On-Demand / Spot)
- 스케일링 신호 (REST는 CPU·RPS, WebSocket은 연결 수)
- scale-in 민감도 (WebSocket은 자동 scale-in 금지)
- 태스크 형상 (rest 1 vCPU / 1 GiB, frontend 0.5 vCPU / 768 MiB 등)
Launch Template의 user data에 스테이지와 역할을 새겨 둔다. ECS_CLUSTER가 스테이지 경계를, ECS_INSTANCE_ATTRIBUTES={"role","purchase"}가 서비스 경계를 만들고, 서비스는 memberOf(attribute:role == <role>) 제약으로 자기 역할의 호스트에만 배치된다.
| 스테이지 | ASG | 구매 | 타입 | min / max | 올라가는 서비스 |
|---|---|---|---|---|---|
| prod | rest-od | On-Demand | t3.small | 2 / 3 | rest 기준 용량 |
| prod | rest-spot | Spot | 속성 기반 (vCPU 2–4, 2–8 GiB) | 0 / 4 | rest 출퇴근·행사 추가분 |
| prod | ws-od | On-Demand | t3.small | 3 / 4 | websocket |
| prod | general-od | On-Demand | t3.small | 1 / 2 | frontend + fastapi |
| stg | prod와 같은 4개 | 〃 | 〃 | 1/2 · 0/2 · 1/3 · 1/2 | 대수만 축소 |
| dev | shared-spot | Spot | vCPU 2–4, 4–8 GiB | 0 / 2 | 4개 서비스 공유 |
Spot은 REST 추가 용량에만 쓴다. Spot을 확보하지 못해도 On-Demand 기준 용량만으로 핵심 처리량을 유지해야 하므로, rest-spot은 SLO 기준 용량에 넣지 않는다. WebSocket은 Spot 회수 때 연결이 한꺼번에 끊기므로 Spot을 쓰지 않는다. Spot은 price-capacity-optimized와 Capacity Rebalance로 회수 위험이 낮은 풀을 고르고, 10개 이상 인스턴스 타입을 후보로 둔다.
각 ASG의 max가 min보다 1대 큰 이유는 롤링 배포 때 새 태스크를 놓을 자리가 필요해서다. t3.small·medium은 ENI가 3개라 awsvpc 태스크를 호스트당 2개까지만 올릴 수 있다. general-od에 frontend·fastapi 2개를 올리면 호스트가 꽉 차므로 max를 2로 두었고, stg websocket은 2태스크를 서로 다른 호스트에 두고 롤링 여유 1대를 더해 ws-od max를 3으로 두었다. dev는 4개 서비스가 shared-spot 2대(호스트당 2태스크)를 꽉 채우므로 롤링 여유가 없어, 기존 태스크를 내리고 새로 띄우는 방식(min 0% / max 100%)으로 배포한다.
| 서비스 | Task CPU / Memory | 컨테이너 hard limit | 포트 |
|---|---|---|---|
| rest | 1 vCPU / 1 GiB | 960 MiB (-Xmx512m) |
8080, 8090 |
| websocket | 0.5 vCPU / 1 GiB | 960 MiB (-Xmx512m) |
8080, 8090 |
| frontend | 0.5 vCPU / 768 MiB | 704 MiB | 3000 |
| fastapi | 0.5 vCPU / 768 MiB | 704 MiB | 8000 |
rest와 websocket은 같은 BE 이미지를 쓰고 APP_ROLE 환경 변수로 실행 역할을 구분한다. ECS Service만 두 개로 나누면 두 태스크 모두에 REST와 WebSocket 기능이 그대로 뜨기 때문에, BE가 이 값으로 REST 컨트롤러·STOMP endpoint·Redis listener를 조건부로 등록해야 실제 자원이 분리된다. frontend·fastapi 스펙은 측정값이 없어 general-od 호스트당 2태스크가 들어가는 잠정값이다.
스케일링은 2단계로 한다. Service Auto Scaling이 태스크 수를 정하고, Capacity Provider의 managed scaling이 그 태스크를 올릴 인스턴스 수를 맞춘다. ASG를 직접 스케일링하지 않으므로, 태스크가 있는 인스턴스는 managed termination protection과 managed draining으로 보호된다.
| 서비스 (prod) | 배치 | 태스크 min / max | 스케일링 정책 |
|---|---|---|---|
| rest | rest-od base 2, weight 0 + rest-spot weight 1 · 호스트당 1태스크 | 2 / 6 | CPU 60% + 평일 06:50·16:50 min 3 → 09:10·19:10 min 2 |
| websocket | ws-od base 3 · 호스트당 1태스크 | 3 / 4 | CPU 60%, scale-in 금지 |
| frontend · fastapi | general-od · binpack(memory) | 1 / 2 | CPU 70% |
REST의 기준 2태스크는 항상 On-Demand에 두고 3번째 태스크부터 Spot에 뜨도록 weight 0 전략을 쓴다. 출퇴근 피크는 시간이 정해져 있으므로 CPU가 오르기를 기다리지 않고 피크 10분 전에 min을 3으로 올리고, 피크 10분 뒤 되돌린다. stg에서는 rest desired를 1에서 2로 올렸을 때 두 번째 태스크가 rest-spot에만 뜨는지 먼저 검증한다.
WebSocket은 자동 scale-in으로 태스크가 내려가면 그 태스크에 붙은 연결이 모두 끊기므로 scale-in을 막는다. 동적 지표는 태스크당 STOMP 세션 수가 맞지만, 앱이 이 지표를 발행하기 전까지는 CPU로 scale-out만 한다. rest max 6은 "태스크 수 × HikariCP 풀 크기"가 DB 연결 예산 안에 들어오는지로 다시 확정해야 한다.
모든 서비스는 롤링 배포(min 100% / max 200%)와 deployment circuit breaker 자동 롤백을 쓴다. Stg·Prod는 Blue/Green으로 바꾸는 것이 목표다.
| 스테이지 | MySQL | Redis (채팅 fan-out) |
|---|---|---|
| prod | RDS for MySQL Single-AZ | ElastiCache primary + replica, 자동 장애 조치 |
| stg | 마이그레이션·QA 기간에만 RDS | ElastiCache 단일 노드, QA 기간에만 |
| dev | dev EC2 MySQL 컨테이너 (DDL 스키마만) | dev EC2 Redis 컨테이너 |
Prod DB를 RDS Single-AZ로 정한 근거는 V2 DB 인프라 설계를 따른다. 관리형 백업·PITR과 DB 생명주기 분리는 얻되, Multi-AZ의 상시 standby 비용은 보류한 단계적 선택이다. Prod 배포 전에 Prod DB 무중단 마이그레이션(MySQL 컨테이너 → RDS)이 먼저 끝나야 한다.
dev는 Prod RDS 비용이 개발 환경에 중복되지 않도록 컨테이너를 유지한다. 앱이 MySQL과 Redis에 의존하므로 dev에도 두 의존성이 떠 있어야 하고, dev EC2는 비용을 줄이기 위해 매일 09:00–24:00(KST)에만 켜 둔다. 운영 시간 밖에는 dev 태스크가 DB·Redis에 연결하지 못한다.
REST와 WebSocket을 분리하면 채팅 이벤트를 만든 프로세스와 수신자의 WebSocket 연결을 가진 프로세스가 달라진다. Spring Simple Broker는 구독 정보를 각 프로세스 메모리에 두므로, websocket 태스크가 2개 이상이면 같은 방의 일부 참여자가 메시지를 받지 못하고, rest가 만든 시스템 메시지는 태스크 수와 상관없이 아무에게도 전달되지 않는다. dev에서 Non-Prod ALB로 rest·websocket을 분기해 검증하면서 이 문제를 확인했다.
REST / WebSocket 태스크 ── MySQL 커밋 후 publish ──▶ Redis 채널 moyeota:<stage>:chat:fanout:v1
│ (모든 websocket 태스크가 구독)
▼
각 websocket 태스크의 로컬 Simple Broker /sub/chat/{roomId} → 클라이언트
Redis는 실시간 전달용(at-most-once)이고, 원본과 누락 복구는 MySQL 이력 API가 맡는다. 그래서 스냅샷을 두지 않는다. 다만 prod Redis가 멈추면 모든 WebSocket 태스크의 실시간 전달이 함께 멈추므로, 단일 장애점을 두지 않도록 replica와 자동 장애 조치를 켠다. 상세 설계는 V2 WebSocket 메시지 버스와 Redis 도입 설계를 참고한다.
Redis는 단일 스레드이고, fan-out 송신량은 "발행량 × 구독 태스크 수"로 늘어난다. 그래서 엔진 CPU, 메모리(구독자 출력 버퍼), 네트워크 송신 대역폭 초과를 노드별 알람으로 본다.
스테이지 경계는 Role 이름과 리소스 경로의 <stage>로, 서비스 경계는 Role을 서비스마다 나누는 것으로 만든다.
| Role | 단위 | 권한 범위 |
|---|---|---|
| 컨테이너 인스턴스 Role | 스테이지 | ECS agent 액션을 자기 클러스터로만 제한 + SSM. 이미지·로그·설정 권한 없음 |
| Task Execution Role | 스테이지 | ECR pull, 로그 전송, 자기 스테이지 SSM 경로만 읽기 |
| Task Role | 스테이지 × 서비스 | ECS Exec 채널만. 앱 권한은 필요가 확인될 때 추가 |
| Deploy Role | 스테이지 | GitHub OIDC로만 assume, 자기 클러스터 서비스만 갱신, 자기 스테이지 Role만 PassRole |
인스턴스 Role을 관리형 정책 대신 자기 클러스터로 좁힌 이유는, dev Launch Template에 prod 클러스터 이름이 잘못 들어가도 등록 자체가 거부되게 하기 위해서다. Deploy Role은 앱 레포의 GitHub Environment(dev / staging / production)를 OIDC 토큰의 sub 조건으로 확인하므로, dev 배포 워크플로가 prod 서비스를 갱신할 수 없다.
Task Execution Role은 아직 스테이지당 1개다. 같은 스테이지의 frontend가 DB 비밀번호를 주입받을 수 없도록 서비스별로 나누는 것을 다음 단계로 둔다.
V1은 CI 이미지 빌드용 값을 GitHub vars/secret에, 런타임 값을 EC2 env 파일에 나눠 두었다. 저장소가 분산되어 있어 값이 빠져도 배포 후에야 오류가 보였다. V2는 빌드용 값과 런타임 값을 모두 SSM Parameter Store로 모은다.
| 구분 | 경로 | 관리 방식 | 읽는 주체 |
|---|---|---|---|
| 앱 런타임 | /moyeota/v2/<stage>/<backend·frontend·ai>/<KEY> |
Terraform은 리소스만 생성(더미값 + 값 변경 무시), 실제 값은 콘솔·CLI | Task Execution Role → 태스크 secrets |
| 채팅 브로커 | /moyeota/v2/<stage>/broker/* |
인프라가 만드는 값이라 Terraform이 값까지 관리 | rest·websocket 태스크 |
| FE 빌드 | /moyeota/v2/build/frontend/NEXT_PUBLIC_* |
콘솔·CLI | FE CI Role |
경로에 버전(v2)을 넣은 이유는 V3까지 버전이 이어질 예정이라, 버전 간 Role 접근을 경로로 막기 위해서다. 실제 값을 Terraform state에 남기지 않으므로 state가 노출돼도 시크릿은 노출되지 않는다. FE 빌드 값에 스테이지가 없는 이유는, 한 번 빌드한 이미지 digest를 Stg → Prod로 그대로 승격하므로 번들에 박히는 값이 스테이지마다 다를 수 없기 때문이다.
dev(BE) / develop(FE) ─▶ CI: dev-<sha> ─▶ Dev CD ─▶ moyeota-v2-dev
release/vX.Y.Z ─▶ CI: rc-vX.Y.Z-<sha> ─▶ Stg CD ─▶ moyeota-v2-stg ─▶ stg-vX.Y.Z-<sha> 태그
release/* ─PR─▶ main ─▶ Prod CD: stg- 태그 확인 ─▶ production 승인 ─▶ moyeota-v2-prod ─▶ prod-vX.Y.Z-<sha> 태그
Dev·Prod는 기존 브랜치 정책을 유지하고, 그 사이에 release/* → Stg를 새로 넣었다. Prod는 Stg 배포가 완료된 digest만 재빌드 없이 승격하고 dev → main 직행은 거부한다. 태그는 다시 붙일 수 있지만 digest는 이미지 내용 그 자체이므로, Stg에서 검증한 이미지와 Prod 이미지가 같다는 것을 digest로 보장한다.
| 항목 | Dev | Stg | Prod |
|---|---|---|---|
| 목적 | 속도·자율성 | 운영과의 일관성 검증 | 안정성 |
| 트리거 | dev / develop 머지 | release/* push | main 머지 + 수동 승인 |
| 이미지 | 빌드 산출물 그대로 | rc 이미지 digest | Stg와 같은 digest |
| 인증 | OIDC → dev Deploy Role | OIDC → stg Deploy Role | OIDC → prod Deploy Role + 승인자 |
배포 액션은 digest로 고정된 이미지만 받아, 서비스의 최신 Task Definition에서 이미지만 바꾼 새 revision을 등록하고 서비스 배포가 완료될 때까지 기다린다. circuit breaker 롤백이나 시간 초과는 모두 실패로 끝낸다. BE 이미지는 rest·websocket 두 서비스에, FE 이미지는 frontend에 배포한다.
dev는 dev.moyeota.com DNS가 V1을 가리키는 동안 V1 dev CD를 지우지 않고 V2 dev CD를 함께 돌린다. 같은 CI 산출물을 V1 EC2와 V2 ECS에 각각 배포하고, DNS를 Non-Prod ALB로 넘길 때 V1 dev CD를 제거한다.
| 항목 | 구성 |
|---|---|
| 로그 | 서비스별 CloudWatch 로그 그룹 (보존 dev 7일 / stg 14일 / prod 30일) |
| Container Insights | dev 끔 / stg 켬 / prod enhanced |
| 운영 접근 | SSH 없음. 인스턴스는 SSM Session Manager, 태스크는 ECS Exec (prod는 운영자 그룹만) |
| 비용 스케줄 | dev EC2 09:00–24:00 자동 시작·중지, stg는 QA 기간에만 기동 |
| 장애 실험 (stg) | AWS FIS로 websocket 태스크 종료, rest Spot 회수, 컨테이너 인스턴스 drain |
Stg의 장애 실험은 아래를 확인하기 위한 것이다.
- websocket 태스크가 죽었을 때 대체 태스크가 준비(ALB healthy)되기까지의 시간과, 그 사이 발행된 메시지의 유실 범위
- rest Spot 인스턴스가 회수될 때 2분 안에 drain되고 다른 인스턴스에 대체 태스크가 뜨는지
- 인스턴스 절반이 drain될 때 배치 제약(호스트당 1태스크, 역할별 호스트) 아래에서 Capacity Provider가 인스턴스를 늘리는지
중앙 집중식 모니터링 구축은 다음 마일스톤에서 다룬다.
V2의 전체 구조는 Route 53 → (CloudFront) → Prod ALB / Non-Prod ALB → 스테이지별 ECS 클러스터 → 서비스별 ASG 위의 태스크 → RDS · ElastiCache로 정리된다.
| 영역 | 최종 결정 |
|---|---|
| 실행 환경 | ECS on EC2, 스테이지별 클러스터(dev / stg / prod), 서비스별 ECS Service |
| 네트워크 | V1 VPC 공유, public / private-app / private-data 3계층, 워크로드 AZ-a 단일, NAT 1개 |
| 진입점 | Prod ALB(prod 전용) + Non-Prod ALB(dev·stg host 기반), 서비스별 ip Target Group |
| 격리 | 스테이지: 클러스터·IAM·SSM 경로·서브넷 / 서비스: ASG·태스크 SG |
| 용량 | 기준 용량 On-Demand, REST 추가분만 Spot, 출퇴근 예약 스케일링, WebSocket scale-in 금지 |
| 데이터 | Prod RDS Single-AZ, Redis Pub/Sub fan-out (prod replica 포함), dev는 컨테이너 |
| 배포 | digest 기반 승격 (dev → stg → 승인 → prod), 스테이지별 OIDC Deploy Role |
이 구조가 감수하는 위험은 다음과 같다.
- 워크로드가 AZ-a에만 있어 AZ 장애 시 서비스가 중단된다. 99.9% 가용성은 단일 AZ 장애를 포함한 보장치가 아니다.
- Prod RDS가 Single-AZ라 AZ 장애 시 자동 standby 전환이 없다. 자동 백업·PITR과 복구 리허설로 대응한다.
- Redis Pub/Sub는 전달을 보장하지 않으므로, 장애 조치 중 발행된 메시지는 MySQL 이력 API로 복구해야 한다.
- 단일 AWS 계정이므로 계정 수준의 Prod / Non-Prod 분리는 V3에서 검토한다.
| 항목 | 현재 | 다음 단계 |
|---|---|---|
| 배포 일정 | dev 클러스터에 rest·websocket만 배포 | 10/16 stg 배포 → 10/17 QA → 10/19 prod 배포 |
| stg 서브넷 | dev 서브넷 공유 | stg-a 신설 |
| Prod DB | Compose MySQL 컨테이너 | RDS 생성과 무중단 마이그레이션 시나리오 확정 |
| Prod 진입점 | HTTP, 인터넷 전체 허용 | ACM 인증서 연결, CloudFront 도입 후 prefix list로 제한 |
| Prod 컷오버 | Compose 호스트가 실트래픽 처리 | ECS 규칙 전환 후 Compose 호스트 제거 |
| Task Execution Role | 스테이지당 1개 | 서비스별 분리 |
| WebSocket 스케일링 지표 | CPU | STOMP 세션 수 커스텀 지표 |
| 배포 방식 | 롤링 | Stg·Prod Blue/Green |
| fastapi 외부 노출 |
/ocr/* ALB 규칙 있음 |
담당자와 논의 후 결정 (내부 전용이면 Service Connect만) |
| ALB 2-AZ 대상 | AZ-a에만 대상 | AZ-c에 최소 대상을 둘지 결정 |