FastAPI + Gunicorn + Docker로 Blue-Green 무중단 배포
백엔드로 사용중인 파이썬 프로젝트를 배포해보겠습니다.
원래 두개의 서버를 띄워놓고 nginx로 라우팅(Nginx 링크)만 전환하는 구조였는데
제 백엔드 프로젝트는 스케쥴러를 통해 INSERT를 하다보니 두개를 동시에 띄우는게 좀 그래서
하나 켜고 하나 끄는 구조로 적용했습니다.
📁 프로젝트 디렉토리 구조
root에 바로 main이 있는 구조!
plc_insert_backend/
├── main.py # lifespan에서 APScheduler 실행
├── .env # 환경 변수 (DB 연결 등)
├── .example # .example->.env로 전환
├── Dockerfile # Gunicorn + FastAPI 실행용
├── requirements.txt # pip freeze 결과
├── blue/
│ └── docker-compose.8088.yml # Blue 배포용 (port 8088)
├── green/
│ └── docker-compose.8089.yml # Green 배포용 (port 8089)
├── deploy.sh # Blue-Green 자동 배포 스크립트
🐳 Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
참고로 가상환경사용시 requirements.txt는 아래명령어로 얻을수 있습니다.
pip freeze > requirements.txt
만약 main.py가 app 폴더 아래에 있으면
app.main:app 이런식으로 작성
🐳 docker-compose.8088.yml (blue/)
version: "3.8"
services:
backend-blue:
container_name: backend-blue
image: localhost:5000/backend_back:latest # ✅ Jenkins에서 push한 이미지 사용
env_file:
- ../.env
ports:
- "8088:8000" # 호스트:컨테이너
restart: always
🐳 docker-compose.8089.yml (green/)
version: "3.8"
services:
backend-green:
container_name: backend-green
image: localhost:5000/backend_back:latest # ✅ 동일 이미지 사용
env_file:
- ../.env
ports:
- "8089:8000"
restart: always
🌐 NGINX 설정
📄 sites-available
cd /etc/nginx/sites-available
vi backend.conf
server {
listen 8282;
server_name localhost;
location / {
include /etc/nginx/conf.d/backend-url.inc;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
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;
}
}
sudo ln -s /etc/nginx/sites-available/backend.conf /etc/nginx/sites-enabled/backend.conf
📄 /etc/nginx/conf.d/
vi backend-url-blue.inc
set $backend_upstream http://127.0.0.1:8088;
vi backend-url-green.inc
set $backend_upstream http://127.0.0.1:8089;
sudo ln -s /etc/nginx/conf.d/backend-url-blue.inc /etc/nginx/conf.d/backend-url.inc
변경적용
sudo nginx -t && sudo systemctl reload nginx
🚀 deploy.sh (무중단 전환 + health check + 이전 컨테이너 종료)
#!/bin/bash
# === .env 파일 복사 ===
ENV_PATH="$(dirname "$0")/.env"
ENV_EXAMPLE_PATH="$(dirname "$0")/.example"
if [ ! -f "$ENV_PATH" ]; then
cp "$ENV_EXAMPLE_PATH" "$ENV_PATH"
fi
# === 설정 ===
APP_NAME="backend"
BASE_PATH="/home/youruser/fastapi_backend"
NGINX_INC_DIR="/etc/nginx/conf.d"
BLUE_PORT=8088
GREEN_PORT=8089
BLUE_COMPOSE="${BASE_PATH}/blue/docker-compose.${BLUE_PORT}.yml"
GREEN_COMPOSE="${BASE_PATH}/green/docker-compose.${GREEN_PORT}.yml"
# === Docker 이미지 Pull ===
docker compose -p ${APP_NAME}-blue -f $BLUE_COMPOSE pull
docker compose -p ${APP_NAME}-green -f $GREEN_COMPOSE pull
# === 현재 라우팅 판단 (심볼릭 링크 확인)
CURRENT_LINK=$(readlink "${NGINX_INC_DIR}/${APP_NAME}-url.inc")
if echo "$CURRENT_LINK" | grep -q "green"; then
BEFORE_COLOR="green"
AFTER_COLOR="blue"
BEFORE_PORT=$GREEN_PORT
AFTER_PORT=$BLUE_PORT
BEFORE_COMPOSE=$GREEN_COMPOSE
AFTER_COMPOSE=$BLUE_COMPOSE
else
BEFORE_COLOR="blue"
AFTER_COLOR="green"
BEFORE_PORT=$BLUE_PORT
AFTER_PORT=$GREEN_PORT
BEFORE_COMPOSE=$BLUE_COMPOSE
AFTER_COMPOSE=$GREEN_COMPOSE
fi
echo "🟢 ${APP_NAME} - ${AFTER_COLOR}(${AFTER_PORT}) 컨테이너 실행"
docker compose -p ${APP_NAME}-${AFTER_COLOR} -f ${AFTER_COMPOSE} up -d --force-recreate
for cnt in $(seq 1 10); do
echo "🔍 서버 응답 확인 중... (${cnt}/10)"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${AFTER_PORT}/health)
if [ "$STATUS" == "200" ]; then
echo "🎉 Health Check 성공!"
break
fi
sleep 3
done
if [ $cnt -eq 10 ]; then
echo "❌ Health check 실패. ${AFTER_COLOR} 컨테이너 중단"
docker compose -p ${APP_NAME}-${AFTER_COLOR} -f ${AFTER_COMPOSE} down
exit 1
fi
INC_FILE="${NGINX_INC_DIR}/${APP_NAME}-url.inc"
NEW_INC="${NGINX_INC_DIR}/${APP_NAME}-url-${AFTER_COLOR}.inc"
echo "🔁 NGINX 라우팅 전환 → ${NEW_INC}"
ln -sf "$NEW_INC" "$INC_FILE"
nginx -t && nginx -s reload
echo "🛑 이전 ${BEFORE_COLOR}(${BEFORE_PORT}) 컨테이너 중지"
docker compose -p ${APP_NAME}-${BEFORE_COLOR} -f ${BEFORE_COMPOSE} down
echo "🚀 ${APP_NAME} 블루그린 배포 완료: ${AFTER_COLOR} 서버로 전환"
jenkins
pipeline {
agent any
/* ───────── 공통 환경변수 ───────────────────────────────── */
environment {
LOCAL_REGISTRY = 'localhost:5000/yourapp/backend' // ① 로컬 레지스트리
USER_ID = 'yourid' // ② 서버 계정
HOST_URL = 'your.iptime.org' // ③ 서버 주소
SSH_PORT = '22' // ④ SSH 포트
BASE_PATH = '/home/project/yourapp/backend' // ⑤ 서버 기준 경로
SCRIPT_DIR = "${BASE_PATH}/deploy.sh" // ⑥ deploy.sh 경로
GIT_URL = 'http://iptime.org:44440/app/yourapp.git'
}
/* ───────── 단계별 작업 ───────────────────────────────── */
stages {
/* 1. Git Clone ------------------------------------------------ */
stage('Git Clone') {
steps {
git branch: 'main',
credentialsId: 'gitlab-account-token',
url: "${GIT_URL}"
}
post {
success { echo '✅ Git clone 성공' }
failure { error '❌ Git clone 실패' }
}
}
/* 2. Docker Build --------------------------------------------- */
stage('Build Docker Image') {
steps {
sh "docker build -t ${LOCAL_REGISTRY}:latest ."
}
}
/* 3. Docker Push ---------------------------------------------- */
stage('Push Docker Image') {
steps {
sh "docker push ${LOCAL_REGISTRY}:latest"
}
post {
success { echo '✅ Docker 이미지 push 성공' }
failure { error '❌ Docker 이미지 push 실패' }
}
}
/* 4. deploy.sh / .example 업로드 ------------------------------ */
stage('Upload deploy.sh & .example') {
steps {
script {
sshagent(credentials: ['ssh-credentials']) {
sh """
# 워크스페이스의 최신 파일 → 원격 서버 BASE_PATH 로 복사
scp -P ${SSH_PORT} -o StrictHostKeyChecking=no \\
deploy.sh \\
.example \\
${USER_ID}@${HOST_URL}:${BASE_PATH}/
"""
}
}
}
post {
success { echo '✅ deploy.sh / .example 업로드 완료' }
failure { error '❌ deploy.sh 업로드 실패' }
}
}
/* 5. 원격 배포 실행 ------------------------------------------- */
stage('Deploy to Server') {
steps {
script {
sshagent(credentials: ['ssh-credentials']) {
sh """
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no \\
${USER_ID}@${HOST_URL} "\
chmod +x ${SCRIPT_DIR} && \
sudo -n ${SCRIPT_DIR}"
"""
}
}
}
}
/* 6. 오래된 Docker 정리 --------------------------------------- */
stage('Cleanup Old Docker Images') {
steps {
script {
sshagent(credentials: ['ssh-credentials']) {
sh """
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no \\
${USER_ID}@${HOST_URL} "\
docker image prune -a -f --filter 'until=24h' && \
docker container prune -f"
"""
}
}
}
}
}
/* ───────── 파이프라인 종료 메시지 ────────────────────────── */
post {
always { echo '📦 파이프라인 종료' }
}
}
중요
1) 제일 중요한게
requirement.txt
필요한 패키지는 반드시 다 있어야함.
pip freeze > requirements.txt
2) 스케줄링관련 문제
main.py안에서 scheduler.start()는 @app.on_event("startup") 같은 FastAPI의 비동기 이벤트로 동작합니다.
gunicorn은 워커프로세스를 여러개 돌리기 때문에 scheduler.start() 자체가 누락될 수 있습니다.
고로 uvicorn을 써보세요
dockerfile에서
기존 코드는 주석하고
# CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
uvicorn으로 변경해서 해보세요
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
참고로 main:app 에서 main은 python 파일 실행파일이름과 일치해야합니다.
main.py 면 main:app
app.py면 app:app
이런식으로 써야합니다.
visudo 등록
username ALL=(ALL) NOPASSWD: /home/project/*/deploy.sh
https://jwinjection.tistory.com/449
Ubuntu | 특정사용자 sudo 비밀번호 요구 예외거는법
sudo 비밀번호 요구 예외걸기0. 현재 사용자에게 허용된 sudo 명령 목록확인sudo -l1. visudo 명령어 실행sudo visudo 2. 아래 명령어 추가yourid ALL=(ALL) NOPASSWD: /myproject/*/deploy-script.sh 예시3. 저장ctrl + o -> ente
jwinjection.tistory.com
📝 마무리 요약
- FastAPI 앱은 하나의 코드베이스로 유지
- Blue/Green 디렉토리는 docker-compose 설정만 분리
- NGINX는 set $backend_upstream ... 방식으로 포트 스위칭
- deploy.sh로 실제 실행 → health check → NGINX 전환 → 이전 컨테이너 종료
'DevOps > 🛠️ CICD' 카테고리의 다른 글
리액트 빌드시간 최적화 (2) | 2025.05.26 |
---|---|
React 배포 및 CICD & 백엔드연동 (1) | 2025.05.15 |
CICD | Webhook을 이용한 Blue-Green 배포 구현 (2) | 2024.10.05 |
Jenkins | GitLab webhook설정 (1) | 2024.10.01 |
DuckDNS로 무료 도메인 등록하기 (0) | 2024.10.01 |