CHHB stroy

Docker + CI/CD 파이프라인 구성과 프로덕션 배포 전략 — 삽질 끝에 정리한 실전 가이드 본문

기타

Docker + CI/CD 파이프라인 구성과 프로덕션 배포 전략 — 삽질 끝에 정리한 실전 가이드

CHHB 2026. 5. 18. 14:42

지난 글에서 Docker Desktop 기초를 다뤘는데, 로컬에서 docker compose up 하는 것과 실제 프로덕션에 배포하는 것 사이에는 꽤 큰 간극이 있다. 나도 처음에 "로컬에서 잘 되니까 서버에 올리면 되겠지" 하고 순진하게 생각했다가 한참 고생했다.

코드 푸시하면 자동으로 테스트 돌리고, 이미지 빌드하고, 서버에 배포까지 되는 파이프라인. 그리고 배포할 때 서비스 안 끊기게 하는 전략. 오늘은 이 두 가지를 한번에 정리해보려고 한다.


CI/CD가 뭔데

풀어서 쓰면 Continuous Integration / Continuous Delivery(또는 Deployment)다.

  • CI (지속적 통합): 코드 푸시할 때마다 자동으로 빌드하고 테스트한다. "머지했더니 터졌어요"를 미리 잡는 거다.
  • CD (지속적 배포): 테스트 통과하면 자동으로 스테이징이나 프로덕션에 배포한다. 수동으로 SSH 접속해서 git pull 치는 시대는 끝났다.

손으로 하면 이런 과정이다:

1. 코드 수정
2. 로컬에서 테스트
3. git push
4. 서버에 SSH 접속
5. git pull
6. docker build
7. docker compose down
8. docker compose up -d
9. 잘 되나 확인
10. 안 되면 롤백 (또 수동)

이걸 매번 하다 보면 실수가 생기고, 시간도 잡아먹고, 배포가 무서워진다. CI/CD를 세팅하면 git push만 하면 나머지는 자동이다. 처음 세팅할 때 시간이 좀 들지만, 한번 해놓으면 그 뒤로는 인생이 편해진다.


GitHub Actions로 CI/CD 파이프라인 만들기

CI/CD 도구가 여러 개 있는데 — Jenkins, GitLab CI, CircleCI, GitHub Actions 등 — 나는 GitHub Actions를 제일 많이 쓴다. GitHub 쓰면 별도 설정 없이 바로 쓸 수 있고, 무료 tier도 넉넉해서.

프로젝트 구조

my-app/
├── .github/
│   └── workflows/
│       ├── ci.yml          # PR 시 테스트
│       └── deploy.yml      # main 브랜치 배포
├── Dockerfile
├── docker-compose.yml
├── docker-compose.prod.yml
├── src/
├── tests/
└── ...

Step 1: CI — 푸시할 때마다 자동 테스트

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [develop]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Run tests
        run: npm test
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

      - name: Build check
        run: npm run build

핵심 포인트:

  • services로 Postgres, Redis를 바로 띄울 수 있다. 테스트용 DB를 따로 관리할 필요가 없다.
  • actions/setup-nodecache: 'npm'으로 node_modules 캐시를 활용한다. 이거 안 하면 매번 의존성 설치에 1~2분씩 날린다.
  • healthcheck로 Postgres가 준비될 때까지 기다린다. 이거 없으면 DB 접속 실패로 테스트가 뻗는다.

Step 2: Docker 이미지 빌드 + 레지스트리 푸시

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    # 위의 CI와 동일한 테스트 job (생략)
    # 테스트 통과해야 다음 단계로 진행

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp
            docker compose -f docker-compose.prod.yml pull
            docker compose -f docker-compose.prod.yml up -d
            docker image prune -f

여기서 중요한 것들:

  • GitHub Container Registry(ghcr.io) 를 쓰면 별도 레지스트리 세팅이 필요 없다. Docker Hub도 되지만, private 레포는 유료라서.
  • cache-from: type=gha: GitHub Actions 캐시를 빌드 캐시로 쓴다. 레이어 캐시가 먹히면 빌드 시간이 확 줄어든다.
  • 이미지 태그에 커밋 SHA를 쓴다: latest만 쓰면 어떤 버전이 돌아가는지 추적이 안 된다. SHA 태그를 같이 붙여야 "지금 프로덕션에서 어떤 커밋 기반으로 돌아가는지" 바로 알 수 있다.

