Docker Compose 기반 서비스 실행 환경 구성 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki

1. Docker-compose

name: muro

services:
  frontend:
    image: asia-northeast3-docker.pkg.dev/${GCP_PROJECT_ID}/frontend/frontend:${IMAGE_TAG}
    container_name: frontend
    restart: unless-stopped
    env_file:
      - ./frontend.env
    expose:
      - "3000"
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    logging:
      driver: gcplogs
      options:
        gcp-project: ${GCP_PROJECT_ID}
        labels: "service=frontend"

  backend:
    image: asia-northeast3-docker.pkg.dev/${GCP_PROJECT_ID}/backend/backend:${IMAGE_TAG}
    container_name: backend
    restart: unless-stopped
    env_file:
      - ./backend.env
    expose:
      - "8080"
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s
    logging:
      driver: gcplogs
      options:
        gcp-project: ${GCP_PROJECT_ID}
        labels: "service=backend"

  ai:
    image: asia-northeast3-docker.pkg.dev/${GCP_PROJECT_ID}/ai/ai:${IMAGE_TAG}
    container_name: ai
    restart: unless-stopped
    env_file:
      - ./ai.env
    expose:
      - "8000"
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    logging:
      driver: gcplogs
      options:
        gcp-project: ${GCP_PROJECT_ID}
        labels: "service=ai"

  nginx:
    image: nginx:1.27-alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./data/nginx:/etc/nginx/conf.d:ro
      - ./data/certbot/conf:/etc/letsencrypt:ro
      - ./data/certbot/www:/var/www/certbot:ro
    depends_on:
      frontend:
        condition: service_healthy
      backend:
        condition: service_healthy
      ai:
        condition: service_healthy
    networks:
      - app-net

  certbot:
    image: certbot/certbot
    profiles: ["certbot"]
    volumes:
      - ./data/certbot/conf:/etc/letsencrypt
      - ./data/certbot/www:/var/www/certbot

networks:
  app-net:
    driver: bridge

Docker Volume이 왜 필요할까?

2. Docker network

docker network create --driver bridge app-network

network 묶어야하는 것들 

services:
    nginx:
      networks:
        - app-network

    frontend:
      networks:
        - app-network

    backend:
      networks:
        - app-network

    ai:
      networks:
        - app-network

  networks:
    app-net:
      driver: bridge

Docker network 구조

구성 요소 역할 비유
컨테이너 eth0 컨테이너 내부 네트워크 출입구 각 집의 현관문
veth pair 컨테이너와 호스트를 연결하는 가상 케이블 집과 단지를 연결하는 케이블
Docker Bridge 같은 네트워크의 컨테이너를 연결하는 가상 스위치 아파트 단지 내부 스위치
Host NIC 호스트가 외부 네트워크와 통신하는 실제 인터페이스 단지 밖으로 나가는 출입구

3. 서비스별 .env.example 파일 작성

frontend.env.example

# 프론트엔드 컨테이너 런타임 환경변수.
#
# 주의: Vite는 VITE_* 값을 "빌드 시점"에 정적 파일 안에 박아 넣는다.
# 여기(런타임 env_file)에 VITE_*를 넣어도 이미 빌드된 dist/ 결과물에는 반영되지 않는다.
# API 서버 주소처럼 빌드에 필요한 값은 Dockerfile의 ARG/ENV로 빌드 시점에 주입해야 한다.
# 그래서 이 파일에는 컨테이너가 뜬 "이후"에도 의미 있는 런타임 값만 둔다.

NODE_ENV=production

backend.env.example

# 백엔드(API) 컨테이너 런타임 환경변수.
# 실제 값은 여기 채우지 말 것 - 이 파일(.env.example)은 Git에 커밋된다.
#
# 로컬 개발: cp backend.env.example backend.env 후 직접 값 채우기
# 운영 배포: 배포 스크립트가 github secrets 에서 값을 조회해 VM에서 backend.env를 생성

# --- Database ---
SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/muro
SPRING_DATASOURCE_USERNAME=muro
SPRING_DATASOURCE_PASSWORD=change-me

# --- Cloud Storage ---
GCS_BUCKET_NAME=muro-uploads-dev

# --- 외부 API ---
WEATHER_API_KEY=change-me
MUSIC_API_KEY=change-me

# --- AI Server 연동 ---
AI_BASE_URL=http://localhost:8000
# Backend <-> AI Server 내부 호출 인증용 공유 토큰. ai.env의 AI_SERVICE_TOKEN과 반드시 같아야 한다.
AI_SERVICE_TOKEN=change-me

