카나리 배포 - SSAFY 특화 프로젝트 인프라 개선

Murhyun2·2025년 3월 19일
0

DevOps

목록 보기
1/1

SSAFY 특화 프로젝트에서 인프라를 담당하면서 기존 EC2 서버 한 대에서 모든 도커 컨테이너를 실행하는 방식의 한계를 경험했다. 이 글에서는 그 문제점과, Jenkins와 Nginx를 활용한 카나리 배포 적용 과정을 정리한다.


🔍 문제 인식

기존 단일 EC2 도커 배포 방식의 3가지 한계:

  1. 서비스 중단 시간 발생

    • 재배포 시 컨테이너 재시작으로 인한 다운타임
  2. 롤백 어려움

    • 문제 발생 시 즉시 이전 버전 복구 불가
  3. 리스크 집중

    • 모든 트래픽이 새 버전에 동시 적용되어 장애 영향도 확대

📌 카나리 배포란?

카나리 배포(Canary Deployment)는 새로운 버전의 애플리케이션을 일부 사용자에게 먼저 배포한 후, 이상이 없으면 점진적으로 전체 트래픽으로 확장하는 배포 방식이다. 이를 통해 배포 중 발생할 수 있는 오류를 최소한의 사용자에게만 영향을 주도록 제한할 수 있다.

보통 Kubernetes(k8s)의 Ingress 및 서비스 라우팅 기능을 활용해 트래픽을 분산시키지만, 이번 프로젝트에서는 k8s 없이 Jenkins와 Nginx만으로 카나리 배포를 구현했다.


🛠️ 개선 전략: 카나리 배포

왜 Kubernetes가 아닌 Jenkins + Nginx?

  • 프로젝트 규모 대비 k8s 학습/운용 비용 고려

  • 기존 인프라 스택(Jenkins, Nginx) 활용


🔧 Jenkins + Nginx 기반 카나리 배포 과정

k8s 없이 카나리 배포를 적용하기 위해 Jenkins와 Nginx를 활용한 트래픽 분산 방식을 설계했다. 이 과정은 다음과 같다.

  1. 애플리케이션 컨테이너 실행

    • 기존 버전(app-v1)과 새로운 버전(app-v2)의 컨테이너를 동시에 실행
  2. Nginx를 통한 트래픽 분산

    • Nginx 설정 파일을 수정해 초기에는 10%의 트래픽만 신규 버전(app-v2)으로 보내고, 점진적으로 비율을 늘려감
  3. 배포 모니터링

    • Jenkins 파이프라인을 통해 배포 진행 상황을 모니터링하며, 에러율과 응답 시간 등의 지표를 확인
  4. 롤백 및 전체 전환

    • 문제가 발생하면 즉시 롤백, 안정성이 확인되면 전체 트래픽을 신규 버전(app-v2)으로 전환
  5. 최종 정리

    • 최종적으로 기존 버전(app-v1)의 컨테이너를 종료하여, 신규 버전(app-v2)가 모든 요청을 처리하도록 변경

💡 주요 고민점 및 해결 방안

SSAFY에서 제공하는 EC2 서버는 1대뿐이었기 때문에, 개인적으로 AWS 프리 티어(t2.micro) 서버 2대를 추가로 구성했다.

  • SSAFY에서 제공하는 EC2(관리 서버) → Jenkins, Nginx, MySQL, Redis, Prometheus, Grafana 도커 컨테이너 실행

  • 추가한 2대의 EC2 서버 → 각각 backend 및 frontend 컨테이너 실행

이렇게 구성한 후, 관리 서버에서 Backend와 Frontend Docker 이미지를 빌드하여 Docker Hub에 업로드하고, 각 애플리케이션 서버에서 해당 이미지를 내려받아 실행하려했다.
그러나 아래와 같은 문제들이 발생했다.

1. 각 서버에 docker-compose.yml 파일이 없음

관리 서버에서 git clone을 수행해 Backend와 Frontend 서버에는 docker-compose.yml 파일이 존재하지 않아 docker-compose를 사용할 수 없다.

🎯 해결 방법 :

docker-comlose.yml 파일을 3개로 나눴다.

  • docker-compose.frontend.yml
  • docker-compose.backend.yml
  • docker-compose.infra.yml
    관리 서버에서 각 대상 서버에 ssh 접속 후, 해당 docker-compose 파일들을 scp로 복사했다.

