[🚗 자동화 개발 회고] EC2, PM2, Nginx 를 이용한 Blue/Green 무중단 배포 구축기

devAnderson·2024년 4월 9일
0

TIL

목록 보기
103/106

👋 개요

지난 글의 코맨트에서 남겼던 것처럼, 이미 Docker와 ECS를 기반으로 하는 자동배포를 완성하긴 했지만, 조금 보완하고 싶었던 부분이 있었는데 이는 아래와 같다.


1. 개발 배포에서 조금 부담스럽게 긴 빌드시간

Backend의 경우, 서버를 띄우기 위해 서버 어플리케이션 하나만 띄우는 경우가 거의 없고 데이터베이스나 RDS와 같은 다른 서비스를 동시에 띄우는 경우가 많기 때문에 Docker-compose를 이용하는 배포 전략이 매우 편리하다고 생각한다.
(환경을 한번에 정의해서 띄울 수 있기 때문에 편하다)

그러나, 현재 프론트는 Next.js를 통해 구축되어 있고, 사실상 단일 프로젝트만 빌드하면 충분한 상황이다. 이런 단일 프로젝트 하나를 위해서 Docker image를 빌드하고 컨테이너로 올리는 것은 조금 시간이 오래 걸리는 편에 속한다고 생각했다.

저것도 굉장히 빠른편에 속하며 팀 내에서 개발되고 있는 프로젝트들 중, 크기에 따라 빌드시간만 500초가 넘어가는 프로젝트도 있었다.
현재 올리려고 하는 신규 프로젝트는 단순히 내부 운영진에 대한 QA를 위한 배포만 필요했기 때문에 배포전략에 대한 관심사가 분리되면 좋겠다고 생각했다.

이에 따라,

  • 개발 : pm2를 이용하여 build를 하지 않고 단순 dev 모드 실행 (살짝 downtime 존재)
  • 운영 : build 후 nginx를 이용한 blue/green 무중단 배포

이렇게 분리하기로 마음먹었다.


2. Docker 배포에 따라 요구되는 비교적 높은 EC2 스펙

EC2로 배포하는 것으로 결정하고 나니, 자연스럽게 EC2 내부에도 도커를 설치할 필요가 없게 되었다.

필자가 단일 프론트엔드 프로젝트에서 도커를 사용함에 가장 큰 우려를 느꼈던 부분이 지나치게 차지하는 도커의 캐시 크기들이었다.

당연한 이야기겠지만, 단순 정적 빌드에 비하면 Docker을 통한 배포는 컨테니어 구축을 위한 스펙이 요구되며, 그 결과 생성되는 파일들의 크기가 생각보다 몹시 크다.

위의 이미지는 그냥 nextjs 커맨드로 빈 프로젝트를 만들고 그것을 그대로 Docker로 말아서 올린 후, 다시 내려서 캐시를 지워본 결과다.

단순 보일러 플레이트인 프로젝트도 저런데, 내부 코드가 많은 프로젝트를 도커로 빌드한 후 삭제해보면 캐시 크기만 6G가 넘어가는 상황도 마주하게 되는 경우가 있다.

심지어, 현재 보일러 플레이트는 이미지 크기 최적화가 적용된 Dockerfile 버전이다.
최적화가 안된 이미지의 크기까지 포함되었으면 최소 3기가는 넘었을지 모르겠다.
혹시 도커 이미지를 줄이는 코드가 필요할 사람도 있을지 모르니 nextjs docker 최적화 레퍼런스를 일단 남겨둔다

도커 빌드를 할 때 프로젝트를 있는 그대로 빌드하게 되면

만약 Micro정도의 스펙을 가진 인스턴스를 사용하게 될 경우, 프로젝트 크기가 조금 크다면 Docker로 빌드하는 순간 터져버리는(...) 상황을 자주 마주하게 되기 때문에 EC2 스펙을 계속 높여줘야하는데, 그게 다 돈이다.

단순히 Static한 자원들을 전달하는 목표를 가진 next.js 서버를 구동시키기 위해서 중간 규모 단위의 인스턴스를 대여해야 한다는 것은 조금 오버스펙에 가깝다는 결론을 짓게 되었다.


3. Code Pipeline을 요구하는 ECS의 배포시스템

사실 이미 ECS로 CI/CD를 잘 구축해놓고 나서 왜 EC2로 구현을 하려고 하는지에 의문을 가질 수도 있을 것이다. 하지만, 가장 치명적인 문제가 있었다면 그것은 바로 ECS에서 배포를 할 때 Code Pipeline을 설정하지 않았다면 기본적으로 rolling 배포를 한다는 것이 문제였다.

아래서도 다시 언급하겠지만, rolling 배포는 기본적으로 이미 올라가있는 앱과 새롭게 배포된 앱이 서로 공존하는 시간이 존재하는 문제점이 있다.

실제로 기존버전이 draining 되기 전인 공존시간에 새로고침을 계속 해보게 된다면 웹사이트가 변경전과 변경후의 두가지 버전이 번갈아가면서 호스팅되는 것을 알 수 있다.

항상 최신버전을 보여줘야하는 웹사이트에서 변경전의 UI를 가진 페이지를 사용자가 우연히 사용하게 될 경우, 잘못된 요청이 백엔드로 전달될 가능성을 배제할 수 없다.

이에 프론트에게 가장 적합한 배포 전략은 blue/green 전략이지만, ECS에서 이 배포형태를 사용하려면 "Code Pipeline"을 구축해줘야하는 문제가 있다.

이미 github action self-hosted를 활용해 CI/CD 를 구축해놓은 상태라서, 굳이 금액을 들여가며 Code Pipeline을 추가할 필요가 없다는 것이 최종적인 판단이었다.

이런 이유로 Docker보다는 PM2를 이용한 배포를 하기로 마음먹었다.

서론이 길었다. 그럼 본격적으로 무중단 배포 자동화 과정을 어떻게 구축했는지 기록을 남긴다.


👋 1. PM2?

다들 이미 알고있는 사실이지만, Node.js는 기본적으로 싱글 스레드이다.

정확하게 말하면, javascript를 처리하는 스레드가 하나이다. 즉, CPU의 하나의 코어가 하나의 Node.js 프로세스를 처리하는 형태로 되어있다.

이로 인해 정작 컴퓨터에 코어가 많이 있음에도 불구하고 해당 스펙을 온전히 사용하지 못하는 케이스가 발생한다.

이로 인해 Node.js에서는 Cluster Module을 제공한다.

기본적인 개념으로는 default가 되는 master process를 실행한 후, 다시 fork를 통해 child process들을 만들어서 스크립트를 처리하는 구조로 되어있다.

하지만 그냥 기본적인 Cluster의 기능을 사용하게 될 경우, 작업의 불평등 분배에 대한 처리를 따로 해줘야 하는 경우가 생기기 때문에 이를 대신해서 프록시처럼 중간에서 적절하게 job을 분배하여 프로세스들이 잘 Cluster 모드로 처리할 수 있도록 관리해주는 역할이 필요하다. 바로 그것이 PM2의 기능이라고 이해하면 쉬울 것이다.

더 흥미롭고 자세한 내용은 Node.js의 Cluster module 을 보면 이해하기 정말 쉽게 작성해 주셨으므로 꼭 읽어보면 좋을것이다. 읽는 족족 깨달음의 순간이 있는 글이다. 강추

다시 말하자면, PM2는 우리가 npm run start하는 듯이 프로세스를 실행시키고, 프로세스들을 관리하며, 추가적으로 앱이 에러로 중지되었을 때 자동으로 실행되게 하는 기능들을 포함한 매우 편리한 친구다.

기본적인 설치는 아래와 같다.

1. 설치
npm install -g pm2


2. 특정 스크립트 파일 실행 (-i는 활용할 코어수를 결정함)
pm2 start ./app.js -i max

3. 특정 script 명령어 실행 (blue라는 이름을 붙이며 프로세스 실행)
pm2 npm -- run dev --name blue

4. 현재 pm2를 통해 켜진 프로세스들 확인
pm2 ps

5. pm2 상황 실시간 모니터링
pm2 monit

6. 특정 프로세스 죽이기
pm2 delete pid|name

중간에 보이는 "-i" 나 "--name" 과 같은 옵션에서 볼 수 있듯, PM2를 돌리면 여러가지 추가적인 옵션을 넣어줄 수 있다. 옵션정보 관련 공식문서

만약 명령어가 길어지는 것이 귀찮으면 config파일로 대체하는 것도 가능하다. (또한 배열인것에서 짐작하실 수 있듯이 한번에 여러 프로세스를 키는것도 가능함)

// ecosystem.config.js
module.exports = {
  apps : [{
        name   : "parrot0",
        script : "./parrot-bot-discord/parrot-bot.js",
        args: "2 0",
        time: true,
        max_memory_restart: "600M"
  },
  {
        name: "parrot1",
        script: "./parrot-bot-discord/parrot-bot.js",
        args: "2 1",
        time: true,
        max_memory_restart: "600M"
  }]
}

이후 위 파일은 아래처럼 실행 가능하다.

pm2 start ecosystem.config.js

전반적인 사용방식은 위에서 정리된것과 같다.

다만, 내가 원하는 것은 무중단 배포이고, 여기에서는 앱의 이름을 좀 유동적으로 바꿔야 할 필요가 있기 때문에 config파일은 사용하지 않고 pm2 명령어를 통해 앱을 구동시키려고 한다.


