들어가며
왜 제목이 "바라만 봤던 Blue/Green 무중단 배포" 일까? 이 글은 이전에 작성했던 글과 자연스럽게 이어진다.
https://geniusjun4663.tistory.com/55
[Redis] 열받았던 탓에 도입해본 나의 첫 Redis
들어가며요즘 유명한 기술 스택을 적용해보면서 자주 드는 생각은, "왜 이 기술을 선택해야 했는가"를 설명할 수 있어야 한다고 생각한다. 이번 글에서는, 6개월전 나를 열받게 만들었던 한 상황
geniusjun4663.tistory.com
지난 글에서 나는, 이전 프로젝트에서 겪었던 뜨거워지는 좌절 하나를 이야기했다. 정리하자면 이렇다.
그 당시 나는 AWS S3 + CodeDeploy 기반 무중단 배포를 겨우 구축해 2주 만에 완성했었고, 개인적으로 꽤 만족스러운 경험이었다. 하지만 프로젝트 후반부에 Redis를 도입해야 하는 상황이 생기면서 일이 꼬였다.
지금 생각하면 기존 배포 환경에서도 Redis를 올릴 방법은 분명 있었을 것이다. 하지만 그때의 나는 경험 부족과 시간 압박 때문에 리소스(Redis) 하나 추가하지 못했다.. 결국 백엔드 팀장님이 빠르게 전체 환경을 Docker 기반 Blue/Green 방식으로 재구축하셨고, 나는 그 과정에서 배포 로직을 거의 이해하지 못한 채 하는 일이라고는 docker logs -f로 프론트 연동 오류만 체크하는 수준이었다.
CI/CD를 전담하고 있었는데도, 정작 프로젝트의 배포 구조가 어떻게 동작하는지 이해도 못한 채 PR을 올리는 것이 너무 찝찝했고, 한편으로는 한심하기도 했다. 정말 큰 아쉬움이 남았다.
그래서 이번에는 “내 손으로 끝까지” 해보려고 한다 🔥
그때 팀장님이 도입하셨던 Docker + Nginx + Blue/Green 무중단 배포 방식을 이번엔 완전히 내 방식으로 이해하고, 처음부터 끝까지 직접 구성해보며 정리해보려고 한다.
이 글은 그 과정을 기록한 것이다.
본문으로
🧠 배포 전략에는 무엇이 있을까?
제대로 들어가기 전에 배포방식 크게 4가지에 대해 각각 알아보고자 한다. 구현 난이도나 롤백에 대한 대응력이 각각 달라 상황에 따른 적합한 방식을 잘 선택해야 한다.
1) Recreate Deployment (서비스 중단 후 재배포)
- 장점 : 기존 서버를 모두 종료 → 새 버전을 띄운다. 매우 단순하다. 그냥 빌드해서 직접 서버에 들어가서 실행하면 끝난다.
- 단점 : 하지만 다운타임이 100%. 즉 해커톤이나 개인 프로젝트에는 적합하지만 운용중인 프로젝트에는 어울리지 않는다.
2) Rolling Deployment (롤링 배포)

- 장점 : 순차적으로 서버를 한 대씩 새롭게 교체하기 때문에 별도의 추가 리소스 없이 배포가 가능하다. 운영중인 인프라 내에서 효율적으로 배포가 가능하다. 다운타임 거의 없음
- 단점 : 트래픽이 자연스럽게 신버전/구버전에 섞이는 시점이 존재한다. 이때 새로운 버전에 문제가 있으면 전체 서비스에 문제가 생길 수 있다. 문제 발생 시 완전한 롤백이 번거롭다.
3) Canary Deployment (카나리 배포)

- 장점 : 일부 트래픽(1~10%)만 신버전에 보내보는 방식이다. 문제가 없으면 점차 확대하는 식으로 진행되기에 혹여나 문제가 생기면 해당 인스턴스만 회수하면 된다! 즉 롤백도 매우 빠르다.
- 단점 : 하지만 구현 복잡도 ↑, 트래픽 샘플링, 모니터링 기반 자동화가 필수이다.
- 인프라 경험이 풍부하거나 트래픽이 많은 서비스에서 빛난다.(나중에 도전해보고 싶다!)
4) Blue/Green Deployment (블루/그린 무중단 배포)

