Production 서버 인프라 및 배포 가이드
이 문서는 vivid-ai 서비스의 실제 운영 환경인 Production 서버를 구축하는 전체 과정과 아키텍처, 그리고 구축 과정에서 발생한 이슈와 해결 방법을 상세히 기록한 문서입니다.
1. 개요 및 아키텍처 전략
Production 환경은 개발(Dev) 환경과 완전히 분리된 AWS 계정과 독립적인 VPC를 사용하여 안정성과 보안을 최우선으로 구축되었습니다.
1.1. Dev 환경과의 주요 차이점
| 구분 | Development 환경 | Production 환경 | 비고 |
|---|---|---|---|
| AWS 계정 | Dev 계정 | Prod 계정 (분리됨) | 보안 및 비용 관리 분리 |
| VPC CIDR | 10.12.0.0/16 | 10.20.0.0/16 | 네트워크 충돌 방지 및 명확한 구분 |
| 접근 제어 | IAM User Access Key 허용 | IRSA (IAM Roles for Service Accounts) | Access Key 사용 배제, 보안 강화 |
| 데이터베이스 | 단일 AZ, 퍼블릭 액세스 테스트 | Multi-AZ, 프라이빗 서브넷 강제 | 고가용성 및 보안 필수 |
| Redis | 전송 중 암호화 선택적 | 전송 중 암호화(TLS) 필수 | rediss:// 프로토콜 강제 |
| 이미지 태그 | latest 덮어쓰기 허용 | 태그 불변성(Immutable) 활성화 | 배포 버전 관리 및 롤백 안정성 |
2. 인프라 구축 상세 (AWS 리소스)
2.1. 네트워크 (VPC)
- 이름:
vivid-ai-vpc-prod - CIDR:
10.20.0.0/16 - 서브넷 구성:
- Public Subnet (2개): ALB, Bastion Host, NAT Gateway 배치.
- Private Subnet (2개): EKS 워커 노드, RDS, ElastiCache 배치 (외부 직접 접근 차단).
- NAT Gateway: 비용 효율성을 위해 1개 AZ에만 배치 (향후 트래픽 증가 시 확장 고려).
2.2. EKS 클러스터 (vivid-ai-cluster-prod)
- 버전: 1.30+ (최신 안정 버전)
- 액세스 모드: 퍼블릭 및 프라이빗 (외부
kubectl제어 허용, 워커 노드는 프라이빗 통신). - 노드 그룹 (
vivid-ai-ng-prod):- 인스턴스 타입:
t3.medium(vCPU 2, RAM 4GB) 이상. - 스케일링: 최소 2개, 최대 4개.
- 배치: Private Subnet에만 배치하여 보안 강화.
- 인스턴스 타입:
2.3. 데이터 저장소
- RDS (
vivid-ai-db-prod): PostgreSQL, Multi-AZ 배포(장애 대비), Private Subnet 위치. - ElastiCache (
vivid-ai-redis-prod): Redis(Valkey), 전송 중 암호화(TLS) 활성화, Private Subnet 위치. - S3:
vivid-ai-temp-files-prod(임시, 수명 주기 1일),vivid-ai-permanent-files-prod(영구).
2.4. 메시지 큐 (SQS)
vivid-ai-computation-queue-prod(AI 연산 작업)vivid-ai-post-processing-queue-prod(후처리 작업)- 각 큐에 대응하는 Dead Letter Queue (DLQ) 설정 및 리드라이브 허용 정책 활성화.
2.5. Bastion Host (vivid-ai-bastion-prod)
- 목적: 프라이빗 서브넷에 위치한 RDS 및 기타 리소스에 안전하게 접근하기 위한 중계 서버.
- 인스턴스 타입:
t3.micro(비용 효율성) - OS:
Amazon Linux 2023(또는 Ubuntu) - 배치:
vivid-ai-vpc-prod내 Public Subnet에 배치. - 키 페어:
vivid-ai-prod-key.pem생성 및 안전하게 보관. - 보안 그룹 (
vivid-ai-bastion-sg-prod): SSH(TCP 22) 인바운드 규칙 소스를 사용자 PC의 IP로 제한. (VPN 사용 시echo $SSH_CLIENT로 확인 필요)
2.6. SSH 터널링을 통한 RDS 접속 (PgAdmin4)
- Bastion Host 생성: 위
2.5 Bastion Host섹션 참조. VPC 및 서브넷이 올바른지 반드시 확인 (잘못된 VPC에 생성 시Connection timed out발생). - RDS 보안 그룹 설정:
vivid-ai-rds-sg-prod인바운드 규칙에PostgreSQL (TCP 5432)의 소스로 Bastion Host 보안 그룹(vivid-ai-bastion-sg-prod)을 허용. - SSH 터널링 실행 (로컬 터미널):
ssh -i "path/to/vivid-ai-prod-key.pem" -L 5433:<RDS_ENDPOINT>:5432 ec2-user@<BASTION_PUBLIC_IP><RDS_ENDPOINT>:vivid-ai-db-prod.c9a08m8cuzwg.ap-northeast-1.rds.amazonaws.com<BASTION_PUBLIC_IP>: Bastion Host의 퍼블릭 IP.- 주의: 이 명령어를 실행한 터미널 창을 닫으면 터널이 끊깁니다.
- PgAdmin4 접속 설정:
- Host name/address:
localhost - Port:
5433(SSH 터널링 명령에 지정한 로컬 포트) - Username:
postgres - Password:
vivid-ai-secret-prod에 저장된 비밀번호.
- Host name/address:
3. 보안 및 권한 관리 (IRSA 도입)
Production 환경에서는 장기 자격 증명(Access Key ID/Secret Key)을 애플리케이션에 하드코딩하거나 환경 변수로 주입하는 것을 지양하고, **IRSA (IAM Roles for Service Accounts)**를 전면 도입했습니다.
3.1. IRSA 구성 흐름
- OIDC Provider 생성: EKS 클러스터와 AWS IAM을 연결하는 OIDC Identity Provider 생성.
- IAM Policy 생성: 애플리케이션이 필요한 권한(S3 접근, SQS 전송 등)만 담은 최소 권한 정책 생성 (
vivid-ai-backend-app-policy-prod). - IAM Role 생성: 위 정책을 연결하고, 신뢰 관계(Trust Relationship)에 특정 Service Account(
system:serviceaccount:default:backend-service-account)만 이 역할을 맡을 수 있도록 조건 추가. - Service Account 생성: Kubernetes 내에
backend-service-account를 생성하고eks.amazonaws.com/role-arn어노테이션으로 IAM Role과 연결. - Deployment 적용: Pod 설정에
serviceAccountName: backend-service-account지정.
4. 애플리케이션 배포 및 설정
4.1. 백엔드 (NestJS)
- Redis TLS 연결 (
rediss://):- Production Redis는 TLS가 필수이므로,
app.module.ts와redis.module.ts에서NODE_ENV=production또는REDIS_TLS_ENABLED=true일 때rediss://프로토콜을 사용하도록 로직을 수정했습니다. - 초기 연결 디버깅을 위해
tls: { rejectUnauthorized: false }옵션을 사용했으나, 안정화 후 제거 예정입니다.
- Production Redis는 TLS가 필수이므로,
- DB 연결 (TypeORM):
- RDS 보안 그룹에 EKS 워커 노드 보안 그룹의 접근을 허용하여 연결합니다.
- SSL 연결(
postgresSslEnabled)을 활성화했습니다.
4.2. 프론트엔드 (Next.js)
- 환경 변수: Docker 빌드 시점(
--build-arg)에NEXT_PUBLIC_API_URL을 Production 도메인(https://vivid.place/api)으로 주입하여 빌드합니다.
4.3. Ingress & Load Balancer
- AWS Load Balancer Controller: Helm을 통해 설치하며, Ingress 리소스를 감지하여 ALB를 자동 생성합니다.
- HTTPS (SSL): AWS ACM에서 발급받은 인증서 ARN을 Ingress 어노테이션(
alb.ingress.kubernetes.io/certificate-arn)에 등록하여 HTTPS 통신을 처리합니다. - 리다이렉션:
alb.ingress.kubernetes.io/actions.frontend-redirect설정을 통해 HTTP(80) 요청을 HTTPS(443)로 강제 리다이렉션합니다.
5. 트러블슈팅 (주요 이슈 및 해결)
구축 과정에서 발생했던 주요 이슈와 해결 방법입니다.
5.1. Redis 연결 타임아웃 (ConnectionTimeoutError)
- 증상: 백엔드 앱이 시작되지 않고 로그가 멈추거나 타임아웃 에러 발생.
- 원인 1 (보안 그룹): Redis 보안 그룹에 EKS 워커 노드에서의 접근(
TCP 6379)이 허용되지 않음.- 해결: Redis 보안 그룹 인바운드 규칙에 EKS 노드 보안 그룹 ID 또는 VPC CIDR(
10.20.0.0/16) 추가.
- 해결: Redis 보안 그룹 인바운드 규칙에 EKS 노드 보안 그룹 ID 또는 VPC CIDR(
- 원인 2 (TLS 설정): Prod Redis는 TLS(
rediss://)를 요구하나, 앱이 일반redis://로 시도함.- 해결:
REDIS_TLS_ENABLED환경 변수를 추가하고, 코드에서 이를 감지하여rediss://스키마를 사용하도록 수정. 또한RedisModule에서 직접 클라이언트를 생성하는 부분에도 TLS 로직 적용.
- 해결:
5.2. AWS Load Balancer Controller 권한 문제
- 증상: Ingress를 생성해도 ALB가 생성되지 않음. 로그에
UnauthorizedOperation(ec2:DescribeRouteTables) 또는AccessDenied발생. - 원인: 컨트롤러가 사용하는 IAM 정책(
AWSLoadBalancerControllerIAMPolicy)에 최신 필수 권한이 누락됨. - 해결: IAM 정책을 최신 버전(v2.7.1+) JSON으로 업데이트하고, 컨트롤러 Pod를 재시작하여 권한 반영.
5.3. Ingress 삭제 불가 (Finalizer 문제)
- 증상:
kubectl delete ingress명령이 멈추고 삭제되지 않음. - 원인: 컨트롤러 오류 등으로 인해 AWS 리소스 삭제 확인을 못 받아
finalizer가 해제되지 않음. - 해결:
kubectl patch ingress ... -p '{"metadata":{"finalizers":null}}'명령으로 강제 삭제 후, AWS 콘솔에서 ALB 및 Target Group 수동 정리.
5.4. CreateContainerError (Secret/Config 오류)
- 증상: Pod가 생성되지 않고
CreateContainerError발생. - 원인:
backend-deployment-prod.yaml의 환경 변수 키 이름(POSERGRES_HOST오타)이 Secret의 키(POSTGRES_HOST)와 일치하지 않거나, Joi 유효성 검사에서 필수 환경 변수(AWS_ACCESS_KEY_ID등)가 누락됨. - 해결: YAML 파일 오타 수정 및
app.module.ts의 Joi 스키마에서 IRSA 사용 시 불필요한 키를optional()로 변경.
5.5. Docker 태그 불변성 (Tag Immutability)
- 증상:
latest태그로 이미지 푸시 시 실패. - 원인: Prod ECR 리포지토리에 '태그 불변성'이 설정되어 있어 덮어쓰기 금지됨.
- 해결: 배포 시마다 고유한 태그(예: 날짜시간
20251127...또는 Git 해시)를 생성하여 빌드 및 푸시하고,kubectl set image로 Deployment를 업데이트하는 방식으로 전환.
5.6. VPN 사용 시 SSH 접속 문제
- 증상: VPN 사용 중
ssh: connect to host ... port 22: Connection timed out에러 발생. - 원인: AWS 보안 그룹에 등록된 '내 IP'와 VPN을 통해 나가는 공인 IP가 일치하지 않음. VPN의 Split Tunneling 등의 설정으로 SSH 트래픽 출구 IP가 다를 수 있음.
- 해결:
vivid-ai-bastion-sg-prod의 SSH(TCP 22) 인바운드 규칙 소스를 일시적으로0.0.0.0/0으로 변경하여 접속 테스트.- 접속 성공 후 Bastion Host 내부에서
echo $SSH_CLIENT명령어를 실행하여 실제 접속한 클라이언트 IP를 확인. - 확인된 IP 주소(예:
203.0.113.50/32)로 보안 그룹 규칙을 업데이트하고0.0.0.0/0규칙은 반드시 삭제.
5.7. mv 명령어 사용 시 파일 유실 오개념
- 상황:
mv file.txt non_existent_directory와 같이 존재하지 않는 디렉터리로 파일을 옮기려 할 때 파일이 사라진다고 오해. - 실제 동작:
mv명령은 지정한 대상 경로가 존재하지 않는 디렉터리일 경우, 해당 경로를 새로운 파일 이름으로 간주하여 원본 파일의 이름을 변경한다. 파일은 사라지지 않으며, 원래 내용이 새 이름으로 보존된다. (예시:file.txt의 이름이non_existent_directory로 변경됨) - 예방: 디렉터리로 이동할 때는 대상 경로 뒤에 슬래시(
/)를 붙여 디렉터리임을 명시하거나,mkdir -p로 먼저 디렉터리를 생성 후mv명령을 사용하는 것이 안전하다.
5.8. GitHub Actions OIDC 인증 실패 (InvalidIdentityToken)
- 증상:
Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity에러 발생. - 원인: AWS IAM에 등록된 GitHub OIDC 공급자의 썸프린트(Thumbprint)가 만료되었거나 일치하지 않음.
- 해결: AWS CLI를 사용하여 OIDC 공급자의 썸프린트를 최신 DigiCert Global Root CA 썸프린트(
6938...)와 백업용(1c58...)으로 업데이트.aws iam update-open-id-connect-provider-thumbprint --open-id-connect-provider-arn <ARN> --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 1c58a3a8518e8759bf075b76b750d4f2df264fcd
5.9. IAM 역할 이름 대소문자 불일치 (AccessDenied)
- 증상: OIDC 설정이 올바른데도
AccessDenied에러 발생. CloudTrail 로그에GitHubActions...(대문자 H)로 요청된 기록 확인. - 원인: AWS 리소스 ARN은 대소문자를 구분함. 실제 생성된 역할은
GithubActions...(소문자 h)였으나, 워크플로우 파일(deploy-prod.yml)에 오타가 있었음. - 해결: 워크플로우 파일의
role-to-assume값을 실제 IAM 역할 이름과 정확히 일치하도록 수정.
5.10. GitHub Release 재실행 시 코드 미반영
- 증상: 워크플로우 파일을 수정하고 커밋/푸시했으나, 기존 릴리스(예:
v0.0.1)에서 "Re-run jobs"를 해도 수정 사항이 반영되지 않음. - 원인: GitHub Actions의
release트리거는 릴리스 태그가 생성된 시점의 코드 스냅샷을 사용함. - 해결: 수정된 코드를 반영하려면 새로운 버전(예:
v0.0.3)으로 새 릴리스를 발행해야 함.
5.11. Kubernetes Secret 인코딩 오류 (Invalid UTF-8)
- 증상: Pod가
CreateContainerError상태로 시작되지 않음. 상세 이벤트에grpc: error while marshaling: string field contains invalid UTF-8에러 발생. - 원인: Kubernetes Secret의
data필드에 Base64로 인코딩된 값을 넣을 때, 원본 데이터가 텍스트가 아닌 바이너리이거나 잘못 인코딩되어 디코딩 시 유효하지 않은 UTF-8 문자열이 됨. (예:JWT_SECRET을 평문 그대로 Base64 인코딩하지 않고, 이미 암호화된 바이너리 키 등을 잘못 넣은 경우) - 해결: Secret에 넣을 값은 반드시 **"애플리케이션이 사용할 평문(Plain Text)"을 "Base64로 인코딩한 값"**이어야 함.
echo -n 'MyPlainSecretValue' | base64
6. CI/CD 파이프라인 (GitHub Actions)
Production 환경의 안정성과 보안을 위해 GitHub Actions와 AWS OIDC를 활용한 Release 기반 자동 배포 파이프라인을 구축했습니다.
6.1. 배포 전략 (Polyrepo + GitHub Release)
- 전략: 메인 레포지토리(
vivid-ai) 대신, 각 서브모듈(backend-vivid-ai,frontend-vivid-ai) 레포지토리에서 개별적으로 릴리스하고 배포하는 Polyrepo 방식을 채택했습니다. - 트리거: GitHub 리포지토리에서 Release를 발행(Publish)할 때만 배포가 실행됩니다. (단순 태그 푸시로는 실행되지 않음)
- 장점: 개발(Dev) 브랜치에서의 실수로 인한 배포를 방지하고, 배포 시점을 명시적으로 관리할 수 있습니다.
6.2. 보안 인증 (AWS OIDC)
- GitHub Secrets에 AWS Access Key를 저장하지 않고, **OpenID Connect (OIDC)**를 사용하여 인증합니다.
- IAM Role:
GithubActions-Deploy-Role-Prod(소문자h주의) - 동작 원리:
- GitHub Actions가 OIDC 토큰을 AWS STS에 제공.
- AWS는 토큰을 검증하고
GithubActions-Deploy-Role-Prod역할을 임시로 부여(AssumeRole). - 이 역할은 ECR Push 권한과 EKS 관리자 권한(
system:masters)을 가짐.
6.3. 파이프라인 구성 (deploy-prod.yml)
- 위치: 각 서브 레포지토리의
.github/workflows/deploy-prod.yml - 주요 단계:
- 인증:
aws-actions/configure-aws-credentials(OIDC 사용). - 빌드: Docker 이미지 빌드 및 ECR 로그인.
- 푸시: 릴리스 태그(예:
v1.0.0)를 이미지 태그로 사용하여 ECR에 푸시. - 배포:
aws eks update-kubeconfig로 클러스터 접속 후kubectl set image명령으로 Deployment 업데이트.
- 인증:
6.4. Production 배포 절차 (개발자 가이드)
- 각 서브 레포지토리(
backend또는frontend)에서 코드를 작성하고production브랜치(또는 배포할 브랜치)에 병합. - GitHub 웹사이트의 해당 레포지토리 Releases 페이지로 이동.
- "Draft a new release" 클릭.
- Tag: 새 버전(예:
v1.0.0) 입력. - Target: 배포할 브랜치(예:
production) 선택. - "Publish release" 클릭 -> 자동으로 CI/CD 파이프라인이 실행되어 Production 환경에 배포됨.
6.5. Kubernetes Secret 관리 (보안)
- 원칙:
backend-secret-prod.yaml과 같은 Secret Manifest 파일은 민감 정보를 포함하므로 절대 Git 저장소에 커밋하지 않습니다. - 구현:
- GitHub Repository Secrets에 전체 YAML 파일 내용 또는 민감 변수들을 등록합니다 (예:
BACKEND_SECRET_PROD). - GitHub Actions 워크플로우(
deploy-prod.yml) 실행 중에 이 Secret을 파일로 동적 생성합니다.- name: Deploy new image to EKS
run: |
# GitHub Secret 내용을 임시 yaml 파일로 생성
echo "${{ secrets.BACKEND_SECRET_PROD }}" > backend-secret-prod.yaml
# Apply Kubernetes Manifests
kubectl apply -f backend-secret-prod.yaml
# ... 기타 배포 명령 ...
# 보안을 위해 생성된 파일 즉시 삭제
rm backend-secret-prod.yaml
- GitHub Repository Secrets에 전체 YAML 파일 내용 또는 민감 변수들을 등록합니다 (예: