Home [Server] 무중단 배포
Post
Cancel

[Server] 무중단 배포

기존 배포 방식의 문제점

저희 프로젝트는 Docker, Github Actions를 사용해서 배포합니다. 기존 배포 프로세스는 아래와 같습니다.

  1. 테스트 및 jar 빌드
  2. 빌드된 jar를 포함한 서버 이미지 생성
  3. Docker Hub에 이미지 업로드
  4. SSH로 서버에 접속해 빌드한 이미지 Pull
  5. 기존 서버 컨테이너 삭제하고, 새로운 이미지 run

이 과정에서 주목해야할 부분은 5번입니다. 기존 컨테이너가 삭제되고 새로운 컨테이너가 올라가는 과정에서 서버는 약 10~20초정도 접속할 수 없는 상태가 됩니다.

사용자가 많은 서비스라면 꽤 치명적일 수 있습니다. 특히 저희 서비스는 서비스를 시작한지 얼마 되지않아 버그 테스트가 충분히 이루어지지 않았습니다. 이는 hotfix가 종종 있을 수 있다는 뜻이고, 그때마다 서비스가 중단되면 초기 사용자를 놓치게 됩니다. 따라서 저희는 무중단 배포를 적용해보기로 했습니다.

무중단 배포 도입

무중단 배포 방식은 크게 3가지가 있습니다.

  • Blue-green 배포: 새로운 버전의 컨테이너 셋으로 트래픽을 일괄 변경하는 방식입니다.
  • Rolling 배포: 기존 컨테이너 셋의 일부를 신버전으로 점차 교체해가는 방식입니다.
  • Canary 배포: 새로운 버전의 컨테이너 셋으로 조금씩 트래픽 범위를 늘려가는 방식입니다.

저희 프로젝트에서는 Blue-green 배포를 선택했습니다. 그 이유는

  • Rolling, Canary와 달리 신버전과 구버전이 동시에 구동되지 않으므로 호환성 문제로부터 자유롭습니다.
  • 다른 방식에 비해 Nginx로 구현하기 쉽습니다.

그래서 Nginx를 사용하여 Blue-green 배포 방식을 적용해보았습니다.

배포 방식

deploy1

Nginx의 reverse proxy는 사용자 대신 A서버에게 요청을 보내 받은 응답을 다시 사용자에게 전달합니다. 여기서 nginx가 A서버를 바라본다고 표현합니다.

기존에는 nginx가 blue나 green중 한 쪽을 바라봅니다. 위의 사진에서는 green을 바라보고 있네요. 그리고 다른 쪽에 최신 버전을 배포하고, 배포가 완료되면 nginx가 그 서버를 바라보게하면 됩니다. 정리해보면…

  1. 지금 nginx가 바라보고 있지 않은 서버에 최신 버전을 배포합니다.
  2. 배포가 끝나고 서버가 트래픽처리 가능한 상태가 될 때까지 기다립니다.
  3. 배포가 완전히 완료되면 nginx는 새로운 버전의 서버를 바라봅니다.

※ 참고로 두 서버를 구분하는데 포트를 사용했습니다.

서버 경계

서버를 구분 짓는 경계는 프로젝트에 맞게 정하면 됩니다. 제가 참고한 블로그에서는 하나의 컨테이너에 2개의 서버(blue/green)가 포함된 방식이었습니다.

저희 프로젝트에서는 하나의 클라우드 인스턴스에 2개의 컨테이너(blue/green)를 사용했습니다. 왜냐하면 서버 아키텍쳐가 인스턴스를 중심으로 구성되어있기 때문이었습니다. 확장도 인스턴스 단위로 수행할 예정이었습니다.

그리고 컨테이너 단위로 blue/green을 나누면 좀 더 격리성이 보장된다는 점이 좋았습니다. 배포 과정에서도 서로 영향을 주지 않습니다.

하지만 대신 배포 속도가 약간 느려질 수 있을 것 같습니다. 컨테이너를 추가/삭제/구동 하는 것 보다 서버 파일을 교체하고 다시 시작하는게 더 빠를 것이기 때문입니다.

저희 프로젝트에서는 이미 컨테이너로 배포하도록 스크립트를 짜두었고, 무중단 배포라 약간의 속도저하는 사용자 입장에선 느끼기 어려우므로 컨테이너 단위의 blue/green서버를 구축하였습니다.

한번 더 배포

