SSAFY 특화 프로젝트에서 인프라를 담당하면서 기존 EC2 서버 한 대에서 모든 도커 컨테이너를 실행하는 방식의 한계를 경험했다. 이 글에서는 그 문제점과, Jenkins와 Nginx를 활용한 카나리 배포 적용 과정을 정리한다.
서비스 중단 시간 발생
롤백 어려움
리스크 집중
카나리 배포(Canary Deployment)는 새로운 버전의 애플리케이션을 일부 사용자에게 먼저 배포한 후, 이상이 없으면 점진적으로 전체 트래픽으로 확장하는 배포 방식이다. 이를 통해 배포 중 발생할 수 있는 오류를 최소한의 사용자에게만 영향을 주도록 제한할 수 있다.
보통 Kubernetes(k8s)의 Ingress 및 서비스 라우팅 기능을 활용해 트래픽을 분산시키지만, 이번 프로젝트에서는 k8s 없이 Jenkins와 Nginx만으로 카나리 배포를 구현했다.
프로젝트 규모 대비 k8s 학습/운용 비용 고려
기존 인프라 스택(Jenkins, Nginx) 활용
k8s 없이 카나리 배포를 적용하기 위해 Jenkins와 Nginx를 활용한 트래픽 분산 방식을 설계했다. 이 과정은 다음과 같다.
애플리케이션 컨테이너 실행
Nginx를 통한 트래픽 분산
배포 모니터링
롤백 및 전체 전환
최종 정리
SSAFY에서 제공하는 EC2 서버는 1대뿐이었기 때문에, 개인적으로 AWS 프리 티어(t2.micro) 서버 2대를 추가로 구성했다.
SSAFY에서 제공하는 EC2(관리 서버) → Jenkins, Nginx, MySQL, Redis, Prometheus, Grafana 도커 컨테이너 실행
추가한 2대의 EC2 서버 → 각각 backend 및 frontend 컨테이너 실행
이렇게 구성한 후, 관리 서버에서 Backend와 Frontend Docker 이미지를 빌드하여 Docker Hub에 업로드하고, 각 애플리케이션 서버에서 해당 이미지를 내려받아 실행하려했다.
그러나 아래와 같은 문제들이 발생했다.
관리 서버에서 git clone을 수행해 Backend와 Frontend 서버에는 docker-compose.yml 파일이 존재하지 않아 docker-compose를 사용할 수 없다.
🎯 해결 방법 :
docker-comlose.yml 파일을 3개로 나눴다.
Nginx의 weight 값을 통해 트래픽 비율을 조정하는 방식이므로 nginx.conf 파일에서 트래픽 비율을 일일히 작성한 후, 수동으로 리로드 해야했다.
🎯 해결 방법 :
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
여러 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 보안 그룹에서 관리 서버의 주소만 접근 허용을 했다.
[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.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 ""
기존 방식에서는 배포 승인 단계에서 관리자가 직접 모니터링을 해서 문제 여부를 확인하고 이후 승인을 진행해 신규 버전으로 트래픽을 완전히 이동했다.
매번 수동으로 모니터링을 해야 하는 문제가 있었기 때문에 어떻게 이걸 자동화 처리 할 수 있을까 고민해보았다.
🎯 해결 방법 :
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 "✅ 모든 모니터링 단계가 성공했습니다. 모든 트래픽이 카나리 버전으로 전환되었습니다."
}
}
}
}
}
AWS 프리 티어(t2.micro)는 RAM이 1GB에 불과하여, 상태 체크를 위한 트래픽 발생 시 메모리 사용량이 90%에 달하는 문제가 있었다.
🎯해결 방법 :
각 서버에 swap 메모리를 설정하여 문제 해결
초기 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;
}
}
}
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;
}
코드 병합 및 빌드
각 서버에서 이미지 배포
Nginx 설정 업데이트 및 트래픽 전환
모니터링 및 승인 프로세스
이미지 태그 승격 및 최종 전환
정리 및 롤백 절차
알림 전송
이와 같이, Jenkins, Nginx, 그리고 Prometheus를 활용한 자동화된 파이프라인 덕분에 단일 EC2 환경에서도 안정적이고 점진적인 Canary 배포를 구현할 수 있었다.
고민도 많이하고 고생도 많이 했지만 결과를 보니 매우 뿌듯하고 재밌는 시간이였다.