2. Nginx의 weight 값을 수동으로 설정해야 함

Nginx의 weight 값을 통해 트래픽 비율을 조정하는 방식이므로 nginx.conf 파일에서 트래픽 비율을 일일히 작성한 후, 수동으로 리로드 해야했다.

  • NGINX Plus API를 사용하면 이러한 설정을 실시간으로 동적으로 변경할 수 있어 자동화가 가능하지만, 해당 기능은 유료 서비스여서 추가 비용이 발생한다. 따라서 매번 수동으로 리로드하는 방식의 한계를 극복하기 위해 대안을 모색했다.

🎯 해결 방법 :

gettext의 envsubst를 활용하여 환경 변수(TRAFFIC_SPLIT)를 템플릿에 대입한 후, nginx.conf를 생성하고 -s reload 명령어로 리로드한다.

parameters {
	string(name: 'TRAFFIC_SPLIT', defaultValue: '10', description: '카나리 배포 시 트래픽 비율 (%)')
}

# gettext가 설치되지 않았다면 설치
if ! dpkg -s gettext > /dev/null 2>&1; then
	sudo apt-get update && sudo apt-get install -y gettext
fi

set -a
. ${WORKSPACE}/.env
set +a

export TRAFFIC_SPLIT=${TRAFFIC_SPLIT}
envsubst < ${WORKSPACE}/nginx/nginx.conf.template > ./nginx/nginx.conf

# nginx_lb 컨테이너가 실행 중인지 확인하고 실행되지 않았다면 시작
if ! docker ps --filter "name=nginx_lb" --filter "status=running" | grep -q "nginx_lb"; then
	echo "nginx_lb 컨테이너가 실행 중이지 않습니다. 시작합니다."
    envsubst < \${WORKSPACE}/prometheus.template.yml > ./prometheus.yml
	docker compose -f docker-compose.infra.yml up -d
else
	echo "nginx_lb 컨테이너가 실행 중입니다. nginx 리로드를 수행합니다."
	docker exec nginx_lb nginx -s reload
fi

3. 서로 다른 서버 간 통신 문제

여러 EC2 인스턴스에서 Backend, Frontend, MySQL, Redis, Nginx 등 각 서비스가 독립적으로 실행되면서, 서버 간 보안 문제와 더불어 통신에 장애가 발생했다.

  • 서비스 간 통신 장애: 각 서비스가 다른 EC2 인스턴스에서 실행되면서 서로 통신해야 했지만, 네트워크 설정 및 서비스 자체의 기본 설정으로 인해 연결에 어려움이 있었다.

  • MySQL 접근 제한: MySQL 서버는 기본적으로 로컬 네트워크 인터페이스(127.0.0.1)에서 오는 요청만 허용하도록 설정되어 (bind-address = 127.0.0.1), 다른 EC2 인스턴스(예: Backend 서버)에서 MySQL 데이터베이스로 직접 접근할 수 없었다.

  • Redis 접근 제한: MySQL과 유사하게, Redis 서버 역시 기본 설정 또는 특정 설정 하에서 로컬 인터페이스(127.0.0.1)에만 바인딩되어 있어 다른 EC2 인스턴스에서 Redis 서버로의 접근이 불가능했다.

  • 보안 우려: 서비스 간 통신 및 외부와의 통신 경로에서 데이터가 암호화되지 않거나, 불필요한 접근이 허용될 수 있는 보안상의 문제가 있었다.

🎯 해결 방법 :

각 EC2 서버의 방화벽과 AWS 보안 그룹 설정을 조정하여, 필요한 포트와 프로토콜 간의 통신을 허용했다. 각 서버마다 SSL을 통한 https를 활용하고 외부에서의 접근을 차단하기 위해 AWS 보안 그룹에서 관리 서버의 주소만 접근 허용을 했다.

  • MySQL bind-address 수정:
    MySQL 설정 파일(my.cnf)에서 bind-address 값을 0.0.0.0 로 설정해모든 요청에 대해 열어주어 해결하였다.
[mysqld]
host-cache-size=0
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init-connect = 'SET NAMES utf8mb4'
default-time-zone = 'Asia/Seoul'
bind-address = 0.0.0.0

pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock
default-character-set = utf8mb4