Step 3: 프로덕션용 Docker Compose

로컬 개발용이랑 프로덕션용은 분리하는 게 맞다.

# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/myuser/myapp:latest
    restart: always
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      db:
        condition: service_healthy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'

  db:
    image: postgres:16-alpine
    restart: always
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    restart: always
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
    depends_on:
      - app

volumes:
  pgdata:

로컬 compose와 다른 점:

  • build: . 대신 image:로 레지스트리에서 이미지를 pull한다
  • restart: always로 크래시 시 자동 재시작
  • 로그 크기 제한 설정
  • 리소스 제한(memory, cpus)
  • Redis에 메모리 상한 설정
  • Nginx를 앞에 둬서 리버스 프록시 + SSL 처리

프로덕션 배포 전략

자, 이제 CI/CD로 이미지를 빌드하고 서버에 올리는 것까지 됐다. 근데 문제가 하나 있다. 배포하는 동안 서비스가 끊기면 어떡하지?

새벽 3시에 배포하면 괜찮겠지만, 그건 개발자의 삶의 질 문제가 있고... 사용자가 전 세계에 있으면 새벽이 없다. 서비스 중단 없이 배포하는 전략이 필요하다.

전략 1: Rolling Update (롤링 업데이트)

가장 기본적인 무중단 배포 방식. 구버전 인스턴스를 하나씩 신버전으로 교체한다.

시작 상태:    [v1] [v1] [v1] [v1]

1단계:       [v2] [v1] [v1] [v1]   ← 하나 교체
2단계:       [v2] [v2] [v1] [v1]   ← 하나 더
3단계:       [v2] [v2] [v2] [v1]   ← 하나 더
완료:        [v2] [v2] [v2] [v2]   ← 전부 교체

장점: 추가 서버가 필요 없다. 리소스 효율적.
단점: 배포 중에 v1과 v2가 동시에 떠 있다. DB 스키마 변경이 있으면 호환성을 고려해야 한다.

Docker Swarm으로 구현하면 이렇다:

# docker-compose.prod.yml (Swarm 모드)
services:
  app:
    image: ghcr.io/myuser/myapp:latest
    deploy:
      replicas: 4
      update_config:
        parallelism: 1        # 한 번에 1개씩 교체
        delay: 30s             # 교체 간 30초 대기
        failure_action: rollback
        order: start-first     # 새 컨테이너 먼저 띄우고 옛것 내림
      rollback_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
# Swarm 초기화 (한 번만)
docker swarm init

# 배포
docker stack deploy -c docker-compose.prod.yml myapp

# 업데이트 (이미지 변경 후)
docker service update --image ghcr.io/myuser/myapp:v2 myapp_app

order: start-first가 핵심이다. 새 컨테이너가 먼저 떠서 정상 동작 확인되면 그때 옛 컨테이너를 내린다. 이걸 stop-first로 하면 내리고 올리는 사이에 서비스가 끊긴다.

전략 2: Blue-Green 배포

두 개의 동일한 환경(Blue와 Green)을 준비해놓고, 하나에만 트래픽을 보내는 방식.

현재 상태:
  Blue  (v1) ← 트래픽 여기로
  Green (v1)   대기 중

배포:
  Blue  (v1) ← 트래픽 아직 여기
  Green (v2)   새 버전 배포 + 테스트

전환:
  Blue  (v1)   이제 대기
  Green (v2) ← 트래픽 전환!

문제 발생 시:
  Blue  (v1) ← 다시 여기로 (즉시 롤백)
  Green (v2)   문제 있는 버전

Nginx로 구현하는 게 제일 간단하다.

# /etc/nginx/conf.d/default.conf

# 두 개의 upstream 정의
upstream blue {
    server app-blue:3000;
}

upstream green {
    server app-green:3000;
}

# 현재 활성 환경을 변수로 관리
# active_upstream.conf에서 set $active blue; 또는 set $active green;
include /etc/nginx/conf.d/active_upstream.conf;

server {
    listen 80;
    server_name myapp.com;

    location / {
        proxy_pass http://$active;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /health {
        proxy_pass http://$active/health;
    }
}
# /etc/nginx/conf.d/active_upstream.conf
# 이 파일만 바꾸면 트래픽이 전환된다
set $active blue;

