PPAK

[CI/CD] 프로젝트 무중단 배포 도입기 본문

infra

[CI/CD] 프로젝트 무중단 배포 도입기

PPakSang 2023. 1. 4. 15:59

기존에 진행하던 이웃사이 프로젝트에서 Jenkins 를 통한 CI/CD 를 구축했었다.

그 덕분에 개발 간에 팀원들은 단순히 깃허브에 코드를 올리는 것 만으로도 서로 작업한 내용을 원격서버에 반영되었고, 직접 배포를 하는 과정을 생략함으로써 전체적인 개발 피로를 줄일 수 있었다.

 

관련포스팅

 

[CI/CD] Git Webhook 을 통한 Jenkins-Spring Boot 빌드, 배포 자동화

이전 포스트 에서는 Docker 위에서 Jenkins 개발환경을 셋팅하였다. 본 포스트에서는 실제로 Jenkins project 를 생성하여 빌드 스크립트를 구축하고 Git Webhook 을 이용해 개발자가 빌드 버튼을 매번 누르

ppaksang.tistory.com

 

문제점

기존의 CI/CD 인프라를 살펴보면, 분명 Jenkins 가 배포 과정을 자동으로 수행하지만 Jenkins 가 기존에 동작 중이던 스프링 서버를 내리고 새로운 스프링 서버를 구동시키기는 시간 동안은 서버가 클라이언트의 요청에 응답을 줄 수가 없다. 

 

이 시간은 10~15 초 가량 되었다. 때문에 배포 시간 동안은 잠시 기다렸다가 요청을 보내야 하는 등 불편함이 존재했다. 테스트 서버의 경우 지속성이 보장되지 않아도 되지만 실제 운영하는 서비스의 경우 지속적인 응답을 제공하지 못한다면 사용하기 어려운 인프라로 판단됐다.

 

따라서 기존의 인프라를 보완한 무중단 배포 구조로 인프라를 변경하고자 한다.

 

관련 이미지는 아래와 같다. 전체적인 구조를 보여주기 위해서 불필요한 이미지들을 제외했다.

무중단 배포의 과정을 정리하면 다음과 같다.

 

1. 개발자가 GitHub 에 코드를 올린다.

2. GitHub 에서 Webhook 을 Jenkins 에 전달한다.

3. Jenkins 는 Project Build(Gradle), Docker Build 를 통해 프로젝트 배포 준비를 마친다.

4. Jenkins 는 현재 구동중인 서버의 포트(혹은 프로필) 정보를 통해 새로 배포할 프로젝트의 포트 번호를 결정한다.

5. 배포가 완료되면 Nginx 의 타겟 Url(proxy_pass url) 을 새로운 서버 주소로 변경한다.

6. Nginx 를 재구동한다.

 

위 과정에서 Nginx 는 클라이언트의 요청을 현재 구동 중인 서버로 전달하고 응답을 제공하는 프록시 서버로 동작한다.

그리고 기존의 스프링 서버를 재구동 시키는 시간이 Nginx 를 재구동하는 시간으로 치환됐다.

 

결과적으로 Nginx 를 재구동하는 딜레이는 1초 내외로 약 10~15 초의 응답 지연을 개선 할 수 있다.

 

무중단 배포 도입 과정

서버 프로필 설정

우선 Jenkins 가 현재 구동 중인 서버를 식별하기 위해서는 서버의 프로필을 나누어야한다. 그래서 기존의 application.yml 파일에 위와 같은 프로필 설정 부분을 추가했다.

 

port 가 동일하게 8080 인 이유는 현재 서버가 컨테이너로 패키징되어 도커 위에서 동작하기 때문에 컨테이너 포트 포워딩을 통해서 포트를 다르게(8181, 8282) 로 줄 수 있기 때문에 내부 설정 로직은 단순하게 가져갔다. 위 포트 설정은 생략해도 무관하지만 명확하게 차이를 표시하기 위해서 명시했다.

 

현재 구동 프로필 확인

Jenkins 가 현재 구동 중인 서버로 요청을 보내 프로필 정보를 얻을 수 있는 API 를 추가했다.

해당 응답 정보를 통해 다음에 구동할 서버의 포트 포워딩 주소를 결정한다.

 

Actuator 추가

//    Actuator
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

서버의 상태를 확인하고 동작 가능 여부를 추적하기 위해서 Actuator 를 추가했다.

/actuator/health 경로에서 서버의 동작 가능 여부를 확인할 수 있다.

 

정상적으로 동작 가능하다면 {"Status":"Up"} 이라는 응답을 받을 수 있다

 

저의 경우 Virtual Machine 디스크 용량이 제한적이라 무중단 배포 도입 도중 Status 가 Down 인 경우가 있었는데, 다행히 사용하지 않는 도커 이미지들을 정리하고 나니 정상적으로 동작했다.

 

Server Dockerfile 수정

FROM openjdk:11
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
VOLUME /tmp
EXPOSE 8081
ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${Profile}"]

기존의 서버의 경우 프로필을 별도로 설정하지 않았기 때문에 Gradle Build 의 결과물인 jar 파일을 단순하게 실행하는 코드가 전부였다.

바뀐 점은 추가적인 실행 옵션으로 spring.profiles.active 를 추가하고 도커 이미지를 실행할 때 동적으로 ${Profile} 변수 값을 설정할 수 있도록 했다.

 

Nginx (Proxy Server) 추가