재미있는 점은 기존 컨테이너를 재활용할 수 있다는 점입니다.

deploy2

앞서 배포가 끝나고 Blue 서버를 바라보고 있는 상태입니다. 여기서 1.2버전을 배포한다면 green을 재활용하여 여기에 1.2를 배포하고, 라우팅을 이쪽으로 옮기면 됩니다.

만약 새로운 버전에 심각한 버그가 있다면? 이전 버전으로 라우팅을 변경하는 것으로 쉽게 롤백할 수 있습니다.

Nginx 유연하게 서버 주소 변경

무중단 배포를 위해서는 Nginx가 바라보는 서버를 유연하게 바꿀 수 있어야 합니다. 물론 config파일을 직접 수정해서 바꿀 수 있긴하지만, 자동화하려면 파싱도 해야하고 문제가 복잡해집니다.

그래서 서버 주소 or 포트만 외부 파일로 빼는 방식을 사용합니다. 저는 포트만 외부 파일로 빼는 방식을 선택했습니다.

1
set $service_port 8080;
1
2
3
4
5
6
7
8
9
10
11
12
server {
    ...
    include /etc/nginx/conf.d/service-prod.inc;

    location /api {
        rewrite ^/api(.*)$ $1 break;
        proxy_pass          http://1.1.1.1:$service_port;
        proxy_set_header    Host                $host;
        proxy_set_header    X-Real-IP           $remote_addr;
        proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
    }
}

위와 같이 $service_port를 외부 파일(service-prod.inc)에 정의해두고 불러오는 방식으로 설정했습니다. 이렇게 하면 service-prod.inc를 변경하는 것만으로 쉽게 라우팅을 바꿀 수 있습니다.

서버 주소까지 포함하지 않고 포트만 뺀 이유가 있습니다. 서버 주소까지 파일로 빼면 CD할 때 서버 주소까지 통째로 입력해야합니다. 그러면 서버 주소를 알아야하고 이곳저곳 서버 주소가 분산되므로 유지보수가 어려워집니다. (응집도 감소) 물론 주소를 파싱해서 포트 부분만 바꿀 수도 있겠지만 문제가 복잡해집니다.

그리고 proxy_pass에 변수를 사용하니 기본 rewrite rule이 제대로 동작하지 않는 문제가 있었습니다. 이건 직접 rewrite rule을 설정하여 해결할 수 있습니다.

포트 변경 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
# $1: TARGET_PORT
# $2: TARGET_TYPE: dev | prod