👋 2. 무중단 배포 전략

무중단 배포란, 말 그대로 배포를 하면서 사용자가 느끼는 downtime(서버가 내려서 502코드를 받는 경우 등)이 없이 최신 코드가 배포될 수 있도록 하는 동작을 뜻한다.

깔끔하게 정리된 레퍼런스는 여기 에서 확인하면 좋다.

내용 전개를 위해서 아주 간략하게만 정리하자면,

대표적으로 많이 사용되는 무중단 배포 전략은 크게 아래의 2가지로 나뉜다

1. Rolling

  • what : 기존 인스턴스들을 하나하나씩 바꿔가는 바꿔나가면서 업데이트를 진행하는 전략
  • pros: 비교적 적은 자원으로 배포 가능
  • cons: 구버전과 신버전의 공존으로 인한 호환성 문제 (배포 완료 전까지는 사이트를 접속할 때마다 구버전 신버전이 번갈아가며 뜸...)

2. Blue/Green

  • what : 기존 인스턴스를 유지한 상태에서 새로운 인스턴스를 띄운 후, 완료되면 로드 밸런싱을 새로운 버전으로 돌리고 기존 버전을 삭제하는 방식
  • pros : Rolling때와 같은 호환성 문제는 없음
  • cons : 자원이 두배로 듦 (돈..)

필자는 해당 전략에서 인스턴스를 띄우는 과정 및 로드 밸런싱을 모두 EC2 안에서 처리할 것이기 때문에(가벼운 작업이니까) Blue/Green 전략을 선택하였다.

여기서 인스턴스를 띄우는 부분을 PM2가 담당할 것이고, 로드 밸런싱을 Nginx가 하게 할 것이다.


👋 3. Nginx?

Nginx는 간단하게 이해하면 웹 서버이다.

웹 서버(web server)라 함은 Http 프로토콜을 사용해 클라이언트와 통신하여 html, css, javascript와 같은 정적 소스를 전달해주는 소프트웨어를 말한다.

즉, nginx가 하는 역할은 네트워크를 통해 전달되는 http 요청을 받아서 이에 대한 적절한 응답을 하는 서버라고 할 수 있다.

참고로, nginx가 그 자체로 캐싱 서버로서 사용이 되어 웹서버 역할을 할 때도 있지만, 위에처럼 로드밸런싱을 하게 될 경우 이를 백엔드 서버에서 프록시 서버 역할을 하는 리버스 프록시(Reverse Proxy) 서버의 역할을 하게 되는데, 이 때에는 WAS에 해당 요청을 전달하고 그 WAS에서 오는 응답을 수신하여 이를 다시 클라이언트에게 전달하는 역할을 하게 된다.

WAS(Web Application Server)란, 동적으로 컨텐츠를 생산하여 응답을 전달하는 기능을 가진 소프트웨어라고 할 수 있다. 예를 들어, 특정 회원가입 요청에 대하여 동적으로 이를 분석한 후 처리를 하여 쿠키와 같은 내용을 담아 응답을 전달할 수 있는 서버는 WAS라고 할 수 있다.

비슷하게, 우리가 띄우게 될 Next.js 서버는 서버사이드 랜더링을 통해 동적으로 컨텐츠의 생산이 가능하고 이를 응답으로 전달할 수 있기 때문에 WAS라고 볼 수 있다.

nginx는 기본적으로 네트워크 통신에 대해서 비동기적으로 처리할 수 있도록 만들어져있고, 매우 가벼우며 빠르다는 장점이 있다.

nginx의 탄생역사와 내부 구조에 대한 설명을 아주 이해하기 쉽게 정리한 레퍼런스가 있어 남긴다

실제 설치 및 실행과 관련한 튜토리얼 강좌 링크를 남겨둔다

nginx를 실행시키려면 일단 설치부터 진행해야 한다.

// 1. mac
brew install nginx

// 2. ubuntu
sudo apt-get install nginx

nginx에 대한 기본 명령어들은 아래와 같다.

// 1. nginx 서버 기동 (brew 기준,  /opt/homebrew/etc/nginx/nginx.conf 기준으로 기동) 
nginx

// 2. nginx 특정 config 파일 기준으로 기동
nginx -c [설정파일 절대경로]

// 3. nginx 강제정지
nginx -s stop

// 4. nginx elegnat 정지 (실행중인 request가 있을 경우, 처리를 기다리고 나서 정지
nginx - s quit

// 5. 재기동 
nginx -s reload

nginx는 기본적으로 nginx.conf 파일을 기준으로 작동한다.

