무중단 배포 Blue-Green (2)

드코딩·2026년 4월 27일
post-thumbnail

EC2 단일 서버에서 Blue-Green 무중단 배포 구현하기 — 2편: 실전

MOA 운영 서비스에 적용하기 전, POC 프로젝트로 먼저 검증해본 기록
IOS_MOA 설치
안드로이드 MOA 설치


서버 상황

POC를 위해 이전에 다른 토이 프로젝트를 위해 만들어둔 홈서버를 사용해보고자 한다.
이미 Docker랑 Nginx는 이미 깔려 있었기에 빠르게 시작해볼 수 있었다.

하지만 이전에 설정한 도메인이 만료됐고 서비스도 안 돌아가는 상태라서, 기존 설정을 다 걷어내고 Blue-Green용으로 새로 세팅하기로 했다.

기존 Nginx 설정을 뜯어보니

server {
    listen 80 default_server;
    return 301 https://$host$request_uri;
    location / {
        try_files $uri $uri/ =404;   # ← return 301 때문에 여기 도달 불가 (dead code)
    }
}

server {
    listen 443 ssl;
    server_name www.shinhan-sosol.store;
    ssl_certificate /etc/letsencrypt/live/www.shinhan-sosol.store/fullchain.pem;
    
    location /api/ {
        proxy_pass http://localhost:8080/api/;   # ← 이전 프로젝트 백엔드
    }
}

문제점이 몇 개 보였다.

  1. SSL 인증서가 만료된 도메인 — 도메인이 만료됐으니 의미 없는 설정
  2. proxy_pass가 localhost:8080으로 하드코딩 — Blue-Green은 upstream을 써야 하니까 교체 필요
  3. dead codereturn 301 아래의 location / { try_files... }는 절대 실행 안 된다

전부 걷어내고 새로 구성하기로 했다.


서버 세팅 — 단계별

1단계: 기존 상태 확인

docker --version          # Docker 설치 확인
nginx -v                  # Nginx 설치 확인
sudo docker ps            # 돌아가는 컨테이너 확인
sudo ss -tlnp | grep -E ':(80|8080|8081) '  # 포트 사용 현황

Docker, Nginx 모두 이미 깔려있었고, 돌아가는 컨테이너는 없었다.

2단계: 앱 디렉토리 + 상태 파일 생성

mkdir -p /home/hp-server/Desktop/project/zero-v1/logs
echo "blue" > /home/hp-server/Desktop/project/zero-v1/active-color
touch /home/hp-server/Desktop/project/zero-v1/deploy-history

active-color 파일에 blue를 넣어두면, 첫 배포 시 deploy.sh가 이걸 읽고 "현재 blue니까 다음은 green"으로 판단한다.

3단계: 기존 Nginx 설정 제거

sudo rm /etc/nginx/sites-enabled/default

이전 프로젝트 설정이 통째로 들어있던 파일을 제거해줬다.

4단계: Blue-Green Nginx 설정 생성

# upstream 설정 — deploy.sh가 이 파일을 배포마다 덮어씀
sudo tee /etc/nginx/conf.d/moa-upstream.conf > /dev/null <<'EOF'
upstream moa_backend {
    server 127.0.0.1:8080;
}
EOF

# server 블록 — 모든 요청을 upstream으로 프록시
sudo tee /etc/nginx/conf.d/zero-downtime.conf > /dev/null <<'EOF'
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://moa_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;

        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}
EOF

핵심은 moa-upstream.conf다.
이 파일의 server 127.0.0.1:8080;server 127.0.0.1:8081;로 바꾸고 nginx -s reload 하면 트래픽이 전환된다. deploy.sh가 이 작업을 자동으로 해준다.

5단계: Nginx 검증 + 적용

sudo nginx -t        # 설정 문법 검증
sudo nginx -s reload # 적용
curl -I http://localhost

curl 결과로 502 Bad Gateway가 나왔는데, 이게 정상이다.
Nginx는 떠있지만 upstream인 8080에 아무 컨테이너도 없으니까 502가 맞다.
이제 첫 배포 후에 200이 나오면 된다.