Docker Compose로 두 환경을 구성:

# docker-compose.blue-green.yml
services:
  app-blue:
    image: ghcr.io/myuser/myapp:${BLUE_TAG:-latest}
    environment:
      - NODE_ENV=production
    networks:
      - appnet

  app-green:
    image: ghcr.io/myuser/myapp:${GREEN_TAG:-latest}
    environment:
      - NODE_ENV=production
    networks:
      - appnet

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./active_upstream.conf:/etc/nginx/conf.d/active_upstream.conf
    depends_on:
      - app-blue
      - app-green
    networks:
      - appnet

networks:
  appnet:

배포 스크립트:

#!/bin/bash
# deploy-blue-green.sh

set -e

CURRENT=$(cat active_upstream.conf | grep -oP '(?<=set \$active )\w+')
NEW_VERSION=$1

if [ "$CURRENT" = "blue" ]; then
    TARGET="green"
else
    TARGET="blue"
fi

echo "현재 활성: $CURRENT"
echo "배포 대상: $TARGET (버전: $NEW_VERSION)"

# 1. 비활성 환경에 새 버전 배포
export ${TARGET^^}_TAG=$NEW_VERSION
docker compose -f docker-compose.blue-green.yml pull app-$TARGET
docker compose -f docker-compose.blue-green.yml up -d app-$TARGET

# 2. 헬스체크 대기
echo "헬스체크 대기 중..."
for i in $(seq 1 30); do
    if curl -sf http://app-$TARGET:3000/health > /dev/null 2>&1; then
        echo "헬스체크 통과!"
        break
    fi
    if [ $i -eq 30 ]; then
        echo "헬스체크 실패! 배포 중단."
        exit 1
    fi
    sleep 2
done

# 3. 트래픽 전환
echo "set \$active $TARGET;" > active_upstream.conf
docker compose -f docker-compose.blue-green.yml exec nginx nginx -s reload

echo "배포 완료! 활성 환경: $TARGET"
echo "롤백하려면: ./rollback.sh"
#!/bin/bash
# rollback.sh — 문제 생기면 이거 한 방

CURRENT=$(cat active_upstream.conf | grep -oP '(?<=set \$active )\w+')

if [ "$CURRENT" = "blue" ]; then
    ROLLBACK_TO="green"
else
    ROLLBACK_TO="blue"
fi

echo "set \$active $ROLLBACK_TO;" > active_upstream.conf
docker compose -f docker-compose.blue-green.yml exec nginx nginx -s reload

echo "롤백 완료! 활성 환경: $ROLLBACK_TO"

장점: 롤백이 초 단위로 된다. Nginx 설정 하나만 바꾸면 되니까. 새 버전을 충분히 테스트한 후에 전환할 수 있다.
단점: 서버 리소스가 2배 필요하다. 작은 서비스에서는 오버킬일 수 있다.

전략 3: Canary 배포

트래픽의 일부만 먼저 새 버전으로 보내서 문제 없는지 확인하고, 점진적으로 비율을 늘리는 방식.

1단계: v1 (90%) / v2 (10%)   ← 소수에게만 새 버전
2단계: v1 (70%) / v2 (30%)   ← 문제 없으면 늘림
3단계: v1 (30%) / v2 (70%)   ← 점점 더
4단계: v1 (0%)  / v2 (100%)  ← 완전 전환

Nginx upstream의 weight로 간단하게 구현할 수 있다:

upstream backend {
    server app-stable:3000 weight=9;   # 90%
    server app-canary:3000 weight=1;   # 10%
}

실무에서 제대로 하려면 Kubernetes + Istio 같은 서비스 메시를 쓰는 게 맞지만, 소규모라면 Nginx weight로도 충분하다.

장점: 리스크가 가장 낮다. 전체 사용자에게 영향 가기 전에 문제를 잡을 수 있다.
단점: 모니터링이 잘 돼 있어야 의미가 있다. 에러율이나 응답 시간을 비교할 수 없으면 canary를 띄워봤자 모른다.


어떤 전략을 써야 할까?

솔직히 상황에 따라 다르다. 내 경험상으로는:

소규모 프로젝트, 혼자 또는 소수 개발   → Rolling Update
중간 규모, 빠른 롤백이 중요            → Blue-Green
대규모, 사용자 많고 리스크 최소화       → Canary

처음이면 Rolling Update부터 시작하고, 서비스가 커지면 Blue-Green으로 넘어가는 게 자연스럽다. Canary는 모니터링 인프라가 갖춰져야 의미 있으니까 나중에 고려해도 된다.


배포 자동화에서 빠뜨리기 쉬운 것들

1. 헬스체크 엔드포인트

배포 자동화하려면 앱에 헬스체크 엔드포인트가 있어야 한다. 컨테이너가 떴다고 앱이 준비된 건 아니니까.

// Express.js 예시
app.get('/health', async (req, res) => {
  try {
    // DB 연결 확인
    await db.query('SELECT 1');
    // Redis 연결 확인
    await redis.ping();

    res.status(200).json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
    });
  } catch (error) {
    res.status(503).json({
      status: 'error',
      message: error.message,
    });
  }
});

단순히 200 OK 반환하는 것보다 DB, Redis 같은 의존성까지 확인하는 게 좋다. 앱은 떠 있는데 DB 연결이 끊겨있으면 의미 없으니까.

2. Graceful Shutdown

컨테이너 종료할 때 처리 중인 요청을 끊지 않고 마무리하는 게 중요하다.

// Node.js graceful shutdown
const server = app.listen(3000);

const gracefulShutdown = (signal) => {
  console.log(`${signal} 수신. 서버 종료 시작...`);

  server.close(() => {
    console.log('HTTP 서버 종료 완료');

    // DB 연결 정리
    db.end().then(() => {
      console.log('DB 연결 종료 완료');
      process.exit(0);
    });
  });

  // 10초 안에 종료 안 되면 강제 종료
  setTimeout(() => {
    console.error('강제 종료');
    process.exit(1);
  }, 10000);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Dockerfile에서도 STOPSIGNAL을 신경 써야 한다:

# SIGTERM을 앱이 받을 수 있도록
# exec form으로 CMD 작성 (중요!)
CMD ["node", "server.js"]

# 이렇게 하면 shell이 SIGTERM을 가로챔 — 나쁜 예
# CMD node server.js

CMD를 exec form(["node", "server.js"])으로 쓰지 않으면 shell이 PID 1이 되어서 SIGTERM을 앱이 못 받는다. 이거 모르면 컨테이너 종료할 때 항상 10초(기본 타임아웃) 기다리게 된다.

3. DB 마이그레이션 처리

배포할 때 DB 스키마 변경이 있으면 순서가 중요하다.

#!/bin/bash
# deploy.sh

# 1. 마이그레이션 먼저 실행 (별도 컨테이너에서)
docker compose -f docker-compose.prod.yml run --rm app npm run migrate

# 2. 그 다음 앱 배포
docker compose -f docker-compose.prod.yml pull app
docker compose -f docker-compose.prod.yml up -d app

마이그레이션을 앱 시작 시에 자동으로 돌리는 방법도 있지만, 여러 인스턴스가 동시에 마이그레이션을 돌리면 충돌할 수 있어서 별도로 실행하는 게 안전하다.

그리고 Rolling Update나 Blue-Green에서 v1과 v2가 동시에 뜨는 구간이 있으니까, 마이그레이션은 하위 호환이 되게 작성해야 한다.

-- ❌ 나쁜 예: 컬럼 이름 바꾸기 (v1이 터짐)
ALTER TABLE users RENAME COLUMN name TO full_name;

-- ✅ 좋은 예: 2단계로 나누기
-- 1차 배포: 새 컬럼 추가
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);
UPDATE users SET full_name = name;

-- 2차 배포: 앱 코드가 full_name을 쓰도록 변경 후
-- 3차 배포: 옛 컬럼 삭제
ALTER TABLE users DROP COLUMN name;

이걸 "expand and contract" 패턴이라고 하는데, 무중단 배포에서는 거의 필수다.

4. 시크릿 관리

GitHub Actions에서 비밀번호, API 키 같은 건 절대 코드에 넣지 말고 Secrets를 쓰자.

# GitHub Actions에서 secrets 사용
- name: Deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    JWT_SECRET: ${{ secrets.JWT_SECRET }}

