[공부정리] Nginx를 이용한 무중단 배포

jeyong·2024년 2월 5일
0

공부 / 생각 정리  

목록 보기
27/120
post-custom-banner

이번에 다룰 주제는 Nginx를 이용한 무중단 배포이다.
Nginx 구축방법에 대해서는 다른 좋은 게시글이 많으니, 해당 게시글에서는 코드에 관련된 내용에 대해서 중점적으로 다룰 예정이다.

1. 무중단 배포를 위한 설정

먼저 무중단 배포에 대해서 간단히 설명하자면, 무중단 배포는 사용자 경험을 방해하지 않으면서 새로운 코드 버전을 서비스에 적용하는 기술이다. 무중단 배포를 구현하기위한 많은 기술들이 있지만 Nginx를 이용해서 구현하고자한다. Nginx를 선택한 이유는 아래와 같다.

1-1. Nginx 설치하기

sudo apt install nginx

먼저, Nginx를 시스템에 설치해야 한다. Ubuntu에서는 해당 명령어로 설치할 수 있다.

1-2. 기본 설정 파일 제거하기

sudo rm /etc/nginx/sites-available/default
sudo rm /etc/nginx/sites-enabled/default

Nginx 설치 후 기본적으로 생성되는 설정 파일은 삭제한다. 이를 통해 우리의 프로젝트 설정을 우선 적용할 수 있다:

1-3. 프로젝트 설정 파일 만들기

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를 간단히 테스트 해볼 수 있다.

1-4. 설정 파일 활성화하기

sudo ln -s /etc/nginx/sites-available/project.conf /etc/nginx/sites-enabled/project.conf

Nginx가 새로운 설정 파일을 인식하도록 sites-enabled 디렉토리에 심볼릭 링크를 만든다.

1-5. Nginx 재시작 및 확인하기

udo systemctl restart nginx 
sudo systemctl status nginx 

모든 설정이 끝났다면 Nginx를 재시작하고 상태를 확인한다:

sudo nginx -t
sudo nginx -s reload

또는 Nginx 설정 파일의 문법 오류를 검사하고 서버를 재로드하는 방법도 있다.

1-2. AWS 설정

후에 Nginx를 위한 무중단 배포 구현을 위해 AWS의 설정들도 바꿔주어야한다.

WAS의 로드밸런서 설정 변경

기존에는 WAS의 8080 포트를 바라보고있던 로드밸런서를 80 포트를 바라보게 설정한다.

WAS 인스턴스의 인바운드 보안 규칙 변경

후에 Jenkins 인스턴스가 ssh를 이용해서 WAS 인스턴스의 프라이빗 ip를 이용해서 어떤 Port가 활성화 되어있는지 curl 명령어로 파악해야한다. 해당 과정을 수행하기 위해 WAS 인스턴스의 8080과 8081 포트를 Jenkins 인스턴스의 프라이빗 IP를 허용해준다.

2. Nginx를 활용한 무중단 배포 설정 방법

무중단 배포를 위해 Nginx 설정을 동적으로 관리해야한다. 이를 위해 /etc/nginx/sites-available/project.conf.template 파일을 템플릿으로 사용하고, 배포 시 envsubst 명령어로 환경 변수를 통해 실제 값을 주입하는 방식을 선택하였다.

/etc/nginx/sites-available/project.conf.template

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 환경 변수로 지정하여, 실제 배포 시점에 동적으로 값을 설정할 수 있도록 한다.

/etc/nginx/sites-available/project.conf

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를 재시작하거나 설정을 다시 로드한다.

3. Jenkins 설정

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."
                    }
                }
            }
        }
    }
}

해당 내용은 아래에서 자세히 설명하겠다. 코드가 너무 길어진 관계로 코드를 이용해서 설명하지는 않겠다.

3-1. 초기 설정과 Git 클론

  • Setup 단계: 필요한 변수를 초기화하고, 도커 이미지를 저장할 TAR 파일 이름을 설정한다.
  • Git Clone 단계: 소스 코드 저장소를 클론하고 필요한 경우 서브모듈을 초기화한다.

