글 작성자: beaniejoy

 

1. 무중단 배포?

 무중단 배포(zero-downtime deployment)는 백엔드 개발자에 있어서 절대 놓치면 안되는 중요한 내용입니다. 어떤 서비스를 배포할 때 실제 사용하고 있는 사용자들에게 영향을 미치지 않으면서 새로운 버전의 애플리케이션을 끊김 없이 잘 적용하는 것이 중요합니다.

 만약 새로운 버전의 애플리케이션을 배포하는 과정에서 잠깐이어도 서비스가 멈추는 일이 발생하면 단순히 사용자가 불편을 겪는 것을 넘어서서 결제 같은 중요한 프로세스가 꼬이거나 하는 대형사고가 펼쳐질 수 있습니다.
 그만큼 백엔드 애플리케이션 개발자라고 한다면 서비스 개발을 잘하는 것도 중요하지만 애플리케이션을 장애없이 돌아가게끔 하는 인프라적인 요소들도 잘 챙겨야 합니다.

 무중단 배포에서 개인적으로 생각하기에 중요하게 고려해야하는 것은 가장 먼저 배포과정에서 구버전에서 신버전으로 넘어가면서 끊김이 발생하면 안된다는 것입니다. (이것을 순단이라고 부르기도 하더라고요.)
 두 번째로는 구버전을 중단할 때 이미 들어온 요청들에 대해서 어떻게 할 것인지에 대해 고려해야 합니다.

크게 두 부분을 생각하면서 한 번 무중단 배포를 적용해보려고 합니다.

 

2. 테스트할 시스템 구조와 Rolling 배포 과정

무중단 배포를 적용하기에 앞서서 이번에 적용할 배포 전략에 대해 간략하게나마 언급을 해야될 것 같습니다.

무중단 배포에는 여러 전략들이 존재하는데요. 크게 Rolling, Blue-Green, Canary 전략이 있습니다. 실제로는 3개의 전략을 적절히 혼합해서 사용하는 경우도 있는 것 같은데 저는 무중단 배포의 대표 3인방에 대해서만 적용해보려고 하고 이번 게시글에서는 Rolling 배포 전략에 대해 중점적으로 적용해보고자 합니다.

Rolling 배포 전략의 개념에 대해서는 구  글에 검색하면 아주 자세하게 소개한 멋진 글들이 많은데요. 그것을 참고하시는 것이 베스트일 것 같습니다. 개념을 모르신다면 먼저 해당 배포전략에 대해 알아보시고 해당 글을 읽으시는 것을 추천드립니다.

개인 프로젝트에 적용된 서버 구조는 다음과 같습니다.

클라우드 서버 리소스 비용이 무지막지해서 개인적으로 구매한 라즈베리파이 하나에 포트로 구분된 애플리케이션 2개를 띄어서 nginx를 이용해 load balancing하는 아주 간략한 구조로 세팅했습니다.

Rolling 배포전략이기 때문에 배포시 포트별로 하나하나 적용하게 되는데요. 과정은 다음과 같습니다.

  1. nginx 단에서 upstream에 연결되어 있는 8080포트에 대해 연결 중단(down)
  2. 8080포트 구버전 애플리케이션 shutdown
  3. 8080포트 신버전 애플리케이션 run
  4. 8080포트 신버전 애플리케이션 health checking(실행완료되었는지 체크)
  5. nginx 단에서 upstream에 다시 8080포트 연결
  6. ... 8081 포트도 동일하게 진행

위의 절차대로 진행하는 것을 목표로 한 번 배포 프로세스를 구성해보도록 하겠습니다.

 

3. Jenkins pipeline 빌드 프로세스