# --- 인증 ---
JWT_SECRET=change-me

ai.env.example

# AI Server 컨테이너 런타임 환경변수.
# 실제 값은 여기 채우지 말 것 - 이 파일(.env.example)은 Git에 커밋된다.
#   cp ai.env.example ai.env

# --- LLM / 외부 AI API ---
LLM_API_KEY=change-me

# --- Backend와의 내부 인증 ---
# backend.env의 AI_SERVICE_TOKEN과 동일한 값이어야 인증이 통과된다.
AI_SERVICE_TOKEN=change-me

.gitignore 반영

# 서비스별 실제 env 파일은 절대 커밋하지 않는다. .example만 추적한다.
*.env
!*.env.example

4. Blue-Green 전환용 스크립트 초안 작성

VM에 SSH로 접속하여 컨테이너에 배포는 다음과 같이 이루어진다

  1. 현재 Active/Standby 포트 판별 로직 실행
  2. Standby 컨테이너에 새 버전의 이미지 pull
  3. 배포 검증 (검증에 대한 설명은 5. 배포 검증 및 Rollback 설계 에서 다뤘습니다)
  4. 미리 작성해둔 Blue-Green Swich 파일 실행을 통한 전환 (Nginx 전환)
  5. 이전 Active VM 종료

4번의 Blue-Green 전환용 Nginx switch 스크립트를 작성해야 하는데, v1 의 인프라 아키텍처 구조는 VM 1대에 프론트, 백엔드, AI, Nginx 칸테이너 4종이 들어있고 VM은 총 2대로 (active-stand by) 각 VM에 컨테이너 4종이 들어있다. 2대인 VM 을 이용해 blue-green 배포를 진행해야 하는데 일반적으로는 nginx 전환 스크립트를 통해 blue-green 배포를 진행하는데, 우리의 아키텍처는 VM에 nginx가 각각 들어가 있는 형태이다.

따라서 전환의 단위는 Nginx 설정이 아니라 다른 선택지를 고려해야 한다.

1. 고정 외부 IP를 Active/Standby VM 사이에서 옮겨붙이기

  • 특징: GCP의 예약된 고정 외부 IP 하나를 만들어두고, 이 IP를 항상 지금 Active인 VM의 네트워크 인터페이스에 붙여둔다. 배포 시 이 IP를 새로 검증된 VM으로 옮겨 붙이는 것 자체가 전환이 된다. DNS는 이 고정 IP를 최초 1회만 가리키고 이후 다시 건드리지 않는다.
  • 장점
    • 전환/롤백이 GCP API 호출 한 번으로 수 초 안에 끝난다. DNS 전파 지연이 없다.
    • 로드밸런서 없이 기존 아키텍처(VM별 Nginx 내장)를 그대로 유지할 수 있다.
    • 추가 GCP 리소스가 고정 IP 1개뿐이라 신규 비용이 거의 없다.
  • 단점
    • IP를 떼고 다시 붙이는 그 몇 초 사이에는 어느 VM도 IP를 갖지 않는 짧은 공백이 있다 (완전한 무중단은 아님).
    • 어느 VM이 트래픽을 받게 될지 미리 알 수 없으므로, 두 VM 모두 같은 도메인의 TLS 인증서를 갖고 있어야 한다.
  • 비용: GCP 정책상 고정 IP는 VM에 붙어있는 동안 무료이고, 잠깐 안 붙어있는 순간에만 시간 단위로 극소액이 과금된다. 사실상 추가 비용은 없다.

2. DNS 레코드 전환

  • 특징: 두 VM이 각자 고유 외부 IP를 갖고, 배포할 때마다 DNS A 레코드 값만 Standby VM의 IP로 바꾼다.
  • 장점
    • 구현이 제일 단순하다. Compute API 조작 없이 Cloud DNS API 호출 한 번으로 끝난다.
  • 단점
    • TTL로 인한 전환 지연: DNS 레코드의 TTL이 만료되기 전까지 사용자는 여전히 Blue 서버로 접속한다.
    • 빠른 롤백의 한계: Green 배포 후 버그가 발견돼도, 이미 Green IP를 캐싱한 사용자는 한동안 계속 장애가 있는 서버에 머문다. 즉각적인 장애 전파 차단이 어렵다.
  • 비용: 거의 없음. 다만 TTL을 낮출수록 쿼리 비용이 소폭 증가한다.