- 장점 : 구버전(Blue) 과 신버전(Green) 환경을 동시에 띄워둔다. Nginx 같은 Reverse Proxy가 “어떤 서버로 트래픽을 보낼지” 스위치만 바꾸면 끝이다.
- 트래픽 전환이 0.1초도 안 걸리며, 문제가 생기면 스위치만 다시 원래로 돌리면 즉시 롤백하면 된다.
- 단점 : 두개의 독립된 환경을 동시에 유지해야하기에 리소스 부담이 크다. 전환 시 트래픽이 갑자기 새로운 환경으로 몰리기 때문에 Warming up이 없다면 인스턴스가 버티지 못할 수도 있다.
- 즉 빠른 롤백이 장점이지만 자원부담이 크다.
사실 Blue/Green 배포 방식은 구버전/신버전, 즉 두개의 인스턴스가 필요한 방법이지만 도커를 활용하면 하나의 인스턴스 환경 안에서 무중단 배포를 구현할 수 있다. 하나의 인스턴스로만 무중단 배포를 구현해야 하는 나의 상황(💰💵)과 아주 잘 어울린다!
무엇보다 과거에는 "이해 못하고 바라만 봤던 방법"이다..! 내 것으로 만들어보자.
📦 전체 아키텍처 구성 – 처음부터 다시 쌓아올리자.
이번 프로젝트에서는 “Blue/Green 무중단 배포를 완전히 내 것으로 만들자”는 목표로, 과거 프로젝트와는 방법이 조금 다르다. 그래도 blue/green 배포 방식인 것은 동일하다. 아래 다이어그램이 내가 실제로 구성한 배포 구조다.