이 파일의 구성에 대해서는 위에서 링크를 달아둔 튜토리얼 강좌 를 보면 제일 이해하기 쉬울것이다.

그리고 일단, nginx.conf의 구성에 대해서 파악이 안되면 참으로 고역스럽기 때문에 되도록이면 한번 보는 것을 가장 추천드린다.

그래도 혹시 필요한 사람이 있을지 모르니 필자가 구조를 공부하면서 텍스트 파일에 정리했던 내용을 남겨둔다.

## nginx 역할 = proxy 서버로서 네트워크 요청을 받은 후, 적절한 로드밸런싱을 통해 WAS(Web Application Server, 웹 컨텐츠를 동적으로 생성하는 역할) 에 전달한 후, 정적 응답값을 네트워크를 통해 클라이언트에게 전달하는 중간 대리자.
## index.html, css, javascript와 같은 정적값을 서빙할수도 있고, proxy_pass 로서 포트포워딩을 통한 리버스 프록시(리퀘스트 요청을 받은 후, 실제 내부에 돌아가는 어플리케이션 서버에 요청을 전달해 결과를 받아서 대신 클라이언트에 리턴하는 동작)도 가능하다.
## 프록시처럼 작동하기때문에 중간에서 캐싱도 가능하고 암호화를 통한 전달도 가능.
## 정리 레퍼런스 주소 : https://brunch.co.kr/@springboot/21, https://gonna-be.tistory.com/20
## 개념 레퍼런스 강의 : https://www.youtube.com/watch?v=9t9Mp0BGnyI
## nginx ec2 환경 적용 절차 정리 레퍼런스 : https://develop-const.tistory.com/42
## 로컬머신에서 https 적용해보기(mkcert) 레퍼런스 : https://weezip.treefeely.com/post/nginx-local-https-ssl

## nginx의 기본적인 statement 정의는 simple directive(끝이 ;로 끝나는 한 문장) 과 block directive({} 안에 들어가있는 문장) 으로 나뉜다.
## nginx option 설명들 (레퍼런스 : https://blog.dglee.co.kr/27)

############ CORE #######################
user: 
nginx process가 어느 사용자/그룹으로 동작될 것인지에 대한 설정.(root로 하면 모든 권한을 갖게 되므로 주의)

worker_processes: 
발생되는 이벤트를 처리하는 작업자 프로세스 수. (어려우면 auto로. 보통 CPU 코어수로 정함)

error_log: 
nginx 에러로그를 저장하는 파일명 지정

pid: 
nginx 실행할 때 process id 저장하는 경로 지정

########## EVENT BLOCK ######################
worker_connections:
하나의 worker process가 동시에 처리할 수 있는 커넥션 수. (예를 들어 worker_processes 가 2인데, worker_connections가 1024면 2048개의 커넥션을 처리할 수 있음. 클라이언트 커넥션수 + 프록시 대상 커넥션수)

########## HTTP ###########################
include:
특정 nginx 설정을 포함시킴. 

default_type:
기본 Content_Type을 지정함.

