☁️ 4단계: Docker 컨테이너화 배포 - 100-hours-a-week/7-team-ddb-wiki GitHub Wiki

1. 개요

서비스 구성요소를 Docker 컨테이너로 패키징하여 불변 이미지 기반 배포 체계를 확립하고자 한다. 이를 통해 배포 속도와 운영 안정성을 높이며, DevOps 자동화를 강화한다. 기존 VM 기반 방식의 복잡성과 초기화 지연 문제를 해결하는 것이 목적이다.

2. Docker 도입 배경 및 필요성

2.1 기존 서비스 특성과 배포 구조

현재 FE(Next.js), BE(Spring), AI(FastAPI + 모델)는 서로 다른 런타임을 사용하며, 컴포넌트별로 Jenkins 선언형 파이프라인을 별도 파일로 유지한다. 각 파이프라인은 공통적으로 ① Git main 브랜치 체크아웃 → ② 언어별 빌드·패키징 → ③ GCS 업로드 → ④ Discord 수동 승인 → ⑤ GCP MIG 롤링 업데이트의 흐름을 따른다.

롤링 업데이트 단계에서 Jenkins는 빌드마다 동적으로 스크립트를 생성하여 인스턴스 템플릿에 포함시킨다. VM은 부팅 시 스크립트를 실행하여 GCS에서 아티팩트를 내려받아 의존성 설치·서비스 재시작을 수행한다.

2.2 기존 빅뱅 배포 과정의 문제점

  • 환경 불일치·재현성 저하
    • VM 커널 버전이나 시스템 라이브러리 차이에 따라 동일 스크립트라도 실행 결과가 달라질 수 있다.
    • 세 가지 파이프라인 모두 OS 패치 수준, 패키지 버전, 런타임 설치 경로가 다르므로 장기간 운영 시 “한쪽은 정상, 다른 쪽은 실패” 현상이 반복될 수 있다.
  • 배포 시간 지연 및 스케일-아웃 한계
    • 오토스케일링 그룹이 새 인스턴스를 추가할 때마다 GCS에서 아티팩트를 다운로드하고, 압축 해제 및 의존성 설치를 수행해야 하므로 인스턴스 준비 시간이 수 분 이상 소요된다. 짧은 시간 내에 트래픽이 급증하면 원하는 수준으로 신속하게 스케일-아웃하지 못할 수 있다.
    • 롤백 또한 새 템플릿으로 VM을 재생성하고 스크립트 재실행 과정을 거치므로 장애 복구에 추가 시간이 필요하다.

3. Docker 도입 범위

3.1 컨테이너화 대상 컴포넌트

  • FE (Next.js)

    사용자에게 가장 먼저 노출되고 변경 빈도가 높아 롤백, 롤포워드가 빈번하게 발생한다. 기존 VM에선 npm ci → npm run build → tar.gz 업로드 과정에 3~4분이 걸렸다. 컨테이너화하면 미리 빌드된 이미지를 사용하여 신규 인스턴스 부팅 후 30초 내 서비스 기동이 가능하다.

  • BE (Spring)

    Java 런타임과 Gradle 의존성이 VM 내에서 설치될 때 디스크 I/O 병목이 자주 발생했다. 멀티-스테이지 Dockerfile을 사용하면 빌드 시점에 개발 도구를 제거하고, 최종 런타임 이미지는 JRE와 애플리케이션만 포함된 상태로 구성할 수 있다. 이 방식은 이미지 크기를 축소할 수 있으며, 런타임과 애플리케이션을 불변 레이어로 묶어 배포 재현성과 일관성도 확보된다.

  • AI (LLM 모델)

    Python 머신러닝 패키지는 OS별로 바이너리 호환 문제가 빈번하게 발생한다. Docker 이미지 내부에 핵심 패키지를 미리 빌드된 wheel 또는 바이너리 형태로 포함하면, VM 부팅 후 pip install 중 재컴파일 시간이 제거되어 초기화 시간이 대폭 단축된다. 또한, 컨테이너 레이어로 미리 캐싱된 모델이나 종속성은 빠른 재사용과 일관된 실행 환경 확보에 유리하다.

세 컴포넌트는 각각 독립 VM 한 대씩을 유지하되, 인스턴스 템플릿의 --container-image 옵션을 활용하여 MIG 구조를 유지한다. 운영 데이터베이스는 GCP Cloud SQL을 계속 사용하며, 컨테이너화 대상에 포함하지 않는다.

3.2 컨테이너화 제외 대상과 사유

  • Cloud SQL 데이터의 영속성, 복제, 백업 등을 GCP가 관리하고 있어 컨테이너화할 필요가 없다. 상태 저장 DB를 직접 운영하면 복제 지연, 스토리지 구성, 백업 정책 등 관리 부담이 커지며, 현재 SLA(99.95%)를 유지하기 위해 제외한다. (SLA 확인 필요)
  • Jenkins 현재는 파이프라인 안정화가 우선이며, VM 상에서 빌드·푸시 작업만 수행하면 충분하다. 컨테이너화 시 볼륨 마운트, 플러그인 관리, Docker 권한 설정 등 추가 설계가 필요하므로 2단계 이후로 미룬다.
  • 로드밸런서, Bastion+NAT 등 인프라 컴포넌트 GCP 네이티브 서비스로 제공되며 컨테이너화 대상이 아니다.

결론적으로 애플리케이션 계층(FE, BE, AI)의 VM만 컨테이너 이미지로 전환하고, 데이터베이스와 CI/CD 서버는 기존 구조를 유지하여 위험은 줄이고 효과는 극대화한다.

4. 컨테이너 배포 전략

