GitHub Actions + Docker Blue-Green 무중단 배포 구성

GitHub Actions + Docker Blue-Green 무중단 배포 구성

서비스를 새 버전으로 배포할 때 컨테이너를 내렸다가 다시 올리면 그 사이에 서비스 중단(다운타임) 이 발생한다. Blue-Green 배포는 현재 운영 버전(Blue)을 그대로 두고 새 버전(Green)을 별도로 띄운 뒤, 헬스 체크를 통과하면 Nginx가 트래픽을 전환하는 방식으로 다운타임 없이 배포를 완료한다.

이 글은 AWS EC2 단일 서버에서 Spring Boot 백엔드와 Next.js 프론트엔드를 GitHub Actions + GHCR + Docker + Nginx로 구성하는 전체 과정을 정리한다.


0. 전체 아키텍처 한눈에 보기

  개발자 로컬 PC
  ┌──────────────────────────────────────┐
  │  git push → GitHub (main 브랜치)     │
  └──────────────────┬───────────────────┘
                     │ push 이벤트
                     ▼
  GitHub Actions Runner (ubuntu-latest)
  ┌──────────────────────────────────────────────────┐
  │  1. Checkout                                      │
  │  2. GHCR 로그인 (GITHUB_TOKEN)                   │
  │  3. Docker 이미지 빌드 & Push → ghcr.io           │
  │  4. SSH → EC2 배포 스크립트 실행                  │
  └─────────────────────┬────────────────────────────┘
                        │ SSH
                        ▼
  AWS EC2  t3.small  (Ubuntu 22.04)
  ┌──────────────────────────────────────────────────┐
  │                                                   │
  │   [Nginx : 80]                                    │
  │     /api/**  ──────────▶ [Backend Blue  : 8080]  │
  │     /        ──────────▶ [Frontend Blue : 3000]  │
  │                       ↑                           │
  │                Blue-Green 전환 시                 │
  │                       ↓                           │
  │     /api/**  ──────────▶ [Backend Green : 8081]  │
  │     /        ──────────▶ [Frontend Green: 3001]  │
  │                                                   │
  └──────────────────────────────────────────────────┘
구성 요소 역할
GitHub Actions push 이벤트 감지 → 빌드·배포 자동화
GHCR Docker 이미지 저장소 (GitHub Container Registry)
Docker 컨테이너 실행 환경
Nginx 리버스 프록시 + Blue-Green 트래픽 전환
deploy-*.sh EC2에서 실행되는 Blue-Green 전환 스크립트

1. Blue-Green 배포란?

1-1. 기본 개념

Blue-Green 배포는 운영 환경을 두 벌 유지하며 교차 전환하는 방식이다.

색상 의미
Blue 현재 운영 중인 버전
Green 새로 배포할 버전
[일반 배포]
기존 컨테이너 중지 → (다운타임 발생) → 신규 컨테이너 시작

[Blue-Green 배포]
Green 컨테이너 시작 → 헬스 체크 → Nginx 트래픽 전환 → Blue 컨테이너 종료
                              ↑
                        다운타임 없음

1-2. 포트 구성

구분 Blue Green
Backend 8080 8081
Frontend 3000 3001
Nginx (외부) 80 — (항상 동일)

외부에서 접근하는 포트(80)는 항상 고정이며, Nginx가 내부적으로 Blue/Green 포트 중 활성 컨테이너로 라우팅한다.


2. 기술 스택

구분 기술 버전
Backend Spring Boot 3.3.0
Backend Java 21
Backend Gradle 8.7
Frontend Next.js 16.2.10 (SSR)
Frontend React 19.2.4
Infra AWS EC2 t3.small (Ubuntu 22.04)
Infra Docker 29.1.3
Infra Nginx 1.28.3
CI/CD GitHub Actions
Registry GHCR ghcr.io

3. EC2 인스턴스 설정

3-1. 인스턴스 스펙

Blue-Green 배포 시 Blue/Green 컨테이너가 동시에 실행되므로 최소 사양을 고려해야 한다.

배포 순간 (피크) 메모리 사용량 추정:

[Nginx]            ~50MB
[Backend  Blue ]   ~400MB
[Backend  Green]   ~400MB  ← 배포 시만 동시 실행
[Frontend Blue ]   ~300MB
[Frontend Green]   ~300MB  ← 배포 시만 동시 실행
[OS + Docker   ]   ~600MB
─────────────────────────
합계               ~2.05GB
항목
인스턴스 유형 t3.small (2 vCPU, 2GB RAM)
OS Ubuntu Server 22.04 LTS
스토리지 20GB gp3
Swap 2GB 추가 설정 (총 가용 ~3.9GB)

3-2. Security Group 인바운드 규칙

포트 소스 용도
22 내 IP SSH 접속
80 0.0.0.0/0 HTTP (Nginx)
8080 0.0.0.0/0 Backend Blue
8081 0.0.0.0/0 Backend Green

3-3. 기본 환경 설치

# 패키지 업데이트
sudo apt update && sudo apt upgrade -y

# Docker 설치
sudo apt install -y docker.io
sudo systemctl enable docker && sudo systemctl start docker
sudo usermod -aG docker ubuntu

# Nginx 설치
sudo apt install -y nginx
sudo systemctl enable nginx && sudo systemctl start nginx

# Swap 2GB 설정 (t3.small 안정화)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

설치 확인:

$ docker --version   # Docker version 29.1.3
$ nginx -v           # nginx version: nginx/1.28.3
$ free -h            # Swap: 2.0Gi

4. Nginx 설정

4-1. 설정 파일

Nginx 하나로 프론트엔드 SSR 프록시 + 백엔드 API 프록시 두 가지 역할을 수행한다.

# /etc/nginx/sites-available/backend

server {
    listen 80;

    # Backend API — Blue-Green 포트 전환 대상
    location /api/ {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Frontend Next.js SSR — Blue-Green 포트 전환 대상
    location / {
        proxy_pass http://localhost:3000;  # frontend
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

4-2. Nginx 활성화

sudo ln -sf /etc/nginx/sites-available/backend /etc/nginx/sites-enabled/backend
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

4-3. API_URL 설계

Frontend(Next.js SSR)는 서버 사이드에서 백엔드를 호출한다. 컨테이너를 --network host로 실행하면 localhost가 EC2 호스트를 가리키므로, API_URL=http://localhost로 설정해 Nginx를 통해 백엔드로 라우팅한다.

Next.js SSR 컨테이너 (--network host)
   │ fetch http://localhost/api/items
   ▼
Nginx :80
   │ location /api/ → proxy_pass localhost:8080
   ▼
Spring Boot :8080

location /api/location /보다 더 구체적이므로 Nginx는 API 요청을 백엔드로, 나머지는 프론트엔드로 정확히 라우팅한다.


5. 배포 스크립트

배포 스크립트는 /home/ubuntu/deploy/에 위치하며, GitHub Actions가 SSH로 접속해 호출한다.

5-1. deploy-backend.sh

#!/bin/bash

IMAGE=$1
BLUE_PORT=8080
GREEN_PORT=8081
NGINX_CONF=/etc/nginx/sites-available/backend

# 현재 운영 컨테이너 확인
if docker ps | grep -q "backend-blue"; then
    CURRENT="blue"  ; NEXT="green" ; NEXT_PORT=$GREEN_PORT
    CURRENT_PORT=$BLUE_PORT
else
    CURRENT="green" ; NEXT="blue"  ; NEXT_PORT=$BLUE_PORT
    CURRENT_PORT=$GREEN_PORT
fi

echo "현재: $CURRENT ($CURRENT_PORT) → 새버전: $NEXT ($NEXT_PORT)"

# 새 버전 컨테이너 실행
docker pull $IMAGE
docker stop backend-$NEXT 2>/dev/null || true
docker rm   backend-$NEXT 2>/dev/null || true
docker run -d \
    --name backend-$NEXT \
    --restart unless-stopped \
    -p $NEXT_PORT:8080 \
    $IMAGE

# 헬스체크 (최대 30초 대기)
echo "헬스체크 중..."
for i in {1..10}; do
    sleep 3
    if curl -sf http://localhost:$NEXT_PORT/api/items > /dev/null; then
        echo "헬스체크 성공"
        break
    fi
    echo "대기중... ($i/10)"
done

# Nginx 트래픽 전환
sudo sed -i "s/localhost:[0-9]*/localhost:$NEXT_PORT/" $NGINX_CONF
sudo nginx -t && sudo systemctl reload nginx

