최근 회사에서 Nginx의 upstream 로드 밸런싱 그룹을 이용한 롤링 방식 무중단 배포를 구현 하였다.
이 과정에서 타 레퍼런스와 ChatGPT의 도움을 받으면서 진행 했기에 온전히 내걸로 만들지 못했다는 생각이 있다.
그렇기에 이번 글을 통해서 작성 했던 코드들에 대해서 설명 겸 내 스스로를 위한 정리의 시간을 가지고자 한다.
무중단 배포
란 신규 버전을 배포하는 과정에서 서비스가 중단 되지 않게 하는 것을 의미한다.
CI/CD 환경을 통한 배포는 개발자 중심이라면, 무중단 배포는 사용자 중심이라고 볼 수 있다.
무중단 배포에는 아래의 기법들이 존재한다.
이미지 출처 : https://hudi.blog/zero-downtime-deployment/
이 중 Nginx에서 배포 스크립트를 통한 롤링 배포 방식
을 알아보고자 하며, 스크립트를 보기전 위 이미지를 통해 대강적인 롤링 배포 진행 방식에 대해서 이해 하면 좋을 것 같다.
#!/bin/bash
# === [ 설정값 (더미) ] ===
WAS_IPS=("WAS 1번 IP" "WAS 2번 IP")
WAR_PATH="/home/ubuntu/web_source/ROOT.war" # 웹서버에 저장될 war 파일 경로
REMOTE_USER="ubuntu" # WAS 인스턴스 유저 이름
REMOTE_PATH="/home/ubuntu" # WAS 인스턴스 루트 경로
REMOTE_CMD="sh /home/ubuntu/deploy.sh" # 배포 명령어
ROLLBACK_CMD="sh /home/ubuntu/rollback.sh" # 롤백 명령어
LOG_PATH="/home/ubuntu/logs/deploy.log" # 로그 저장 경로
KEY_PATH="/home/ubuntu/ssh인증키.pem" # ssh 연결간 pem키 경로
UPSTREAM_SCRIPT="/path/to/nginx_upstream_control.sh" # 앞서 작성한 스크립트 경로
# === [ 공통 함수 ] ===
get_current_time() {
date '+%Y-%m-%d %H:%M:%S'
}
check_actuator_health() {
local ip=$1
local port=$2
local max_attempts=60
local wait_time=5
local attempt=1
while [ $attempt -le $max_attempts ]; do
response=$(curl -s --max-time 3 "http://$ip:$port/actuator/health")
status=$(echo "$response" | jq -r '.status' 2>/dev/null)
if [ "$status" == "UP" ]; then
echo "$(get_current_time) - ✅ 헬스체크 통과: $ip"
return 0
fi
((attempt++))
sleep $wait_time
done
echo "$(get_current_time) - ❌ 헬스체크 실패: $ip"
return 1
}
backup_war_file() {
local ip=$1
local timestamp
timestamp=$(date +%Y%m%d%H%M%S)
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "cp $REMOTE_PATH/APP.war $REMOTE_PATH/backup/APP_$timestamp.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ 백업 실패: $ip"
return 1
fi
echo "$(get_current_time) - 🟢 WAR 백업 완료: $ip"
return 0
}
wait_for_connections_to_close() {
local ip=$1
local max_wait=60
local waited=0
echo "$(get_current_time) - 🕒 연결 드레인 시작: $ip"
while true; do
conn_count=$(ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" \
"netstat -an | grep ':8080 ' | grep ESTABLISHED | wc -l")
if [ "$conn_count" -eq 0 ] || [ $waited -ge $max_wait ]; then
break
fi
sleep 1
((waited++))
done
echo "$(get_current_time) - ✅ 연결 드레인 완료 또는 타임아웃: $ip"
}
deploy_tomcat() {
local ip=$1
local port=$2
echo "$(get_current_time) - 🔄 Nginx에서 $ip 분리 중..."
"$UPSTREAM_SCRIPT" "$ip" disable
wait_for_connections_to_close "$ip"
backup_war_file "$ip" || return 1
echo "$(get_current_time) - 🔄 WAR 전송 중: $ip"
scp -i "$KEY_PATH" "$WAR_PATH" "$REMOTE_USER@$ip:$REMOTE_PATH/APP.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ WAR 전송 실패: $ip"
return 1
fi
echo "$(get_current_time) - 🔄 원격 배포 스크립트 실행: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$REMOTE_CMD"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ 배포 스크립트 오류, 롤백 시작: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$ROLLBACK_CMD"
echo "$(get_current_time) - 🔄 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
if ! check_actuator_health "$ip" "$port"; then
echo "$(get_current_time) - 🔄 헬스체크 실패로 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
echo "$(get_current_time) - 🔄 Nginx에 $ip 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
echo "$(get_current_time) - 🎉 $ip 배포 완료!"
return 0
}
# === [ 메인 로직 ] ===
for ip in "${WAS_IPS[@]}"; do
echo "-----------------------------------------------------"
echo "$(get_current_time) - 🚀 $ip 배포 시작"
if ! deploy_tomcat "$ip" "8080"; then
echo "$(get_current_time) - ❌ $ip 배포 실패. 프로세스를 중단합니다." | tee -a "$LOG_PATH"
exit 1
fi
done
echo "$(get_current_time) - 🎊 모든 서버 배포가 성공적으로 완료되었습니다!" | tee -a "$LOG_PATH"
냅다 던져진 예시 롤링 배포 스크립트
현재 클라우드 환경에서 WEB, WAS1, WAS2번 인스턴스가 각각 별도로 있다고 가정하자.
이 때 위 배포 스크립트는 WEB에서 실행 되어서 각 WAS1, WAS2번 인스턴스에 ssh로 접근해 배포를 수행한다.
위 롤링 배포 스크립트의 실행 순서와 각 순서에 호출 될 메서드들의 역할을 살펴보자.
WAS_IPS=("WAS 1번 IP" "WAS 2번 IP")
WAR_PATH="/home/ubuntu/web_source/ROOT.war" # 웹서버에 저장될 war 파일 경로
REMOTE_USER="ubuntu" # WAS 인스턴스 유저 이름
REMOTE_PATH="/home/ubuntu" # WAS 인스턴스 루트 경로
REMOTE_CMD="sh /home/ubuntu/deploy.sh" # 배포 명령어
ROLLBACK_CMD="sh /home/ubuntu/rollback.sh" # 롤백 명령어
LOG_PATH="/home/ubuntu/logs/deploy.log" # 로그 저장 경로
KEY_PATH="/home/ubuntu/ssh인증키.pem" # ssh 연결간 pem키 경로
UPSTREAM_SCRIPT="/path/to/nginx_upstream_control.sh" # 롤링 배포를 위한 스크립트 경로
위 코드에서는 배포 스크립트에서 핵심인 WEB 인스턴스에 있는 ROOT.war 파일을 롤링 방식으로 각 WAS 인스턴스들에 배포하는데 사용 되는 설정 값들이 작성 되어있다.
→ 이 때 WEB 인스턴스의 ROOT.war 파일은 Jenkins를 통해서 사전 주입 되어있다고 가정하자.
# === [ 메인 로직 ] ===
for ip in "${WAS_IPS[@]}"; do
echo "-----------------------------------------------------"
echo "$(get_current_time) - 🚀 $ip 배포 시작"
if ! deploy_tomcat "$ip" "8080"; then
echo "$(get_current_time) - ❌ $ip 배포 실패. 프로세스를 중단합니다." | tee -a "$LOG_PATH"
exit 1
fi
done
먼저 설정값에 있는 WAS 인스턴스의 각 IP들을 가져와 deploy_tomcat 메서드에 해당 IP와 포트를 넘겨 호출하는 역할을 맡고 있다.
deploy_tomcat() {
local ip=$1
local port=$2
echo "$(get_current_time) - 🔄 Nginx에서 $ip 분리 중..."
"$UPSTREAM_SCRIPT" "$ip" disable
wait_for_connections_to_close "$ip"
backup_war_file "$ip" || return 1
echo "$(get_current_time) - 🔄 WAR 전송 중: $ip"
scp -i "$KEY_PATH" "$WAR_PATH" "$REMOTE_USER@$ip:$REMOTE_PATH/ROOT.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ WAR 전송 실패: $ip"
return 1
fi
echo "$(get_current_time) - 🔄 원격 배포 스크립트 실행: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$REMOTE_CMD"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ 배포 스크립트 오류, 롤백 시작: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$ROLLBACK_CMD"
echo "$(get_current_time) - 🔄 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
# 헬스체크
if ! check_actuator_health "$ip" "$port"; then
echo "$(get_current_time) - 🔄 헬스체크 실패로 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
echo "$(get_current_time) - 🔄 Nginx에 $ip 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
echo "$(get_current_time) - 🎉 $ip 배포 완료!"
return 0
}
위 코드는 배포 대상 WAS 인스턴스들을 로드 밸런싱 IP에서 분리하고, SSH를 통해 배포까지 진행하는 실질적인 코드이다.
코드가 길기 때문에 해당 메서드 내에서의 프로세스를 정리 해보겠다.
1. Nginx 업스트림에서 대상 WAS 분리(disable
)
배포를 진행할 WAS 인스턴스로는 client 요청이 전달 되면 안되기 때문에 대상을 분리 해야 한다.
"$UPSTREAM_SCRIPT" "$ip" disable
그렇기 위해서 위 코드를 통해서 아래 쉘 파일을 실행해서 NGINX 로드 밸런서 설정에서 특정 서버(IP)를 동적으로 활성화(enable) 또는 비활성화(disable) 한다.
아래 쉘 코드는 별도로 설명하지는 않겠다.
#!/bin/bash
TARGET_IP="$1" # 예: APP1_IP
MODE="$2" # disable or enable
# 1) 인자 유효성 검사
if [ -z "$TARGET_IP" ] || [ -z "$MODE" ]; then
echo "사용법: $0 <IP> <disable|enable>"
exit 1
fi
if [ "$MODE" == "disable" ]; then
# 2) 특정 서버를 ‘down’ 상태로 표시
sed -i.bak -E "s/^(server[[:space:]]+$TARGET_IP:8080[^;]*);/\\1 down;/" "$UPSTREAM_FILE"
echo "[NGINX] ✅ $TARGET_IP 서버를 다운 상태로 표시했습니다."
elif [ "$MODE" == "enable" ]; then
# 3) ‘down’ 표기를 제거하여 활성화
sed -i.bak -E "s/^(server[[:space:]]+$TARGET_IP:8080[^;]*) down;/\\1;/" "$UPSTREAM_FILE"
echo "[NGINX] ✅ $TARGET_IP 서버를 다시 활성화했습니다."
else
echo "사용법: $0 <IP> <disable|enable>"
exit 1
fi
# 4) Nginx 설정 문법 검사
if nginx -t; then
# 5) 테스트 성공 시 설정 재적용
systemctl reload nginx
echo "[NGINX] 🔄 설정이 성공적으로 적용되었습니다."
else
# 6) 테스트 실패 시 재적용 없이 종료
echo "[NGINX] ❌ 설정 테스트에 실패했습니다. 적용을 취소합니다."
exit 1
fi
2. 연결 드레인 대기
wait_for_connections_to_close "$ip"
위 코드에서는 아래 메서드를 호출 해서 Nginx에서 해당 WAS를 제외한 후, 남아 있는 8080 포트의 ESTABLISHED 커넥션이 모두 끊어지거나 최대 60초가 될 때까지 대기하는데, 이를 연결 드레인
이라고 부른다.
wait_for_connections_to_close() {
local ip=$1
local max_wait=60 # 최대 60초 대기
local waited=0
echo "$(get_current_time) - 🕒 연결 드레인 시작: $ip"
while true; do
conn_count=$(ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "netstat -an | grep ':8080 ' | grep ESTABLISHED | wc -l")
if [ "$conn_count" -eq 0 ] || [ $waited -ge $max_wait ]; then
break
fi
sleep 1
((waited++))
done
echo "$(get_current_time) - ✅ 연결 드레인 완료 또는 타임아웃: $ip"
}
연결 드레인이란 정확히는 "서버에서 현재 연결된 클라이언트 세션이 정상적으로 종료될 시간을 주고, 새로운 연결은 차단하는 상태”을 의미한다.
이를 통해 배포간 서비스의 안정성과 사용자들의 사용성을 증대할 수 있다.
3. WAR 백업
backup_war_file "$ip" || return 1
echo "$(get_current_time) - 🔄 WAR 전송 중: $ip"
scp -i "$KEY_PATH" "$WAR_PATH" "$REMOTE_USER@$ip:$REMOTE_PATH/ROOT.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ WAR 전송 실패: $ip"
return 1
fi
위 명령어를 통해서는 아래 메서드를 호출 해서 각 WAS 인스턴스에 SSH로 접근 후 현재(배포 이전) WAR 파일을 백업 폴더에 복사 해두는 작업을 수행 한다.
backup_war_file() {
local ip=$1
local timestamp
timestamp=$(date +%Y%m%d%H%M%S)
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "cp $REMOTE_PATH/ROOT.war $REMOTE_PATH/backup/ROOT_$timestamp.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ 백업 실패: $ip"
return 1
fi
echo "$(get_current_time) - 🟢 WAR 백업 완료: $ip"
return 0
}
4. 새로운 WAR 파일 전송 및 원격 배포 스크립트 실행
echo "$(get_current_time) - 🔄 WAR 전송 중: $ip"
scp -i "$KEY_PATH" "$WAR_PATH" "$REMOTE_USER@$ip:$REMOTE_PATH/ROOT.war"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ WAR 전송 실패: $ip"
return 1
fi
echo "$(get_current_time) - 🔄 원격 배포 스크립트 실행: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$REMOTE_CMD"
if [ $? -ne 0 ]; then
echo "$(get_current_time) - ❌ 배포 스크립트 오류, 롤백 시작: $ip"
ssh -i "$KEY_PATH" "$REMOTE_USER@$ip" "$ROLLBACK_CMD"
echo "$(get_current_time) - 🔄 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
위 코드에서는 원격 WAS 인스턴스에 SSH로 접근 하여 특정 경로에 WAR 파일을 전송하고, 해당 WAS 인스턴스에 있는 배포 스크립트를 원격으로 실행한다.
이 때 WAR를 배포하는데 문제가 발생하면 해당 WAS 인스턴스에 대한 배포 과정은 종료 된다.
만약 원격 배포 스크립트간 오류가 발생하면, 롤백을 시작하고 upstream에 해당 WAS 인스턴스를 재 등록한다.
아래는 WAS 인스턴스에서 톰캣을 재실행하는 역할을 하는 예시 배포 스크립트이다.
#!/bin/bash
TOMCAT_HOME="/home/ubuntu/apache-tomcat-9.0.XX"
WAR_FILE="/home/ubuntu/ROOT.war"
WEBAPPS_DIR="$TOMCAT_HOME/webapps"
BACKUP_DIR="/home/ubuntu/backup"
echo "[DEPLOY] ⏹ Tomcat 중지 중..."
$TOMCAT_HOME/bin/shutdown.sh
# 혹시 모를 잔여 프로세스 강제 종료
sleep 5
PID=$(ps -ef | grep "$TOMCAT_HOME" | grep -v grep | awk '{print $2}')
if [ -n "$PID" ]; then
echo "[DEPLOY] ⚠️ 프로세스 $PID 강제 종료"
kill -9 $PID
fi
echo "[DEPLOY] 🧹 기존 배포 파일 정리 중..."
rm -rf "$WEBAPPS_DIR/ROOT"
rm -f "$WEBAPPS_DIR/ROOT.war"
echo "[DEPLOY] 📦 새 WAR 파일 이동 중..."
cp "$WAR_FILE" "$WEBAPPS_DIR/ROOT.war"
echo "[DEPLOY] ▶ Tomcat 재시작 중..."
$TOMCAT_HOME/bin/startup.sh
echo "[DEPLOY] ✅ 배포 스크립트 완료"
5. 정상 실행 시 헬스체크, 실패 시 재등록
if ! check_actuator_health "$ip" "$port"; then
echo "$(get_current_time) - 🔄 헬스체크 실패로 Nginx에 $ip 다시 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
return 1
fi
echo "$(get_current_time) - 🔄 Nginx에 $ip 등록 중..."
"$UPSTREAM_SCRIPT" "$ip" enable
echo "$(get_current_time) - 🎉 $ip 배포 완료!"
return 0
위 코드에서는 헬스체크 통과 시 Nginx에 다시 등록(enable
) 후 완료 메시지 출력하는 역할을 한다.