3. Nginx를 제외하고 로드밸런서 도입

  • 특징: 각 VM의 Nginx를 없애고, 그 자리를 GCP의 관리형 로드밸런서(경로별 라우팅까지 대체하려면 L7 Application Load Balancer 필요)로 교체한다.
  • 장점
    • 헬스체크 실패 시 자동으로 비정상 VM을 제외해줘서, 수동 전환 스크립트 자체가 필요 없어진다.
    • Google 관리형 SSL 인증서를 쓰면 인증서를 VM마다 동기화할 필요가 없어진다.
    • 2차 설계에서 결국 도입하기로 한 ALB와 방향이 같아서, 나중에 다시 갈아엎을 필요가 없다.
  • 단점
    • 설계 1단계에서 "비용, 복잡도 문제로 Cloud Load Balancing은 배제한다"고 이미 명시적으로 결정한 원칙과 충돌한다.
    • VM이 2대뿐인 v1 트래픽 규모(Peak RPS 3.4)에는 로드밸런서가 처리해야 할 만큼의 부하가 없다
      • 지금 시점에는 과한 선택이라고 생각한다.
  • 비용: Network Load Balancer(L4)는 forwarding rule당 저렴하지만 경로 기반 라우팅을 지원하지 않아 Nginx를 완전히 대체할 수 없다. 경로 라우팅까지 대체하려면 Application(HTTP/S) LB가 필요한데, 최소 이용료 + 처리 데이터량(GB) 과금이 붙어 v1 기준으로는 유의미하게 비용이 늘어난다.

최종 선택: 1. 고정 외부 IP 재할당

  • v1의 핵심 가치인 "속도"와 "최소 비용"에 가장 부합한다

    • 전환과 롤백이 초 단위로 끝나면서 추가 비용은 사실상 없다.
  • 1단계에서 이미 확정한 아키텍처(VM별 Nginx 내장, Cloud Load Balancing 배제)를 그대로 유지할 수 있어 재설계 비용이 없다.

  • 2번(DNS 전환)은 탈락: 3단계 CD 설계에서 "Green 환경 트래픽 전환 후 장애 발견 시 즉시 Rollback"을 요구 사항으로 이미 정해뒀는데, DNS 방식은 TTL 캐싱 때문에 이 요구 사항을 만족시킬 수 없다.

  • 3번(LB 도입)은 지금 시점엔 과하다고 판단: 방향 자체는 맞다 (2차 설계에서 결국 ALB를 도입하기로 했다). 하지만 VM 2대, Peak RPS 3.4 수준인 v1에 로드밸런서까지 필요한 트래픽 규모가 아니고, Active-Standby 구조이기 때문에 운영중에는 VM이 1대만 동작하기 때문에 트래픽을 나눠주는 기능이 필요없다.

  • 4번(Nginx 분리)은 탈락: SPOF를 하나 새로 만들고, 그걸 다시 없애려면 결국 1번과 같은 IP 전환 문제를 풀어야 하는 구조라 문제를 해결하지 못하고 VM 비용만 늘린다.

  • 참고자료 : https://blog.naver.com/nicehosting/224231150189

  • 참고자료 : https://docs.cloud.google.com/compute/docs/ip-addresses/configure-static-external-ip-address?hl=ko

  • 참고자료 : https://cloud.google.com/vpc/network-pricing?hl=ko

  • 참고자료 : https://docs.cloud.google.com/vpc/docs/reserve-static-external-ip-address?hl=ko#gcloud

스크립트 작성

  1. 고정 IP 예약하고 할당 (최초 1회 설정)
# 고정 IP 예약
gcloud compute addresses create muro-prod-ip --region=asia-northeast3

# 지금 Active인 VM에 이 IP를 붙인다
gcloud compute instances add-access-config <active-vm-name> \
  --zone=asia-northeast3-a \
  --access-config-name="External NAT" \
  --address=$(gcloud compute addresses describe muro-prod-ip --region=asia-northeast3 --format='value(address)')
  1. 배포 전환
# 1) Standby VM에 새 버전 배포 + 헬스체크/Smoke Test 통과 확인

# 2) 기존 Active VM에서 IP를 떼고, 검증된 Standby VM에 붙인다
gcloud compute instances delete-access-config <old-active-vm> \
  --zone=asia-northeast3-a \
  --access-config-name="External NAT"

gcloud compute instances add-access-config <new-active-vm> \
  --zone=asia-northeast3-a \
  --access-config-name="External NAT" \
  --address=$(gcloud compute addresses describe muro-prod-ip --region=asia-northeast3 --format='value(address)')  

# 3) 이제 old-active-vm은 IP가 없는 새 Standby가 됨 (다음 배포 때 여기에 새 버전을 올림)