이번에 다룰 주제는 Nginx를 이용한 무중단 배포이다.
Nginx 구축방법에 대해서는 다른 좋은 게시글이 많으니, 해당 게시글에서는 코드에 관련된 내용에 대해서 중점적으로 다룰 예정이다.
먼저 무중단 배포에 대해서 간단히 설명하자면, 무중단 배포는 사용자 경험을 방해하지 않으면서 새로운 코드 버전을 서비스에 적용하는 기술이다. 무중단 배포를 구현하기위한 많은 기술들이 있지만 Nginx를 이용해서 구현하고자한다. Nginx를 선택한 이유는 아래와 같다.
sudo apt install nginx
먼저, Nginx를 시스템에 설치해야 한다. Ubuntu에서는 해당 명령어로 설치할 수 있다.
sudo rm /etc/nginx/sites-available/default
sudo rm /etc/nginx/sites-enabled/default
Nginx 설치 후 기본적으로 생성되는 설정 파일은 삭제한다. 이를 통해 우리의 프로젝트 설정을 우선 적용할 수 있다:
sudo vi /etc/nginx/sites-available/project.conf
다음으로, 프로젝트에 맞는 설정 파일을 만든다. project.conf 파일을 생성하고 아래 내용을 입력한다.
server {
listen 80;
server_name yourdomain.com; # 실제 도메인으로 변경
location / {
proxy_pass http://localhost:8080; # 요청을 전달할 주소와 포트
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
해당 내용을 이용해서 Nginx를 간단히 테스트 해볼 수 있다.
sudo ln -s /etc/nginx/sites-available/project.conf /etc/nginx/sites-enabled/project.conf
Nginx가 새로운 설정 파일을 인식하도록 sites-enabled 디렉토리에 심볼릭 링크를 만든다.
udo systemctl restart nginx
sudo systemctl status nginx
모든 설정이 끝났다면 Nginx를 재시작하고 상태를 확인한다:
sudo nginx -t
sudo nginx -s reload
또는 Nginx 설정 파일의 문법 오류를 검사하고 서버를 재로드하는 방법도 있다.
후에 Nginx를 위한 무중단 배포 구현을 위해 AWS의 설정들도 바꿔주어야한다.
기존에는 WAS의 8080 포트를 바라보고있던 로드밸런서를 80 포트를 바라보게 설정한다.
후에 Jenkins 인스턴스가 ssh를 이용해서 WAS 인스턴스의 프라이빗 ip를 이용해서 어떤 Port가 활성화 되어있는지 curl 명령어로 파악해야한다. 해당 과정을 수행하기 위해 WAS 인스턴스의 8080과 8081 포트를 Jenkins 인스턴스의 프라이빗 IP를 허용해준다.
무중단 배포를 위해 Nginx 설정을 동적으로 관리해야한다. 이를 위해 /etc/nginx/sites-available/project.conf.template 파일을 템플릿으로 사용하고, 배포 시 envsubst 명령어로 환경 변수를 통해 실제 값을 주입하는 방식을 선택하였다.
log_format custom_logging_json escape=json
'{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"remote_user":"$remote_user",'
'"request":"$request",'
'"status":"$status",'
'"body_bytes_sent":"$body_bytes_sent",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent",'
'"http_x_forwarded_for":"$http_x_forwarded_for",'
'"port":"$PORT"'
'}';
server {
listen 80;
access_log /var/log/nginx/access.log custom_logging_json;
location / {
proxy_pass http://localhost:$PORT; # 포트 8081로 요청 전달
proxy_set_header Host $host; # 현재 요청의 호스트 이름을 전달
proxy_set_header X-Real-IP $remote_addr; # 클라이언트의 실제 IP 주소를 전달
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 요청이 프록시를 거쳐온 IP 주소들을 전달
}
}
먼저, 설정 템플릿 파일 /etc/nginx/sites-available/project.conf.template을 생성한다. 이 파일에는 Nginx가 요청을 어떻게 처리할지에 대한 설정이 포함된다. 특히, proxy_pass 지시어에서 사용할 포트 번호를 $PORT 환경 변수로 지정하여, 실제 배포 시점에 동적으로 값을 설정할 수 있도록 한다.
export PORT=8081
envsubst '$PORT' < /etc/nginx/sites-available/project.conf.template | sudo tee /etc/nginx/sites-available/project.conf > /dev/null
배포 스크립트를 실행할 때, envsubst 명령어를 사용하여 $PORT 환경 변수에 실제 포트 번호를 주입하고, 이를 바탕으로 실제 Nginx 설정 파일 /etc/nginx/sites-available/project.conf을 생성한다. 이렇게 하면, 각 배포 사이클마다 필요에 따라 포트 번호를 변경할 수 있으며, 이를 통해 무중단 배포를 구현할 수 있다.
log_format custom_logging_json escape=json
'{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"remote_user":"$remote_user",'
'"request":"$request",'
'"status":"$status",'
'"body_bytes_sent":"$body_bytes_sent",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent",'
'"http_x_forwarded_for":"$http_x_forwarded_for",'
'"port":"8081"'
'}';
server {
listen 80;
access_log /var/log/nginx/access.log custom_logging_json;
location / {
proxy_pass http://localhost:8081; # 포트 8081로 요청 전달
proxy_set_header Host $host; # 현재 요청의 호스트 이름을 전달
proxy_set_header X-Real-IP $remote_addr; # 클라이언트의 실제 IP 주소를 전달
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 요청이 프록시를 거쳐온 IP 주소들을 전달
}
}
위에 명령으로 환경 변수 PORT의 값을 참조하여 템플릿 파일에서 해당 변수를 실제 값으로 치환하고, 결과를 /etc/nginx/sites-available/project.conf에 저장한다. 저장 후, Nginx 설정을 적용하기 위해 Nginx를 재시작하거나 설정을 다시 로드한다.
Jenkins 파이프라인을 사용하여 Nginx 기반의 무중단 배포를 자동화하는 과정을 설명하겠다. [공부정리] Docker를 이용한 Jenkins 설치 및 CI/CD 구축에서 작성했던 코드를 리펙토링 하였다.
pipeline {
agent any
environment {
IMAGE_NAME = "jeyongsong/automaticstore"
CONTAINER_NAME = "automaticstore"
SSH_CONNECTION = "ubuntu@ip-172-31-38-96"
SSH_CONNECTION_CREDENTIAL = "aws_key"
SSH_HOST = "ip-172-31-38-96"
BLUE_PORT = "8080"
GREEN_PORT = "8081"
PORT="\$PORT"
}
stages {
stage("Setup") {
steps {
script {
BRANCH = "main"
EXECUTE_PROFILE = "dev"
currentTime = new Date().format('yyyyMMddHHmmss', TimeZone.getTimeZone('Asia/Seoul'))
TAR_FILE = "automaticstore_${currentTime}.tar"
echo "Setup complete: TAR_FILE=${TAR_FILE}"
}
}
}
stage('Git Clone') {
steps {
echo "Cloning branch: ${BRANCH}"
git branch: BRANCH, url: 'https://github.com/joon6093/O2O_Automatic_Store_System_Demo.git'
withCredentials([GitUsernamePassword(credentialsId: 'submodule_security_token', gitToolName: 'Default')]) {
sh 'git submodule update --init --recursive'
}
}
}
stage('Prepare SSH') {
steps {
sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
sh "ssh-keyscan -H ${SSH_HOST} >> ~/.ssh/known_hosts"
echo "SSH setup complete for host: ${SSH_HOST}"
}
}
}
stage('Determine Deployment Color') {
steps {
script {
def bluePortStatus = sh(script: "curl -s -o /dev/null -w '%{http_code}' http://${SSH_HOST}:${BLUE_PORT} || echo 'down'", returnStdout: true).trim()
def greenPortStatus = sh(script: "curl -s -o /dev/null -w '%{http_code}' http://${SSH_HOST}:${GREEN_PORT} || echo 'down'", returnStdout: true).trim()
echo "Blue port status: ${bluePortStatus}"
echo "Green port status: ${greenPortStatus}"
if (bluePortStatus == "401") {
DEPLOYMENT_COLOR = "green"
TARGET_PORT = GREEN_PORT
echo "Blue is active. Switching to Green."
} else if (greenPortStatus == "401") {
DEPLOYMENT_COLOR = "blue"
TARGET_PORT = BLUE_PORT
echo "Green is active. Switching to Blue."
} else {
echo "Neither Blue nor Green is active. Defaulting to Blue."
DEPLOYMENT_COLOR = "blue"
TARGET_PORT = BLUE_PORT
}
IMAGE_TAG = "${IMAGE_NAME}:${DEPLOYMENT_COLOR}"
}
}
}
stage('Docker Image Build') {
steps {
echo "Building Docker image with tag: ${IMAGE_TAG}"
sh "docker build -t ${IMAGE_TAG} ."
sh "docker save ${IMAGE_TAG} > ${TAR_FILE}"
}
}
stage('Transfer Image to WAS Server') {
steps {
echo "Transferring image to WAS server: ${SSH_CONNECTION}"
sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
sh "scp ${TAR_FILE} ${SSH_CONNECTION}:~/"
}
}
}
stage('Docker Imag Clean up') {
steps {
echo "Cleaning up: Removing Docker image ${IMAGE_TAG} and tar file ${TAR_FILE}"
sh "docker rmi -f ${IMAGE_TAG}"
sh "rm -f ${TAR_FILE}"
}
}
stage('Deploy on WAS Server') {
steps {
sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
sh """
echo "Deploying on WAS server: ${SSH_CONNECTION}"
ssh ${SSH_CONNECTION} 'docker stop ${CONTAINER_NAME}-${DEPLOYMENT_COLOR} || true && docker rm ${CONTAINER_NAME}-${DEPLOYMENT_COLOR} || true'
ssh ${SSH_CONNECTION} 'docker rmi ${IMAGE_TAG} || true'
ssh ${SSH_CONNECTION} 'docker load -i ~/${TAR_FILE}'
ssh ${SSH_CONNECTION} 'docker run -d -p ${TARGET_PORT}:8080 -m 256m --cpus=0.25 -e PROFILE=${EXECUTE_PROFILE} --name ${CONTAINER_NAME}-${DEPLOYMENT_COLOR} ${IMAGE_TAG}'
"""
}
}
}
stage('Health Check and Update Nginx Configuration') {
steps {
script {
def attempts = 10
echo "Starting health check on port: ${TARGET_PORT}"
for (int i = 0; i < attempts; i++) {
def healthCheckCommand = "curl -s -o /dev/null -w '%{http_code}' http://${SSH_HOST}:${TARGET_PORT} || echo 'down'"
def healthCheckStatus = sh(script: healthCheckCommand, returnStdout: true).trim()
echo "Health check attempt ${i+1}: Status ${healthCheckStatus}"
if (healthCheckStatus == "401") {
echo "Health check passed for container on port ${TARGET_PORT}"
sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
sh """
echo "Updating Nginx configuration for port ${TARGET_PORT}"
ssh ${SSH_CONNECTION} "export PORT=${TARGET_PORT} && envsubst '$PORT' < /etc/nginx/sites-available/project.conf.template | sudo tee /etc/nginx/sites-available/project.conf > /dev/null && sudo nginx -s reload"
"""
}
break
} else {
if (i < attempts - 1) {
echo "Health check failed, retrying in 30 seconds..."
sleep 30
} else {
error "Health check failed after ${attempts} attempts."
}
}
}
}
}
}
stage('Final Health Check via Nginx') {
steps {
script {
def finalAttempts = 5
echo "Starting final health check via Nginx"
def finalSuccess = false
for (int i = 0; i < finalAttempts; i++) {
def finalHealthCheckCommand = "curl -s -o /dev/null -w '%{http_code}' http://${SSH_HOST} || echo 'down'"
def finalHealthCheckStatus = sh(script: finalHealthCheckCommand, returnStdout: true).trim()
echo "Final health check attempt ${i+1}: Status ${finalHealthCheckStatus}"
if (finalHealthCheckStatus == "401") {
echo "Final health check passed via Nginx"
finalSuccess = true
break
} else {
if (i < finalAttempts - 1) {
echo "Final health check failed via Nginx, retrying in 30 seconds..."
sleep 30
}
}
}
if (finalSuccess) {
// 트래픽이 새 버전으로 성공적으로 전환된 것을 확인한 후 이전 버전 정리
String oldColor = DEPLOYMENT_COLOR == "blue" ? "green" : "blue"
sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
echo "Stopping and removing old ${oldColor} containers"
sh "ssh ${SSH_CONNECTION} 'docker stop ${CONTAINER_NAME}-${oldColor} || true && docker rm ${CONTAINER_NAME}-${oldColor} || true'"
}
} else {
error "Final health check failed via Nginx after ${finalAttempts} attempts."
}
}
}
}
}
}
해당 내용은 아래에서 자세히 설명하겠다. 코드가 너무 길어진 관계로 코드를 이용해서 설명하지는 않겠다.
파이프라인이 잘 실행된 모습이다.
access.log에 기록된 로그를 보면 블루서버의 포트인 8080으로 설정되어있는 것을 알 수 있다.
실행중인 도커 컨테이너를 보면 블루이미지를 이용한 블루 서버를 실행중인 모습을 알 수있다.
Nginx의 project.conf 파일을 열어보면 PORT 변수를 이용해서 8080포트를 동적으로 잘 선택한 모습을 알 수있다.
파이프라인이 잘 실행된 모습이다.
access.log에 기록된 로그를 보면 그린서버의 포트인 8081으로 설정되어있는 것을 알 수 있다.
실행중인 도커 컨테이너를 보면 그린이미지를 이용한 그린 서버를 실행중인 모습을 알 수있다.
Nginx의 project.conf 파일을 열어보면 PORT 변수를 이용해서 8081포트를 동적으로 잘 선택한 모습을 알 수있다.
이번 무중단 배포 구현 과정을 통해 많은 도전과 시행착오를 겪었다. 처음에는 Nginx를 활용해 동적으로 포트 번호를 관리하는 방식을 구현하려 했으나, 예상치 못한 버그로 인해 다른 접근 방식을 선택해야 했다. 특히, Jenkins에서 envsubst '$PORT'를 처리하는 과정에서 발생한 문제는 해결하기 위해 수많은 시도와 온라인 Groovy 컴파일러를 활용한 학습까지 필요했다.
결국, 문제를 해결하고 무중단 배포를 성공적으로 구현할 수 있었다는 사실에 큰 만족감을 느낀다. 이 경험을 통해 Nginx를 이용한 무중단 배포가 구현의 단순성과 비용적인 측면에서 매우 유리하다는 것을 알게되었다. 앞으로도 이러한 기술을 프로젝트에 계속 적용하면서, 배운 지식을 바탕으로 더 나은 배포 전략을 모색할 것이다.