FE가 낋여주는 배포 일지(5)_EC2 + Nginx+ S3+codedeploy로 블루/그린 무중단 배포를 해보자

MIlo·2025년 10월 20일
post-thumbnail

필요성

기존에 EC2 + Nginx + codedeploy 로 배포를 했다. 이 방법도 초반에 깃허브 액션으로 ssh연결을 통해 배포하던 방법보다는 굉장히 빠르고 안정적으로 배포를 할 수 있었다. 하지만 하직 큰 문제가 존재했다.
새로운 코드를 서버에 받아와서 새로운 서비스를 실행하는 순간에 기존의 서비스가 꺼지고, 새로운 서비스가 켜진다. 이 순간 사용자의 트래픽은 끊긴다. 그래서 이를 해결할 수 있는 블루/그린 전략을 해볼 것이다.

블루/그린 무중단 배포 전략

1. 동적 전환을 위한 Nginx 설정

Ngninx 가 바라보는 애플리케이션 포트를 배포 시점에 동적으로 변경할 수 있도록 proxy_pass 설정을 외부의 파일로 분리한다. 이로써 배포 스크립트가 Nginx설정을 건드리지 않고 단 하나의 파일만 수정함으로써 안정하게 포트를 조작할 수 있다.

1. 메인 Nginx 설정

먼저 /etc/nginx/sites-available/default 에서 프록시 설정은 바꿔줘야 한다.
기존에는 아래와 같이 nginx가 외부의 요청을 내부의 3000번 포트로 연결하도록 설정을 했다.

2.분리된 프록시 설정 파일

하지만 이제는 외부의 proxy.conf 파일에서 proxy_pass를 결정한다.

# /etc/nginx/sites-available/default
server{
	listen 443 ssl;
    listen [::]:443 ssl;
    server_name supabase.y-minion.link;
    
    # ...
    location / {
    	include /etc/nginx/conf.d/proxy_pass.inc
    }
}

-> 보통 3000 번 포트에 애플리케이션을 실행하지만 무중단 배포를 위해 임의로 3001,3002포트를 사용한다.(3000번 포트로 해도 상관없다. 하지만 블루그린 배포에서는 포트 2개를 사용한다는 것만 알아두자.)

3. codedeploy가 수정할 포트

프록시 설정파일 내부가 아닌 외부의 별도 파일에서 외부의 트래픽을 연결할 proxy_pass를 정해준다. 이렇게 하면 배포 스크립트는 이 파일의 내용만 수정해 안전하게 조작할 수 있다.
초기에는 블루 포트(3001)를 가리키도록 설정한다.

#/etc/nginx/conf.d/proxy_pass.inc
proxy_pass http://127.0.0.1:3001;

2. CodeDeploy 배포 명세서 (appspec.yml)

CodeDeploy에게 배포 프로세스에서 어떤 스크립트를 실행할지 지시하는 appsepec.yml을 작성한다. 이때 프로젝트의 루트 디렉토리에 작성해야한다.
서비스 중단을 유발하는 ApplicationStop 훅은 의도적으로 사용하지 않는다.
- 기존(블루)의 어플리케이션을 중단하게 되면 무중단 배포의 목적이 깨져버린다.
- 하나의 서버에 블루,그린 어플리케이션을 띄워놓고 그린 어플리케이션의 헬스 체크가 완료되고 그린으로 트래픽 전환이 완료되면 기존(블루)의 어플리케이션을 종료한다.

version: 0.0
os: linux
files:
  - source: /
    destination: /home/ubuntu/application
    overwrite: yes

permissions:
  - object: /home/ubuntu
    pattern: "**"
    owner: ubuntu
    group: ubuntu

hooks:
  AfterInstall:
    # 1. 새 버전의 코드 의존성 설치
    - location: scripts/after-install.sh
      timeout: 300
      runas: ubuntu
  ApplicationStart:
    # 2. 새 버전(그린)을 새로운 포트에 실행
    - location: scripts/start-server.sh
      timeout: 300
      runas: ubuntu
  ValidateService:
    # 3. 그린 서버 검증 -> 트래픽 전환 -> 구버전(블루) 정리
    - location: scripts/validate_and_switch_and_cleanup.sh
      timeout: 90
      runas: ubuntu

3. 핵심 배포 스크립트 작성

appspec.yml 이 참조하는 스크립트들은 프로젝트내 scripts/폴더에 작성한다.

1. scripts/after_install.sh

S3로부터 받아온 파일을 저장한 디렉토리로 이동한뒤 프로젝트의 의존성을 설치한다.
이때 환경변수를 설정해주는 부분을 주목하자.
기본적으로 우리가 ssh 로 직접 서버에 접근해서 node를 사용하는 경우(대화형 쉘)과는 다르게 배포 스크립트는 비대화형 쉘에서 실행되면서 node의 경로는 PATH에 추가되지 않는다. 그래서 환경변수에 추가하지 않으면 pm2나 npm같은 명령어는 못찾는다. 그래서 의도적으로 환경변수에 node의 경로를 추가해서 관련 명령어들을 사용할 수 있도록 한다.