1. GCP VM에서 Blue/Green 두 개의 컨테이너를 띄운다
가장 핵심은 다음이다:
- Blue 컨테이너: 8081
- Green 컨테이너: 8082
둘 중 하나만 실제 서비스로 연결된다. 그리고 Github Actions가 새로운 이미지를 push하면, 내가 선택한 쪽(Green or Blue)에 신버전을 띄우고 → Health Check → Nginx 라우팅 전환을 수행한다.
아래는 Blue 컨테이너 설정 파일이다. Green은 단순히 포트만 8082로 바뀐 버전이며, 나머지는 동일하다.
services:
app-blue:
image: image
container_name: blue container
ports:
- "8081:8080" // 포트 포워딩 중
environment:
DB_URL: "jdbc:postgresql://(공개 IP):5432/~"
DB_USERNAME: (hostName)
DB_PASSWORD: (password)
REDIS_HOST: (host)
REDIS_PORT: (port)
JWT_SECRET: (key)
JWT_ACCESS_EXP_SECONDS: ~
JWT_REFRESH_EXP_SECONDS: ~
GOOGLE_WEB_CLIENT_ID: (key)
restart: always
환경변수 Spring 내부 application.yml에 숨기지 않고 Docker 단에서 주입해버리니까, GitHub Actions → Docker → VM 전체 흐름이 훨씬 깔끔해졌다.
2. Nginx는 단 한 가지 역할만 한다: 🔁 “트래픽을 Blue 또는 Green에 보낸다”
내 Nginx 설정의 핵심은 아래 한 줄이다.
upstream lotto_backend {
# server 127.0.0.1:8081; # Blue
server 127.0.0.1:8082; # Green
}
이 주석을 교체하는 것만으로 트래픽이 순식간에 Blue ↔ Green으로 전환된다.
전체 설정은 아래와 같다.
upstream lotto_backend {
# Blue server
# server 127.0.0.1:8081;
# Green server
server 127.0.0.1:8082;
}
server {
server_name <your-domain> <api.your-domain>;
# certbot authentication
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Blue/Green proxy
location / {
proxy_pass http://lotto_backend;
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;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/<your-domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<your-domain>/privkey.pem;
}
SSL 인증서는 Certbot으로 자동 관리하고 있고, 모든 요청이 proxy_pass http://lotto_backend; 로 전달된다.
이게 바로 Blue/Green의 단순함이다. Nginx는 그냥 스위치 역할만 한다.
3. 배포 전환 스크립트 – switch-to-blue.sh
Blue/Green을 바꾸는 방법은 그저 스크립트를 실행하는 것이다.
#!/bin/bash
set -e
NGINX_CONF="/etc/nginx/sites-available/cheonghak.io.kr"
echo "[INFO] Checking Blue health..."
curl -sf http://127.0.0.1:8081/health || {
echo "[INFO] Blue server is NOT healthy. Aborting switch."
exit 1
}
echo "[INFO] Switching from Green(8082) → Blue(8081)..."
sudo sed -i 's/server 127.0.0.1:8082;/# server 127.0.0.1:8082;/' $NGINX_CONF
sudo sed -i 's/# server 127.0.0.1:8081;/server 127.0.0.1:8081;/' $NGINX_CONF
echo "[INFO] Reloading nginx..."
sudo nginx -t && sudo systemctl reload nginx
echo "[INFO] Switch completed! Now serving BLUE."
이 스크립트는 다음을 보장한다:
- Blue 컨테이너 health 체크
- health 실패 시 전환 중단 → 롤백 필요 없음
- Nginx 설정을 Blue로 변경
- 문법 체크
- Reload
- 즉시 서비스 전환
Green 전환 스크립트도 완전히 동일한 구조다.
🛠️ GitHub Actions로 자동화한 Blue/Green 배포 전체 흐름
방금 봤던 설정 파일들을 하나하나 직접 실행해주면 그것은 무중단 배포가 아니다! Blue/Green 방식의 핵심은 두 버전을 동시에 운영하면서, 새로운 버전을 안정적으로 배포하고 문제가 없을 때만 트래픽을 전환하는 것이다.
그래서 이번 프로젝트에서는 CI + CD를 GitHub Actions로 자동화했다.
구성은 아래 두 파일로 나뉜다.
- CI (ci.yml): Docker 이미지 자동 빌드 & DockerHub Push
- CD (cd.yml): GCP VM에 접속해 Green → Health → Switch → Blue Down
아래처럼 정리할 수 있겠다.
[CI] build & push → [CD] pull & run green → health check → nginx switch → stop blue
1. CI – Build & Docker Publish
CI 단계는 “코드가 main으로 들어올 준비가 됐는지 확인하고 Docker 이미지로 만드는 단계”다.
아래가 전체가 CI(ci.yml) 파일이다:
name: CI - Build & Docker Publish
on:
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build without tests
run: ./gradlew clean build -x test
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker image
run: |
docker buildx create --use
docker buildx build \
--platform linux/amd64 \
-t ${{ secrets.DOCKER_USERNAME }}/woowa-lotto:latest \
--push .
✔ 이 CI의 핵심 포인트는 단 3가지다
- PR 기준으로만 Docker 이미지 빌드됨
→ “main에 들어가기 전” 코드 품질 보장 - docker buildx 사용 → AMD64 빌드 강제
→ GCP VM이 amd64 기반이기 때문에 buildx가 필수, 나는 맥북 M4 pro 유저다! - latest 태그 자동 push
→ CD가 항상 최신 이미지를 pull 할 수 있음
즉, “코드 → Docker 이미지” 과정이 완전히 자동화 완료다.
2. CD – Green 배포 → Health Check → Switch → Blue 종료
CD는 진짜로 Blue/Green 전략이 작동하는 곳이다. main에 push가 발생하면 아래 작업이 순서대로 자동 실행된다.(merge 시)
name: CD - Deploy to GCP VM
on:
push:
branches: [ "main" ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Deploy to GCP VM via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.GCP_HOST }}
username: ${{ secrets.GCP_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
#!/bin/bash
set -e
NGINX_CONF="/etc/nginx/sites-enabled/cheonghak.io.kr"
echo "[CD] Detecting current ACTIVE color..."
if grep -q "server 127.0.0.1:8081;" "$NGINX_CONF"; then
ACTIVE="blue"
TARGET="green"
TARGET_PORT=8082
TARGET_COMPOSE="docker-compose.green.yml"
ACTIVE_COMPOSE="docker-compose.blue.yml"
SWITCH_SCRIPT="switch-to-green.sh"
else
ACTIVE="green"
TARGET="blue"
TARGET_PORT=8081
TARGET_COMPOSE="docker-compose.blue.yml"
ACTIVE_COMPOSE="docker-compose.green.yml"
SWITCH_SCRIPT="switch-to-blue.sh"
fi
echo "[CD] ACTIVE: $ACTIVE"
echo "[CD] TARGET to deploy: $TARGET ($TARGET_PORT)"
echo "[CD] Pulling latest docker image..."
docker pull ${{ secrets.DOCKER_USERNAME }}/woowa-lotto:latest
echo "[CD] Starting $TARGET container..."
docker compose -f "$TARGET_COMPOSE" up -d
echo "[CD] Checking health of $TARGET..."
for i in {1..10}
do
if curl -sf "http://127.0.0.1:${TARGET_PORT}/health"; then
echo "[CD] $TARGET is healthy!"
break
fi
echo "[CD] $TARGET is not healthy yet... retrying ($i/10)"
sleep 3
if [ $i -eq 10 ]; then
echo "❌ Deployment failed: $TARGET failed health check."
exit 1
fi
done
echo "[CD] Switching NGINX to $TARGET..."
bash "$SWITCH_SCRIPT"
echo "[CD] Shutting down old $ACTIVE container..."
docker compose -f "$ACTIVE_COMPOSE" down || true
echo "[CD] Deployment completed successfully! Now serving: $TARGET"
코드가 한번에 보기엔 너무 길다. GitHub Actions에서 실행되는 CD 로직 중 핵심 명령어만 발췌해보자.
실제로 Blue ↔ Green 전환을 수행하는 핵심은 아래 네 단계다.
1) 현재 ACTIVE(Blue/Green) 판단
if grep -q "server 127.0.0.1:8081;" "/etc/nginx/sites-enabled/cheonghak.io.kr"; then
ACTIVE="blue"
TARGET="green"
TARGET_PORT=8082
else
ACTIVE="green"
TARGET="blue"
TARGET_PORT=8081
fi
Nginx 설정 파일에서 활성화된 upstream 포트를 읽어
현재 서비스 중인 색과 이번에 배포할 색을 자동으로 판단한다.
2) TARGET 컨테이너 먼저 실행
docker compose -f "$TARGET_COMPOSE" up -d
새 버전을 백그라운드에서 먼저 띄워 Health 체크를 기다린다.
(이때 기존 Active 버전은 절대 건드리지 않는다.)
3) Health check 성공 시 Nginx 전환
bash "$SWITCH_SCRIPT"
green으로 갈지 blue로 갈지 자동 판단한 뒤 해당 스크립트를 실행한다. 스위치 스크립트가 하는 일은 두 가지이다.
- upstream 포트 변경
- nginx reload
4) 이전 ACTIVE 컨테이너 종료
docker compose -f "$ACTIVE_COMPOSE" down
새 버전이 정상 확인되면 이전 버전 컨테이너를 안전하게 종료한다.
이로써 항상 Blue 또는 Green 중 딱 하나만 남는 구조를 유지한다.
집중해서 읽어봤다면 여기서 의문이 들 수 있다고 생각한다. 아니 아까 blue/green 무중단 배포 설명할 때 구버전(Blue)과 신버전(Green) 환경을 동시에 띄워둔다면서요?! 왜 기존 컨테이너 내리시죠?
전통적인 Blue/Green 방식은 두 환경을 동시에 유지하기 때문에 인프라 자원이 2배로 든다는 단점이 있다.
이번 프로젝트는 두 컨테이너를 항상 켜두는 대신, 한쪽만 Active 상태로 띄우고 필요한 순간에만 Green을 임시로 기동 → 헬스체크 → 스위칭 후 Active만 유지하는 전략을 사용했다. (헬스체크 후 스위칭 되기 때문에 무중단 배포라는 점은 동일하다!)
기본 개념은 유지하면서도, 단일 VM 환경에 맞게 최적화한 방식이다.
이렇게 된다면 하나의 vm안에서 컨테이너 하나만 띄어놓는 전략을 취할 수 있지 않겠는가!
💫 겪었던 문제들..
1) GitHub Actions → GCP VM SSH 인증 실패
🔍 문제
GitHub Actions에서 GCP VM으로 SSH 접속을 시도했지만, 다음 메시지가 반복적으로 발생했다
ssh: handshake failed
ssh: unable to authenticate
📌 원인
- GitHub Actions에서 사용할 public key가 VM의 authorized_keys에 등록되어 있지 않음
- GCP OS Login 기능이 켜져 있어 authorized_keys가 재부팅 시 계속 덮어씌워짐(구글이 자동으로 on 해놓는다.. 그럼 자꾸 githubActions가 vm 들어가지를 못한다.)
✔ 해결
- VM 인스턴스에서 OS Login 비활성화
- ~/.ssh/authorized_keys에 GitHub Actions용 public key 등록
- private key는 GitHub Secret (SSH_PRIVATE_KEY)에 저장
- 재부팅 후 정상 작동 확인
2) Green 컨테이너 헬스체크 실패
🔍 문제
새 버전(Green)이 올라오지만 다음 상태가 반복되었다.
green is not healthy yet... retrying
📌 원인
- Spring Boot 애플리케이션이 기동되는 시간보다 헬스체크 주기가 너무 짧음
- 컨테이너는 Running이지만 /health 엔드포인트는 준비되지 않은 상태
✔ 해결
- 3초 × 10회 retry 로직 적용
- 실제로 거의 마지막 retry에서 응답이 성공하며 배포 안정성 확보