[mysql]
default-character-set = utf8mb4
  • redis - redis.conf 설정:
    bind 0.0.0.0 와 Backend 애플리케이션의 Redis 설정에서 TLS 연결 옵션을 활성화하여 Backend와 Redis 서버 간의 통신을 암호화했다.
# redis.conf.template

# 비밀번호 설정
requirepass $REDIS_PASSWORD
# Non-TLS 포트 비활성화 (TLS 포트만 사용)
port 0
# TLS 포트 활성화
tls-port 6379
# TCP keepalive 설정 (네트워크 불안정 시 연결 유지)
tcp-keepalive 300
bind 0.0.0.0
databases 16

# ## TLS/SSL 설정 ##
# 서버 인증서 파일 경로
tls-cert-file /etc/redis/certs/redis.crt
# 서버 개인 키 파일 경로
tls-key-file /etc/redis/certs/redis.key

# 클라이언트 인증서 요구 안 함 (단방향 TLS)
tls-auth-clients no
# 허용할 TLS 프로토콜 버전
tls-protocols "TLSv1.2 TLSv1.3"
# 서버가 선호하는 암호화 스위트 사용
tls-prefer-server-ciphers yes

save 900 1
save 300 10
save 60 10000

dbfilename dump.rdb
dir /data

maxmemory 1gb
maxmemory-policy allkeys-lru

# CONFIG 명령어 비활성화
rename-command CONFIG ""
# 복제 관련 명령어 비활성화
rename-command SLAVEOF ""
rename-command REPLICAOF ""
# 전체 삭제 명령어 비활성화
rename-command FLUSHALL ""
rename-command FLUSHDB ""
# KEYS 명령어 비활성화 (성능 이슈 방지)
rename-command KEYS ""
  • 네트워크 방화벽 및 보안 그룹 조정:
    각 EC2 인스턴스에 연결된 보안 그룹의 인바운드 규칙을 수정해 관리 서버에서만 접근 가능하도록 그룹 규칙을 엄격하게 제한했다.

4. 관리자의 수동 승인 절차

기존 방식에서는 배포 승인 단계에서 관리자가 직접 모니터링을 해서 문제 여부를 확인하고 이후 승인을 진행해 신규 버전으로 트래픽을 완전히 이동했다.
매번 수동으로 모니터링을 해야 하는 문제가 있었기 때문에 어떻게 이걸 자동화 처리 할 수 있을까 고민해보았다.

🎯 해결 방법 :

Prometheus를 활용하여 자동으로 모니터링을 할 수 있는 방법을 고민해보았고, Prometheus에서 배포 상태(오류율, 응답 시간)를 모니터링해서 자동 승인하도록 개선했다.

해당 쿼리를 통해 canary 서비스에서 발생한 SERVER_ERROR 요청을 체크해 오류율을 체크했다.

sum(rate(http_server_requests_seconds_count{outcome="SERVER_ERROR", job="backend-canary"}[5m])) 
/ 
sum(rate(http_server_requests_seconds_count{job="backend-canary"}[5m])) * 100

또한 (총 응답 시간 변화율) / (요청 개수 변화율) = 평균 응답 시간 으로
각 요청당 평균 응답 시간을 계산해서 응답 시간을 체크했다.

sum(rate(http_server_requests_seconds_sum{job="backend-canary"}[5m])) 
/ 
sum(rate(http_server_requests_seconds_count{job="backend-canary"}[5m]))