# Argument 검증
if [ $# != 2 ]; then
  echo "Argument count must be 2"
  exit 1
fi

# 포트 변경
echo "대상 서버: $2"
echo "대상 포트: $1"
echo "set \$service_port $1;" | sudo tee /etc/nginx/conf.d/service-"$2".inc

# Nginx 리로드
echo "Nginx 리로드"
sudo service nginx reload

그리고 이렇게 nginx가 바라보는 서버 포트를 수정해주는 스크립트를 작성했습니다. 실제 이 스크립트를 사용하는 방법은 아래와 같습니다.

1
2
# dev 서버의 포트를 8081로 변경
./switch.sh 8081 dev

Argument로 dev나 prod까지 받는 이유는 개발, 운영 서버가 분리되어있기 때문입니다.

자동 배포 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#!/bin/bash
# $1: DOCKER_USERNAME
# $2: REPO
# $3: GITHUB_SHA

# Settings
BLUE_CONTAINER_NAME="$2-blue"
BLUE_PORT=8081
GREEN_CONTAINER_NAME="$2-green"
GREEN_PORT=8082
HEALTH_CHECK_WAIT=10
NGINX_IP=1.1.1.1
PROFILE=prod

# Argument 검증
if [ $# != 3 ]; then
  echo "Argument count must be 3"
  exit 1
fi

# 배포할 대상 blue/green 탐색
BLUE_RESULT=$(curl -o /dev/null -w "%{http_code}" http://localhost:$BLUE_PORT)
if [ "$BLUE_RESULT" == 200 ]; then
  NEXT_PORT=$GREEN_PORT
  PREV_CONTAINER_NAME=$BLUE_CONTAINER_NAME
  NEXT_CONTAINER_NAME=$GREEN_CONTAINER_NAME
else
  NEXT_PORT=$BLUE_PORT
  PREV_CONTAINER_NAME=$GREEN_CONTAINER_NAME
  NEXT_CONTAINER_NAME=$BLUE_CONTAINER_NAME
fi

echo "배포할 대상 포트: $NEXT_PORT"
echo "배포할 대상 컨테이너 이름: $NEXT_CONTAINER_NAME"

# 신버전 이미지를 바탕으로 대상 컨테이너 다시 생성
docker pull "$1"/"$2":"$3"
docker tag "$1"/"$2":"$3" "$2"
docker stop "$NEXT_CONTAINER_NAME"
docker rm "$NEXT_CONTAINER_NAME"
docker run -d --name "$NEXT_CONTAINER_NAME" -p "$NEXT_PORT":8080 -v ~/application.yml:/usr/app/application.yml -v ~/logs:/usr/app/logs -e TZ=Asia/Seoul -e AGENT_NAME=dev --restart=always --network=app-net "$2"

echo "$HEALTH_CHECK_WAIT 초 후 Health check"
sleep $HEALTH_CHECK_WAIT

# Health check
for RETRY in {1..10}; do
  RESPONSE_CODE=$(curl -o /dev/null -w "%{http_code}" http://localhost:$NEXT_PORT)

  if [ "$RESPONSE_CODE" == 200 ]; then
      echo "연결 성공"
      break
  else
      echo "연결 실패 #$RETRY (Response Code: $RESPONSE)"
  fi

  if [ "$RETRY" == 10 ]; then
    echo "Health check 실패"
    exit 1
  fi

  echo "$HEALTH_CHECK_WAIT 초 후 다시 시도..."
  sleep $HEALTH_CHECK_WAIT
done

# 새로 올린 container로 nginx port 변경
ssh -i key.pem ubuntu@$NGINX_IP ./switch.sh $NEXT_PORT $PROFILE

# 이전 blue/green container 정지
docker stop "$PREV_CONTAINER_NAME"

배포가 어떻게 이루어지는지 흐름을 정리해보겠습니다.

  1. 입력받은 argument를 검증합니다.
  2. blue와 green중 어디에 신버전을 배포할지 결정합니다. 현재 꺼져있는 컨테이너를 대상으로 배포합니다.
  3. 신버전 이미지로 대상 컨테이너를 다시 생성하고 실행합니다.
  4. Health check를 통해 대상 컨테이너가 뜰 때까지 대기합니다.
  5. 컨테이너가 성공적으로 떴으면, 아까 만든 switch.sh를 활용하여 라우팅을 새로운 컨테이너로 변경합니다.
  6. 이전 container는 자원 낭비를 줄이기 위해 중지합니다.

주의할 부분은 4번 과정입니다. 4번 과정 중 컨테이너가 끝까지 뜨지 않는다면, 문제가 발생했다고 보고 배포 과정을 중단해야합니다. 중단되었다는 것을 배포자에게 알리는 것 까지 하면 좋겠죠.

저는 github actions를 사용하는데, 종료 코드가 0이 아니면 배포 실패로 간주해서 알려주도록 설정했습니다.

컨테이너가 떴는지 확인하는 방법은 curl을 이용합니다. curl로 요청을 보내보고 200 응답을 준다면 정상적으로 떴다고 인지합니다.

그리고 이 스크립트에서 받는 인자는 사실 여기에 하드 코딩해도 문제는 없지만, 배포 설정을 한 곳에 모아두고 싶어서 이렇게 구성했습니다. 이렇게 하면 github secrets를 수정하는 것만으로 배포 설정을 관리할 수 있습니다.

Github actions CD 스크립트

1
2
3
4
5
6
7
8
9
10
11
  - name: SSH Remote Commands
    uses: appleboy/ssh-action@master
    env:
        REPO: $
    with:
        host: $
        username: ubuntu
        key: $
        envs: GITHUB_SHA,REPO
        script: |
        ./deploy.sh $ $REPO ${GITHUB_SHA::7}

CD 스크립트에서는 deploy.sh를 실행시켜주기만 하면 됩니다. deploy 실행과 함께 필요한 인자들을 넘겨주었습니다.

참고

  • https://jojoldu.tistory.com/267
  • https://tecoble.techcourse.co.kr/post/2022-11-01-blue-green-deployment/
  • https://www.samsungsds.com/kr/insights/1256264_4627.html
This post is licensed under CC BY 4.0 by the author.

[Spring] 버스 도착정보 수집

-