네부캠 그룹프로젝트 - 4. github Actions, docker, nginx를 이용한 배포 자동화 및 무중단 배포

date
Dec 19, 2022
thumbnail
slug
naver-camp-project4
author
status
Published
tags
Project
summary
Github Actions, Docker, Nginx를 이용한 Blue-Green 자동화된 무중단 배포
type
Post
updatedAt
Jan 23, 2023 03:32 PM
boostcampwm-2022/web21-devrank

1️⃣ 왜 무중단 배포를 고민하게 되었을까?

지금 하고 있는 devrank는 네부캠 그룹프로젝트 기간이 끝나도 꾸준히 유지 보수하면서 기능을 확장해나가려 한다. 그러다보니 자동화된, 주기적인 배포의 필요성이 커졌다.
하지만 일반적인 배포 시 기존 서버를 내리고, 새롭게 서버를 올리는 과정에서 필연적인 down time이 발생할 수 밖에 없다.
내가 생각하는 백엔드 엔지니어의 가장 중요한 덕목은 서버를 죽지 않게 하는 것, 예외 처리를 아주 잘해서 최대한 500에러를 클라이언트에게 보여주지 않는 것인데 배포로 인해 down time이 발생하면 아무리 성능이 좋거나 예외 처리를 잘했어도 사용자에게 불쾌한 경험을 줄 수 밖에 없다.
AWS ECS나 K8S같이 컨테이너를 관리해주는 서비스를 이용하면 훨씬 편하겠지만, AWS ECS를 하기에는 비용적인 문제, K8S는 해보지 않아서 너무나 짧은 그룹 프로젝트 기간 내에 배워서 적용하기에는 무리가 있었다.

Blue-Green 방식을 선택한 이유

  • 롤링 배포 방식은 두 대 이상의 서버 인스턴스를 이용해 로드 밸런싱할 때 이점이 있는 것 같은데, 우리는 인스턴스 1개만 일단 유지할 생각이다.
  • 카나리 배포 방식은 복잡성이 높아 보이고, 무엇보다 A/B 테스트가 굳이 현재 서비스에서 필요가 없다고 생각했다.
결론적으로 Nginx를 한 서버 내의 로드 밸런서로 이용하여 blue-green 무중단 배포를 선택하였다. blue와 green 컨테이너를 항상 유지하지 말고, 새 버전의 이미지가 올라올 때만 띄워서 교체하는 식으로 하려한다.
  • AWS의 서비스를 이용하는 것보다 비용적인 이점이 크다. (추가적인 인스턴스가 필요하지 않다. 현재 서비스 규모로는 인스턴스 1개로 충분하다.)
  • 현재는 한 인스턴스 내에서 Nginx를 통한 로드 밸런싱을 통해 무중단을 하기 때문에 아무래도 컨테이너 오케스트레이션 도구에 비해서 scale-out이 불편하지만, 현재 서비스 규모를 생각하면 지금은 고려 사항이 아니라고 생각된다.
    • node서버, redis, nginx 모두 도커라이징되어 있으므로 나중에 scale-out이 반드시 필요한 상황이 되어도 마이그레이션할 수 있을 것이다.
가용성 측면에서 AWS ECS나 K8S 등 다른 방식보다 불리하겠지만, 일단은 시간적인 제약과 비용적인 문제로 인해 현재는 최선의 선택같다.

2️⃣ 무중단배포 흐름

notion image
  1. Local환경에서 github repo로 push한다.
  1. main 브랜치에 merge가 일어나면 배포용 github actions가 실행된다.
  1. 도커 서버 이미지를 빌드한 후, dockerHub에 push한다.
  1. dockerHub에 설정된 웹훅을 통해 서버 인스턴스로 요청을 보낸다.
  1. 서버 인스턴스에 설정된 EndPoint를 통해 요청을 받아 deploy용 쉘 스크립트를 실행한다.
  1. docker로 현재 Blue Container가 떠있다면, dockerHub에서 새로운 이미지를 pull받아서 Green Container를 띄운다.
  1. 새롭게 뜬 Green Container에 HTTP 요청을 보내서 Health Check를 한다.
  1. Health Check가 완료되면 Nginx의 Proxy를 Green Container로 변경한다.
  1. Blue Container를 내린다.

