Logo

Prolip's Blog

프리티어 환경에서 무중단 배포 달성하기
  • #Deployment
  • #Project
  • #ModuReview

프리티어 환경에서 무중단 배포 달성하기

Prolip

2025-03-27

Next.js AWS EC2에 배포하기, 프리티어 환경에서 nginx, pm2, 쉘스크립트를 활용해 Blue-Green 무중단 배포하기.

무중단 배포?

이번에 진행한 프로젝트의 배포 파이프라인을 구성하면서 생긴 고민에 대해 작성해보려고 합니다. 우선 현재 프로젝트의 배포 흐름은 다음과 같은데요.

  1. main 브랜치에 변경이 감지됩니다. (push 혹은 merge)
  2. 변경이 감지되면, github-actions에 의해 프로젝트를 빌드한 뒤, node-modules를 제외한 모든 파일을 압축해 S3 스토리지로 업로드합니다. 이후 코드 디플로이 에이전트를 실행합니다.
  3. 코드 디플로이는 업로드한 프로젝트 파일을 EC2 인스턴스로 옮깁니다. 이 때, appspec.yml 파일에 명시한대로 성공적으로 프로젝트 파일을 EC2 인스턴스에 설치한 뒤 지정한 쉘 스크립트를 실행합니다.
  4. 쉘 스크립트로 프로젝트에 필요한 종속성 설치 후 pm2를 사용해 백그라운드 상태로 프로젝트를 실행합니다.

대략적인 흐름은 다음과 같은데요. 문제는 이 4번입니다. 4번에서 종속성 설치가 모두 끝난 뒤 pm2라는 프로세스 매니저를 사용해 프로젝트를 재실행하는데요.

프로젝트가 잠시 꺼졌다가 켜지는 사이에 유저가 어플리케이션을 사용하며 어떤 요청을 보낸다면 정상적인 서비스를 제공할 수 없게 됩니다.

무중단 배포는 정말 말 그대로 서비스가 중단되지 않은 상태로 유저에게 새로운 버전을 배포할 수 있는 방식을 말하는데요. 위의 흐름으로 현재 구성된 배포 파이프라인은 개발자 중심으로 구성되어 있다고 볼 수 있고, 앞으로 말하는 무중단 배포는 사용자를 중심으로 이루어진다고 말할 수 있어요.

pm2

사실 pm2는 EC2 인스턴스에서 SSH 접속을 종료하더라도 백그라운드에서 웹 어플리케이션을 지속적으로 실행시키고 싶어서 사용했습니다.

하지만 pm2는 이런 단순한 프로세스 매니저 역할 외에 무중단 배포를 가능하게 해주는 2가지 핵심 기능이 있는데요.

Graceful Reload

Graceful Reload는 기존에 실행 중인 프로세스를 바로 종료하지 않고, 새로운 프로세스를 먼저 실행하고 이후에 기존 프로세스를 교체하는 방법입니다.

wait_ready, listen_timeout, kill_timeout 등의 옵션을 사용해 새로운 프로세스가 실행된 후에 정상적으로 실행이 되었는지 확인, 기존 프로세스가 완전히 종료되면 교체하는 등의 방식으로 무중단 배포가 가능합니다. zero donwtime 이라고 하더라구요.

이 방식의 장점은 새로운 프로세스가 실행될 때까지 기존 프로세스가 유지되므로 서비스가 끊기지 않는다는 장점이 있어요. 만약 API 요청을 처리하고 있어도 새로운 프로세스가 실행될 때까지 기존 프로세스가 요청을 계속 받을 수 있어요.

클러스터 모드

클러스터 모드는 Node.js의 클러스터 모듈을 활용해 여러 프로세스를 띄우는 방식인데요. CPU 코어 수만큼 프로세스를 띄우는 방식입니다. 이 방식도 위에서와 마찬가지로 하나의 프로세스가 재시작되는 동안 다른 프로세스가 요청을 계속 처리할 수 있다는 의미입니다.

예를 들어, 4개의 프로세스(1, 2, 3, 4)를 사용 중일 때 현재 1번과 4번 프로세스가 요청을 처리 중이라면 쉬고 있는 2번과 3번 프로세스를 새 프로세스로 바꿉니다.

2번과 3번 프로세스가 실행되면, 이후 발생하는 트래픽을 옮기고 실행 중이던 1번과 4번 프로세스의 작업이 모두 끝나면 프로세스를 교체하는 방식으로 무중단 배포가 가능해요.

하지만..

이 좋은 기능들을 현재 환경에서 사용할 수 없다는 문제가 발생했는데요..

1. Graceful Reload를 사용하지 못한 이유..

우선 pm2의 기본적인 reload 매커니즘은 다음과 같아요. 우선 Next-App이라는 프로세스가 1개 있다고 가정해볼게요. 여기서 reload를 실행하면 마스터 프로세스는 이 1번 프로세스를 _old_1 프로세스로 옮겨요.