log_format:
작성되는 로그의 포맷 (포맷형식 주소 : https://nginx.org/en/docs/varindex.html)

access_log:
클라이언트가 log에 acess하기 위한 경로

sendfile:
리눅스 서버의 sendfile 시스템콜의 사용유무 설정(설정시 커널에 최적화 알고리즘을 통해 파일전송 속도가 빨라지며 메모리 사용 감소)

keepalive_timeout:
클라이언트의 접속 유지시간 지정.

tcp_nopush:
TCP 연결을 맺은 클라이언트에게 데이터를 즉시 보낼지, 버퍼를 두고 보낼지에 대한 설정. (on이면 즉시 보내기인데, 보안적 이슈로 권장되지 않음. 보안제외 즉각적인 전송이 더 중요하면 성능적 이점)

########## SERVER ##############################
listen:
접근 포트의 지정 (설정하여 server 블록에서 처리할 수 있게 함)

server_name:
호스트 네임 지정 (배포환경에 주소에 맞춰 설정해야 함. 해당 호스트네임으로 요청이 들어오는 것을 nginx가 프록시로 중간처리함)

access_log:
HTTP쪽과다름(저기는 접근). 로그 저장위치 설정함

error_page:
클라이언트의 HTTP요청이 error_code_list에 설정한 에러일 경우, 지정된 error_oage를 전송함.
(단, WAS에서 전달되는 페이지가 없어야 함. 예를 들어, www.example.com/nopage로 접근했을 때 해당 리소스가 없는 상태고 응답코드가 404인 경우에는 지정된 nginx의 페이지가 전달된다)

location:
end-path처럼 경로별로 어떤 리소스를 전달할 것인지에 대해서 설정하는 부분.

#######################################################################################

http {
    server { # 전달받은 요청에 대해서 어떤 서버로 전달할지 정의내리는 부분
        listen 8080; # 8080포트로 오는 요청을 주시하고 있겠다. (즉, 이 포트로 들어오는 요청을 proxy처럼 중간에서 처리한다)
        root /Users/anderson/Desktop/project; # 루트 페이지 파일경로 (!절대경로여야 함, serving하고 싶은 파일. 맨 뒤에 index.html이 생략되어있는것임)

        # url은 /number/~path로 유지하면서 로드되는 자원은 /count 로 바꾸고 싶을 때(rewrite)는 아래처럼 작성한다
        rewrite ^/number/(\w+) /count/$1; ($1은 variable을 의미하며, 정규식 매칭부를 뜻한다)

        location ~* /count/[0-9] { # dynamic route에 대해서는 이렇게 정의하면 된다.
            root /Users/anderson/Desktop/project;
            try_files /index.html =404; 
        }

        location /fruits {
            # 위에 root에 대한 경로를 설정했다면, location은 다른 경로들에 대한 url을 정의하는 부분
            # 위에 /fruits라고 해놓았기 때문에, 8080포트의 /fruits로 들어오는 요청에 대한 처리를 담당하게 된다.
            
            root /Users/anderson/Desktop/project/fruits; (!절대경로여야 함. index.html이 생략되어있는 형태)
        }

        location /carbs {
            # 이렇게 alias를 설정하면, 8080포트의 /carbs로 오는 요청에 대해서 /fruits가 전달되도록 만들 수 있다.
            alias /Users/anderson/Desktop/project/fruits;
        }

        location /vegetables {
            root /Users/anderson/Desktop/project #이렇게 할 경우, default action은 해당 폴더의 index.html을 서빙하려고 할것이다
            try_files /vegetables/veggies.html /index.html =404; # 하지만 이렇게 try_files를 설정해주면 root를 기준으로 try_files에 있는 경로의 파일을 찾아서 서빙하려고 할 것이다. 그리고도 못찾으면 (root 기준으로) index.html을 사용하게 하고, 그것조차 없으면 404코드를 내뱉게 한다(=404)
        }

        location /crops {
            return 307 /fruits # /crops로 오는 요청에 대해서 /fruits로 리다이렉트해라 redirect해라
        }
    }

    types { # 응답으로 전달하는 정적 값에 대해서 어떤식으로 브라우저가 처리해야될지에 대해 MIME-type을 정의하는 부분
        text/css       css; # 이 말은 .css 확장자를 가진 파일의 MIME-type 은 text/css어야한다고 일러두는 것과 같다.(즉, 클라이언트 네트워크에서는 response Header의 Content-Type이 text/css가 되어있을 것)
        text/html      html; # .html 확장자를 가진 파일의 MIME-type은 text/html이어야 한다고 일러두는 것과 같다.

        # 보통 위처럼 일일이 정의하기보다, 하나의 파일을 가져와서 "include" 키워드로 포함시킨다.
        include mime.types; # 해당 파일은 nginx.conf파일과 같은 레벨에 있어야 저렇게 인식시킬 수 있음.      
    }     
} 

## load balancer = round-robin 알고리즘을 내부적으로 이용하고 있음(CPU 사용시간 분배 알고리즘 https://jwprogramming.tistory.com/17).
## 아래 예시는 expose를 7777포트로 해둔 도커파일의 이미지로 컨테이너를 포트별로 1111:7777, 2222:7777 ... 이렇게 여러 컨테이너가 띄워져 있는 상황이라고 가정하자.
http {
    include mime.types;

    # 현재 로드밸런싱을 받아야 하는 remote 백엔드의 주소들을 정의한다.(현재는 로컬에서 진행중이므로 모두 다 127.0.0.1 ip로 되어있음) 
    # upstream을 묶어둔 block directive의 별칭은 하단에 location부분의 prox_pass에서 도메인으로서 사용된다.
    upstream backendserver { 
        server 127.0.0.1:1111;
        server 127.0.0.1:2222;
        server 127.0.0.1:3333;
        server 127.0.0.1:4444;
    }

    server {
        listen 8080;
        location / {
            proxy_pass htttp://backendserver/; # 위에서 정의했던 upstream 그룹에게 적절하게 요청들을 분배해서 전달한다!
        }
    }
}

// nginx 커맨드

* nginx : 서버시작
* nginx -s stop : 서버종료(워커들이 요청을 처리중이더라도 그냥 종료한다.)
* nginx -s quit : 워커 프로세스가 현재 요청 처리를 완료할 때까지 대기하고 모두 처리완료된 후에 서버 종료.
* nginx -s reload : nginx config를 새로 로드한다. 마스터 프로세스가 설정을 다시 로드하라는 요청을 받으면 설정 유효성 검사후 새로운 워커 프로세스를 시작하고, 이전 워커 프로세스에게 종료 메시지를 보내게 되고 이전 워커 프로세스는 요청을 완료하게 되면 종료된다.


👋 4. 그래서 이제 진짜로 배포 과정

앞에는 개념 정리를 위해 나열한 내용이었다면, 이제 진짜 배포 코드로 들어가보도록 하자

개발환경 에서는 무중단 배포를 적용하지 않았기 때문에, 아래에는 운영환경 의 내용을 정리하도록 한다.

위에서 설명했던 개념들에 따라 코드로 반영하게 될 대략적인 청사진은 아래와 같다.

1. Git Action

여기서는 next 빌드 폴더를 생성한 뒤, SCP를 통해 EC2 환경에 전달하고, Deploy script를 실행하는 과정을 정의한다.

name: Continuous Deployment to Main Branch

on:
  pull_request:
    branches:
      - main
    types: [closed]

jobs:
  CD:
    runs-on: self-hosted
    if: github.event.pull_request.merged == true
    env:
      BRANCH: main
      EC2_PEM: ${{ secrets.EC2_PEM }}
      ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
      USERNAME: ubuntu
      PUBLIC_DNS: ec2-3-38-245-191.ap-northeast-2.compute.amazonaws.com
      PROJECT_PATH: ~/ems-admin
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ env.BRANCH }}
          token: ${{ env.ACCESS_TOKEN }}

      - name: Set node environment
        uses: actions/setup-node@v4
        with:
          node-version: 20.11.1 # see .nvmrc

      - name: Caching Primes # for this case, node_modules
        id: cache-primes
        uses: actions/cache@v4
        with:
          path: node_modules
          key: npm-packages-${{ hashFiles('**/package-lock.json') }}

      - name: Install dependencies if no cache
        if: steps.cache-primes.outputs.cache-hit != 'true'
        run: npm install

      - name: Build
        run: |
          echo "build .next"
          npm run build

      - name: Create key.pem
        run: |
          echo "${{ env.EC2_PEM }}" > key.pem
          chmod 400 key.pem

      - name: SCP to EC2
        run: |
          echo "Transferring .next directory to EC2 instance"
          scp -i key.pem -r .next ${{ env.USERNAME }}@${{ env.PUBLIC_DNS }}:${{ env.PROJECT_PATH }}

      - name: SSH into EC2 and run commands
        env:
          NODE_PATH: /home/ubuntu/.nvm/versions/node/v20.12.1/bin
        run: |
          ## 1. Access to EC2 
          ssh -i key.pem ${{ env.USERNAME }}@${{ env.PUBLIC_DNS }} << 'EOF'

          ## 2. run new project using pm2
          export PATH="$PATH:${{ env.NODE_PATH }}"
          cd ${{ env.PROJECT_PATH }}
          # git reset --hard
          # git pull
          # npm run build

          # check npm path
          if ! command -v npm &> /dev/null; then
              echo "Error: npm command not found"
              exit 1
          fi

          npm run deploy

          echo "success for deployment"
          EOF