3) Blue 컨테이너는 down되었는데 Nginx는 여전히 Blue를 바라봄
🔍 문제
CD 로그에서는 이렇게 출력됐다.
Shutting down BLUE container...
Deployment to GREEN completed successfully.
그러나 실제 VM에서 nginx 설정을 확인해보니
server 127.0.0.1:8081;
# server 127.0.0.1:8082;
즉, 여전히 Blue(8081)를 바라보고 있었고 실제 트래픽도 Blue에 들어가고 있었다.
📌 원인
Nginx 설정 파일을 수정하는 과정에서 sudo 패스워드를 요구한다. GitHub Actions 로그에도 다음과 같은 경고가 있었다.
sudo: a terminal is required to read the password
sudo: a password is required
- switch-to-green.sh 내부에서
sudo sed -i 실행 시도 - GitHub Actions가 sudo 비밀번호를 입력할 수 없음
- 내부적으로 sed 수정이 실패
- 그러나 스크립트는 set -e가 없어 “성공처럼 보임”
- Docker는 정상 교체되었으나 Nginx upstream이 수정되지 않음
결과적으로 Blue 컨테이너는 죽었는데 Nginx는 그대로 8081을 바라봄
✔ 해결
GCP VM에서 sudo 비밀번호 없이 sed/nginx reload를 허용하였다.
sudoers 설정 수정:
ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /usr/bin/sed, /usr/sbin/nginx -t, /bin/systemctl reload nginx
switch-to-green.sh를 다시 실행
GitHub Actions가 다시 실행되면서 정상적으로:
- 8081 → 주석 처리
- 8082 → 활성화
이제 Nginx upstream도 정상적으로 변경된다.
4) Pull Request시 CI가 한번 더 돌아가는 현상
🔍 문제
main branch에 Pull Request시 코드 검증이 되야하니 CI workflow가 돌아가게끔 설정했었는데, main에 merge시 ci가 또 돌아가는 문제가 있었다.
📌 원인
원인은 아래 로직에 있다.
# ci.yml
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
# cd.yml
on:
push:
branches: ["main"]
main 브랜치에 push, 즉 merge시 cd가 돌아가는 것은 원하는 흐름이지만 ci가 돌아가는 것은 원치 않는 흐름이었다. 하지만 나는 ci.yml 로직이 main 브랜치에 push, merge시 돌아가게끔 설정했었다.
✔ 해결
아래와 같이 ci는 Pull Request시, cd는 merge시에 돌아게끔 수정하였다!
# ci.yml
on:
pull_request:
branches: ["main"]
# cd.yml
on:
push:
branches: ["main"]
마무리하며
솔직히 말하면 이번 프로젝트에서 굳이 “무중단 배포”까지 구현할 필요는 없었다. 하지만 이 방식은 6개월 전의 나를 순식간에 무너뜨렸던 그 기억을 다시 떠오르게 한다. 그때를 생각하면 지금도 머리가 뜨거워진다. 애정을 들여 만든 프로그램을 능력 부족 때문에 제대로 지켜주지 못한 느낌이 너무 싫었다. 아마 그래서 더 악착같이 이번 배포 환경을 공부했던 것 같다.
돌이켜보면 아쉬운 점도 많다. 그 당시 진짜 중요한 건 무중단 배포가 아니라 Redis를 안정적으로 붙이는 일이었는데, 왜 그렇게 조급했고 왜 그렇게 자신감이 없었는지 모르겠다. 거의 바로 포기해버린 나를 떠올리면 지금도 아쉽다. 만약 과거의 나에게 한마디 할 수 있다면 “밤을 새더라도 한번 질러보고, 부딪혀봐!” 라고 말해주고 싶다.
6개월 전 가장 뜨거웠던 기억을 꺼내 이렇게 다시 완성시켜보니 확실하게 느껴지는 건 성장감이다.
최근 스스로 얼마나 성장했는지 메타인지가 흐려질 때도 있었는데, 예전에 실패했던 일을 이번엔 스스로 구현해냈다는 사실이 나를 다시 일으켜 세운다. 가끔 멈춰있는 듯한 기분이 들 때면 이렇게 예전에 포기했던 도전을 다시 해보는 것도 꽤 괜찮은 방법이라는 걸 배웠다