서버에서는 .env 파일로 관리하되, 이 파일은 배포 스크립트로 전달하지 말고 서버에 직접 만들어두자. CI/CD 파이프라인 로그에 시크릿이 찍히는 사고를 방지하려면.

더 제대로 하려면 HashiCorp Vault나 AWS Secrets Manager 같은 걸 쓰는 게 맞지만, 소규모에서는 GitHub Secrets + 서버 .env 조합이면 충분하다.

5. 배포 알림

배포됐는데 아무도 모르면 안 된다. 최소한 Slack 알림은 보내자.

# GitHub Actions workflow에 추가
- name: Notify Slack
  if: always()
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    text: |
      배포 ${{ job.status == 'success' && '성공 ✅' || '실패 ❌' }}
      커밋: ${{ github.sha }}
      작성자: ${{ github.actor }}
    webhook_url: ${{ secrets.SLACK_WEBHOOK }}

if: always()를 붙여야 실패했을 때도 알림이 간다. 성공만 알려주면 의미가 없다.


전체 파이프라인 흐름 정리

모든 걸 합치면 이런 흐름이 된다:

개발자가 코드 푸시
    ↓
[CI] GitHub Actions 트리거
    ↓
[CI] 의존성 설치 + 린트 + 테스트
    ↓ (실패 시 여기서 중단 + 슬랙 알림)
[CD] Docker 이미지 빌드
    ↓
[CD] 이미지를 레지스트리에 푸시 (ghcr.io)
    ↓
[CD] 서버에 SSH 접속
    ↓
[CD] DB 마이그레이션 실행
    ↓
[CD] 새 이미지 pull + 배포 (Rolling / Blue-Green)
    ↓
[CD] 헬스체크 확인
    ↓ (실패 시 자동 롤백 + 슬랙 알림)
[CD] 배포 완료 슬랙 알림

실전 GitHub Actions 완성본

위의 모든 것을 합친 실전 워크플로우.

# .github/workflows/deploy.yml
name: Build & Deploy

on:
  push:
    branches: [main]

concurrency:
  group: deploy-production
  cancel-in-progress: false

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports: ['5432:5432']
        options: --health-cmd "pg_isready" --health-interval 10s --health-timeout 5s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp

            # 마이그레이션
            docker compose -f docker-compose.prod.yml run --rm app npm run migrate

            # 새 이미지 pull & 재시작
            docker compose -f docker-compose.prod.yml pull app
            docker compose -f docker-compose.prod.yml up -d app

            # 헬스체크
            sleep 10
            curl -f http://localhost:3000/health || exit 1

            # 정리
            docker image prune -f

      - name: Notify Slack - Success
        if: success()
        uses: 8398a7/action-slack@v3
        with:
          status: success
          text: "🚀 배포 성공! (${{ github.sha }})"
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

      - name: Notify Slack - Failure
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          text: "❌ 배포 실패! (${{ github.sha }}) - 확인 필요"
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

concurrency 설정이 은근 중요하다. 배포가 동시에 두 번 돌면 꼬이니까, cancel-in-progress: false로 진행 중인 배포가 끝날 때까지 다음 배포를 대기시킨다.


마무리

CI/CD 파이프라인이랑 배포 전략은 처음 세팅할 때 시간이 좀 드는 게 사실이다. 근데 한번 만들어놓으면 그 뒤로는 코드 푸시만 하면 알아서 테스트하고 배포해주니까, 투자 대비 효과가 확실하다. 매번 SSH 접속해서 수동 배포하던 시절을 생각하면 지금은 천국이다.

핵심 정리:

  • GitHub Actions + Docker로 CI/CD 파이프라인을 구성하자
  • 이미지 태그에 커밋 SHA를 쓰면 버전 추적이 쉽다
  • 소규모는 Rolling Update, 빠른 롤백이 필요하면 Blue-Green
  • 헬스체크 엔드포인트와 Graceful Shutdown은 무중단 배포의 전제 조건이다
  • DB 마이그레이션은 하위 호환이 되게 작성하자 (expand and contract)
  • 시크릿은 코드에 절대 넣지 말자
  • 배포 알림은 실패할 때도 보내야 의미가 있다

궁금한 점이나 다른 방식으로 하고 계신 분들은 댓글로 공유해주세요! 🙌