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이 왜 필요할까?
-
DB 같은 경우는 Volume이 필요하다
- 특히
MySQL과 같은 DB는 컨테이너의 생명 주기와 관계없이 데이터를 영속적으로 저장해야하고, 많은 경우 여러 개의 Docker 컨테이너가 하나의 저장 공간을 공유해서 데이터를 읽거나 써야한다. - 이렇게 Docker 컨테이너의 생명 주기와 관계없이 데이터를 영속적으로 저장할 수 있도록 별개의 공간인,
Volume이 필요하다.
- 특히
-
백엔드나 AI 같은 경우 log를 저장해야한다.
- 컨테이너가 장애가 생겨서 재시작하는 경우 볼륨을 설정해놓지 않으면 컨테이너가 왜 재시작했는지 이유를 알 수 없다. 그렇기에 호스트 볼륨에 마운드해두어 재시작하더라도 사후 분석이라도 가능하다
- 그럼 백엔드가 재시작하는 경우는 뭐가있을까?
OOM Killer- 백엔드 프로세스가 Docker 컨테이너에 할당된 메모리나 호스트의 메모리를 초과할때 발생할 수 있다. 예를 들어
- DB 쿼리로 수십만 건의 데이터를 객체로 매핑하는 경우
-
왜 Nginx를 볼륨에
:ro를 붙여서 읽기전용으로 마운트해줘야할까?- Nginx는 해당 디렉터리의 파일을 읽기만 하면 동작할 수 있는데 쓰기를 열어줄 이유가 없다.
- 이러한 권한은 보안 취약점으로 이어진다.
-
참고자료 : https://sw-ym.tistory.com/64
-
참고자료 : https://medium.com/sjk5766/create-react-app-docker-compose-%EA%B5%AC%EC%84%B1-b01e1219d14e
-
참고자료 : https://github.com/100-hours-a-week/lulu-til/blob/main/July/2026-07-02.md
-
참고자료 : https://confluence.curvc.com/spaces/ASD/pages/152208756/Docker+container+log+%EA%B4%80%EB%A6%AC
-
참고자료 : https://docs.docker.com/engine/logging/drivers/gcplogs/
-
참고자료 : https://nayoungs.tistory.com/entry/Docker-Docker-Volume
-
참고자료 : https://docs.docker.com/reference/compose-file/services/#depends_on
-
참고자료 : https://jhzlo.tistory.com/82
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로 접속하여 컨테이너에 배포는 다음과 같이 이루어진다
- 현재 Active/Standby 포트 판별 로직 실행
- Standby 컨테이너에 새 버전의 이미지 pull
- 배포 검증 (검증에 대한 설명은 5. 배포 검증 및 Rollback 설계 에서 다뤘습니다)
- 미리 작성해둔 Blue-Green Swich 파일 실행을 통한 전환 (Nginx 전환)
- 이전 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://docs.cloud.google.com/compute/docs/ip-addresses/configure-static-external-ip-address?hl=ko
-
참고자료 : https://docs.cloud.google.com/vpc/docs/reserve-static-external-ip-address?hl=ko#gcloud
스크립트 작성
- 고정 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) 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가 됨 (다음 배포 때 여기에 새 버전을 올림)