6단계: GitHub Actions Self-Hosted Runner 설치

리포지토리 Settings → Actions → Runners → New self-hosted runner에서 안내하는 명령어를 따라 했다.

# runner 디렉토리에서
bash config.sh --url https://github.com/subsub97/blue-green --token <토큰>

실행하면 이런 화면이 뜬다:

runner-start

"Connected to GitHub"가 뜨면 성공이다. 이후 Runner group, name, labels 등을 물어보는데 전부 Enter(기본값)로 넘기면 된다.

Configure 끝나고 서비스 등록:

sudo bash svc.sh install
sudo bash svc.sh start

7단계: GitHub Secrets 설정

리포지토리 Settings → Secrets and variables → Actions에서:

NameValue
DOCKER_USERNAMEgodqhr721
DOCKER_PASSWORDDocker Hub 토큰

여기까지 하면 서버 세팅은 끝이다. main에 push하면 자동으로 빌드→배포가 시작된다.

첫 배포가 돌아가면 GitHub Actions에서 이런 로그를 볼 수 있다:

GitHub Actions 첫 배포 결과

Current: blue(:8080), Next: green(:8081)으로 첫 배포가 green 컨테이너에 올라간다. 배포 완료 후 API를 찍어보면:

deploy-info API 응답

version에 git commit SHA, color에 green이 찍힌다. 이게 나오면 첫 배포는 성공이다.


트러블슈팅

서버 세팅하면서 생각보다 잔에러가 많이 났다. 하나하나 정리해보자.

1. chown: invalid user 'ubuntu:ubuntu'

$ sudo chown -R ubuntu:ubuntu /home/hp-server/Desktop/project/zero-v1/
chown: invalid user: 'ubuntu:ubuntu'

원인: 이 서버의 유저명이 ubuntu가 아니라 hp-server였다. 스크립트나 가이드에서 ubuntu를 기본으로 쓰는 경우가 많은데, 실제 서버의 유저명에 맞춰야 한다.

해결:

sudo chown -R hp-server:hp-server /home/hp-server/Desktop/project/zero-v1/

서버 세팅 가이드를 그대로 복붙하면 이런 데서 막힌다. 자기 서버의 유저명은 whoami로 확인하자.


2. must not run with sudo

$ sudo ./config.sh --url ...
Must not run with sudo

원인: GitHub Actions Runner의 config.shroot로 실행하면 안 된다. 보안 정책상 일반 유저로 실행해야 한다.

해결: root로 접속해있었으면 일반 유저로 전환 후 실행한다.

su - hp-server
cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url https://github.com/... --token ...

3. sudo ./svc.sh: command not found

$ sudo ./svc.sh install
sudo: ./svc.sh: command not found

원인: sudo로 실행할 때 상대 경로가 제대로 해석이 안 되는 경우가 있다.

해결: bash로 직접 절대 경로를 지정해서 실행한다.

sudo bash /home/hp-server/Desktop/project/zero-v1/actions-runner/svc.sh install

4. svc.sh: No such file or directory

$ ls
actions-runner-linux-x64-2.333.1.tar.gz  bin  config.sh  env.sh  externals  run.sh ...

svc.sh가 파일 목록에 없었다.

원인: svc.shconfig.sh를 실행한 후에 생성된다. config 과정에서 Runner 설정이 완료되면 서비스 등록에 필요한 파일들이 생긴다.

해결: config.sh를 먼저 실행하고 나면 svc.sh가 생성되어 있다.


5. config.sh: No such file or directory

~/Desktop/project/zero-v1 $ bash config.sh --url ...
bash: config.sh: No such file or directory

원인: config.shactions-runner 디렉토리 안에 있는데, 한 단계 상위에서 실행하고 있었다.

해결:

cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url ...

에러 메시지가 "No such file or directory"이면 일단 pwd로 현재 위치부터 확인하자.


6. Permission denied on config.sh

$ bash config.sh --url ...
touch: cannot touch '.env': Permission denied
System.UnauthorizedAccessException: Access to the path '..._diag' is denied.