echo "전환 완료: $NEXT ($NEXT_PORT) 운영중"

# 구버전 컨테이너 종료
sleep 5
docker stop backend-$CURRENT 2>/dev/null || true
docker rm   backend-$CURRENT 2>/dev/null || true
echo "이전 컨테이너($CURRENT) 제거 완료"

5-2. deploy-frontend.sh

#!/bin/bash

IMAGE=$1
BLUE_PORT=3000
GREEN_PORT=3001
NGINX_CONF=/etc/nginx/sites-available/backend

if docker ps | grep -q "frontend-blue"; then
    CURRENT="blue"  ; NEXT="green" ; NEXT_PORT=$GREEN_PORT
else
    CURRENT="green" ; NEXT="blue"  ; NEXT_PORT=$BLUE_PORT
fi

echo "현재: $CURRENT → 새버전: $NEXT ($NEXT_PORT)"

docker pull $IMAGE
docker stop frontend-$NEXT 2>/dev/null || true
docker rm   frontend-$NEXT 2>/dev/null || true
docker run -d \
    --name frontend-$NEXT \
    --restart unless-stopped \
    --network host \
    -e PORT=$NEXT_PORT \
    -e API_URL=http://localhost \
    $IMAGE

echo "헬스체크 중..."
for i in {1..10}; do
    sleep 3
    if curl -sf http://localhost:$NEXT_PORT > /dev/null; then
        echo "헬스체크 성공"
        break
    fi
    echo "대기중... ($i/10)"