CI/CD 과정은 Jenkins와 ansible을 사용했습니다. 이전에 작성한 글 중 jenkins와 ansible로 CI/CD 프로세스 만들어보는 내용으로 정리한 것이 있습니다. Jenkins나 ansible에 대해서 처음 접하신 분들은 이 글을 읽으시기 전에 첨부해드린 제 글을 읽으시고, 공식 document도 참고하시면서 꼭 한 번 만들어보는 것을 추천드립니다. 
(https://beaniejoy.tistory.com/103)

 

Jenkins와 Ansible을 이용한 CI/CD 프로세스 만들어보기 (내가 만든 서비스 배포까지)

Jenkins와 Ansible을 이용해 Spring Boot application을 빌드하고 서버에 배포하는 모든 과정(CI/CD)을 자동화해보는 내용을 정리해보려고 합니다. 이 글을 통해 Jenkins와 Ansible을 통해 빌드, 배포 과정을 처음

beaniejoy.tistory.com

(이번 글에서는 Rolling 배포전략에 대해서 직접 적용해보는 것을 목표로 하는 글이기 때문에 jenkins, ansible에 대해 기본적인 사용법을 알고 있다는 것을 전제로 정리해보려 합니다.)

 

4. Rolling 배포 전략을 적용하기 전 고려해야할 사항(중요)

맨 위의 문단에서 무중단 배포에서 크게 고려해야될 부분이 2가지가 있음을 언급드렸습니다.
가장 먼저 배포과정에서 구버전에서 새로운 버전으로 마이그레이션하는 상황에서 끊김이 발생하면 안된다는 내용입니다.

# nginx config 변경 후 단순히 restart하면 문제는 없을까?
sudo systemctl restart nginx

배포과정에서 nginx 설정중에 upstream 내용을 수정해서 적용해야 하는데요. 이 때 단순히 설정을 바꾸고 nginx를 restart하면 예기치 않은 문제(nginx config syntax 오류 등)로 nginx가 종료되어버리는 상황이 발생할 수 있습니다. 이는 서비스 중단을 초래할 수 있는데요. 이러한 부분들을 신중하게 고려해서 배포 스크립트를 작성해야 합니다.

# nginx config에 대한 기본적인 syntax 에러 체크
if ! sudo nginx -t;
then
echo "Invalid nginx conf > upstream(${PORT}}) ${toggle_status}"
exit 40
fi

# nginx에 downtime이 발생하지 않도록 reload를 통해 바뀐 config 적용
sudo systemctl reload nginx

nginx -t를 통해 기본적인 syntax를 체크한 다음 restart 대신 reload로 바뀐 config 내용을 nginx에 적용함으로써 안전하게 nginx 설정내용을 변경할 수 있습니다.

두 번째로, 구버전을 중단할 때 이미 들어온 요청들에 대해서 어떻게 할 것인지에 대해 고려해야 합니다.
새로운 버전의 애플리케이션 실행에만 관심을 가지면 안됩니다. 이전 버전에 대해서 어떻게 처리해야할지도 신중히 생각을 해야합니다.
만약 이전 버전에서 요청을 처리하는 중에 있는데 배포 과정에서 이전 버전의 애플리케이션을 무턱대고 shutdown 해버리면 해당 요청은 영문도 모르는 상태로 실패처리가 될 것입니다.

$ kill -TERM [PID] (or kill -15 [PID])

프로세스 종료할 때 signal로 SIGKILL(9), SIGTERM(15)을 주로 비교하는데요. 관련 내용에 대해서 꼭 찾아서 보시는 것을 추천드립니다. (영어이긴 하지만 추천하는 글 하나 링크드립니다.)

 

A Study of Graceful Shutdown for Spring Boot Applications

Recently, I took a look at the restart scripts of the project and found that Ops has been using kill-9<pid> to restart springboot embedded tomcat, in fact, we almost unanimously agree that kill-9<pid> is a more violent way, but few people can analyze what

www.springcloud.io

간단하게 요약하자면 kill -9보다는 kill -15를 사용해서 애플리케이션을 종료하라는 것입니다.
> SIGKILL(9)을 통해 프로세스를 종료하면 컴퓨터 사용 중에 플러그인을 뽑으면 죽어버리듯이 애플리케이션을 즉시 종료시켜버려 애플리케이션 입장에서는 상당히 불친절합니다.
> SIGTERM(15)은 JVM단에서 해당 시그널을 받고 애플리케이션에 등록된 shutdown hook을 실행하게 됩니다. 이 때 실행되고 있는 모든 thread가 종료될 때까지 기다렸다가 안전하게 애플리케이션을 down시키는 그런 후작업들을 진행할 수 있게 된다고 보시면 될 것 같습니다.

// application.yaml 설정 파일
server:
  shutdown: graceful
  
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s # default 30s (graceful shutdown)

Spring Boot application에 기본적으로 제공하고 있는 graceful shutdown 기능이 있는데요. 해당 기능을 꼭 설정해야 내장 톰캣이 SIGTERM에 반응해서 말그대로 우아하게 애플리케이션을 종료할 수 있습니다.

 

5. Ansible playbook, 배포 스크립트 작성하기

 

5-1. Ansible playbook 살짝 살펴보기

배포를 위한 기본적인 ansible playbook 스크립트 내용은 위에 첨부해드린 게시글에서 정리했는데요. 해당 글을 참고하시면 좋을 것 같습니다. 여기서는 rolling 배포와 관련된 내용들만 언급드리려 합니다. (ansible roles 사용하여 스크립트를 적용했습니다.)

- name: "[Rolling Update] Run all tasks"
  ansible.builtin.include_tasks:
    file: rolling_update/main.yml
    apply:
      tags:
        - rolling_update
  tags:
    - rolling_update

rolling update를 위한 전용 script 파일(rolling_update/main.yml)을 실행해줍니다.

# rolling_update/main.yml
- name: "[Rolling Update] Copy service script file to remote server"
  ansible.builtin.template:
    src: rolling-update-deploy.sh.j2
    dest: "{{ remote_app_path }}/service.sh"
    owner: "{{ remote_server_user[ansible_distribution] }}"
    group: "{{ remote_server_group[ansible_distribution] }}"
    mode: 0755

- name: "[Rolling Update] Run service script"
  ansible.builtin.include_tasks:
    file: deploy.yml
    apply:
      tags:
        - rolling_update
  loop:
    - 8080
    - 8081

rolling 배포전략을 위한 실행 스크립트 파일을 원격서버로 전송한 다음에 해당 스크립트 파일을 실행하게 됩니다.
위의 스크립트 내용에서 중요한 것은 ansible playbook의 loop를 이용해서 8080, 8081 포트를 타깃으로 배포를 차례대로 진행하게 된다는 것입니다. 

  1. nginx 단에서 upstream에 연결되어 있는 8080포트에 대해 연결 중단(down)
  2. 8080포트 구버전 애플리케이션 shutdown
  3. 8080포트 신버전 애플리케이션 run
  4. 8080포트 신버전 애플리케이션 health checking(실행완료되었는지 체크)
  5. nginx 단에서 upstream에 다시 8080포트 연결
  6. ... 8081 포트도 동일하게 진행

위의 내용을 다시 적어보았는데요. 8080 포트부터 배포를 진행하고, 8081 포트도 동일한 과정으로 배포를 진행하게 됩니다. 그림으로 간략하게 묘사하자면 다음과 같습니다.

# rolling_update/deploy.yml
- name: "[Rolling Update] Shutdown old application - {{ item }}"
  ansible.builtin.command: "{{ remote_app_path }}/service.sh shutdown {{ item }}"
  register: result
  become: false

- ansible.builtin.debug:
    msg: "{{ result.stdout_lines }}"

- name: "[Rolling Update] Run service script - {{ item }}"
  ansible.builtin.command: "{{ remote_app_path }}/service.sh run {{ item }}"
  register: result
  become: false

- ansible.builtin.debug:
    msg: "{{ result.stdout_lines }}"

ansible playbook loop를 통해 실행되는 deploy.yml 파일 내용입니다. 원격 서버로 전송된 실행 스크립트를 사용해서 먼저 해당 port의 이전 버전 애플리케이션을 shutdown 시키고 새로운 버전의 애플리케이션을 실행(run)하는 과정으로 배포가 진행됩니다.

다음으로 실행 스크립트를 한 번 살펴볼까요.

 

5-2. 실행 스크립트 살펴보기

5-2-1. 이전 버전의 애플리케이션 종료

case "${COMMAND}" in
  shutdown)
    shutdown
    ;;
  run)
    run_app
    ;;
  *)
    echo "${COMMAND} > Not supported command"
    exit 40;