yml의 문법적인 부분은 지난글 의 정리 내용으로 갈음한다.

아마 누군가는 왜 굳이 .next를 scp(secure copy SSH protocol)를 통해서 전달하느냐에 대해서 의문을 가질 수 있겠다.

물론 ec2 환경에서 코드를 최신화 한 뒤 빌드를 하게 될 경우, scp를 통해서 압축하고 전달하는 시간은 매우 아낄 수 있다.

그럼에도 scp로 전송하는 이유는 EC2 환경에 불필요한 CPU 사용을 하지 않기 위해서이다.

인스턴스 스펙을 최대한 경량화하는 방향으로 서비스를 진행하려고 하면 아무래도 무거운 build 작업으로 인해 cpu그래프가 급격하게 튀는 경우가 생기게 된다.

이로 인하여 인스턴스가 중지되는 상황을 방지하기 위해서 빌드 과정을 로컬 맥에서 진행한 후, 결과물만 전송하는 것이다.

더불어 본인의 mac 환경을 host로 지정하여 build를 하기 때문에 EC2에서 하는 것 보다는 훨씬 더 빠른 속도로 빌드도 가능하다.

2. Deploy.sh

여기서는 현재 떠있는 PM2의 프로세스 이름을 확인해서, 이것이 "blue" 일 경우 "green" 이라는 이름을 가진 프로세스를 새로 띄우며 nginx로 여기에 로드밸런싱을 한다. 반대일 경우는 반대로 진행된다.

이를 위해 필자가 세팅한 프로젝트 환경은 아래와 같다.


1. root directory에 nginx 파일 설정 (conf 파일을 프로젝트 내에서 관리하기 위함)