done

sudo sed -i "s|proxy_pass http://localhost:[0-9]*;  # frontend|proxy_pass http://localhost:$NEXT_PORT;  # frontend|" $NGINX_CONF
sudo nginx -t && sudo systemctl reload nginx

echo "전환 완료: $NEXT ($NEXT_PORT) 운영중"

sleep 5
docker stop frontend-$CURRENT 2>/dev/null || true
docker rm   frontend-$CURRENT 2>/dev/null || true
echo "이전 컨테이너($CURRENT) 제거 완료"

스크립트 실행 권한 부여:

chmod +x /home/ubuntu/deploy/deploy-backend.sh
chmod +x /home/ubuntu/deploy/deploy-frontend.sh

6. 프로젝트 파일 구성

6-1. 백엔드 (Spring Boot)

Dockerfile

# Build stage
FROM gradle:8.7-jdk21-alpine AS builder
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY src src
RUN gradle bootJar -x test --no-daemon

# Run stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

ItemController.java

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class ItemController {

    record Item(Long id, String name) {}

    @GetMapping("/items")
    public List<Item> getItems() {
        return List.of(
            new Item(1L, "Item A"),
            new Item(2L, "Item B"),
            new Item(3L, "Item C")
        );
    }
}

6-2. 프론트엔드 (Next.js)

next.config.ts — standalone 빌드 설정 필수

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Dockerfile

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs \
    && adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

page.tsx — 백엔드 API 호출 (SSR)

interface Item {
  id: number;
  name: string;
}

async function getItems(): Promise<Item[]> {
  const res = await fetch(`${process.env.API_URL}/api/items`, {
    cache: "no-store",
  });
  return res.json();
}

export default async function Home() {
  const items = await getItems();
  return (
    <main style=>
      <h1>Item List</h1>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </main>
  );
}

7. GitHub Actions Workflow

7-1. Workflow permissions 설정

GHCR(GitHub Container Registry)을 사용하려면 각 레포지토리에서 권한 설정이 필요하다.

Settings → Actions → General → Workflow permissions

● Read and write permissions  ← 선택

7-2. 백엔드 Workflow

# backend_cicd/.github/workflows/deploy.yml

name: Backend CI/CD

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $

      - name: Docker Build & Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/$/backend-cicd:latest

      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1
        with:
          host: $
          username: $
          key: $
          script: |
            echo "$" | docker login ghcr.io \
              -u "$" --password-stdin
            /home/ubuntu/deploy/deploy-backend.sh \
              ghcr.io/$/backend-cicd:latest