esac

shutdown과 run에 대한 진행을 각각 하기 위해 별도의 function으로 구분을 지었습니다. 

shutdown() {
  echo "-----------------------------------------"
  echo "[SHUTDOWN] ${MODULE_NAME}:${PORT}"
  echo "-----------------------------------------"

  private_toggle_proxy_upstream down

  # ...
}

shutdown 부터 살펴보면 가장 먼저 proxy(nginx)에 설정된 upstream 내용 중 포트 하나에 대해 down 설정을 통해서 nginx와 애플리케이션의 연결을 끊습니다. 8080부터 시작할테니 8080포트로 실행되고 있는 애플리케이션과 nginx 연결을 끊게 될 것입니다.

private_toggle_proxy_upstream() {

  # ... from_upstream_status, to_upstream_status 설정하는 내용 생략 ...
  
  if [ -n "${target_upstream_status}" ]
  then
    echo "proxy upstream ${PORT} status - already ${toggle_status}"
  else
    echo "proxy upstream ${PORT} setting ${toggle_status}"
    sudo sed -i "s/\(${from_upstream_status}\)/${to_upstream_status}/g" "${PROXY_CONF_FILE}"
  fi

  if ! sudo nginx -t;
  then
    echo "Invalid nginx conf > upstream(${PORT}}) ${toggle_status}"
    exit 40
  fi

  sudo systemctl reload nginx
}