nginx.conf 파일의 내용은 아래와 같이 되어있다.

// nginx.conf

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    keepalive_timeout  65;

    server {
        listen       8090;
        server_name  localhost;
        
        location / {
            proxy_http_version 1.1;

            proxy_pass http://localhost:20160;  # WAS 경로
            proxy_cache_bypass $http_upgrade;
            
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }
    }

    # HTTPS 
    # server {
    #     listen       443 ssl;
    #     listen       [::]:443 ssl ipv6only=on;
    #     server_name  local.httpstest.com; # 요청 타겟 호스트

    #     ssl_certificate      /Users/choeucheol/Documents/dev/bems/bems-admin/nginx/local.httpstest.com.pem;
    #     ssl_certificate_key  /Users/choeucheol/Documents/dev/bems/bems-admin/nginx/local.httpstest.com-key.pem;

    #     ssl_session_cache    shared:SSL:1m;
    #     ssl_session_timeout  5m;
    #     ssl_ciphers  HIGH:!aNULL:!MD5;
    #     ssl_prefer_server_ciphers  on;

    #     underscores_in_headers on;
    #     add_header Strict-Transport-Security 'max-age=31536000' always;

    #     location / {
    #         proxy_http_version 1.1;

    #         proxy_pass http://localhost:20160;  # WAS 경로
    #         proxy_cache_bypass $http_upgrade;
            
    #         proxy_set_header Upgrade $http_upgrade;
    #         proxy_set_header Connection 'upgrade';
    #         proxy_set_header Host $host;
    #     }
    # }
}

HTTPS 부분을 주석처리한 이유는, 현재 구축한 배포 프로세스 상 EC2에 전달되는 패킷이 HTTPS가 아닌 SSL 부분이 복호화된 HTTP 패킷이기 때문에 그렇다. 즉, nginx 서버가 받게 되는 패킷은 무조건 http이기 때문에 https인 케이스를 고려할 필요가 없었다.
(그래도 누군가에겐 필요한 내용일것이라 생각되어 주석으로 남긴다.)

말로만 설명하니까 더 이해하기 어려울 것 같아서 이것저것 다 빼고 간단하게 도식화를 해 보았다.

예를 들어 현재 배포 환경인 AWS EC2에 HTTPS가 적용되어 있는 상황이라고 가정하자.

우리가 구매한 도메인을 통해 Route 53에서 호스팅 영역을 만들어주고나면, A 레코드를 통해 우리의 요청이 타겟팅된 EC2를 향해 날아가게 된다.

그런데 만약 A record의 destination이 로드밸런서로 되어있을 경우, 이 HTTPS 패킷은 그대로 로드밸런서로 전달되게 되는데,

로드밸런서의 리스너는 들어오는 요청에 대해서 포트포워딩을 위한 대상그룹(Target Group)을 정할 수 있다.

즉, 아래와 같은 소리이다.

나는 너가 요청하는 포트를 듣고(listening), 이것을 분석해서 특정 대상그룹으로 묶여있는 EC2들에게 적절하게 요청을 분배할거야. 이 때에 그 EC2의 특정 포트를 향해 날릴거야.

이 때에, 대상그룹이 트래픽을 라우팅할 로드 밸런서 유형에 해당하는 포트를 80으로 설정했을 경우, HTTPS 요청을 HTTP로 변경한 뒤 등록된 대상 EC2에게 등록한 포트로 날리게 된다.

아래는 EC2의 8090 포트로 요청을 전달하도록 설정한 대상그룹의 형태이다.

포트가 8090인 이유는 위에서 nginx.conf에 설정으로 nginx 서버 블록에 8090을 listen하도록 설정했기 때문이다.

해당 내용을 나도 이번에 테스트해보다가 알게 되었다.
궁금하면 EC2와 Load balancer의 보안그룹을 서로 다르게 한 후, EC2의 보안그룹에서 인바운드에 HTTPS를 빼보면 명확해진다.
인바운드에 HTTPS 포트인 443이 없는 상태에서 외부에서 도메인 이름을 통해 HTTPS request를 날려도 EC2가 정확하게 응답하는 것을 확인할 수 있다.

즉, Load Balancer 전까지는 HTTPS Secure 통신이 진행되지만 로드밸런서를 거치는 순간 그 이후는 로드밸런서의 리스너에 정해진 타겟그룹의 라우팅 형태에 따라 형태를 바꾼 후 포워딩된다는 것을 알 수 있다.

첫번째 설정에 대해서 설명이 좀 길었다. 바로 두번째 설정에 대해서 기술하겠다.

2. package.json에 Blue,Green 및 deploy 스크립트 등록