docker run --name an-nginx -v /etc/nginx:/etc/nginx -v /home/psh/bootDir:/bootDir nginx

 

nginx 설정파일의 경우 호스트 머신에 내장되어있는 폴더로 마운트했고, Jenkins 와 함께 사용하는 bootDir 폴더도 nginx 컨테이너에 마운트 시켰다.

 

nginx.conf

sites-enabled 아래 default 파일로 분류되어 있는 설정 파일 이지만 편의상 nginx.conf 파일로 적는다.

server {
	listen 80 default_server;
	listen [::]:80 default_server;

        server_name _;

        include /bootDir/service-url.inc;
        location / {
            proxy_pass $service_url;
        }
    }

위와 같이 bootDir 폴더의 service-url.inc(Jenkins 가 설정하는 서버 경로가 저장된 파일) 의 service_url 을 가져와서 proxy_pass 경로로 설정한다.

 

Jenkins 설정

크게 바뀌는 부분은 없지만 무중단 배포를 도입함에 따라서 Jenkins 가 수행하는 명령이 많아졌다.

크게 deploy 와 switch 로 나뉘고 해당하는 부분을 쉘 스크립트로 저장해서 실행한다.

 

deploy.sh

#!/bin/bash
echo "> 현재 구동중인 profile 확인"
CURRENT_PROFILE=$(curl -s http://$serverIP/status/profile)
echo "> $CURRENT_PROFILE"

if [ $CURRENT_PROFILE == an1 ]
then
  IDLE_PROFILE=an2
  IDLE_PORT=8282
elif [ $CURRENT_PROFILE == an2 ]
then
  IDLE_PROFILE=an1
  IDLE_PORT=8181
else
  echo "> 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE"
  echo "> an1를 할당합니다. IDLE_PROFILE: an1/8181"
  IDLE_PROFILE=an1
  IDLE_PORT=8181
fi

echo "> $IDLE_PROFILE 배포"
docker run -p $IDLE_PORT:8080 -d --net an-bridge --name=$IDLE_PROFILE-boot-server 
-v /home/psh/ANDir/logs:/tmp ppaksang/an-boot-server:1.0 --Profile=$IDLE_PROFILE,prod

echo "> $IDLE_PROFILE 10초 후 Health check 시작"
echo "> curl -s http://$serverIP:$IDLE_PORT/health "
sleep 10

for retry_count in {1..10}
do
  response=$(curl -s http://$serverIP:$IDLE_PORT/actuator/health)
  up_count=$(echo $response | grep 'UP' | wc -l)

  if [ $up_count -ge 1 ]
  then
    echo "> Health check 성공"
    break
  else
    echo "> Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다."
    echo "> Health check: ${response}"
  fi

  if [ $retry_count -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done

echo "> 스위칭을 시도합니다..."
sleep 5

/bootDir/switch.sh

deploy.sh 는 아래와 같은 순서로 동작한다.

1. 현재 동작 중인 서버의 프로필 정보를 불러온다.

2. 불러 온 프로필 정보를 바탕으로 실행 할 프로필을 결정한다.

3. 컨테이너를 생성한다.

4. 서버가 구동됐는지 주기적으로 확인한다.

5. 구동이 확인되면 Nginx 재구동 쉘스크립트(switch.sh) 를 실행한다.

 

switch.sh

#!/bin/bash
echo "> 현재 구동중인 Port 확인"
CURRENT_PROFILE=$(curl -s http://$serverIP/status/profile)

if [ $CURRENT_PROFILE == an1 ]
then
  IDLE_PORT=8282
elif [ $CURRENT_PROFILE == an2 ]
then
  IDLE_PORT=8181
else
  echo "> 일치하는 Profile이 없습니다. Profile:$CURRENT_PROFILE"
  echo "> 8181을 할당합니다."
  IDLE_PORT=8181
fi

echo "> 전환할 Port : $IDLE_PORT"
echo "> Port 전환"
echo "set \$service_url http://$serverIP:${IDLE_PORT};" | tee /bootDir/service-url.inc

echo "> Nginx Reload"
docker container restart an-nginx
docker container rm -f $CURRENT_PROFILE-boot-server

switch.sh 는 간단하다.

1. 현재 구동 중인 프로필 정보를 확인한다.

2. 해당 정보를 바탕으로 교체할 server-url 을 결정한다. -> service-url.inc 파일에 덮어쓴다.

3. Nginx 를 재구동한다.

4. 기존에 동작 중인 서버를 종료한다.

 

Jenkins 의 General - Build Steps 에서 프로젝트 빌드, 도커 이미지 빌드 후에 아래와 같이 deploy.sh 를 실행하기만 하면 위에서 명시한 과정이 수행되고 무중단 배포가 완료된다.

 

빌드 완료(배포 완료)

 

이렇게 이웃사이 프로젝트에 무중단 배포를 성공적으로 도입했다.

 

OS 에서 직접 동작하는 애플리케이션이 아니기 때문에 컨테이너를 간 설정 파일을 공유하는 방식에 있어서 고민을 조금 했었다. 그 결과 공유 디렉토리를 하나 만들고 두 컨테이너가 같이 마운트해서 사용하는 방식으로 Jenkins 와 Nginx 간 파일 공유를 수행했다.

 

외부 개발자가 배포하는 과정에서 바뀐 점은 없지만(원격 레포지토리에 코드 반영) 클라이언트의 경우 이전의 배포 과정에서 생기던 응답 지연 (10~15초) 시간을 더 이상 겪지 않아도 된다.

Comments