stage('Monitor Canary with Prometheus') {
    agent { label 'public-dev' }
    steps {
        script {
            sleep(20) // 카나리 배포 후 안정화 대기 (20초)
            
            def trafficPercentages = [10, 30, 60, 100]
            def overallSuccess = true
            
            for (def percent in trafficPercentages) {
                echo "카나리 버전으로 트래픽을 ${percent}%로 설정합니다..."
                
                def stableWeight = 100 - percent
                def canaryWeight = percent
                
                if (percent == 100) {
                    sh """
                        set -a
                        . \${WORKSPACE}/.env
                        set +a
                        envsubst '\$EC2_PUBLIC_HOST \$STABLE_BACKEND_PORT \$CANARY_BACKEND_PORT \$STABLE_FRONTEND_PORT \$CANARY_FRONTEND_PORT' < \${WORKSPACE}/nginx/nginx.canary.develop.conf.template > ./nginx/nginx.conf
                        docker exec nginx_lb nginx -s reload
                    """
                } else {
                    withEnv(["STABLE_WEIGHT=${stableWeight}", "CANARY_WEIGHT=${canaryWeight}"]) {
                        echo "환경 변수 설정: STABLE_WEIGHT=${env.STABLE_WEIGHT}, CANARY_WEIGHT=${env.CANARY_WEIGHT}"
                        sh """
                            set -a
                            . \${WORKSPACE}/.env
                            set +a
                            envsubst '\$EC2_PUBLIC_HOST \$STABLE_BACKEND_PORT \$CANARY_BACKEND_PORT \$STABLE_FRONTEND_PORT \$CANARY_FRONTEND_PORT \$STABLE_WEIGHT \$CANARY_WEIGHT' < \${WORKSPACE}/nginx/nginx.develop.conf.template > ./nginx/nginx.conf
                            docker exec nginx_lb nginx -s reload
                        """
                    }
                }
                
                echo "카나리 버전을 ${percent}% 트래픽으로 모니터링합니다..."
                def startTime = System.currentTimeMillis()
                def endTime = startTime + (env.MONITORING_DURATION.toLong() * 1000)
                def stageSuccess = true
                
                parallel(
                    "Generate Traffic": {
                        script {
                            def duration = env.MONITORING_DURATION.toInteger()
                            echo "카나리 버전을 ${percent}% 트래픽으로 설정. 트래픽을 생성합니다..."
                            sh """
                                for i in \$(seq 1 ${duration}); do
                                    curl -s http://${EC2_PUBLIC_HOST}/api/actuator/health || true
                                    sleep 1
                                done
                            """
                            echo "테스트 트래픽 생성 완료!"
                        }
                    },
                    "Monitor Metrics": {
                        script {
                            def metricCheckStart = System.currentTimeMillis()
                            echo "카나리 버전을 ${percent}% 트래픽으로 모니터링합니다..."
                            
                            while (System.currentTimeMillis() < endTime) {
                                try {
                                    def upQuery = "up{job=\"backend-canary\"}"
                                    def encodedUpQuery = URLEncoder.encode(upQuery, "UTF-8")
                                    def upResponse = sh(script: "curl -s \"http://${EC2_PUBLIC_HOST}:${PROMETHEUS_PORT}/api/v1/query?query=${encodedUpQuery}\"", returnStdout: true).trim()
                                    echo "Up Status Response: ${upResponse}"
                                    
                                    def upJson = readJSON(text: upResponse)
                                    def isUp = upJson.data.result.any { it.metric.job == "backend-canary" && it.value[1] == "1" }
                                    
                                    if (!isUp) {
                                        echo "backend-canary 서비스가 아직 준비되지 않았습니다. 대기 중..."
                                        sleep(10)
                                        continue
                                    }
                                    
                                    def timeRange = "5m"
                                    def errorRateQuery = "sum(rate(http_server_requests_seconds_count{outcome=\"SERVER_ERROR\", job=\"backend-canary\"}[${timeRange}])) / sum(rate(http_server_requests_seconds_count{job=\"backend-canary\"}[${timeRange}])) * 100"
                                    def encodedQuery = URLEncoder.encode(errorRateQuery, "UTF-8")
                                    def errorRateResponse = sh(script: "curl -s \"http://${EC2_PUBLIC_HOST}:${PROMETHEUS_PORT}/api/v1/query?query=${encodedQuery}\"", returnStdout: true).trim()
                                    echo "Error Rate Response: ${errorRateResponse}"
                                    
                                    def errorRateJson = readJSON(text: errorRateResponse)
                                    def errorRate = errorRateJson.data.result ? errorRateJson.data.result[0].value[1].toFloat() : 0.0
                                    
                                    def responseTimeQuery = "sum(rate(http_server_requests_seconds_sum{job=\"backend-canary\"}[${timeRange}])) / sum(rate(http_server_requests_seconds_count{job=\"backend-canary\"}[${timeRange}]))"
                                    def encodedRespTimeQuery = URLEncoder.encode(responseTimeQuery, "UTF-8")
                                    def responseTimeResponse = sh(script: "curl -s \"http://${EC2_PUBLIC_HOST}:${PROMETHEUS_PORT}/api/v1/query?query=${encodedRespTimeQuery}\"", returnStdout: true).trim()
                                    echo "Response Time Response: ${responseTimeResponse}"
                                    
                                    def responseTimeJson = readJSON(text: responseTimeResponse)
                                    def responseTime = responseTimeJson.data.result ? responseTimeJson.data.result[0].value[1].toFloat() : 0.0
                                    
                                    echo "현재 오류율: ${errorRate}%, 응답 시간: ${responseTime}초"
                                    
                                    if (errorRate > env.ERROR_RATE_THRESHOLD.toFloat() || responseTime > env.RESPONSE_TIME_THRESHOLD.toFloat()) {
                                        stageSuccess = false
                                        error("❌ 카나리 모니터링 실패: 오류율(${errorRate}%) 또는 응답 시간(${responseTime}초)이 임계값을 초과했습니다!")
                                    }
                                    
                                    sleep(10)
                                } catch (Exception e) {
                                    echo "모니터링 중 오류 발생: ${e.message}"
                                    if (e.message.contains("카나리 모니터링 실패")) {
                                        throw e
                                    }
                                    sleep(10)
                                }
                            }
                            
                            if (stageSuccess) {
                                echo "✅ 카나리 모니터링 성공: ${percent}% 트래픽에서 모든 메트릭이 정상 범위 내에 있습니다."
                            }
                        }
                    }
                )
                
                if (!stageSuccess) {
                    overallSuccess = false
                    error("❌ ${percent}% 트래픽에서 카나리 모니터링 실패!")
                }
                
                if (percent == 100 && overallSuccess) {
                    echo "✅ 모든 모니터링 단계가 성공했습니다. 모든 트래픽이 카나리 버전으로 전환되었습니다."
                }
            }
        }
    }
}