이후에 새로운 1번 프로세스를 만들고, 1번 프로세스가 요청을 받을 준비가 되면 마스터 프로세스에게 ‘ready’ 이벤트를 보내게 돼요. 이후에 마스터 프로세스가 이제 _old_1 프로세스는 쓸모가 없어졌으니 ‘SIGINT’ 시그널을 해당 프로세스로 보내고 종료를 기다려요.

이제 기본값인 1600ms 동안 기다린 후 아직도 종료되지 않았다면 ‘SIGKILL’ 시그널을 보내 강제로 종료하게 돼요. 이렇게 프로세스가 재시작되는데요.

하지만.. 이 reload는 우아하게 재실행되지는 않아요. 여기서 우아하지 못하다는 의미는 재실행 과정에서 사이드 이펙트가 발생할 수 있기 때문인데요.

어플리케이션이 처음 실행된 후에 DB를 연결한다던지, 캐싱 혹은 무언가를 시도하기 때문에 가동 후에 안정화되기까지 시간이 소요될 수 있어요. 하지만 기본적인 pm2의 reload 매커니즘은 어플리케이션이 가동된 후에 실제로 아직 요청을 받을 준비가 되지 않았음에도 ready 이벤트를 보내요.

그럼 위에서 설명한 매커니즘에 의해 마스터 프로세스는 _old 프로세스로 ‘SIGINT’를 보내고 이후에 결국 교체되겠죠? 하지만 실제로 요청을 받을 준비가 되어있지 않던 새로운 프로세스로 교체되어버렸기 때문에 사용자가 요청했을 때 서비스가 중단되는 문제가 발생해요.

그래서 pm2 공식 문서의 Graceful Start 항목에서 어플리케이션 로직 단에서 준비가 됐을 때, 직접 ready 신호를 보내도록 권장하고 있어요.