4.1 이미지 빌드 방식과 태깅 규칙

  • 멀티‑스테이지 Dockerfile을 사용하여 의존성 설치·빌드 단계와 런타임 단계를 분리한다. 빌드 단계에서는 캐시가 잘 작동하도록 의존성 정의 파일을 먼저 복사한 뒤 설치한다.
  • 태그 구조는 <서비스명>:<빌드번호> 형식으로 통일한다. 예시→be:42. latest는 사용하지 않고, 배포 자동화 스크립트가 명시적 태그를 참조하도록 한다.

4.2  레지스트리 관리 방안

  • Google Artifact Registry(GAR)를 선택한다. GCP MIG가 이미 사용하는 프로젝트 네트워크 내부에서 egress 요금 없이 이미지를 가져올 수 있다.
  • GAR 레포지토리는 맞춤 리전을 asia-northeast3에 생성하여 레지스트리와 VM 간 네트워크 지연을 최소화한다.
  • 모든 이미지는 GAR에 저장 및 버전 관리된다.
  • 30일 이상 된 이미지는 자동으로 삭제한다.

4.3 이미지 최적화 및 품질 보장 기법

  • 멀티‑스테이지 빌드로 최종 이미지에 빌드 도구를 포함하지 않는다.
  • 공식 slim/buster 기반 이미지(node:18-slim, eclipse-temurin:17-jre-jammy, python:3.10-slim)를 채택해 각 이미지 크기를 최적화한다.
  • Python은 pip install --no-cache-dir 옵션으로 캐시 제거 후 레이어를 최소화한다.

4.4 리소스 자원 할당

대상 호스트 사양 컨테이너 CPU 제한 메모리 제한 비고
BE (Spring Boot) 1 CPU, 3.75 GiB spring boot 0.6 CPU 1.5 GiB 최소 JVM GC/Heap 확보
node-exporter 0.05 CPU 128 MiB 기본 OS 메트릭 수집
promtail 0.15 CPU 384 MiB 애플리케이션 로그 수집 최적화
FE (Next.js) 1 CPU, 3.75 GiB next.js 0.7 CPU 2 GiB 빌드된 정적 파일 서비스 기준
node-exporter 0.1 CPU 128 MiB 기본 OS 메트릭 수집
AI (모델 + FastAPI) 사양 미정 - - - -
Monitoring 1 CPU, 3.75 GiB prometheus 0.4 CPU 1.2 GiB 15초 주기 수집 기준
grafana 0.2 CPU 256 MiB 알림/대시보드 렌더링
loki 0.2 CPU 1.0 GiB 중소규모 로그 처리

4.5 무중단 배포 전략

  • 무중단 배포 전략 선정 배경

    현재는 MVP 출시 이후 약 4주간의 초기 운영 기간을 거친 상태로, 사용자 피드백과 데이터 수집을 통해 서비스를 점진적으로 개선하고 있는 단계이다. 아직 대규모 트래픽이나 폭발적인 사용자 증가는 발생하지 않았기 때문에, 대규모 트래픽을 고려한 Canary 또는 이중 인프라를 요구하는 blue-Green은 과도한 전략으로 판단된다. 롤링 업데이트는 적은 리소스로도 안정적인 배포를 보장할 수 있어, 서비스 초기 단계에 가장 현실적인 선택이다.

    블루-그린 배포는 동일한 환경을 2개 운영해야 하므로 비용 부담이 크다. 롤링 업데이트는 기존 인스턴스를 순차적으로 교체하므로, 별도 리소스 증설 없이도 동작 가능하여 운영 비용을 최소화할 수 있다.

    롤링 업데이트는 현재 구조에서도 쉽게 구현되며, 이후 쿠버네티스 도입 시에도 Deployment의 기본 전략으로 자연스럽게 이전 가능하다.

    이에 따라, 롤링 업데이트는 적은 리소스로 안정성과 무중단 배포를 모두 확보할 수 있어, 현재 서비스 성장 단계에 가장 현실적이고 효율적인 선택으로 판단된다.

  • 롤링 업데이트 방식에 따른 무중단 배포 구성 방법

    Google Cloud Platform(GCP)의 Managed Instance Group(MIG) 기능을 활용하여 롤링 업데이트를 구성하였다. 주요 설정값은 다음과 같다.

    설정 항목 설명
    max-surge 1 교체 과정 중 동시에 늘어날 수 있는 최대 인스턴스 수
    max-unavailable 1 교체 과정 중 동시에 중단될 수 있는 최대 인스턴스 수
    min-ready 30초 새 인스턴스가 정상 상태로 확인되어야 하는 최소 대기 시간

    이를 통해 인스턴스를 하나씩 교체하며 무중단으로 배포할 수 있도록 하였으며, 각 인스턴스는 최소 30초 동안 정상 응답을 유지해야 다음 교체가 진행된다.

  • 배포 절차

    단계 작업 내용
    1 현재 사용 중인 인스턴스 템플릿 이름 저장 gcloud compute instance-groups managed describe 명령을 통해 MIG에서 사용 중인 템플릿 이름을 OLD_TMPL 변수에 저장
    2 새 Docker 이미지 빌드 및 푸시 새 코드로 이미지를 빌드하고 <서비스명>:<빌드번호> 형식의 태그로 GAR에 푸시
    3 Discord 승인 요청 Jenkins가 Discord 웹훅으로 배포 승인 메시지를 전송하고, 사람이 수동으로 승인
    4 새 스타트업 스크립트 작성 및 새 인스턴스 템플릿 생성 새 이미지로 컨테이너를 실행하는 startup-script를 생성하고, 이를 포함한 새로운 인스턴스 템플릿 생성
    5 롤링 업데이트 실행 MIG에 새 템플릿을 적용하여 롤링 업데이트를 수행 (max-surge=1, max-unavailable=1)

5. 모니터링 전략

5.1 도구 선정 및 선택 배경