3-2. SSH 준비와 배포 색상 결정

  • Prepare SSH 단계: SSH 연결을 위해 필요한 호스트의 SSH 키를 known_hosts 파일에 추가한다.
  • Determine Deployment Color 단계: 현재 서비스 중인 애플리케이션의 포트를 확인하여 블루/그린 배포 중 어떤 환경에 배포할지 결정한다. 만약 둘 다 활성화되어 있지 않다면, 기본적으로 블루 환경에 배포한다.

3-3. 도커 이미지 빌드 및 전송

  • Docker Image Build 단계: 지정된 태그로 도커 이미지를 빌드하고 TAR 파일로 저장한다.
  • Transfer Image to WAS Server 단계: 생성된 도커 이미지 TAR 파일을 웹 애플리케이션 서버(WAS)로 전송한다.

3-4. WAS 서버에 배포

  • Deploy on WAS Server 단계: WAS 서버에 접속하여 기존에 실행 중인 컨테이너를 정지하고 제거한 뒤, 새 이미지로 컨테이너를 실행한다. 여기서 -m 256m --cpus=0.25와 같은 리소스 제한 옵션을 사용하여 컨테이너 리소스 사용을 제한할 수 있다.

3-5. 헬스 체크 및 Nginx 설정 업데이트

  • Health Check and Update Nginx Configuration 단계: 새로 배포된 애플리케이션의 헬스 체크를 수행하고, 통과하면 Nginx 설정을 업데이트하여 트래픽을 새 버전으로 전환한다. 이 때, envsubst 명령어를 사용하여 Nginx 설정 템플릿에서 포트 번호를 동적으로 설정한다.

3-6. 최종 헬스 체크 및 정리 작업

  • Final Health Check via Nginx 단계: Nginx를 통한 최종 헬스 체크를 수행하여 서비스가 정상적으로 작동하는지 확인한다. 성공적으로 트래픽이 전환되면, 이전 버전의 컨테이너를 정지하고 제거하여 정리한다.

4. 실행결과

4-1. 첫번째 실행

파이프라인 결과

파이프라인이 잘 실행된 모습이다.

cat /var/log/nginx/access.log

access.log에 기록된 로그를 보면 블루서버의 포트인 8080으로 설정되어있는 것을 알 수 있다.

docker ps

실행중인 도커 컨테이너를 보면 블루이미지를 이용한 블루 서버를 실행중인 모습을 알 수있다.

cat /etc/nginx/sites-available/project.conf

Nginx의 project.conf 파일을 열어보면 PORT 변수를 이용해서 8080포트를 동적으로 잘 선택한 모습을 알 수있다.

4-2. 두번째 실행

파이프라인 결과

파이프라인이 잘 실행된 모습이다.

cat /var/log/nginx/access.log

access.log에 기록된 로그를 보면 그린서버의 포트인 8081으로 설정되어있는 것을 알 수 있다.

docker ps

실행중인 도커 컨테이너를 보면 그린이미지를 이용한 그린 서버를 실행중인 모습을 알 수있다.

cat /etc/nginx/sites-available/project.conf

Nginx의 project.conf 파일을 열어보면 PORT 변수를 이용해서 8081포트를 동적으로 잘 선택한 모습을 알 수있다.

5. 마무리

이번 무중단 배포 구현 과정을 통해 많은 도전과 시행착오를 겪었다. 처음에는 Nginx를 활용해 동적으로 포트 번호를 관리하는 방식을 구현하려 했으나, 예상치 못한 버그로 인해 다른 접근 방식을 선택해야 했다. 특히, Jenkins에서 envsubst '$PORT'를 처리하는 과정에서 발생한 문제는 해결하기 위해 수많은 시도와 온라인 Groovy 컴파일러를 활용한 학습까지 필요했다.

결국, 문제를 해결하고 무중단 배포를 성공적으로 구현할 수 있었다는 사실에 큰 만족감을 느낀다. 이 경험을 통해 Nginx를 이용한 무중단 배포가 구현의 단순성과 비용적인 측면에서 매우 유리하다는 것을 알게되었다. 앞으로도 이러한 기술을 프로젝트에 계속 적용하면서, 배운 지식을 바탕으로 더 나은 배포 전략을 모색할 것이다.

profile
노를 젓다 보면 언젠가는 물이 들어오겠지.
post-custom-banner

0개의 댓글