원인: actions-runner 디렉토리를 root로 압축 해제해서, 파일 소유자가 root였다. 일반 유저(hp-server)로 실행하니까 권한이 없었던 거다.

해결:

sudo chown -R hp-server:hp-server ~/Desktop/project/zero-v1/actions-runner

소유권 변경 후 다시 config.sh 실행하니까 정상적으로 진행됐다.


7. curl에서 404 응답

$ curl http://58.227.208.141//api/deploy-info
# 404 Not Found

원인: URL에 슬래시가 두 개 들어갔다. //api/deploy-info가 아니라 /api/deploy-info여야 한다.

해결:

curl http://58.227.208.141/api/deploy-info

사소한 오타지만 404가 나오면 "API가 안 되나?" 하고 다른 데서 원인을 찾게 된다. URL부터 다시 확인하는 습관이 필요하다.


이해하고 넘어가야 할 것들

서버 세팅하면서 몇 가지 "이거 어떻게 되는 거지?" 싶었던 부분이 있었다. 정리해본다.

Q. deploy.sh는 어떻게 실행될까?

GitHub Actions의 deploy job이 self-hosted runner에서 실행한다.

main에 push
  → GitHub Actions 트리거
    → build job (GitHub 서버): 테스트 + Docker 이미지 빌드
    → deploy job (내 서버의 runner): deploy.sh 실행

방금 설치한 runner가 GitHub의 에이전트 역할을 해서, GitHub 리포에 있는 코드를 서버에 내려받고 실행해주는 거다.

Q. DEPLOY_COLOR는 어떻게 주입될까?

Spring Boot의 ${DEPLOY_COLOR:local}은 환경변수에서 값을 읽는다. 이 환경변수는 deploy.sh가 docker run할 때 넣어준다.

# deploy.sh의 Step 1에서 상태 파일을 읽고 다음 컬러를 결정
NEXT="green"  # (현재가 blue이므로)

# Step 3에서 컨테이너 시작할 때 환경변수로 주입
sudo docker run -e DEPLOY_COLOR="${NEXT}" -e APP_VERSION="${DOCKER_TAG}" ...

컨테이너 자체는 자기가 blue인지 green인지 모른다. deploy.sh가 상태 파일을 보고 결정해서 환경변수로 알려주는 거다.


무중단 검증

여기가 제일 중요한 부분이다. "진짜 무중단인가?"를 어떻게 확인했는지.

방법: 0.5초마다 요청을 계속 보내면서 배포

터미널 2개를 열고:

터미널 1 — 모니터링 (0.5초마다 요청)