항목 Prometheus + Grafana Scouter Datadog
기반 구조 오픈소스, 시계열 메트릭 기반 Java 전용 APM, Agent-Push 방식 SaaS 기반 통합 모니터링
적합 환경 컨테이너/Kubernetes + 다중 언어 JVM 기반 모놀리식 서비스 빠른 도입 + 통합형 솔루션
언어 지원 모든 언어 (Exporter 방식) Java (Spring, Tomcat 등) 모든 언어 (Agent 또는 API 방식)
메트릭 수집 방식 Pull (Kubernetes 친화적, Exporter 대상 주기적 수집) Agent 기반 Push (JVM에 설치된 Scouter Agent가 메트릭을 Scouter 서버로 전송) Agent 기반 Push (Datadog Agent가 메트릭을 수집 후 전송)
로그 수집 Loki, Elasticsearch 등과 연동 불가 (APM에 집중) 자체 Log Management 포함
설치 복잡도 중간 (구성 필요) 간단 (Java Agent만 설정) 매우 쉬움 (Agent 설치만)
시각화 도구 Grafana (강력한 커스터마이징) 내장 UI (간단하지만 정적) 통합 UI (직관적, 제한적 커스터마이징)
알림 시스템 Alertmanager, Grafana 일부 내장 경고 Slack, PagerDuty 등 완비
확장성 (K8s) kube-prometheus 연동 최적화 제한적 (컨테이너 환경 부적합) 우수, 다만 비용 증가
비용 무료 (자체 호스팅) 무료 유료 (사용량 기반 과금)

Prometheus + Grafana는 다음과 같은 상황에 특히 적합하다:

  • Spring Boot, FastAPI 등 다양한 언어 기반 서비스가 혼합되어 있고
  • Docker → Kubernetes로 점진적 전환 중이며
  • 자체 호스팅 기반의 비용 효율적이고 유연한 인프라 구성을 원하는 경우

Scouter는 Java 기반 단일 WAS 환경의 성능 분석(Apm, SQL, GC 등)에 특화되어 보조 도구로는 훌륭하지만, 컨테이너 기반 멀티 서비스 아키텍처에는 부적합하다.

Datadog은 사용성과 기능 면에서 탁월하지만, 비용과 벤더 종속성이 높아 성장 단계의 프로젝트에는 부담이 될 수 있다.

따라서 Prometheus + Grafana를 모니터링 도구로 선정하였다.

5.2 메트릭 수집

Prometheus Server는 Docker 기반 VM 내 컨테이너로 배포되며, 서비스 디스커버리 없이 정적 설정으로 구성된다. 이후 Kubernetes 도입 시 kube-prometheus-stack으로 자동 디스커버리를 확장한다.

메트릭 수집 및 처리

항목 내용
수집 대상 VM(be, ai, fe)의 CPU, 메모리, 디스크, 네트워크 리소스 및 애플리케이션 메트릭 (JVM, FastAPI, Next.js)
수집 도구 node_exporter (호스트 자원 수집), BE(Micrometer + Prometheus Registry), AI(prometheus_client), FE(prom-client)
수집 방식 Prometheus가 15초 간격으로 pull 수집 (static_configs 사용)
메트릭 노출 BE: /actuator/prometheus, AI: /metrics 엔드포인트, FE: /metrics 엔드포인트

메트릭 보관 방식

항목 내용
보존 기간 기본 15일, 필요 시 30일 확장 가능
보관 위치 Prometheus 서버 로컬 디스크 (/prometheus) 또는 외부 디스크 마운트
장기 보관 확장 remote-write 설정으로 Thanos, Cortex, Cloud Managed Prometheus로 전송 가능
백업 정책 Prometheus TSDB 디렉토리 snapshot 백업 (선택사항)

5.3 로그 수집

로그 수집 및 처리