// package.json

  "scripts": {
    "dev": "next dev -p 20160",
    "start:blue": "next start -p 20160",
    "start:green":"next start -p 20161",
    "deploy":"sh script/deploy.sh"
  },

처음 dev 스크립트는 개발 서버에 자동배포를 위해서 만들어놓았고, 두번째에 있는 스크립트 두개가 Blue/Green deploy에 사용되는 스크립트이다.

그리고 마지막으로 보이는 "deploy" 스크립트가 실질적으로 배포환경에서 무중단 배포를 진행하는 쉘 스크립트 명령어를 정의한 sh 파일을 실행시키는 부분이다.

3. root directory의 script/deploy.sh 생성 및 작성

스크립트를 실행시키기 위한 폴더와 파일을 구현한다.
필자는 이미 자동화 과정 프로세스를 제작하던 도중 각종 스크립트를 만들었기에(예를 들어 git issue 및 pr 자동화라든가) 이 부분을 script 폴더 내에 저장하고 있었다. 여기에 "deploy.sh"를 만든다.

"deploy.sh" 의 내용은 아래와 같다.

#!/bin/bash

# (prerequisite) move to project root
cd "$(dirname "$0")" || exit
cd ..

# variables
NGINX_DIR="$PWD/nginx"
NGINX_CONF="${NGINX_DIR}/nginx.conf"

BLUE_PORT=20160
BLUE_PROCESS_NAME="blue"

GREEN_PORT=20161
GREEN_PROCESS_NAME="green"

# functions
edit_conf_port() {
    TARGET_PORT=$1

    sed -i '' "s/proxy_pass http:\/\/localhost:[0-9]\+;/proxy_pass http:\/\/localhost:${TARGET_PORT};/" $NGINX_CONF
}

blue_green_deploy() {
    DEPLOY_TARGET=$1
    IS_FIRST_DEPLOY=$2

    DEPLOY_PORT=""
    CURRENT_PROCESS=""
    
    if [ $DEPLOY_TARGET = "green" ]; then
        DEPLOY_PORT=$GREEN_PORT
        CURRENT_PROCESS=$BLUE_PROCESS_NAME
    else
        DEPLOY_PORT=$BLUE_PORT
        CURRENT_PROCESS=$GREEN_PROCESS_NAME
    fi

    pm2 start --name $DEPLOY_TARGET npm -- run "start:${DEPLOY_TARGET}"
    edit_conf_port $DEPLOY_PORT

    if [ -n "$IS_FIRST_DEPLOY" ]; then
        sudo nginx -c $NGINX_CONF
    else
        sudo nginx -c $NGINX_CONF -s reload
        pm2 delete $CURRENT_PROCESS
    fi
}

### deploy start 
# step 1. get deploy target
BLUE_PID=$(pm2 list | grep -c "blue")
GREEN_PID=$(pm2 list | grep -c "green")
DEPLOY_TARGET=""

if [ $BLUE_PID = 1 ]; then
    DEPLOY_TARGET="green"
elif [ $GREEN_PID = 1 ]; then  
    DEPLOY_TARGET="blue"
else
    echo "no process was detected. start blue"
    blue_green_deploy "blue" "first_deploy"

    echo "Deploy finished"
    exit 0
fi

# step 2. run blue_green_deploy
blue_green_deploy "$DEPLOY_TARGET"
echo "Deploy finished"

당연하지만, shell script 내에도 함수를 정의할 수 있다.

필자는 conf 파일 내부를 읽어서 포트번호를 변경하는 스크립트를 "edit_conf_port" 라는 함수로 따로 빼두었고,

또한 "blue_green_deploy" 로 교체하는 내용이 반복되서 사용된다고 확인하여 이것을 함수로 따로 빼놓았다.

스크립트의 내용은 위에서 설명했던 것과 같이,

  1. 현재 배포된 프로세스 이름 확인
  2. pm2로 새 프로세스 생성 후 nginx.conf 타겟 서버포트를 새 프로세스로 포트포워딩
  3. 기존 프로세스 삭제

로 이루어지는 내용이다.


👋 5. 맺음글

긴 여정이었다.

처음엔 한번도 써보지도 못한 Docker, yml, shell script 등등 프론트 코드만 짜다가 생전 해보지도 않았던 상황에서 배포 자동화 프로세스를 구축하느라 애를 정말 많이 먹었다.

모르는 내용들을 얻어터져가며 만드느라 시간이 꽤 많이 걸렸지만, 그래도 정말로 많은 것을 배우는 시간이었다.

값진 경험을 한 만큼 이를 남겨놓고 추후 나를 위해, 그리고 이 글을 읽는 누군가를 위해 도움이 되었으면 좋겠다.

profile
자라나라 프론트엔드 개발새싹!

0개의 댓글