var http = require('http') var app = http.createServer(function(req, res) { res.writeHead(200) res.end('hey') }) var listener = app.listen(0, function() { console.log('Listening on port ' + listener.address().port) // Here we send the ready signal to PM2 await db.set(); process.send('ready') })

이렇게 app.listen으로 해당 포트를 열고 준비가 완료되면 process.send로 마스터 프로세스로 직접ready 신호를 보내는 코드를 구성합니다.

pm2 start app.js --wait-ready --listen-timeout 10000

이후에 이 코드를 실행할 때 —wait-ready 옵션으로 직접 ready 신호를 보내길 기다릴 수 있어요.

다음으로 우아하게 종료하는 방법은 다음과 같은데요.

const express = require('express'); const app = express(); const port = 3000 app.listen(3000, () => { process.send('ready'); console.log(`server is running:: ${port}...`); }) process.on(SIGINT | SIGTERM, () => { app.close(() => { console.log('서버를 종료할 준비를 해요.'); process.exit(0); }) })

마스터 프로세스가 보낸 ‘SIGINT’ 신호를 받으면 내부적으로 .close() 메서드를 호출해 기존 요청은 처리하고, 새로운 요청은 막을 수 있어요. 이후에 모든 요청이 끝나면 process.exit(0)을 호출해 서버를 종료하게 돼요. 그럼 기존 유저는 새로운 프로세스가 올라와도 보내놓은 요청에 대한 답을 받을 수 있겠죠?

또 기본값인 1600ms가 너무 짧다면 아래와 같이 옵션으로 지정할 수 있어요.

pm2 start app.js --kill-timeout 3000

하지만 저희 프로젝트는 일반적인 노드 서버가 아닌 Next.js를 사용했는데요. 우선 Next.js는 기본적으로 자체적인 라우팅도 지원하고, 최적화된 빌드 시스템도 제공해주는 프레임워크입니다.

즉, 이미 만들어진 집에 들어가 사는 것과 같은데요. 위에서 설명한 방법들을 사용하기 위해선 PM2 마스터 프로세스에게 보낼 이벤트와 받을 이벤트를 직접 다뤄야 한다는 의미이기도 합니다.

Express 같은 서버 런타임에서는 쉽게 구현할 수 있는데 Next.js의 기본 실행 방식에서는 제어가 불가능해요. 사실 완전히 불가능한건 아니고 커스텀 서버를 만들어야만 하는데요.

이 커스텀 서버를 직접 구현하면 루트 경로에 server.js를 만들고.. package.json에서 next start 대신 직접 server.js를 실행하도록 바꿔야 해요.

이전에 vercel 배포 시 웹소켓이 사용이 불가능해 커스텀 서버를 만들어봤던 경험이 있어서 하려면 할 수는 있지만.. Next.js의 자동 정적 최적화, 기본적인 서버 최적화 기능을 포기해야 한다는 단점이 있어요.

여기서 자동 정적 최적화란 동적인 데이터 요청이 없는 페이지인지를 알아서 결정하고 미리 SSG 형식으로 렌더링하게 되는데요. 우리는 전혀 신경쓰지 않고 SSR과 SSG를 포함한 하이브리드 앱을 만들 수 있어요. 알아서 골라주니까요. Rendering: Automatic Static Optimization | Next.js

Before deciding to use a custom server, keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements.

공식 문서에서도 진짜 너희 앱이 도저히 우리 라우터로 구현할 수 없을 때만 사용해!! 명심해!! 라고 하고 있을 정도로 그냥 웬만하면 쓰지 말라고 말하고 있어요.

const cleanup = ()=>{ if (cleanupStarted) { // We can get duplicate signals, e.g. when `ctrl+c` is used in an // interactive shell (i.e. bash, zsh), the shell will recursively // send SIGINT to children. The parent `next-dev` process will also // send us SIGINT. return; } cleanupStarted = true; (async ()=>{ debug('start-server process cleanup'); // first, stop accepting new connections and finish pending requests, // because they might affect `nextServer.close()` (e.g. by scheduling an `after`) await new Promise((res)=>server.close((err)=>{ if (err) console.error(err); res(); })); // now that no new requests can come in, clean up the rest await Promise.all([ nextServer == null ? void 0 : nextServer.close().catch(console.error), cleanupListeners == null ? void 0 : cleanupListeners.runAll().catch(console.error) ]); debug('start-server process cleanup finished'); console.log('SIGTERM 신호 내가 잡았어요.'); // 제발 잡아라 process.exit(0); })();} if (!process.env.NEXT_MANUAL_SIG_HANDLE) { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); }

이건 제가 Next.js가 SIGTERM 신호를 어떻게 잡나 보려고 직접 코드 까보다가 알았는데 커스텀 서버 쓰면 이런 코드를 사용하지 못한다는 거니까요. 결과적으로 Next.js를 사용하면서 Graceful Reload 하나만 바라보고 커스텀 서버를 도입하기에는 성능, 유지보수성 측면에서 적절하지 못하다고 느껴서 사용하지 않기로 결정했어요.

2. 클러스터 모드를 사용하지 못한 이유..

위에서 잠깐 설명했듯, 클러스터 모드는 CPU 코어 갯수만큼 프로세스를 띄우는 방식입니다. 이건 우리 프로젝트의 지갑 사정으로 인해 애초에 불가능한 방법이었는데요.

우리가 현재 사용하고 있는 EC2 인스턴스는 프리티어인 t2.micro로 CPU 코어가 1개뿐입니다. 클러스터 모드를 사용하려면 최소 2개 이상의 프로세스를 실행할 수 있어야 해요. 즉 CPU 코어가 2개 이상인 환경에서만 사용이 가능하다는 의미입니다.

직접 클러스터 모드로 실행해봤는데요. 바로 꺼져서 마음을 접었습니다.

결론적으로..

위에서 설명한 이유들로 결국 t2.micro 프리티어 환경에서 Next.js를 사용하는 우리 프로젝트는 결국 PM2의 Graceful Reload와 클러스터 모드를 활용한 무중단 배포가 불가능하다는 결론이 나왔어요.

물론 이건 제 경험에서 나오는 답변이기 때문에 혹시 제가 모르는 무중단 배포 방법이 있을 수 있다고 생각해요.

하지만 무중단 배포가 불가능하다고 해도 주어진 상황에서 downtime을 1-3초까지라도 줄일 수 있는 모든 방법을 생각해봐야한다고 생각했습니다. 이제 그 방법들을 소개해보려고 해요.

Blue-Green 배포

Blue-Green 배포는 2개의 독립적인 환경(Blue, Green)을 번갈아 사용하는 배포 전략인데요. 보통 Blue를 현재 실행(운영) 중인 환경, Green을 새로운 버전의 환경으로 나타내요.

현재 Blue 환경에서 트래픽을 처리하고 있는 동안 새로운 버전을 다른 환경인 Green에서 준비하고 새로운 버전이 준비되면 Blue 환경의 트래픽을 모두 Green으로 옮겨요. 이후 Green 환경으로 트래픽이 모두 전환되면 이후에 기존 Blue 환경을 종료하는 방식입니다.

하지만 이 트래픽이 전환되는 시점에 2개의 프로세스가 모두 켜져있기 때문에 비용이 잠시 증가하는 문제가 발생해요.

우선 장점이라면 기존 프로세스를 종료하고 새 프로세스를 실행하는 방식이 아니고 새로운 환경에 실행시킨 뒤에 트래픽을 전환하니 서비스가 중단되는 다운타임이 최소화된다고 볼 수 있어요.

그리고 배포한 새로운 버전이 문제가 있을 때, 즉시 기존 환경으로 트래픽을 되돌릴 수 있어서 복구할 수 있는 롤백이 용이하다는 장점도 있어요.

사실 이 Blue-Green을 로드밸런서로 트래픽을 분산시키면서 달성하는 레퍼런스를 정말 많이 봤고, 도커를 사용하는 방법도 정말 많이 봤는데요. 우선 무중단 배포만을 위해 도커를 도입하기에는 배우는 데 필요한 우리 리소스도 낭비겠다 싶어서 딱히 고려하지 않았어요.

다음으로 로드밸런서인데요. 고려하지 않은건 아니고 직접 구현은 해봤습니다.

upstream app_server { server 인스턴스:3000 server 인스턴스:3001 } server { listen 443; server_name modu-review.com; location / { proxy_pass http://app_server; } }

이렇게 nginx를 사용하면 아주 쉽게 modu-review.com으로 들어온 트래픽을 app_server 블록의 3000번 포트와 3001번 포트로 분산시키는 로드밸런싱을 구현할 수 있어요.

여기서 3000번 포트가 하나만 실행되어있어도 트래픽 분산에 문제가 되지 않길래 3000번 포트를 Blue, 3001번 포트를 Green으로 실행시키는 방식으로 구현해봤는데요.

문제는 3001번 포트인 Green에 대한 헬스 체크가 이루어지지 않은 상태에서 트래픽이 분산되어버려 프로세스가 실행되는 시점에 요청을 보내면 요청에 대한 응답이 이상하게 오는 일(css가 오지 않는다거나, 혹은 클릭했을 때 무응답)이 빈번해 포기하게 되었어요.

어떻게 보면 아래에서 소개할 제가 구현한 방식은 정말 정석적인 Blue-Green 방식은 아닐 수 있습니다.

하지만 적어도 ‘아 CPU 코어 1개만 더 있으면 이것보단 쉽게 만들 수 있겠다.’ 싶을 거예요. 우선 우리는 3000번 포트와 3001번 포트를 번갈아 사용하며 이 Blue-Green 배포 방식을 달성할 거예요.

Nginx 활용하기

일반적으로 Nginx는 다음과 같이 설정하는데요.

server {
    server_name modu-review.com;
    listen 80;

    location / {
      proxy_pass http://127.0.0.1:3000;
    }
}

server 블록을 보면, server_name은 사용할 도메인 이름을 의미하고, listen 80은 http 요청인 80번 포트에 대한 요청을 받겠다는 의미입니다.

location /는 전체 경로에 대해 proxy_pass에 지정한 아이피 주소의 3000번 포트로 연결하겠다는 의미입니다.

우리 프로젝트는 modu-review.com DNS A레코드에 EC2 퍼블릭 IPv4 주소를 지정해놨는데요. 그럼 누군가 modu-review.com에 접속을 시도하면, EC2 인스턴스로 연결이 이어지고 nginx가 내부에서 실행 중인 로컬 서버로 연결해줍니다.

1. Nginx의 서비스 URL을 동적으로 변경하기

위에서 설명한 Blue-Green은 독립적인 환경 2개를 활용할 수 있어야 해요. 그럼 3000번 포트를 Blue, 3001번 포트를 Green이라고 가정할 때, 위에서 설정한 방식으로는 포트가 고정되어있기 때문에 불가능해요.

그럼 우리는 이 포트를 동적으로 변경할 수 있어야 하는데요. nginx에선 아래와 같이 include를 사용해 설정을 모듈화할 수 있습니다.

# /etc/nginx/sites-available/modu-review.com server { server_name modu-review.com; listen 80; location / { include /etc/nginx/conf.d/service-url.inc; proxy_pass $service_url; } } # /etc/nginx/conf.d/service-url.inc set $service_url http://127.0.0.1:3000;

이렇게 설정해두면 /etc/nginx/conf.d/service-url.inc 이 경로에 작성된 내용을 마치 현재 설정 파일 내부에 직접 작성한 것처럼 동작해요.

굳이 이렇게 분리하는 이유는 포트를 동적으로 바꿀 때 nginx.conf를 직접 수정할 필요 없이 파일 단위로 특정 값을 동적으로 변경할 수 있어 유지보수가 쉽기 때문입니다.

이 설정을 활용하면, 이후에 배포 쉘 스크립트에서 service-url 파일을 동적으로 변경해 nginx의 proxy_pass가 참조하고 있는 포트를 실시간으로 바꿀 수 있게 돼요.

2. 배포 스크립트 구성하기

1) 현재 실행 중인 포트 확인

우선 Blue-Green에 앞서 현재 실행 중인 포트를 확인해야 해요.

# /etc/nginx/conf.d/service-url.inc set $service_url http://127.0.0.1:3000; # script.sh CURRENT_PORT=$(sed -n "s/^set \$service_url http:\/\/$SERVER_IP:\([0-9]*\);/\1/p" /etc/nginx/conf.d/service-url.inc)

이 스크립트는 저장된 service_url 파일을 읽어 정규 표현식을 사용해 뒤에 적힌 포트 번호를 잘라내는 스크립트입니다. 만약 아래와 같이 저장되어있다면 3000이라는 값이 CURRENT_PORT에 할당됩니다.

2) 새로운 배포 포트 확인하기

if [ $CURRENT_PORT -eq 3000 ]; then NEW_PORT=3001 OLD_NAME="next-blue" NEW_NAME="next-green" else NEW_PORT=3000 OLD_NAME="next-green" NEW_NAME="next-blue" fi

다음으로 CURRENT_PORT 값에 따라 새로운 버전이 실행될 포트를 설정합니다.

  • 현재 3000(Blue) 포트에서 실행 중이라면, 3001(Green)에 배포.
  • 현재 3001(Green) 포트에서 실행 중이라면, 3000(Blue)에 배포.

위와 같이 판단해 Blue, Green을 번갈아 가며 실행하는 구조로 새로운 프로세스를 독립적인 환경에서 실행하기 때문에 안정성을 확인하고 트래픽을 전환할 수 있게 돼요.

3) 새로운 버전 실행 준비

DEPLOY_PATH="/home/ubuntu/deploy" PROJECT_ROOT="/home/ubuntu/releases" CURRENT_TIME=$(date "+%y-%m-%d-%H-%M-%S") RELEASE_PATH="$PROJECT_ROOT/$CURRENT_TIME" SYMLINK_PATH="/home/ubuntu/application" PREVIOUS_RELEASE=$(readlink -f $SYMLINK_PATH) mv $DEPLOY_PATH $RELEASE_PATH mkdir $DEPLOY_PATH ln -sfn $RELEASE_PATH $SYMLINK_PATH cd $SYMLINK_PATH

새로운 배포 파일을 위한 디렉터리 구조를 설정하는 과정으로 코드 디플로이가 S3 스토리지에서 가져온 폴더의 이름을 현재 배포 시간으로 변경해 releases 폴더로 이동시켜요.

심볼릭 링크를 활용하는 이유는 배포 과정에서 releases 디렉터리에 배포 버전이 계속 쌓일 가능성이 커지는데 현재 실행 중인 프로세스가 어떤 버전을 가리키고 있는지 쉽게 추적하기 위해서입니다.

4) 새로운 버전 실행

$PM2_PATH start "node_modules/next/dist/bin/next" --name $NEW_NAME --no-autorestart -- start --port $NEW_PORT >> $LOG_FILE

pm2를 사용해 새로운 버전을 실행해요. 동적으로 포트를 변경하기 위해서 해당 경로의 파일을 직접 실행시켜요.

  • —name: pm2에 지정하는 옵션으로 실행할 프로세스의 이름을 지정할 수 있어요. 현재 Blue라면 Green으로 실행되겠죠?
  • —no-autorestart: pm2는 프로세스가 종료됐을 때 자동으로 재실행하는 좋은..? 기능이 있어요. 하지만 직접 SIGTERM 신호를 보내 프로세스를 종료했는데 자꾸 혼자서 재실행시켜서 해당 옵션을 사용했어요.
  • —: 아무 인자 없이 단순히 -를 두 개 사용하면 더이상 pm2에 옵션을 지정하지 않겠다는 의미입니다.
  • start: 그냥 실행시키면 dev 모드로 실행시키는 거 같았어요. 네트워크 탭에 webpack-hmr 파일을 불러오려고 해서 명시적으로 빌드한 파일을 start 하도록 옵션을 지정했어요.
  • —port: Next.js가 실행될 포트를 지정할 수 있어요. 만약 3000에서 실행 중이라면 3001이 지정되겠죠?

5) Health Check (새로운 프로세스가 정상 작동하는지 확인하기)

HEALTH_CHECK_SUCCESS=false SUCCESS_COUNT=0 # 연속 성공 횟수 저장 # 넉넉하게 20번 수행 for i in {1..20}; do echo "Health Check 시도 $i / 20" >> $LOG_FILE status_code=$(curl -s -o /dev/null -w "%{http_code}" http://$SERVER_IP:$NEW_PORT) echo "Health Check HTTP 상태 코드: $status_code" >> $LOG_FILE if [ "$status_code" -eq 200 ]; then SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) echo "Health Check 성공 ($SUCCESS_COUNT / 2) : $CURRENT_TIME" >> $LOG_FILE if [ "$SUCCESS_COUNT" -eq 2 ]; then # 확실하게 2번 성공하면 통과 echo "새로운 버전이 정상적으로 작동 중입니다. : $CURRENT_TIME" >> $LOG_FILE HEALTH_CHECK_SUCCESS=true break fi else SUCCESS_COUNT=0 # 실패하면 다시 0으로 리셋 echo "Health Check 실패 (연속 성공 횟수 초기화) : $CURRENT_TIME" >> $LOG_FILE fi sleep 5 done if [ $HEALTH_CHECK_SUCCESS = false ]; then echo "Health Check에 모두 실패해 배포를 중단합니다. : $CURRENT_TIME" >> $LOG_FILE $PM2_PATH delete $NEW_NAME >> $LOG_FILE 2>&1 ln -sfn $PREVIOUS_RELEASE $SYMLINK_PATH exit 1 fi

새로운 버전이 정상적으로 작동하는지 HTTP 상태 코드를 체크해요. 최대 20번을 시도하고 연속으로 2번 200번 응답이 오면 성공으로 간주하고 이 구문을 탈출해요.

만약 실패한다면, 아래의 if 블럭에서 실행한 프로세스를 delete로 지우고 심볼릭 링크를 이전 배포 버전으로 변경해요.

Blue-Green 배포 방식의 장점 중 하나로 기존 프로세스가 아직 동작하고 있기 때문에 새로운 프로세스가 불안정하다면 롤백이 가능합니다.

6) 기존 프로세스 종료 준비하기