항목 내용
수집 대상 Docker 컨테이너 stdout/stderr 로그, VM 내 파일 로그(/var/log/ddb-backend/*.log, /var/log/ddb-ai/*.log)
수집 도구 promtail (→ Loki 전송)
수집 방식 promtail이 inotify 실시간 감지 및 10~30초 주기 재스캔으로 로그 수집
로그 포맷 JSON 구조화 로그

로그 보관 방식

항목 내용
보존 기간 기본 7~14일 (디스크 공간에 따라 조정)
보관 위치 Loki 서버 로컬 디스크(/loki) 또는 GCS compatible storage로 연동 가능
로그 회전 promtail 레벨 logrotate 가능, Loki retention 설정 가능

5.4 시각화 및 알림

  • Grafana는 자동 프로비저닝 기능을 사용하여 BE, AI 서버의 대시보드를 템플릿화하여 배포하며, 서비스 이름, 환경(dev/prod), 버전 등은 변수로 설정하여 재사용성을 확보한다.
  • 대시보드는 다음 주요 항목을 포함한다:
    • BE: JVM 메모리/GC, HTTP 요청 수 및 상태코드 분포, DB 연결 수 등
    • AI: 모델 응답 지연(p95/p99), 호출 횟수, 오류 비율, 사용자별 요청 등
    • FE: 응답 지연 시간(p95/p99), 오류 비율, 메모리 사용량 등
  • Grafana 다음과 같은 조건의 알림(예시)을 설정한다:
    • p95 응답 지연 > 500ms 5분 지속 시 Critical
    • HTTP 5xx 비율 > 2% 10분 지속 시 Warning
    • JVM Heap 사용률 > 85% 10분 이상 시 Info
    • FE 서버 메모리 사용률 > 80% 10분 이상 시 Warning
  • 알림 채널은 Discord Webhook, Grafana OnCall 로 구성하며, 당직 로테이터 및 SLO/Error Budget 초과에 대한 자동 경고 기능을 병행 운영한다.
  • 대시보드 내 Error Budget 패널을 통해 운영 SLO의 실시간 상태를 가시화하고, 초과 소모 발생 시 실시간 경고로 운영 리스크를 최소화한다.

6. 시스템 구성도 및 기술 명세

6.1 전체 시스템 구성도

아키텍처

스크린샷_2025-04-24_14 40 54

CI/CD 파이프라인

image

6.2 상세 배포 단계

단계 작업 내용 사용 도구 / 명령 비고
1 Git 체크아웃 git main 브랜치 기준
2 Docker 멀티스테이지 빌드 docker build BE는 포트 8080, FE는 포트 3000, AI는 포트 8000
3 이미지 푸시 (GAR) docker pushgcloud auth configure-docker GAR 위치: ${LOCATION}-docker.pkg.dev/...
4 Discord 배포 승인 요청 discordSend 메시지에 서비스명 포함 (ex. [FE])
5 Startup Script 포함 템플릿 생성 gcloud compute instance-templates create 템플릿 이름에 MIG명, Build번호 포함
6 MIG 롤링 업데이트 실행 gcloud compute instance-groups managed rolling-action start-update 새 템플릿으로 업데이트 수행
7 VM 부팅 → 컨테이너 기동 Startup Script 실행 docker stoppullrun 흐름
8 안정화 대기 및 헬스체크 wait-until --stable 최대 15분(--timeout=900)
9 실패 시 롤백 처리 이전 템플릿으로 재배포 새 템플릿 불안정하면 자동 복구
10 배포 결과 Discord 알림 discordSend (post block) 성공/실패 결과 전송

6.3 데이터 저장 및 영속성 전략

서비스 데이터 저장 방식
Cloud SQL GCP Cloud SQL Managed Storage 사용. VM과 무관하게 고가용성 및 자동 백업으로 데이터 보존
Prometheus /mnt/prometheus:/prometheus로 GCP Persistent Disk를 별도 생성 후 마운트하여 메트릭 데이터 저장.
Loki /mnt/loki:/loki로 GCP Persistent Disk를 별도 생성 후 마운트하여 로그 데이터 저장.
애플리케이션 로그 stdout/stderr, VM 디스크(/var/log/*.log)

6.4 Docker 관련 예시

BE

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /src
COPY gradlew settings.gradle build.gradle ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon
COPY src ./src
RUN ./gradlew bootJar -x test

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /src/build/libs/*.jar app.jar
ENV JAVA_OPTS="-XX:+UseG1GC"
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar app.jar"]

AI

FROM python:3.11-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8000
EXPOSE 8000
HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1
ENTRYPOINT ["uvicorn","app.main:app","--host","0.0.0.0","--port","8000"]

FE

# 빌드 단계
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

# 런타임 단계
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app ./
ENV PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:3000/health || exit 1
ENTRYPOINT ["npm","start"]

Prometeus + Grafana

version: '3.8'

services:
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
    restart: unless-stopped

volumes:
  prometheus_data:
  grafana_data: 

6.5 Docker 기술 요약

항목 컨테이너 베이스 이미지 컨테이너 수 역할
Spring Boot ddb-be eclipse-temurin:21-jdk-alpine VM 당 1개 8080 BE API 서버
FastAPI ddb-ai python:3.11-slim 1개 8000 AI API 서버 + 추천 모델
Next.js ddb-fe node:20-slim VM 당 1개 3000 클라이언트 UI 및 SSR
Prometheus prometheus prom/prometheus:latest 1개 9090 메트릭 수집 서버
Grafana grafana grafana/grafana:latest 1개 3000 메트릭 시각화 및 대시보드

6.6 Jenkins 스크립트 명세

BE

pipeline {
  agent any // 모든 Jenkins 에이전트에서 실행 가능

  parameters {
    // Discord에서 배포 승인(✅ 버튼 클릭) 후 true로 바꿔 재실행
    booleanParam(name: 'APPROVED', defaultValue: false,
                 description: 'Discord ✅ 승인 후 true 로 재실행')
  }

  environment {
    // GCP 관련 설정
    PROJECT_ID     = 'my-gcp-project'
    REGION         = 'asia-northeast3'
    LOCATION       = 'asia-northeast3'
    MIG_NAME       = 'my-prod-mig'

    // Docker 이미지 및 서비스 설정
    SERVICE_NAME   = 'be' // Backend 서비스 식별자
    REPOSITORY     = 'ddb-backend' // GAR 내 저장소 이름 (실제 환경에 맞게 수정 필요)

    // Jenkins 빌드 번호를 이용한 이미지 태그 생성
    BUILD_NUMBER   = "${env.BUILD_NUMBER}"
    IMAGE_TAG      = "${SERVICE_NAME}:${BUILD_NUMBER}"
    FULL_IMAGE_TAG = "${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_TAG}"
  }

  stages {

    stage('Git Checkout') {
      steps {
        // GitHub 저장소에서 main 브랜치 체크아웃
        git branch: 'main',
            url: 'https://github.com/100-hours-a-week/7-team-ddb-be.git'
      }
    }

    stage('Docker Build & Push (GAR)') {
      steps {
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCLOUD_KEY')]) {
          sh '''
            // GCP 서비스 계정 인증
            gcloud auth activate-service-account --key-file=$GCLOUD_KEY

            // Google Artifact Registry 인증
            gcloud auth configure-docker ${LOCATION}-docker.pkg.dev --quiet

            // Docker 이미지 빌드 및 GAR로 푸시
            docker build -t $FULL_IMAGE_TAG .
            docker push $FULL_IMAGE_TAG
          '''
        }
      }
    }

    stage('Ask Approval (Discord)') {
      steps {
        // Discord에 승인 요청 메시지 전송 (버튼 포함)
        discordSend message: "Docker 이미지 태그 ${IMAGE_TAG} 배포 승인?",
                     buttons: '✅,❌',
                     webhookURL: credentials('Discord-Webhook')
      }
    }

    stage('Deploy to MIG (Docker)') {
      when { expression { params.APPROVED == true } } // Discord 승인 후에만 실행
      steps {
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCLOUD_KEY')]) {
          sh '''
            set -e
            // GCP 인증
            gcloud auth activate-service-account --key-file=$GCLOUD_KEY

            // 현재 MIG가 사용하는 인스턴스 템플릿 이름 가져오기 (롤백 대비)
            OLD_TMPL=$(gcloud compute instance-groups managed describe $MIG_NAME \
                        --project=$PROJECT_ID --region=$REGION \
                        --format="value(versions[0].instanceTemplate.basename)")

            // 새로운 스타트업 스크립트 작성
            STARTUP_FILE="/tmp/startup-${BUILD_NUMBER}.sh"
            cat > $STARTUP_FILE <<EOF
#!/bin/bash
set -e
docker stop ddb-be || true
docker rm ddb-be || true
docker pull $FULL_IMAGE_TAG
docker run -d --name ddb-be -p 8080:8080 $FULL_IMAGE_TAG
EOF

            chmod +x $STARTUP_FILE

            // 새 인스턴스 템플릿 생성
            NEW_TMPL="tmpl-${MIG_NAME}-${BUILD_NUMBER}"
            gcloud compute instance-templates create $NEW_TMPL \
              --project=$PROJECT_ID \
              --region=$REGION \
              --machine-type=e2-small \
              --image-family=cos-stable \
              --image-project=cos-cloud \
              --boot-disk-size=10GB \
              --metadata-from-file startup-script=$STARTUP_FILE

            // MIG에 새로운 템플릿을 적용하여 롤링 업데이트 시작
            gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
              --project=$PROJECT_ID --region=$REGION \
              --version=template=$NEW_TMPL \
              --type=proactive \
              --max-surge=1 --max-unavailable=1 --min-ready=30

            // 안정성 검사: 일정 시간 내 MIG가 안정되지 않으면 롤백
            if ! gcloud compute instance-groups managed wait-until $MIG_NAME \
                  --project=$PROJECT_ID --region=$REGION \
                  --stable --timeout=900 ; then
              echo "❌ 새 버전 불안정 → 롤백 실행"
              gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
                --project=$PROJECT_ID --region=$REGION \
                --version=template=$OLD_TMPL \
                --type=proactive \
                --max-surge=1 --max-unavailable=1 --min-ready=30
              exit 1
            fi

            echo "✅ 배포 완료"
          '''
        }
      }
    }
  }

  post {
    success {
      // 빌드 성공 시 Discord 알림 전송
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 성공",
        webhookURL: "$DISCORD"
      }
    }
    failure {
      // 빌드 실패 시 Discord 알림 전송
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 실패",
        webhookURL: "$DISCORD"
      }
    }
  }
}

AI

pipeline {
  agent any // 모든 Jenkins 에이전트에서 실행 가능

  parameters {
    // Discord에서 배포 승인 후 true로 변경하여 파이프라인 재실행
    booleanParam(name: 'APPROVED', defaultValue: false, description: 'Discord ✅ 승인 후 true로 재실행')
  }

  environment {
    // 서비스 식별자 및 GCP 관련 환경 변수
    SERVICE_NAME   = 'ai' // AI 서버 (FastAPI)
    PROJECT_ID     = 'my-gcp-project'
    REGION         = 'asia-northeast3'
    LOCATION       = 'asia-northeast3'
    MIG_NAME       = 'fastapi-prod-mig' // Managed Instance Group 이름
    REPOSITORY     = 'ddb-ai' // Docker 이미지 저장소 이름 (GAR)

		// Jenkins 빌드 번호를 이용한 이미지 태그 생성
    BUILD_NUMBER   = "${env.BUILD_NUMBER}"
    IMAGE_TAG      = "${SERVICE_NAME}:${COMMIT_HASH}-${BUILD_NUMBER}"
    FULL_IMAGE_TAG = "${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_TAG}"
  }

  stages {

    stage('Git Checkout') {
      steps {
        // GitHub 저장소에서 코드 체크아웃 (main 브랜치)
        git branch: 'main', url: 'https://github.com/100-hours-a-week/7-team-ddb-ai.git'
      }
    }

    stage('Docker Build & Push (GAR)') {
      steps {
        // 서비스 계정 인증을 위해 GCP 키 파일 사용
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCLOUD_KEY')]) {
          sh '''
            // GCP 인증 및 Docker 인증 구성
            gcloud auth activate-service-account --key-file=$GCP_KEY
            gcloud auth configure-docker ${LOCATION}-docker.pkg.dev --quiet

            // Docker 이미지 빌드 및 GAR로 푸시
            docker build -t $FULL_IMAGE_TAG .
            docker push $FULL_IMAGE_TAG
          '''
        }
      }
    }

    stage('Ask Approval (Discord)') {
      steps {
        // Discord에서 배포 승인 여부 요청 (✅ 버튼 클릭 필요)
        discordSend message: "[AI] Docker 이미지 태그 ${IMAGE_TAG} 배포 승인?",
                     buttons: '✅,❌',
                     webhookURL: credentials('Discord-Webhook')
      }
    }

    stage('Deploy to MIG (Docker)') {
      // Discord 승인 후에만 실행
      when { expression { params.APPROVED == true } }

      steps {
        // 다시 서비스 계정 인증 후 배포 실행
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCP_KEY')]) {
          sh '''
            set -e
            gcloud auth activate-service-account --key-file=$GCP_KEY

            // 기존 MIG 템플릿 이름 저장 (롤백 대비)
            OLD_TMPL=$(gcloud compute instance-groups managed describe $MIG_NAME \
              --project=$PROJECT_ID --region=$REGION \
              --format="value(versions[0].instanceTemplate.basename)")

            // 새로운 스타트업 스크립트 생성 (기존 컨테이너 중지 → 새 이미지 실행)
            STARTUP_FILE="/tmp/startup-${BUILD_NUMBER}.sh"
            cat > $STARTUP_FILE <<EOF
#!/bin/bash
set -e
docker stop ddb-ai || true
docker rm ddb-ai || true
docker pull $FULL_IMAGE_TAG
docker run -d --name ddb-ai -p 8000:8000 $FULL_IMAGE_TAG
EOF

            chmod +x $STARTUP_FILE

            // 새 인스턴스 템플릿 생성
            NEW_TMPL="tmpl-${MIG_NAME}-${BUILD_NUMBER}"
            gcloud compute instance-templates create $NEW_TMPL \
              --project=$PROJECT_ID \
              --region=$REGION \
              --machine-type=e2-small \
              --image-family=cos-stable \
              --image-project=cos-cloud \
              --boot-disk-size=10GB \
              --metadata-from-file startup-script=$STARTUP_FILE

            // MIG 롤링 업데이트 실행
            gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
              --project=$PROJECT_ID --region=$REGION \
              --version=template=$NEW_TMPL \
              --type=proactive \
              --max-surge=1 --max-unavailable=1 --min-ready=30

            // 안정성 확인: 900초 내 안정되지 않으면 롤백
            if ! gcloud compute instance-groups managed wait-until $MIG_NAME \
                  --project=$PROJECT_ID --region=$REGION \
                  --stable --timeout=900 ; then
              echo "❌ 새 버전 불안정 → 롤백"
              gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
                --project=$PROJECT_ID --region=$REGION \
                --version=template=$OLD_TMPL \
                --type=proactive \
                --max-surge=1 --max-unavailable=1 --min-ready=30
              exit 1
            fi
          '''
        }
      }
    }
  }

  post {
    success {
      // 빌드 성공 시 Discord로 알림 전송
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 성공",
        webhookURL: "$DISCORD"
      }
    }
    failure {
      // 빌드 실패 시 Discord로 알림 전송
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 실패",
        webhookURL: "$DISCORD"
      }
    }
  }
}

FE

pipeline {
  agent any // 어떤 Jenkins 노드에서도 실행 가능

  parameters {
    // Discord 수동 승인: ✅ 클릭 시 true로 설정 후 재실행
    booleanParam(name: 'APPROVED', defaultValue: false,
                 description: 'Discord ✅ 승인 후 true로 재실행')
  }

  environment {
    // 프론트엔드 서비스 설정
    SERVICE_NAME    = 'fe'
    PROJECT_ID      = 'my-gcp-project'
    REGION          = 'asia-northeast3'
    LOCATION        = 'asia-northeast3'
    MIG_NAME        = 'fe-prod-mig'
    REPOSITORY      = 'ddb-frontend' // Google Artifact Registry 저장소 이름

    // Jenkins 빌드 번호를 이용한 이미지 태그 생성
    BUILD_NUMBER    = "${env.BUILD_NUMBER}"
    IMAGE_TAG       = "${SERVICE_NAME}:${COMMIT_HASH}-${BUILD_NUMBER}"
    FULL_IMAGE_TAG  = "${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_TAG}"
  }

  stages {

    stage('Git Checkout') {
      steps {
        // GitHub 저장소의 main 브랜치에서 소스코드 체크아웃
        git branch: 'main',
            url: 'https://github.com/100-hours-a-week/7-team-ddb-fe.git'
      }
    }

    stage('Docker Build & Push (GAR)') {
      steps {
        // GCP 인증을 위해 서비스 계정 키 파일 사용
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCP_KEY')]) {
          sh '''
            // GCP 서비스 계정 활성화 및 GAR 인증 설정
            gcloud auth activate-service-account --key-file=$GCP_KEY
            gcloud auth configure-docker ${LOCATION}-docker.pkg.dev --quiet

            // Docker 이미지 빌드 및 Google Artifact Registry로 푸시
            docker build -t $FULL_IMAGE_TAG .
            docker push $FULL_IMAGE_TAG
          '''
        }
      }
    }

    stage('Ask Approval (Discord)') {
      steps {
        // Discord 알림: 배포 승인 요청 메시지
        discordSend message: "[FE] Docker 이미지 태그 ${IMAGE_TAG} 배포 승인?",
                     buttons: '✅,❌',
                     webhookURL: credentials('Discord-Webhook')
      }
    }

    stage('Deploy to MIG (Docker)') {
      // Discord 승인 시에만 배포 단계 진행
      when { expression { params.APPROVED == true } }
      steps {
        withCredentials([file(credentialsId: 'GCP-SA-JSON', variable: 'GCP_KEY')]) {
          sh '''
            set -e
            gcloud auth activate-service-account --key-file=$GCP_KEY

            // 현재 MIG에서 사용 중인 템플릿 이름 확인 (롤백 대비)
            OLD_TMPL=$(gcloud compute instance-groups managed describe $MIG_NAME \
              --project=$PROJECT_ID --region=$REGION \
              --format="value(versions[0].instanceTemplate.basename)")

            // 새 인스턴스를 위한 startup script 생성
            STARTUP_FILE="/tmp/startup-${BUILD_NUMBER}.sh"
            cat > $STARTUP_FILE <<EOF
#!/bin/bash
set -e
docker stop ddb-fe || true
docker rm ddb-fe || true
docker pull $FULL_IMAGE_TAG
docker run -d --name ddb-fe -p 3000:3000 $FULL_IMAGE_TAG
EOF

            chmod +x $STARTUP_FILE

            // 새로운 인스턴스 템플릿 생성
            NEW_TMPL="tmpl-${MIG_NAME}-${BUILD_NUMBER}"
            gcloud compute instance-templates create $NEW_TMPL \
              --project=$PROJECT_ID \
              --region=$REGION \
              --machine-type=e2-small \
              --image-family=cos-stable \
              --image-project=cos-cloud \
              --boot-disk-size=10GB \
              --metadata-from-file startup-script=$STARTUP_FILE

            // MIG 롤링 업데이트 실행
            gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
              --project=$PROJECT_ID --region=$REGION \
              --version=template=$NEW_TMPL \
              --type=proactive \
              --max-surge=1 --max-unavailable=1 --min-ready=30

            // 안정성 검사 실패 시 롤백
            if ! gcloud compute instance-groups managed wait-until $MIG_NAME \
                  --project=$PROJECT_ID --region=$REGION \
                  --stable --timeout=900 ; then
              echo "❌ 새 버전 불안정 → 롤백"
              gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
                --project=$PROJECT_ID --region=$REGION \
                --version=template=$OLD_TMPL \
                --type=proactive \
                --max-surge=1 --max-unavailable=1 --min-ready=30
              exit 1
            fi
          '''
        }
      }
    }
  }

  post {
    // 성공 시 Discord 알림
    success {
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 성공",
        webhookURL: "$DISCORD"
      }
    }
    // 실패 시 Discord 알림
    failure {
      withCredentials([string(credentialsId: 'Discord-Webhook', variable: 'DISCORD')]) {
        discordSend description: """
        제목 : ${currentBuild.displayName}
        결과 : ${currentBuild.result}
        실행 시간 : ${currentBuild.duration / 1000}s
        """,
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        title: "${env.JOB_NAME} : ${currentBuild.displayName} 실패",
        webhookURL: "$DISCORD"
      }
    }
  }
}

7. 운영 안정성 확보 전략

7.1 OpenVPN + NAT

현재 비용 절감을 위해 Cloud NAT 대신, OpenVPN + NAT 기능을 수행하는 단일 VM 인스턴스를 구성하여 Private Subnet의 외부 통신 및 운영 접속을 담당하고 있다. 하지만 해당 인스턴스가 다운되면 다음과 같은 단일 장애 지점(SPOF) 문제가 발생한다. 이에 따라, 해당 인스턴스의 자동 복구 전략이 필요하다.

  • 이중화 구성과 셀프힐링 구성의 비교

    항목 이중화 구성 셀프힐링 구성
    구성 방식 OpenVPN+NAT 인스턴스를 2개 이상 생성하여 AZ 분산 및 트래픽 이중화 1개의 OpenVPN+NAT 인스턴스를 MIG에 넣고, 헬스체크 실패 시 자동 복구
    장애 발생 시 처리 트래픽을 다른 인스턴스로 수동/자동 전환 (라우팅 필요) 죽은 인스턴스를 자동 삭제하고 동일 템플릿으로 새로 생성
    구성 복잡도 높음 (라우팅 테이블 관리, 고정 IP 관리 필요) 낮음 (1개 인스턴스, 단순 템플릿 관리)
    비용 높음 (2개 이상 인스턴스 + 관리 비용) 낮음 (1개 인스턴스만 운영)
    적용 추천 시점 대규모 트래픽 또는 중요 네트워크 게이트웨이 환경 소규모 서비스, 운영 편의성을 우선할 경우

    현재 서비스는 비용 효율이 중요하고 트래픽이 많지 않으므로 복잡한 이중화 구성보다 인스턴스가 죽었을 때 자동으로 다시 살아나는 셀프힐링 방식이 현실적이고 효율적이다.

  • 셀프힐링 구성의 동작 방식

    셀프힐링 구성은 GCP의 Managed Instance Group(MIG) 기능을 활용하여, OpenVPN + NAT 역할을 수행하는 단일 인스턴스의 상태를 지속적으로 감시하고, 문제가 발생했을 경우 자동으로 복구하는 구조를 의미한다.

    구체적으로는, 다음과 같은 방식으로 동작한다:

    1. 헬스체크 기반 상태 감시

      MIG는 SSH(TCP 22번 포트)와 같은 헬스체크 기준을 통해 해당 인스턴스의 상태를 주기적으로 점검한다. 헬스체크를 통과하지 못하는 상태가 일정 시간 이상 지속될 경우, 인스턴스가 비정상으로 판단된다.

    2. 비정상 인스턴스 자동 종료 및 재생성

      비정상으로 판단된 인스턴스는 MIG에 의해 자동으로 삭제되며, 동일한 설정(인스턴스 템플릿)을 기반으로 새로운 인스턴스가 자동으로 생성된다.

    3. 고정 IP 유지 및 설정 자동화

      새로 생성된 인스턴스는 기존의 고정 IP를 그대로 할당받고, 스크립트를 통해 OpenVPN 및 NAT 기능에 필요한 네트워크 설정(포워딩, iptables 등)을 자동으로 복구하도록 구성된다.

    이러한 구조를 통해 인스턴스 단일 장애가 발생하더라도, 운영자의 수동 개입 없이 빠르게 복구가 이루어지며, Private Subnet의 외부 통신 기능과 운영자 접속 경로가 유지된다.

7.2 이미지 롤백 전략

  1. 배포 전 현재 인스턴스 템플릿 이름 저장

    OLD_TMPL=$(gcloud compute instance-groups managed describe $MIG_NAME \
      --project=$PROJECT_ID --region=$REGION \
      --format="value(versions[0].instanceTemplate.basename)")
    • 현재 MIG가 사용 중인 인스턴스 템플릿을 기억해둔다.
    • 이 템플릿은 정상적으로 작동하고 있는 이미지를 기반으로 한다.
    • 나중에 문제가 생기면 이 템플릿으로 롤백에 사용된다.
  2. 무중단 배포를 진행 (롤링 업데이트)

    gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
      --version=template=$NEW_TMPL \
      --type=proactive \
      --max-surge=1 --max-unavailable=1 --min-ready=30
    • 새로운 이미지가 적용된 새 인스턴스 템플릿을 생성하여 배포한다.
    • 롤링 방식으로 하나씩 교체되므로 서비스 중단 없이 배포가 진행된다.
  3. 오류 감지 시 자동 롤백

    if ! gcloud compute instance-groups managed wait-until $MIG_NAME \
          --stable --timeout=300 ; then
      gcloud compute instance-groups managed rolling-action start-update $MIG_NAME \
        --version=template=$OLD_TMPL
    fi
    • wait-until --stable 명령을 통해 MIG의 상태를 5분간 감시한다.
    • 새 이미지에서 헬스체크 실패, 인스턴스 불안정 등의 문제가 감지되면, 기억해둔 OLD_TMPL로 다시 롤링 업데이트하여 원래 상태로 복구한다.

8. 개발 및 운영 비용

환경별 총 비용 (15시간 기준)

환경 월 예상 비용 (USD)
dev 168.34
prod 238.61 ~ 296.82
(공통/공유) 16.59
총합 423.54 ~ 481.77

서비스별 상세 비용 (15시간 기준)

환경 서비스 GCP 제품 월 예상 비용 (USD)
(공통) Cloud DNS 0.6
(공통) Compute Engine (shared instance)(g1-small) 15.99
dev instance (3) Compute Engine (N1-Standard-1) 86.01
dev alb Networking (L7 LB) 28.94
dev bucket Cloud Storage 2.3
dev cdn Cloud CDN 1.95
dev db Cloud SQL (db-g1-small) 19.36
dev redis Cloud Memorystore (Shared-Core Nano) 29.78
prod instance (3 ~ 5) Compute Engine (N1-Standard-1) 86.01 ~ 144.22
prod alb Networking (L7 LB) 30.14
prod bucket Cloud Storage 2.3
prod cdn Cloud CDN 1.95
prod db (2) Cloud SQL (db-standard-1) 88.43
prod redis Cloud Memorystore (Shared-Core Nano) 29.78

9. 정량적 비교

항목 소스 기반 (JAR + GCS) Docker 기반 (이미지 + GAR) 비교 설명
배포 속도 3~10분 1~2분 GCS에서 jar 복사 + systemctl 기동 대비, Docker는 이미지 실행만 수행
환경 일관성 낮음 매우 높음 서버 환경에 의존 vs 컨테이너 이미지에 환경 포함
의존성 설치 시간 VM마다 필요 (1~2분) 없음 Docker는 의존성 포함됨
버전 추적 용이성 Git 커밋 기반 + 파일명 이미지 태그 (서비스:빌드번호) 태그가 훨씬 명시적이고 추적 쉬움
롤백 속도 수 분 (재배포 필요) 수 초 (이미지 태그 교체)
확장성 중간 (GCS 접근 + jar 실행) 매우 높음 (이미지 복제)
운영 복잡도 높음 (서버마다 스크립트/경로 상이) 낮음 (컨테이너 기반 자동화 용이)
DevOps 확장 가능성 제한적 우수 이미지 서명, ArgoCD, GitOps 연계 용이

10. 한계점 및 리스크

  • 각 MIG 인스턴스에 스크립트로 단일 컨테이너를 직접 실행하여 VM이 재시작될 때 컨테이너가 자동 복원되지 않거나, Docker 데몬 오류로 서비스가 기동되지 않는 위험이 있다.
  • MIG는 VM CPU 지표만으로 스케일-아웃을 결정하며, 컨테이너별 메모리와 비즈니스 지표를 반영하기 어렵다.

11. 기대효과

항목 설명
환경 통일 - 언어 실행 환경과 의존성을 포함한 단일 컨테이너 이미지를 빌드하고, VM 부팅 후 docker pull && docker run 수준의 명령만으로 서비스가 즉시 구동되므로, 환경 불일치 문제가 사라지고 초기화 시간도 수십 초 단위로 단축된다.
배포 파이프라인 단순화 - BE, AI, FE 각각의 파이프라인을 공통 Docker Build & Push 스테이지와 공통 MIG 템플릿 갱신 스테이지로 구성하면, Jenkins 공유 라이브러리 등을 통해 유지보수 지점을 통합할 수 있다.
- 스크립트 작성 없이, Jenkins는 빌드된 Docker 이미지를 레지스트리에 푸시한 뒤 해당 이미지를 참조하는 새로운 --container-image 값으로 MIG 템플릿을 생성하면 된다.
무중단 롤백 / 스케일-아웃 가속 - 이전 이미지 태그로 템플릿 버전을 되돌리면 즉시 롤백이 가능하다.
- VM 부팅 후 docker pull && docker run만으로 컨테이너가 실행되므로 초기화 시간이 단축되며, Auto-scaling Cool-down 시간도 크게 줄일 수 있다.
이미지 최적화 및 배포 속도 향상 - CI 단계에서 멀티-스테이지 빌드를 통해 불필요한 개발 의존성을 제거해 이미지 크기를 최소화하고, 네트워크 전송 시간을 절감할 수 있다.

12. 결론

컨테이너 이미지는 실행 환경과 애플리케이션을 함께 묶어 배포하므로, 환경 불일치를 없애고 초기화 시간을 크게 줄일 수 있다. 이로 인해 오토스케일링이 빨라지고, 이미지 태그를 이용한 버전 관리와 롤백도 쉬워진다. DevOps 자동화가 강화되면서 배포 실패는 줄고 복구 속도는 빨라지며, 인프라 비용과 운영 인력 부담도 감소한다.

하지만 이러한 장점에도 불구하고, 단일 노드 중심의 Docker만으로는 서비스 간 연동, 자동 스케일링, 복구 등의 기능을 체계적으로 운영하기 어렵다. 이 때문에 Kubernetes(kubeadm)와 같은 컨테이너 오케스트레이션 도구가 필요하다. Kubernetes는 여러 노드에 걸친 분산 배포, 자동화된 헬스체크, 자가 복구, 서비스 디스커버리 등을 기본으로 제공하여, 복잡한 마이크로서비스 환경에서도 안정적이고 확장 가능한 운영을 가능하게 만든다.

⚠️ **GitHub.com Fallback** ⚠️