while true; do
  RESULT=$(curl -s -o /tmp/resp.txt -w "%{http_code}" http://<서버IP>/api/deploy-info 2>/dev/null)
  BODY=$(cat /tmp/resp.txt)
  VERSION=$(echo "$BODY" | grep -o '"version":"[^"]*"' | head -1)
  COLOR=$(echo "$BODY" | grep -o '"color":"[^"]*"' | head -1)
  echo "$(date '+%H:%M:%S') | HTTP ${RESULT} | ${VERSION} | ${COLOR}"
  sleep 0.5
done

터미널 2 — 코드 수정 후 push해서 배포 트리거

git add . && git commit -m "test deploy v2" && git push

결과

22:30:01 | HTTP 200 | "version":"abc1234" | "color":"green"
22:30:01 | HTTP 200 | "version":"abc1234" | "color":"green"
22:30:02 | HTTP 200 | "version":"abc1234" | "color":"green"
... (배포 진행 중 — 계속 200) ...
22:31:15 | HTTP 200 | "version":"abc1234" | "color":"green"
22:31:15 | HTTP 200 | "version":"def5678" | "color":"blue"    ← 여기서 전환!
22:31:16 | HTTP 200 | "version":"def5678" | "color":"blue"
22:31:16 | HTTP 200 | "version":"def5678" | "color":"blue"

실제로 돌려본 결과:

무중단 검증 — while loop 모니터링

502가 단 하나도 없다. 모든 요청이 HTTP 200이고, version과 color가 한 순간에 green→blue로 바뀌는 걸 볼 수 있다. 전환 사이에 요청 실패가 없었다.

이걸로 Blue-Green 무중단 배포가 정상 동작한다는 걸 확인했다.

판단 기준

결과의미
모든 응답이 HTTP 200무중단 성공
version/color가 한 순간에 바뀜Blue-Green 전환 정상
중간에 HTTP 502가 있음무중단 실패 — 다운타임 발생

정리

EC2 단일 서버에서 Nginx + Docker + GitHub Actions로 Blue-Green 무중단 배포를 구현하고 검증까지 완료했다.

핵심 포인트를 정리하면:

  1. Nginx upstream 전환이 Blue-Green의 핵심이다. 새 컨테이너가 완전히 준비된 후에 트래픽을 전환하니까 다운타임이 0이다.

  2. Health Check만으로는 부족하다. Nginx를 경유하는 Smoke Test까지 해야 "실제 사용자 관점에서 정상"인지 확인할 수 있다.

  3. 서버 세팅에서 삽질이 제일 많았다. 코드 구현보다 Runner 권한 문제, 경로 문제, 소유권 문제를 잡는 데 시간이 더 걸렸다.

  4. 자동 롤백 구조가 있으니까 배포가 편하다. Health check 실패하면 기존 컨테이너가 계속 돌아가니까 사용자 영향 없이 롤백된다.

이제 이 구조를 MOA 운영 서비스에 적용하면 되는데, POC에서는 안 다뤘지만 실제 MOA에 붙이면서 고민해야 할 것들이 있다.


다음 — MOA에 실제 적용하면서 다뤄볼 것들

POC는 순수한 API 서버였다. 근데 MOA는 그렇게 단순하지 않다. 실제로 적용하면서 추가로 풀어야 할 문제들이 있는데, 미리 정리해둔다.

배치 작업과 배포의 충돌

MOA에는 매일 12시에 돌아가는 알림 배치가 있다. 만약 이 배치가 FCM을 100건 보내는 도중에 배포가 들어오면?

12:00:00  Blue에서 알림 배치 시작 (FCM 100건 발송 중...)
12:00:30  배포 → Nginx 전환 → Blue에 SIGTERM
12:00:32  graceful shutdown 시작
          → HTTP 요청은 보호됨
          → ★ 근데 @Scheduled 배치는 HTTP 요청이 아님
12:01:02  30초 후 강제 종료 → 배치가 50건만 보내고 죽음 💥

server.shutdown: graceful이 보호하는 건 HTTP 요청뿐이다. @Scheduled 배치는 graceful shutdown 대상이 아니라서, Spring이 컨텍스트를 닫으면 그냥 중간에 끊긴다.

이 문제를 어떻게 풀 건지 — 배치를 graceful shutdown 대상에 포함시키는 방법, 배치 자체를 멱등하게 만드는 방법, 배포 전 배치 실행 여부를 체크하는 방법 등을 MOA 적용편에서 다뤄보려 한다.

그 외 MOA 적용 시 고려사항

  • DB 스키마 변경이 있는 배포: Blue와 Green이 동시에 같은 DB를 보는데, 컬럼 삭제 같은 breaking change가 있으면 Blue가 터진다. 하위 호환성을 유지하면서 2단계로 나눠 배포해야 한다.
  • 기존 CI/CD 파이프라인 전환: 현재 MOA의 docker stop → docker run 방식을 deploy.sh 기반으로 교체하면서 기존 workflow를 어디까지 건드릴지.
  • SSL 환경에서의 Nginx 설정: POC는 HTTP로 테스트했는데, MOA는 HTTPS(Let's Encrypt)가 걸려있다. upstream 설정을 SSL server 블록 안에 넣는 구조로 바꿔야 한다.

이런 부분들을 실제로 적용하면서 다음 글에 정리해보겠다.

claude + gemini이와 함께 공부하며 작성했습니다.

profile
기록하면서 레베럽

0개의 댓글