3️⃣ Nest.js 도커라이징을 위한 Dockerfile

FROM node:16-alpine3.14

COPY nest-cli.json .
COPY tsconfig.build.json .
COPY tsconfig.json .
COPY package.json .
COPY yarn.lock .

RUN yarn install

COPY libs ./libs
COPY src ./src

RUN yarn build 

ENTRYPOINT ["yarn", "start:prod"]
  • src와 libs만 포함한다.
  • Typescript로 작성된 Nest.js를 build하여 Javascript로 변환한다.

4️⃣ 도커 이미지 빌드를 위한 Actions

name: deploy

on:
  push:
    branches: [ "main" ] # main branch에 push 발생 시
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: 16.x
        cache: 'yarn'
        cache-dependency-path: 'backend/yarn.lock'
    - name: extract version
      run: |
        VERSION=$(grep -o -E "v[0-9]+\.[0-9]+\.[0-9]+" <<< "${{ github.event.head_commit.message }}")
        echo "VERSION=${VERSION}" >> $GITHUB_ENV
    - name: create Github release
      uses: actions/create-release@v1
      with:
        tag_name: ${{ env.VERSION }}
        release_name: ${{ env.VERSION }}
      env:
        GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    - name: Login to Docker Hub
      uses: docker/login-action@v2
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}
    - run: |
        cd backend
        docker build -t devrank/backend-api:latest -f Dockerfile . --platform linux/x86_64
        docker push devrank/backend-api:latest
        docker tag devrank/backend-api:latest devrank/backend-api:${{ env.VERSION }}
        docker push devrank/backend-api:${{ env.VERSION }}
  • main 브랜치에 push 발생 시 동작한다.
  • dockerHub에 로그인 한 후, 새로운 이미지를 빌드하고 push한다.

5️⃣ reverse proxy를 위한 nginx 설정

upstream backend-api {
  server blue:3001; 
}

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

  location / {
    proxy_pass http://backend-api; # 동일한 네트워크 상의 container로 proxy
    proxy_redirect     off;
    proxy_set_header   Host $host;
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Host $server_name;
  }
}
  • 80포트로 요청이 들어오면, 현재 떠 있는 Blue 또는 Green 컨테이너로 전달한다.
  • CloudFlare에서 HTTPS를 레이어가 따로 설정해놨으므로, letsencrypt로 인증서를 발급 받고 443으로 redirect해주는 등 번거로운 작업은 필요 없다.

6️⃣ docker-compose.yml

version: '3.7'

services:
  nginx:
    image: nginx
    container_name: nginx
    restart: always
    ports:
      - '80:80'
    volumes:
      - ./nginx/:/etc/nginx/conf.d/
      - ./logs/nginx/:/var/log/nginx/
  blue:
    image: devrank/backend-api:latest
    container_name: blue
    restart: always
    env_file:
      - ./.env
    volumes:
      - ./logs/nest/:/logs/
    expose:
      - 3001
    depends_on:
      - redis
  green:
    image: devrank/backend-api:latest
    container_name: green
    restart: always
    env_file:
      - ./.env
    volumes:
      - ./logs/nest/:/logs/
    expose:
      - 3001
    depends_on:
      - redis
  redis:
    image: redis:6.2.5
    command: redis-server
    ports:
      - 6379:6379
    volumes:
      - ./data/:/data
  • nginx,redis도 docker로 띄우고, node.js 앱은 blue, green이라는 이름으로 띄운다.
  • docker-compose up을 통해 blue와 green을 한 번에 띄우지는 않을 것이다.
  • 두 API 서버는 도커 네트워크 상에서만 Nginx를 통해 프록시하므로 expose를 통해 동일 네트워크 상에서만 노출한다.
  • redis는 차후에 다른 인스턴스로 scale-out할 지 모르므로 6379로 연결해놓고, 나중에 필요시 방화벽으로 6379를 열어주면 될 듯 하다.

