GitHub Actions + Docker Blue-Green 무중단 배포 구성
- 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 install 후 package-lock.json 커밋 |
11. 마치며
GitHub Actions + GHCR + Docker + Nginx 조합으로 단일 EC2 서버에서 Blue-Green 무중단 배포를 구성했다.
핵심은 세 가지다.
- GHCR을 사용하면
GITHUB_TOKEN하나로 이미지 빌드·저장·배포가 완결된다. Docker Hub 계정이 필요 없다. - 배포 스크립트가 현재 운영 색상을 감지해 반대 색상 포트에 새 버전을 띄우고, 헬스체크 통과 후 Nginx만 reload한다. 컨테이너 중단 없이 전환이 완료된다.
- Secrets는 레포지토리별로 개별 등록해야 한다. 두 레포에 동일한 Secrets을 각각 등록하지 않으면
missing server host오류가 발생한다.