$PM2_PATH sendSignal SIGINT $OLD_NAME >> $LOG_FILE 2>&1

기존 프로세스에 SIGINT 신호를 보내 새로운 요청을 받지 않도록 유도할 수 있어요.

const cleanup = ()=>{ if (cleanupStarted) { return; } cleanupStarted = true; (async ()=>{ debug('start-server process cleanup'); await new Promise((res)=>server.close((err)=>{ if (err) console.error(err); res(); })); await Promise.all([ nextServer == null ? void 0 : nextServer.close().catch(console.error), cleanupListeners == null ? void 0 : cleanupListeners.runAll().catch(console.error) ]); debug('start-server process cleanup finished'); console.log('SIGINT 신호 내가 잡았어요!!'); // 제발 잡아라 process.exit(0); })();} if (!process.env.NEXT_MANUAL_SIG_HANDLE) { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); }

이렇게 신호를 보냈을 때, Next.js가 과연 신호를 진짜 잡을 수 있는게 맞나? 싶어서 코드를 들여다봤는데요. 경로는 /node_moduels/next/dist/server/lib/start-server.js 파일입니다.

환경변수에 NEXT_MANUAL_SIG_HANDLE 값을 설정하지 않을 경우 SIGINT, SIGTERM 이벤트가 발생했을 때 cleanup 함수를 실행하는 것처럼 보였어요. 내부적으로 await을 사용해 server.close()를 호출해 모든 요청이 종료되길 기다리고 성공적으로 종료되면 process.exit(0)을 호출해 서버를 종료하는 코드로 보입니다.

그런데.. 진짜 작동하는지가 궁금해서 실행되는 cleanup 함수에 console.log를 추가하고 빌드한 뒤에 pm2 sendSignal SIGINT를 사용해봤는데요..

image

아주 잘 잡고 있다는걸 확인할 수 있었습니다. 그럼 Next.js는 SIGINT, SIGTERM 신호를 받으면 기존 요청을 마무리하고 종료하기 때문에 사용자 요청이 끊기지 않아 Graceful Shutdown이 가능하다는 사실을 알 수 있게 됩니다.

7) Nginx 설정 변경하기

echo "set \$service_url http://$SERVER_IP:${NEW_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc sudo nginx -s reload

이제 종료 신호도 보냈고 Nginx의 proxy_pass 대상 포트를 변경해야 해요. 새로운 포트를 기록해 해당 경로에 저장합니다. 그럼 nginx의 설정에서 해당 파일을 참조하기 때문에 동적인 변경이 가능해지는데요.

그럼 이 변경된 포트로 트래픽을 옮기기 위해 설정을 다시 불러와야 해요. nginx -s reload는 기존에 요청을 처리하는 워커 프로세스를 유지하고 새로운 워커 프로세스를 변경한 설정으로 시작하게 만들어요.

그러니까 이것도 graceful한 reload 방식이라는 의미인데요. 하여튼 현재 처리 중인 요청을 완료하고 새로운 설정을 적용하며 워커 프로세스를 점진적으로 업데이트하는 방식입니다.

하지만 여기서도 문제가 하나 발생하긴 하는데 마지막에 알려드리겠습니다.

8) 기존 프로세스 종료

echo "기존 서버가 정상적으로 종료되도록 90초간 대기합니다. : $CURRENT_TIME" >> $LOG_FILE sleep 90 echo "기존 서버를 종료합니다 : $CURRENT_TIME" >> $LOG_FILE # kill time 90초가 지나도 기존 요청이 마무리되지 않는다면 강제로 kill $PM2_PATH delete $OLD_NAME >> $LOG_FILE 2>&1

이제 기존 프로세스에게 SIGINT 신호를 보냈으니 정상적으로 종료되도록 90초간 대기해요. 혹시 90초가 지나도 살아있다면 강제로 종료해요.

그래서 전체적인 흐름으로 보자면?

#!/bin/bash DEPLOY_PATH="/home/ubuntu/deploy" PROJECT_ROOT="/home/ubuntu/releases" CURRENT_TIME=$(date "+%y-%m-%d-%H-%M-%S") RELEASE_PATH="$PROJECT_ROOT/$CURRENT_TIME" PM2_PATH="/home/ubuntu/.nvm/versions/node/v22.14.0/bin/pm2" SYMLINK_PATH="/home/ubuntu/application" PREVIOUS_RELEASE=$(readlink -f $SYMLINK_PATH) PROJECT_NAME='Modu-Review-Client' LOG_FILE="/home/ubuntu/log/DeployLog_$CURRENT_TIME.log" echo "SERVER_IP: $SERVER_IP" >> $LOG_FILE NVM_DIR="/home/ubuntu/.nvm" export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" echo "현재 실행 중인 버전: $PREVIOUS_RELEASE" >> $LOG_FILE echo "현재 실행 중인 포트를 확인합니다. : $CURRENT_TIME" >> $LOG_FILE CURRENT_PORT=$(sed -n "s/^set \$service_url http:\/\/$SERVER_IP:\([0-9]*\);/\1/p" /etc/nginx/conf.d/service-url.inc) echo "" && echo "" >> $LOG_FILE 2>&1 if [ $CURRENT_PORT -eq 3000 ]; then NEW_PORT=3001 OLD_NAME="next-blue" NEW_NAME="next-green" else NEW_PORT=3000 OLD_NAME="next-green" NEW_NAME="next-blue" fi echo "모두의 리뷰 새로운 버전을 $NEW_PORT 포트에서 실행합니다. : $CURRENT_TIME" >> $LOG_FILE echo "" && echo "" >> $LOG_FILE 2>&1 mv $DEPLOY_PATH $RELEASE_PATH mkdir $DEPLOY_PATH ln -sfn $RELEASE_PATH $SYMLINK_PATH cd $SYMLINK_PATH echo "필요한 의존성을 설치합니다. : $CURRENT_TIME" >> $LOG_FILE pnpm install >> $LOG_FILE 2>&1 echo "" && echo "" >> $LOG_FILE 2>&1 echo "서버를 실행합니다. : $CURRENT_TIME" >> $LOG_FILE $PM2_PATH start "node_modules/next/dist/bin/next" --name $NEW_NAME --no-autorestart -- start --port $NEW_PORT >> $LOG_FILE echo "" && echo "" >> $LOG_FILE 2>&1 echo "Health Check를 수행합니다. : $CURRENT_TIME" >> $LOG_FILE HEALTH_CHECK_SUCCESS=false SUCCESS_COUNT=0 # 연속 성공 횟수 저장 for i in {1..20}; do echo "Health Check 시도 $i / 20" >> $LOG_FILE status_code=$(curl -s -o /dev/null -w "%{http_code}" http://$SERVER_IP:$NEW_PORT) echo "Health Check HTTP 상태 코드: $status_code" >> $LOG_FILE if [ "$status_code" -eq 200 ]; then SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) echo "Health Check 성공 ($SUCCESS_COUNT/2) : $CURRENT_TIME" >> $LOG_FILE if [ "$SUCCESS_COUNT" -eq 2 ]; then # 2번 성공하면 통과 echo "새로운 버전이 정상적으로 작동 중입니다. : $CURRENT_TIME" >> $LOG_FILE HEALTH_CHECK_SUCCESS=true break fi else SUCCESS_COUNT=0 # 실패하면 다시 0으로 리셋 echo "Health Check 실패 (연속 성공 횟수 초기화) : $CURRENT_TIME" >> $LOG_FILE fi sleep 5 done if [ $HEALTH_CHECK_SUCCESS = false ]; then echo "Health Check에 모두 실패해 배포를 중단합니다. : $CURRENT_TIME" >> $LOG_FILE $PM2_PATH delete $NEW_NAME >> $LOG_FILE 2>&1 ln -sfn $PREVIOUS_RELEASE $SYMLINK_PATH exit 1 fi echo "" && echo "" >> $LOG_FILE 2>&1 echo "기존 서버($OLD_NAME)에 종료 요청을 보냅니다. (SIGTERM) : $CURRENT_TIME" >> $LOG_FILE $PM2_PATH sendSignal SIGINT $OLD_NAME >> $LOG_FILE 2>&1 echo "Nginx 프록시 포트를 $CURRENT_PORT 에서 $NEW_PORT 로 변경합니다 : $CURRENT_TIME" >> $LOG_FILE echo "set \$service_url http://$SERVER_IP:${NEW_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc sudo nginx -s reload # graceful reload 마스터 프로세스에게 요청을 보내고 기존 워커 프로세스는 유지 echo "" && echo "" >> $LOG_FILE 2>&1 echo "기존 서버가 정상적으로 종료되도록 90초간 대기합니다. : $CURRENT_TIME" >> $LOG_FILE sleep 90 # kill time 90초가 지나도 기존 요청이 마무리되지 않는다면 강제로 kill echo "기존 서버를 종료합니다 : $CURRENT_TIME" >> $LOG_FILE $PM2_PATH delete $OLD_NAME >> $LOG_FILE 2>&1
  1. 현재 실행 중인 Blue/Green 포트를 확인해 새로운 포트를 선택한다.
  2. 새로운 포트에서 Next.js를 실행한다.
  3. Health Check을 통해 정상적으로 작동하는지 확인한다.
    1. 만약 실패한다면, 이전 버전으로 롤백한다.
  4. 기존 프로세스에 SIGINT 신호를 보내 Graceful Shutdown을 유도한다.
  5. Nginx의 proxy_pass 포트를 변경해 트래픽을 새로운 버전으로 전환한다.
  6. 기존 프로세스를 일정 시간 동안 대기한 뒤 종료시킨다.
server { server_name modu-review.com; location / { limit_req_zone $binary_remote_addr zone=req_limit:10m rate=5r/s; limit_req zone=req_limit burst=10 nodelay; limit_req_status 429; include /etc/nginx/conf.d/service-url.inc; proxy_pass $service_url; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; } listen 443 ssl; ssl_certificate /etc/letsencrypt/live/modu-review.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/modu-review.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; } server { listen 443 ssl; server_name www.modu-review.com; location / { return 301 https://modu-review.com$request_uri; expires epoch; } ssl_certificate /etc/letsencrypt/live/modu-review.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/modu-review.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; } server { listen 80; server_name modu-review.com www.modu-review.com; location / { return 301 https://modu-review.com$request_uri; expires epoch; } }

3. 근본적인 문제

위의 7번 Nginx 설정 변경하기 탭에서 문제가 하나 발생한다고 했었는데요..

nginx 워커 프로세스 수는 기본적으로 시스템의 CPU 코어 수만큼 생성 돼요. t2.micro는 1vCPU라 결국 워커 프로세스도 1개만 생성된다는 의미이기도 합니다.

그럼 결국 1코어 환경에서 nginx -s reload를 사용해도 실제로 잠깐의 서비스 중단이 발생해요. 이건 워커 프로세스가 1개뿐이라 트래픽 분산이나 완벽한 무중단 배포가 불가능하다는 의미입니다..

이건 해결할 수 없는 기술적인 제약이라고 생각했습니다. 결국 약 1초에서 길다면 2초까지 서비스 중단이 불가피하다고 판단했습니다.

하지만 최소한의 리소스로 무중단 배포에 근접했고, 무엇보다 롤백 메커니즘을 따르기 때문에 서비스의 안정성은 어느정도 확보했다고 생각합니다.

결론

결국 위에서 장황하게 설명했듯, pm2의 Graceful Reload나 클러스터 모드를 사용할 수 없는 환경에서 nginx와 Blue-Green 배포 방식을 활용해 무중단 배포를 어느정도 달성했습니다.

해결해야 했던 문제를 나열해보자면,

  1. pm2의 graceful reload 방식이 Next.js에서 제대로 동작하지 않음
    1. 커스텀 서버를 도입하면 Next.js의 최적화 기능을 잃어버림
  2. 클러스터 모드를 활용하려면 CPU 코어가 최소 2개 필요
    1. t2.micro 환경은 1vCPU로 사용이 불가능
  3. 배포 중 기존 프로세스를 단순히 종료한 뒤 실행하는 방식은 다운타임이 크게 발생
  4. 배포에 실패한다면 서비스 중단

그래서 이 문제들은 어떻게 해결했을까요?

  1. Nginx의 proxy_pass를 동적으로 변경해 트래픽을 즉시 새로운 프로세스로 전환하도록 설정
  2. Health Check를 수행한 뒤 트래픽 변경
    1. 새 버전이 정상적으로 실행되는지 확인하기 때문에 배포에 실패해도 롤백 가능
  3. SIGINT 신호를 활용한 Graceful Shutdown
    1. 단순히 pm2 delete으로 프로세스를 삭제하면 기존 프로세스가 요청을 처리하지 못한채 종료됨.
    2. 정상적으로 요청을 마무리하고 종료하도록 유도

이렇게 결과적으로 t2.micro 환경에서도 다운타임을 최소화하면서 안전하게 배포가 가능한 구조를 만들 수는 있었습니다.

사실 CPU 코어 1개만 더 늘리면 더 쉬웠을 거 같기는 한데요. 그래도 이번에 이렇게 구현하면서 좋은 경험을 했다고 정신 승리를 해봤습니다..

프론트엔드에서의 에러와 예외

프론트엔드에서의 에러와 예외

효율적인 에러 처리를 위한 몸부림

효율적인 에러 처리를 위한 몸부림