무중단 롤링 배포 with nginx & tomcat

Kevin·2025년 6월 21일
3

Server

목록 보기
13/14
post-thumbnail

서론

최근 회사에서 Nginx의 upstream 로드 밸런싱 그룹을 이용한 롤링 방식 무중단 배포를 구현 하였다.

이 과정에서 타 레퍼런스와 ChatGPT의 도움을 받으면서 진행 했기에 온전히 내걸로 만들지 못했다는 생각이 있다.

그렇기에 이번 글을 통해서 작성 했던 코드들에 대해서 설명 겸 내 스스로를 위한 정리의 시간을 가지고자 한다.


본론

무중단 배포란?

무중단 배포란 신규 버전을 배포하는 과정에서 서비스가 중단 되지 않게 하는 것을 의미한다.

CI/CD 환경을 통한 배포는 개발자 중심이라면, 무중단 배포는 사용자 중심이라고 볼 수 있다.

무중단 배포에는 아래의 기법들이 존재한다.

  1. 블루-그린 배포 : 두 개의 블루 / 그린 환경을 만들어, 하나에서 운영중인 동안 새 버젼은 다른 하나에 배포 하고, 테스트가 끝나면 트래픽을 그린으로 전환
  2. 카나리 배포 : 전체 트래픽 중 일부만 새 버젼으로 보내고, 점차 비율을 높여 완전 이전
  3. 롤링 배포 : 여러 인스턴스를 그룹으로 나눠서 순차적으로 업데이트

이미지 출처 : 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로 접근해 배포를 수행한다.

위 롤링 배포 스크립트의 실행 순서와 각 순서에 호출 될 메서드들의 역할을 살펴보자.



1. 설정 값 지정

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를 통해서 사전 주입 되어있다고 가정하자.



2. 메인 로직 실행

# === [ 메인 로직 ] ===
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와 포트를 넘겨 호출하는 역할을 맡고 있다.



3. 실질적인 톰캣 배포

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) 후 완료 메시지 출력하는 역할을 한다.

profile
Hello, World! \n

0개의 댓글