5. AWS 프리티어 서버의 낮은 메모리

AWS 프리 티어(t2.micro)는 RAM이 1GB에 불과하여, 상태 체크를 위한 트래픽 발생 시 메모리 사용량이 90%에 달하는 문제가 있었다.

🎯해결 방법 :

각 서버에 swap 메모리를 설정하여 문제 해결


6. 버전 간 트래픽 불일치 문제

초기 nginx 설정에서는 단순히 weight 값에 따라 트래픽을 분산하다 보니, canary 프론트엔드에 접속한 클라이언트가 의도치 않게 stable 백엔드로(& stable -> canary) 연결되는 문제가 발생했다.
아래와 같이 backend와 frontend에 대해 단순 weight 기반으로 분산하고 있었다.

    upstream backend {
        server $EC2_BACKEND_HOST:$STABLE_BACKEND_PORT weight=$STABLE_WEIGHT;
        server $EC2_BACKEND_HOST:$CANARY_BACKEND_PORT weight=$CANARY_WEIGHT;
    }

    upstream frontend {
        server $EC2_FRONTEND_HOST:$STABLE_FRONTEND_PORT weight=$STABLE_WEIGHT;
        server $EC2_FRONTEND_HOST:$CANARY_FRONTEND_PORT weight=$CANARY_WEIGHT;
    }

🎯 해결 방법 :

Nginx의 split_clients와 map 기능을 활용해 클라이언트별로 stable과 canary 버전을 명확히 분리했다.

  • 버전 분할: 클라이언트의 IP와 브라우저 정보를 기반으로 split_clients를 사용해 각 요청에 stable 또는 canary 버전을 할당한다.

  • 쿠키 설정: 최초 접속 시 할당된 버전을 쿠키에 저장해, 재방문 시 동일한 버전으로 연결되도록 한다.

  • 업스트림 매핑: map 지시어를 사용해 $final_version 값에 따라 각각의 frontend와 backend 업스트림 그룹(frontend_stable, frontend_canary, backend_stable, backend_canary)을 지정하여 트래픽을 일관되게 라우팅한다.

이러한 방식으로 동일 클라이언트는 항상 같은 버전의 프론트엔드와 백엔드를 사용하게 되어 트래픽 불일치 문제를 해결할 수 있었다.

events {
    worker_connections 1024;
}