#!/bin/bash
NVM_BIN_PATH="/home/ubuntu/.nvm/versions/node/v22.20.0/bin"

export PATH="$NVM_BIN_PATH:$PATH"

cd /home/ubuntu/application

npm install

2. scripts/start_server.sh

1단계에서 만든 /etc/nginx/conf.d/proxy_pass.inc에서 현재의 포트 번호를 알아낸다. -> CURRENT_BLUE_PORT
그리고 블루환경에서 사용하지 않는 포트를 그린 환경의 포트로 지정해야한다.
그린환경의 포트를 정하면 그린환경에서 새로운 어플리케이션을 그린환경 전용 포트에 실행한다.
이때! 그린환경의 포트를 다른 스크립트에서 참조할 수 있도록 별도의 파일(/tmp/green_port.txt)에 저장한다.

#!/bin/bash

cd /home/ubuntu/application

if [ ! -f /etc/nginx/conf.d/proxy_pass.inc]; then
    CURRENT_BLUE_PORT=3001
else
    CURRENT_BLUE_PORT=$(grep -oP '(?<=:)\d+' /etc/nginx/conf.d/proxy_pass.inc)
fi

if [ "$CURRENT_BLUE_PORT" -eq 3001 ]; then
    GREEN_PORT=3002
else
    GREEN_PORT=3001
fi

echo ">>> Blue Port: $CURRENT_BLUE_PORT"
echo ">>> Green Port (New Server): $GREEN_PORT"

PORT=$GREEN_PORT pm2 start "npm run start" --name "app-$GREEN_PORT"


pm2 save

echo $GREEN_PORT > /tmp/green_port.txt

⭐️중요!) npm run start 를 하기전에 먼저 해당 어플리케이션을 실행 시킬 PORT를 지정해줘야 한다.

3. scripts/validate_and_switch_and_cleanup.sh

무중단 배포의 핵심 로직이 담긴 중요한 스크립트다.
/tmp/green_port.txt에서 현재의 실행중인 그린환경의 포트 번호를 확인한다. 그리고 그린 환경의 서버가 잘 작동하는지 헬스체크를 한뒤 통과가 되면 /etc/nginx/conf.d/proxy_pass.inc의 포트 번호를 변경한다.
그리고 블루 환경의 어플리케이션은 종료한다.

#!/bin/bash

NVM_BIN_PATH="/home/ubuntu/.nvm/versions/node/v22.20.0/bin"

export PATH="$NVM_BIN_PATH:$PATH"

if [ ! -f /tmp/green_port.txt ]; then
   echo  ">>> [Error]Could not find port file. The start_server step may have failed."
   exit 1
fi

GREEN_PORT=$(cat /tmp/green_port.txt)

echo ">>> [Step 1] Health check for New Green Server on port $GREEN_PORT"

for i in {1..10}; do
    RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:"$GREEN_PORT"/health)
    echo ">>> [debug] current code is $RESPONSE_CODE"

    if [ "$RESPONSE_CODE" -eq 200 ]; then
        echo ">>> [Success] Health check successful."

        IDLE_PORT=$(grep -oP '(?<=:)\d+' /etc/nginx/conf.d/proxy_pass.inc)

        echo ">>> [Step 2] Switching Nginx to Green Port: $GREEN_PORT"
        echo "proxy_pass http://127.0.0.1:$GREEN_PORT;" | sudo tee /etc/nginx/conf.d/proxy_pass.inc

        sudo systemctl reload nginx
        echo ">>> [Success] Nginx reloaded. Traffic is now served by Green server."

        echo ">>> [Step 3] Stopping Old Blue server on port $IDLE_PORT"
        pm2 stop "app-$IDLE_PORT"
        pm2 delete "app-$IDLE_PORT"
        pm2 save

        exit 0

    fi

    echo ">>> Health check failed. Retrying ... ($i/10)"
    sleep 1
done

echo ">>> [Error] Health check failed after all retries. Rolling back to Blue server. GREEN_PORT is $GREEN_PORT"
pm2 stop "app-$GREEN_PORT"
pm2 delete "app-$GREEN_PORT"
pm2 save
exit 1

4. 헬스체크 전용 api 구현

헬스체크를 위한 API를 만들어야한다.->app/health/route.ts

import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

export async function GET() {
  return NextResponse.json(
    {
      status: "ok",
      message: "Health check successful.",
      timestamp: new Date().toISOString(),
    },
    {
      status: 200,
    }
  );
}

주의해야할 점 (middleware설정)

만약 헬스 체크를 위한 api 요청을 보낼때 권한이 없어서(ex.로그인을 하지 않아 토큰이 없는 경우) 로그인 라우트로 리다이렉트가 될 수 있다. 그래서 헬스체크를 위한 api요청은 리다이렉트 대상이 되지 않도록 미들웨어에서 설정해야한다.
아래는 예시 코드다.

matcher에서 health관련 path는 검사하지 않도록 제외해주면 서버 내부에서 헬스체크를 해도 리다이렉트 되지 않고 안전하게 요청이 전달 된다.

import { updateSession } from "@/lib/supabase/middleware";
import { type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    "/((?!_next/static|health|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};
profile
앵맹!

0개의 댓글