7-3. 프론트엔드 Workflow

# frontend_cicd/.github/workflows/deploy.yml

name: Frontend CI/CD

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $

      - name: Docker Build & Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/$/frontend-cicd:latest

      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1
        with:
          host: $
          username: $
          key: $
          script: |
            echo "$" | docker login ghcr.io \
              -u "$" --password-stdin
            /home/ubuntu/deploy/deploy-frontend.sh \
              ghcr.io/$/frontend-cicd:latest

8. GitHub Secrets 설정

두 레포지토리(backend_cicd, frontend_cicd) 각각 에 등록해야 한다.

Settings → Secrets and variables → Actions → New repository secret

Secret 이름 비고
SERVER_HOST EC2 Public IP 예: 13.125.xxx.xxx
SERVER_USER ubuntu EC2 기본 사용자명
SERVER_SSH_KEY .pem 파일 전체 내용 BEGIN ~ END 포함

DOCKER_USERNAME / DOCKER_PASSWORD 불필요 — GHCR은 GITHUB_TOKEN으로 자동 인증된다. Docker Hub 계정 없이 GitHub 계정만으로 이미지를 관리할 수 있다.


9. Blue-Green 배포 흐름 (End-to-End)

9-1. 전체 시퀀스

1. git push → main
      ↓
2. GitHub Actions 트리거
      ↓
3. Docker 이미지 빌드 & GHCR Push
   ghcr.io/sisipapa/backend-cicd:latest
      ↓
4. SSH로 EC2 접속
      ↓
5. EC2: 현재 운영 색상 확인
   docker ps | grep backend-blue  → Blue 운영 중
      ↓
6. Green 포트(8081)에서 새 컨테이너 실행
   docker run -p 8081:8080 ...
      ↓
7. 헬스체크 (최대 30초)
   curl http://localhost:8081/api/items → 200 OK
      ↓
8. Nginx 설정 변경 → reload
   sed: localhost:8080 → localhost:8081
   nginx -t && systemctl reload nginx
      ↓
9. 트래픽 전환 완료 (무중단)
      ↓
10. 구버전 Blue 컨테이너 종료
    docker stop backend-blue

9-2. 롤백

배포 이전 버전으로 돌리고 싶을 때는 스크립트를 다시 실행하면 Green↔Blue 전환이 반복된다. 또는 구버전 이미지 태그를 명시해 수동 실행할 수 있다.

# 수동 롤백 (EC2 직접 실행)
/home/ubuntu/deploy/deploy-backend.sh ghcr.io/sisipapa/backend-cicd:이전태그

10. 트러블슈팅

증상 원인 해결
Nginx 500 Internal Server Error /home/ubuntu 디렉토리 권한 부족 chmod 755 /home/ubuntu /home/ubuntu/deploy /home/ubuntu/deploy/frontend
missing server host 해당 레포에 Secrets 미등록 두 레포 각각 SERVER_HOST, SERVER_USER, SERVER_SSH_KEY 개별 등록
docker login 실패 Workflow permissions 미설정 Settings → Actions → General → Read and write permissions 설정
헬스체크 실패 (Spring Boot) JVM 기동 시간 초과 (30초 이내 미응답) deploy-backend.sh의 반복 횟수·sleep 시간 증가
package-lock.json 없음 npm install 미실행 로컬에서 npm installpackage-lock.json 커밋

11. 마치며

GitHub Actions + GHCR + Docker + Nginx 조합으로 단일 EC2 서버에서 Blue-Green 무중단 배포를 구성했다.

핵심은 세 가지다.

  1. GHCR을 사용하면 GITHUB_TOKEN 하나로 이미지 빌드·저장·배포가 완결된다. Docker Hub 계정이 필요 없다.
  2. 배포 스크립트가 현재 운영 색상을 감지해 반대 색상 포트에 새 버전을 띄우고, 헬스체크 통과 후 Nginx만 reload한다. 컨테이너 중단 없이 전환이 완료된다.
  3. Secrets는 레포지토리별로 개별 등록해야 한다. 두 레포에 동일한 Secrets을 각각 등록하지 않으면 missing server host 오류가 발생한다.

참고