스크립트 내용이 너무 길어서 중요한 내용만 축약해보았습니다. sed 명령어를 통해서 파일에 정규표현식으로 nginx config 파일의 내용을 변경하는 부분이 있습니다. 위의 스크립트 내용을 8080 포트에 대해서 실행하게 되면 다음과 같이 nginx가 설정됩니다.

upstream service {
  least_conn;
  server 127.0.0.1:8080 down;
  server 127.0.0.1:8081;
  keepalive 16;
}

8080포트에 down 설정이 되고나서 nginx -t를 통해 syntax 검사를 하고 nginx reload를 통해 실제 nginx에 적용하게 됩니다. 이렇게 되면 8081포트만 서비스가 이루어지고 있는 상태가 됩니다.

  private_toggle_proxy_upstream down
  
  echo ">> kill current running application(${PORT})"
  current_pid=$(pgrep -f "java.*${PORT}")
  if [ -z "${current_pid}" ];
  then
    echo "no current running application >> pass killing process"
  else
    echo "stop java application(${PORT}) - ${current_pid}"
    sudo kill -TERM "${current_pid}"
    sleep 2
  fi

애플리케이션 하나를 nginx와 연결 끊고 난 이후에 해당 포트로 실행되고 있는 java 애플리케이션의 PID 값을 알아냅니다. 그리고 graceful shutdown을 위한 SIGTERM을 통해 프로세스 종료를 진행합니다. 이렇게 되면 특정 포트의 애플리케이션 종료가 완료됩니다.

 

5-2-2. 신규 버전의 애플리케이션 실행

run_app() {
  cd "${APP_WORKSPACE}" || exit 40

  echo ">> run java application"
  if ! java --version;
  then
    echo "no JRE setup > JRE is required!!"
    exit 50
  fi

  # ...
}

애플리케이션 실행 전 원격 서버에 jre가 제대로 설치되어 있는지 체크해줍니다. 여기서 더 나아가서 Sprign Boot application에서 사용된 java 버전까지 일치하는지도 체크하면 좋을 것 같습니다.

run_app() {
  # ...
  
  nohup java -jar \
    -Dspring.profiles.active="${SPRING_PROFILE}" \
    -Dserver.port="${PORT}" \
    "${APP_WORKSPACE}"/"${MODULE_NAME}".jar 1>"${STD_OUT}" 2>"${STD_ERR}" &

  # ...
}

nohup을 통해 백그라운드에서 java가 실행되도록 해줍니다. 여기에는 spring boot profile, port에 대한 환경변수도 등록해줍니다.

run_app() {
  # ...
  
  echo ">> application(${PORT}) health check"
  private_app_health_check

  # ...
}

