Skip to main content

Production 서버 인프라 및 배포 가이드

이 문서는 vivid-ai 서비스의 실제 운영 환경인 Production 서버를 구축하는 전체 과정과 아키텍처, 그리고 구축 과정에서 발생한 이슈와 해결 방법을 상세히 기록한 문서입니다.


1. 개요 및 아키텍처 전략

Production 환경은 개발(Dev) 환경과 완전히 분리된 AWS 계정독립적인 VPC를 사용하여 안정성과 보안을 최우선으로 구축되었습니다.

1.1. Dev 환경과의 주요 차이점

구분Development 환경Production 환경비고
AWS 계정Dev 계정Prod 계정 (분리됨)보안 및 비용 관리 분리
VPC CIDR10.12.0.0/1610.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-prodPublic 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)

  1. Bastion Host 생성: 위 2.5 Bastion Host 섹션 참조. VPC 및 서브넷이 올바른지 반드시 확인 (잘못된 VPC에 생성 시 Connection timed out 발생).
  2. RDS 보안 그룹 설정: vivid-ai-rds-sg-prod 인바운드 규칙에 PostgreSQL (TCP 5432)의 소스로 Bastion Host 보안 그룹(vivid-ai-bastion-sg-prod)을 허용.
  3. 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.
    • 주의: 이 명령어를 실행한 터미널 창을 닫으면 터널이 끊깁니다.
  4. PgAdmin4 접속 설정:
    • Host name/address: localhost
    • Port: 5433 (SSH 터널링 명령에 지정한 로컬 포트)
    • Username: postgres
    • Password: vivid-ai-secret-prod에 저장된 비밀번호.

3. 보안 및 권한 관리 (IRSA 도입)

Production 환경에서는 장기 자격 증명(Access Key ID/Secret Key)을 애플리케이션에 하드코딩하거나 환경 변수로 주입하는 것을 지양하고, **IRSA (IAM Roles for Service Accounts)**를 전면 도입했습니다.

3.1. IRSA 구성 흐름

  1. OIDC Provider 생성: EKS 클러스터와 AWS IAM을 연결하는 OIDC Identity Provider 생성.
  2. IAM Policy 생성: 애플리케이션이 필요한 권한(S3 접근, SQS 전송 등)만 담은 최소 권한 정책 생성 (vivid-ai-backend-app-policy-prod).
  3. IAM Role 생성: 위 정책을 연결하고, 신뢰 관계(Trust Relationship)에 특정 Service Account(system:serviceaccount:default:backend-service-account)만 이 역할을 맡을 수 있도록 조건 추가.
  4. Service Account 생성: Kubernetes 내에 backend-service-account를 생성하고 eks.amazonaws.com/role-arn 어노테이션으로 IAM Role과 연결.
  5. Deployment 적용: Pod 설정에 serviceAccountName: backend-service-account 지정.

4. 애플리케이션 배포 및 설정

4.1. 백엔드 (NestJS)

  • Redis TLS 연결 (rediss://):
    • Production Redis는 TLS가 필수이므로, app.module.tsredis.module.ts에서 NODE_ENV=production 또는 REDIS_TLS_ENABLED=true일 때 rediss:// 프로토콜을 사용하도록 로직을 수정했습니다.
    • 초기 연결 디버깅을 위해 tls: { rejectUnauthorized: false } 옵션을 사용했으나, 안정화 후 제거 예정입니다.
  • 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) 추가.
  • 원인 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가 다를 수 있음.
  • 해결:
    1. vivid-ai-bastion-sg-prod의 SSH(TCP 22) 인바운드 규칙 소스를 일시적으로 0.0.0.0/0으로 변경하여 접속 테스트.
    2. 접속 성공 후 Bastion Host 내부에서 echo $SSH_CLIENT 명령어를 실행하여 실제 접속한 클라이언트 IP를 확인.
    3. 확인된 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 ActionsAWS 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 주의)
  • 동작 원리:
    1. GitHub Actions가 OIDC 토큰을 AWS STS에 제공.
    2. AWS는 토큰을 검증하고 GithubActions-Deploy-Role-Prod 역할을 임시로 부여(AssumeRole).
    3. 이 역할은 ECR Push 권한과 EKS 관리자 권한(system:masters)을 가짐.

6.3. 파이프라인 구성 (deploy-prod.yml)

  • 위치: 각 서브 레포지토리의 .github/workflows/deploy-prod.yml
  • 주요 단계:
    1. 인증: aws-actions/configure-aws-credentials (OIDC 사용).
    2. 빌드: Docker 이미지 빌드 및 ECR 로그인.
    3. 푸시: 릴리스 태그(예: v1.0.0)를 이미지 태그로 사용하여 ECR에 푸시.
    4. 배포: aws eks update-kubeconfig로 클러스터 접속 후 kubectl set image 명령으로 Deployment 업데이트.

6.4. Production 배포 절차 (개발자 가이드)

  1. 각 서브 레포지토리(backend 또는 frontend)에서 코드를 작성하고 production 브랜치(또는 배포할 브랜치)에 병합.
  2. GitHub 웹사이트의 해당 레포지토리 Releases 페이지로 이동.
  3. "Draft a new release" 클릭.
  4. Tag: 새 버전(예: v1.0.0) 입력.
  5. Target: 배포할 브랜치(예: production) 선택.
  6. "Publish release" 클릭 -> 자동으로 CI/CD 파이프라인이 실행되어 Production 환경에 배포됨.

6.5. Kubernetes Secret 관리 (보안)

  • 원칙: backend-secret-prod.yaml과 같은 Secret Manifest 파일은 민감 정보를 포함하므로 절대 Git 저장소에 커밋하지 않습니다.
  • 구현:
    1. GitHub Repository Secrets에 전체 YAML 파일 내용 또는 민감 변수들을 등록합니다 (예: BACKEND_SECRET_PROD).
    2. 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