7️⃣ 배포 시 무중단 배포를 위해 실행될 shell script

#!/bin/sh

IS_GREEN=$(docker ps | grep green) # 현재 실행중인 App이 blue인지 확인

DEFAULT_CONF="./nginx/default.conf"

docker-compose up -d nginx
docker-compose up -d redis

if [ -z $IS_GREEN  ];then # blue라면
	echo "### BLUE => GREEN ###"
	echo "1. get green image"
	docker-compose pull green # 이미지 받아서
	echo "2. green container up"
	docker-compose up -d green # 컨테이너 실행
	while [ 1 = 1 ]; do
		echo "3. green health check..."
		sleep 3
		REQUEST=$(docker exec nginx curl http://green:3001) # green으로 request
		if [ -n "$REQUEST" ]; then # 서비스 가능하면 break
			echo "health check success"
			break ;
		fi
	done;
	sed -i 's/blue/green/g' $DEFAULT_CONF # nginx가 green을 가리키도록 변경
	echo "4. reload nginx"
	docker exec nginx service nginx reload
	echo "5. blue container down"
	docker-compose stop blue
else
	echo "### GREEN => BLUE ###"
	echo "1. get blue image"
	docker-compose pull blue
	echo "2. blue container up"
	docker-compose up -d blue
	while [ 1 = 1 ]; do
		sleep 3
		echo "3. blue health check..."
		REQUEST=$(docker exec nginx curl http://blue:3001)if [ -n "$REQUEST" ]; then
				echo "health check success"
				break ;
		fi
    done;
	sed -i 's/green/blue/g' $DEFAULT_CONF
	echo "4. reload nginx"
	docker exec nginx service nginx reload
	echo "5. green container down"
	docker-compose stop green
fi
  • docker ps 명령을 통해 현재 떠있는 컨테이너가 Blue인지 Green인지 확인한다.
  • 만약 Blue라면, DockerHub에서 최신 이미지를 받아와서 Green 컨테이너를 새로 띄운다.
  • docker exec nginx curl http://green:3001 을 통해 green 컨테이너에 3초마다 요청을 보낸다. (Health Checking)
  • Health checking이 끝나면 Nginx 설정 파일에서 proxy를 green으로 변경한다.
  • nginx를 reload하여 설정 파일을 적용하고, blue 컨테이너를 down 한다.

8️⃣ adnanh/webhook을 이용하여 서버 측 EndPoint 정의

서버 측 EndPoint를 정의하기 위해 adnanh/webhook을 이용하려 한다.Go로 작성된 가벼운 App으로 서버를 위한 HTTP EndPoint를 json으로 쉽게 정의할 수 있다. dockerHub에 새 이미지가 push되면 이 EndPoint를 이용하여 서버측에서 deploy.sh를 실행할 것이다.

왜 유명한 Jenkins나 Travis CI같은 도구를 사용하지 않았는가?

  • 일단 새로 배워서 도입하기에는 짧은 프로젝트 기간에 무리라고 생각되었다.
  • 또한 CI는 Github Actions를 통해 진행하므로, 굳이 서버측에서 CI기능까지 필요하다고 생각되지 않는다.
  • 따라서 단지 CD를 위해 EndPoint만 정의하는 역할이라면 최대한 가벼운 도구를 사용하는 게 낫다고 판단되었다. (서버의 인스턴스 사양도 최저 스펙이기도 하다.)
  • 아래처럼 hooks로 실행할 endpoint를 json으로 정의한다.
// hooks.json
[
  {
    "id": "deploy", // 정의된 포트로 '/deploy' endpoint
    "execute-command": "/home/ddong/web21-devrank/backend/deploy.sh", // 실행할 sh
    "command-working-directory": "/home/ddong/web21-devrank/backend",
    "response-message": "Execute re-deploy",
    "trigger-rule":
    {
        // 쿼리스트링으로 token=****** 를 보내야 실행된다
        "match":
        {
          "type": "value",
          "value": "******",
          "parameter":
          {
            "source": "url",
            "name": "token"
          }
        }
    }
  }
]

9️⃣ 최종 테스트

notion image
  • 서버가 재부팅되도 이 endpoint는 동작해야하므로, webhook 서버를 service로 등록해준다.
 
 
notion image
  • 정의된 endpoint로 요청을 보내면, deploay.sh가 실행되면서 위처럼 API 서버 컨테이너가 blue에서 green으로 변경된 것을 볼 수 있다.

🔟 현재 방식에서 발생 가능한 문제

이전에 라인 기술블로그에서 PM2를 활용한 Node.js 무중단 서비스하기 글을 흥미롭게 읽었던 적이 있다. 여기서 했던 고민과 비슷한 고민이 들었다.
간략하게 내용을 요약해보면 pm2 reload 시 기존 프로세스를 대체할 새로운 프로세스를 새로 띄우고 기존 프로세스를 종료시키는데, 이 때 두가지 문제가 발생할 수 있다는 내용이었다.
  • 새로운 프로세스가 요청을 받을 준비가 되지 않았는데 요청이 들어오는 경우
  • 클라이언트 요청을 처리하는 도중에 프로세스가 죽어버리는 경우
우리 방식은 HTTP 요청을 통해 Health Check를 하므로, 첫 번째 경우는 문제가 되지 않는다.
하지만 두 번째 경우를 생각해보면 문제가 발생할 수 있었다.
만약에 기존에 blue 컨테이너가 켜져있었고, 특정 HTTP 요청을 컨테이너에서 처리하고 있었다고 생각해보자.
green 컨테이너가 올라오고, Nginx를 통해 proxy가 green 컨테이너로 바뀌고 blue 컨테이너를 down 시키면 처리 중이던 사용자의 요청은 사라지고, 사용자는 timeout을 겪게 될 것이다.

🛠️ (문제 해결) Graceful Shutdown

nest.js는 express나 fastify를 래핑하고, 우리 nest.js 앱은 default 설정인 express를 기반으로 동작한다.그리고 express에서 서버를 실행하는 부분을 살펴보면 아래와 같다.
app.listen = function(){
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};
따라서 node의 http 모듈을 보면 해결법을 찾을 수 있을 거라고 생각했고, 결론적으로 nodejs 공식 문서의 http 부분에서 server.close() 메소드를 찾을 수 있었다.설명을 읽어보면 아래와 같다.
HTTP 서버가 새 연결을 수락하지 못하도록 중지합니다.모든 기존 연결은 유지됩니다.
정확히 원하던 동작이다. 기존 컨테이너의 node 서버가 close를 통해 새로운 요청을 차단하고, 기존 요청들을 모두 처리한 후 실행되는 콜백으로 DB 커넥션을 끊어내고 자체적으로 프로세스를 종료시키면 된다.
이제 컨테이너 내부로 SIGINT나 SIGTERM 신호를 보내면 끝이다. 찾아보니 docker stop명령은 자체적으로 foreground 프로세스에게SIGTERM 신호를 보내게 된다. 따라서 기존에 작성한 쉘 스크립트는 수정할 필요가 없다.
최종적으로 아래와 같은 코드를 node app에 추가하여 SIGTERM 이벤트에 대한 핸들러를 추가해주면 된다.
// 종료 요청 신호 받기 (by docker stop)
process.on('SIGTERM', () => { 
  // 더 이상 요청을 받지 않는다 && 남은 요청 처리
  server.close(() => { 
		mongoose.connection.close(false, () => { // db 연결 종료
			process.exit(0);  // 최종적으로 프로세스 종료
    });
  });
});
 

참고