CHECK_MAX_COUNT=10
CHECK_COUNT=0
private_app_health_check() {
  if [ $CHECK_COUNT -gt $CHECK_MAX_COUNT ];
  then
    echo "Failure of running java application(${PORT})"
    exit 50
  fi

  status_code=$(curl --connect-timeout 1 --max-time 3 -i -X GET http://localhost:"${PORT}"/monitoring/health | awk '/HTTP\/1/{print $2}')
  if [[ $status_code =~ ^2[0-9]{2}$ ]];
  then
    echo "application(${PORT}) health check > UP"
  else
    echo "application(${PORT}) health check > DOWN"
    sleep 3
    CHECK_COUNT=$(expr ${CHECK_COUNT} + 1)
    private_app_health_check
  fi
}

java 애플리케이션이 백그라운드로 실행되면서 완전히 up된 상태인지 체크를 해줘야 합니다. curl을 통해 java application에 등록해준 health check용 api를 3초 텀으로 계속 상태 체크를 하게 됩니다.

특히, curl 요청시 i option을 부여하면 HTTP response 전문을 확인할 수 있는데 가장 첫 번째 줄에 HTTP/1.1 200 응답중 200 status code를 확인함으로써 애플리케이션이 제대로 떠있는지 확인할 수 있습니다.
만약 애플리케이션의 health check api 응답이 200대의 성공응답으로 아직 받지 못했다면 recursive 형식으로 다시 해당 funciton을 호출하게 됩니다.

Spring Boot 애플리케이션이 실행완료되는 시점까지 오래걸린다면 CHECK_MAX_COUNT 값을 상향 조정해서 health check를 더 많이 잡을 수 있습니다. 개발하고 있는 프로젝트의 규모에 따라 health check 횟수를 조정하면 될 것 같습니다.

run_app() {
  # ...
  
  private_toggle_proxy_upstream up
}

health check까지 수행해서 애플리케이션이 제대로 뜬 상태임을 확인했다면 nginx config에서 upstream down시켰던 것을 다시 원복을 시킴으로써 해당 포트와 nginx를 다시 연결해야 합니다. 위에서 nginx upstream 연결을 down할 때 사용했던 function을 사용해서 처리하면 됩니다. 위의 과정까지 성공적으로 마쳤다면 nginx upstream에는 이제 8080, 8081 포트가 둘 다 연결된 상태가 됩니다.

실행 스크립트에서 rolling 배포 방식에 있어 중요한 부분만 일부 가져와서 배포 과정을 정리해보았는데요. 위의 과정을 8080, 8081 포트에 대해서 각각 수행한다고 보시면 됩니다.

 

6. Rolling 배포방식에 대한 생각과 정리

rolling 배포방식과 실행스크립트를 통해서 구체적으로 어떻게 애플리케이션이 적용되는지 알아보았습니다. rolling 배포방식은 다른 배포방식에 비해 가장 간단한 방식이라 할 수 있습니다. 서비스 중인 여러 개의 애플리케이션을 차례차례로 구버전에서 신버전으로 갈아끼우는 방식으로 진행되다보니 스크립트 작성하는 것도 크게 어렵진 않았는데요. 하지만 제가 몸을 담고 있는 실무에서는 rolling 방식보다 blue-green 방식을 선호하는 것 같습니다. 이유는 다음과 같습니다.

 

6-1. rolling 배포과정에서 특정 애플리케이션에 트래픽이 몰리는 상황 발생

위의 예시를 가지고 보면 8080포트에 대해서 v2 적용중인 상황에서는 8081포트의 애플리케이션만 온전히 모든 트래픽들을 감당해야 합니다. 트래픽을 분산 처리했다가 하나로 집중된다면 애플리케이션이 감당 못할 수 있습니다.

 

6-2. 이전 버전과 새로운 버전이 혼합된 상태 발생

rolling 방식은 배포 과정에서 두 개의 버전이 혼용된 상태가 될 수 있는데요. 이부분도 rolling 방식의 단점이라고 많은 곳에서 언급하고 있습니다. 

만약 4개의 애플리케이션이 서비스되고 있는 상태에서 rolling 배포를 적용한다면 배포 과정에서 아주 잠깐이더라도 위 그림과 같이 v1, v2 버전이 혼용되어 트래픽을 처리하는 상태가 될 수 있습니다. 버전이 혼용되어 적용되어있다는 것 자체가 어떤 사이드 이펙트를 초래할지 모르기 때문에 rolling 배포 방식을 선택하는데 있어서 이러한 부분도 충분히 고려해야할 것 같습니다. 

rolling 방식에 대해서 정리해보았는데요. 다음 글에서 Blue Green 배포 전략을 이용한 무중단 배포에 대해 작성해보도록 하겠습니다.

위의 내용들에 대한 실행 스크립트 내용은 github repo에서 관리하고 있는데 참고용으로 링크걸어두겠습니다.
(https://github.com/beaniejoy/ansible-deploy-script/blob/main/roles/run-app/templates/rolling-update-deploy.sh.j2)

위에서 설정한 ansible role 관련 내용은 다음의 github repo 참고하시면 될 것 같습니다.
(https://github.com/beaniejoy/ansible-deploy-script)

 

GitHub - beaniejoy/ansible-deploy-script: ansible playbook for application(Spring Boot) deployment

ansible playbook for application(Spring Boot) deployment - GitHub - beaniejoy/ansible-deploy-script: ansible playbook for application(Spring Boot) deployment

github.com

틀린 내용이 있을 수 있습니다. 언제나 건강한 피드백 환영합니다. 감사합니다.