http {
    # HTTP 업그레이드 관련 기본값 처리
    map $http_upgrade $connection_upgrade {
        default upgrade;
        "" close;
    }

    # 버전 분할 설정 (같은 IP와 브라우저를 사용하는 경우 항상 동일한 버전을 배정)
    split_clients "${remote_addr}${http_user_agent}" $version_key {
        $STABLE_WEIGHT% "stable";
        * "canary";
    }

    # 쿠키가 존재하면 쿠키의 값을, 없으면 split_clients 결과($version_key)를 사용
    map $cookie_service_version $final_version {
        "" $version_key;
        default $cookie_service_version;
    }

    # 업스트림 그룹 정의
    upstream frontend_stable {
        server $EC2_FRONTEND_HOST:$STABLE_FRONTEND_PORT;
    }
    upstream frontend_canary {
        server $EC2_FRONTEND_HOST:$CANARY_FRONTEND_PORT;
    }
    upstream backend_stable {
        server $EC2_BACKEND_HOST:$STABLE_BACKEND_PORT;
    }
    upstream backend_canary {
        server $EC2_BACKEND_HOST:$CANARY_BACKEND_PORT;
    }

    # 최종 버전 값($final_version)에 따른 프론트엔드/백엔드 매핑
    map $final_version $frontend_group {
        "stable" "frontend_stable";
        "canary" "frontend_canary";
    }
    map $final_version $backend_group {
        "stable" "backend_stable";
        "canary" "backend_canary";
    }

    # HTTP 접근을 HTTPS로 리다이렉트
    server {
        listen 80;
        server_name j12b209.p.ssafy.io;
        return 301 https://$host$request_uri;
    }

    # HTTPS 서버 블록
    server {
        listen 443 ssl http2;
        server_name j12b209.p.ssafy.io;

        # SSL 인증서 설정
        ssl_certificate /etc/letsencrypt/live/j12b209.p.ssafy.io/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/j12b209.p.ssafy.io/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;

        # Next.js 서버로 프록시
        location / {
            proxy_pass http://$frontend_group;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            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;

            # 버전 쿠키 설정 (최초 요청시만 : 이후 동일한 클라이언트가 재방문할 때 같은 버전을 계속 사용하도록 하게)
            if ($cookie_service_version = "") {
                add_header Set-Cookie "service_version=$final_version; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax";
            }
        }

        # 정적 파일 제공 (Next.js 빌드된 파일)
        location /_next/ {
            proxy_pass http://$frontend_group;
            proxy_cache_valid 200 1h;
            proxy_set_header Cache-Control "public, max-age=31536000, immutable";
        }

        # API 요청은 백엔드 그룹으로 전달
        location /api/ {
            proxy_pass http://$backend_group;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            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;
        }
    }
}

7. 버전 간 정적파일 불일치

6번의 트래픽 불일치 문제를 수정하면서 테스트를 해보니 front와 back은 동일하게 매핑 되었지만 웹 페이지를 호출할때 두 버전 간 정적 파일 불일치 문제로 인해 404 에러가 발생하는 경우가 간헐적으로 생겼다.

# 정적 파일 제공 (Next.js 빌드된 파일)
location /_next/ {
    proxy_pass http://$frontend_group;
    proxy_cache_valid 200 1h;
    proxy_set_header Cache-Control "public, max-age=31536000, immutable";
}

여기서 $frontend_group은 클라이언트가 할당받은 버전에 따라 stable 혹은 canary 컨테이너로 라우팅된다.
문제는 두 버전 간 정적 파일이 완전히 일치하지 않을 때 발생한다. 예를 들어 HTML은 canary 버전에서 생성되었지만 클라이언트의 일부 정적 파일 요청은 stable 버전에서 처리되어 해당 파일이 없으면 404 에러가 발생한다.

🎯 해결 방법 :

정적 파일 요청 시 우선 stable 버전에서 파일을 찾고, 해당 파일이 없으면 canary 버전으로 fallback하는 방식을 적용했다. 이를 위해서 Nginx의 proxy_intercept_errors와 error_page 지시어를 활용했다.

  • 클라이언트가 /_next/ 또는 /images/에 요청하면 우선 stable 버전 컨테이너(frontend_stable)에서 정적 파일을 찾는다.

  • stable 버전에 해당 파일이 없는 경우, Nginx는 404 에러를 가로채고 error_page 지시어에 따라 fallback 처리로 지정된 canary 블록(@canary, @canary_images)으로 요청을 전달한다.

  • canary 버전 컨테이너(frontend_canary)에서 요청을 처리하여 정적 파일을 제공한다.

# 정적 파일의 경우, stable에서 먼저 찾고 없으면 canary로 fallback
location /_next/ {
    proxy_intercept_errors on;
    proxy_pass http://frontend_stable;
    proxy_cache_valid 200 1h;
    proxy_set_header Cache-Control "public, max-age=31536000, immutable";
    # 만약 stable 버전에 파일이 없으면 error_page를 통해 @canary로 요청 전달
    error_page 404 = @canary;
}

location @canary {
    proxy_pass http://frontend_canary;
    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;
}

# /images/ 경로 fallback 설정
location /images/ {
    proxy_intercept_errors on;
    proxy_pass http://frontend_stable;
    error_page 404 = @canary_images;
}

location @canary_images {
    proxy_pass http://frontend_canary;
    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;
}

🤷 동작 흐름 (워크플로우)

  1. 코드 병합 및 빌드

    • Git의 master 브랜치에 코드가 병합되면, Git Webhook을 통해 Jenkins에 배포 요청이 전달된다.
    • Jenkins는 저장소에서 코드를 pull한 후, 환경 변수 파일(.env)을 로드하여 배포 환경을 구성한다.
    • 이후 Backend와 Frontend의 Canary 버전 이미지를 빌드하여 Docker Hub에 push한다.
  2. 각 서버에서 이미지 배포

    • 각 애플리케이션 서버(Frontend 및 Backend)의 EC2 인스턴스에 SSH로 접속해, 사전에 준비한 docker-compose 파일(각각 docker-compose.frontend.yml, docker-compose.backend.yml)을 사용하여 최신 Docker 이미지를 pull 및 실행한다.
    • 배포 후, gettext의 envsubst를 활용해 nginx.conf.template 파일의 환경 변수 값을 치환하여 실제 nginx.conf 파일을 생성한다.
  3. Nginx 설정 업데이트 및 트래픽 전환

    • 초기 실행 시 docker-compose.infra.yml을 사용해 Nginx, Prometheus 등 인프라 컨테이너들을 기동한다.
    • 이미 컨테이너가 실행 중이면 nginx -s reload 명령으로 nginx 설정을 업데이트한다.
    • Canary 배포 단계에서는 트래픽 분배 비율을 10%, 30%, 60%, 100%로 점진적으로 변경하여, 새로운 버전의 안정성을 검증한다.
  4. 모니터링 및 승인 프로세스

    • 배포 과정 동안 Jenkins 파이프라인은 임의의 트래픽을 생성하고, Prometheus를 통해 Canary 버전의 응답 시간과 오류율을 모니터링한다.
    • 설정한 임계값 이하의 성능 지표가 확인되면 해당 단계가 승인되고, 다음 트래픽 전환 단계로 진행된다.
  5. 이미지 태그 승격 및 최종 전환

    • Canary 버전의 이미지가 안정적으로 검증되면, 이를 stable 및 latest 태그로 승격하여 Docker Hub에 push 한다.
    • 각 애플리케이션 서버는 최신 (latest) 이미지를 pull한 후, Nginx의 트래픽 라우팅을 최신 버전으로 전환한다.
  6. 정리 및 롤백 절차

    • 혹시 모를 장애에 대비해, 이전 stable 버전 이미지를 최소 3개를 보관하고 불필요한 Docker 이미지는 정리한다.
    • 배포의 어느 단계에서라도 문제가 발생하면, 자동으로 이전 stable 버전으로 롤백하는 절차가 실행된다.
  7. 알림 전송

    • 모든 배포 단계가 종료되면, Jenkins 빌드 결과를 Mattermost와 같은 협업 도구로 전송해 팀원들에게 배포 상태를 공유한다.

이와 같이, Jenkins, Nginx, 그리고 Prometheus를 활용한 자동화된 파이프라인 덕분에 단일 EC2 환경에서도 안정적이고 점진적인 Canary 배포를 구현할 수 있었다.
고민도 많이하고 고생도 많이 했지만 결과를 보니 매우 뿌듯하고 재밌는 시간이였다.


Jenkins pipeline

  • pipeline 실행 결과
  • 실패시 이전 버전으로 롤백

카나리 버전 트래픽 전환

  • 테스트를 위해 위에 작성한 쿠키 설정은 주석처리 하였다.
profile
왜?를 생각하며 개발하기

0개의 댓글