Nginx, nestjs pm2

jaegeunsong97·2023년 11월 17일

pm2 ecosystem

pm2로 무중단배포를 구현하는 것의 기본원리 : pm2가 제공하고 있는 클러스터 모드
클러스터 모드로 실행 시, pm2는 인스턴스가 가지고 있는 코어 수 만큼 프로세스를 새로 생성 가능
이 프로세스를 이용해서 롤링방식의 무중단 배포를 구현

pm2로 여러 프로세스를 생성하고, instance에 새로운 내용이 배포가 되면, pm2 reload 명령어를 실행하여, 0번 프로세스 -> 1번 프로세스 순으로 순차적으로 배포를 실행시킬 수 있다.

즉, 0번 프로세스가 배포를 하면 1번 프로세스를 이용하고 이런 방식

nestjs ecosystem 구성

{
  "apps": [
    {
      "name": "example", // 생성할 프로세스의 이름
      "script": "dist/main.js", // 프로세스 생성 시 pm2 start를 할 경로(보통 서버 파일)
      "instances": 0, // 생성할 인스턴스의 개수, 0, -1 => 최대 코어 수
      "exec_mode": "cluster" // 클러스터 모드
    }
  ]
}

script에 dist/main.js가 되어있는 이유 : 사양이 낮은 Instance의 경우, NestJS의 typescript 파일들을 컴파일/빌드하는데 굉장히 많은 시간들이 할애되기 때문 -> ec2 강제종료 또는 먹통 가능성 있음
즉, pm2를 실행하기 전에 빌드 작업은 사전에 마무리해야하는 것이고, pm2로는 컴파일이 완료된 js 파일을 실행하는 것이다.

pm2 start ecosystem.json => pm2 start dist/main.js

Deploy Nest JS App using PM2 on Linux (Ubuntu) Server

  1. (Install Nest JS CLI)
$ sudo npm i -g @nestjs/cli
  1. (Install PM2)
$ sudo npm install pm2@latest -g
  1. (Clone Code Repository)
$ git clone http://coderepo.com/projectname.git
$ cd projectname
$ npm install
  1. (Build Project)
$ npm run build
  1. (Run Project)
$ pm2 start dist/main.js --name <application_name>

There are some PM2 commands to make your application auto restart after system reboot

$ pm2 startup systemd
$ pm2 save

cd와 연계

pm2로 실행하는 것은 컴파일이 완료된 js 파일이어야 함을 위에서 설명했다.
이에, appspec의 afterInstall hook과 연계되는 scripts를 이용하면 CD로 자동적으로 코드가 배포된 이후에 빌드 -> pm2 reload가 연속해서 자동으로 실행될 수 있도록 할 수 있다.

1. appspec.yml

version: 0.0
os: linux
files:
  - source: /
    destination: /home/ubuntu/build/
hooks:
  AfterInstall:
    - location: scripts/after.install.sh
      timeout: 300


2. after.install.sh
#!/bin/bash
sudo chmod -R 777 /home/ubuntu/build // sudo 권한 부여

#navigate into our working directory
cd /home/ubuntu/build 프로젝트 루트 경로 접근

#install node modules & update swagger & pm2 reload
sudo npm ci 패키지 설치
sudo npm run build ts 컴파일 실행
sudo pm2 reload 프로세스 이름 pm2 reload

nestjs pm2 연결

ec2 ssh 접속
install pm2

$ sudo npm install pm2@latest -g

Project Clone

$ git clone <project-Url>
$ cd projectName
$ npm install

Project build

$ npm run build

Project run

$ pm2 start dist/main.js --name <applicationName> // ecosystem 실행

변경사항 적용

git pull origin main
npm run build
pm2 reload <applicationName>

pm2 명령어

pm2 list : 실행중인 프로세스 목록 확인
pm2 status : 실행중인 프로세스 상태 확인
pm2 kill : 모든 프로세스 없애기
pm2 start [applicationName]: 프로세스 실행
pm2 stop [applicationName] : 실행중인 프로세스 중지
pm2 delete [applicationName] : 프로젝트 삭제
pm2 restart [applicationName] : 실행중인 프로세스 재시작 ( 프로세스 kill 후 재실행 )
pm2 reload [applicationName]: 실행중인 프로세스 리로드 ( 프로세스 kill 하지 않고 적용 )
pm2 log : 작업중인 로그 실시간 확인
pm2 monit: 메모리 / CPU 사용량 확인

Nginx 로드밸런싱

cluster mode

pm2의 cluster mode

  • 코드 수정없이 Node.js 애플리케이션을 CPU에 따라 확장 가능
  • 안정성과 성능 향상
  • Node.js에서 cluster mode 사용

nodejs의 cluster mode

  • 멀티코어 시스템을 이용하기 위해서 Node.js 프로세스들을 클러스터로 사용 가능
  • 모든 서버포트를 공유하는 하위 프로세스를 생성
  • 하위 프로세스들은 child_process.fork()로 생성, 부모 자식 간의 통신을 위한 IPC 채널을 가지며, 생성된 각 프로세스틑 자체 V8 가짐

process 간의 불평등한 문제

운영체제의 문제

  • 서버에 요청 보내면 하나의 Worker만 일하는 것을 알 수 있음
    • 마지막에 fork()된 프로세스가 주로 담당
  • artillery 라이브러리를 이용해서 많은 트래픽을 주면 적어도 2개이상의 프로세스가 일하는 것을 볼 수 있음
  • 원래대로면 8개의 프로세스가 균등하게 일해야하지만, 2개의 프로세스가 70%이상의 일을 처리
  • Round-Robin은 맞는데 운영체제의 Scheduler의 영향을 받기 때문(OS가 1개의 프로세스로 충분하다고 판단할 경우 일을 분배하지 않기 때문에 생기는 문제)

불평등 분배로 인한 메모리 누수

  • 하나의 프로세스에 대기열이 많아질수록 점유하고 있는 메모리 크기도 늘어나서 메모리 누수의 가능성 증가
    • 해결하기 위해 자식 프로세스까리 통신을 구축하고, 실제로 트래픽을 분배하는 건 비용을 늘리는 것
  • 이 문제를 해결하기 위해서는 별도의 Proxy를 두는 게 저렴(1개의 Proxy 서버를 두고 그 서버가 요청을 받아서 여러 기준(알고리즘)을 통해 직접 분배)

이렇게 분배하는 것을 로드 밸런싱

Process 간의 평등한 분배(Nginx, pm2)

  • 메모리 측면에서 더 개선할 수 있도록 Nginx 사용
    • 이유 : 정적자원에 대한 캐시 등등, 여기서는 로드 밸런싱
// main.ts
...
import { SetResponseHeader } from "./middleware/zero-downtime-deploy/set-response-header.middleware";
import { GlobalService } from "./middleware/zero-downtime-deploy/is-disable-keep-alive.global";

async function bootstrap() {

  ...

  GlobalService.isDisableKeepAlive = false;

  app.use(SetResponseHeader);

  // Starts listening to shutdown hooks
  app.enableShutdownHooks();

  await app.listen(PORT as string, () => {
    process.send("ready");
    console.log(`application is listening on port ${PORT}`);
  });
}
bootstrap();
// set-response-header.middleware.ts
import { NextFunction, Request, Response } from "express";
import { GlobalService } from "./is-disable-keep-alive.global";

export function SetResponseHeader(req: Request, res: Response, next: NextFunction) {
  if (GlobalService.isDisableKeepAlive) {
    res.set("Connection", "close");
  }
  next();
}
// graceful-shutdown.ts
import { Injectable, OnApplicationShutdown } from "@nestjs/common";
import { GlobalService } from "./is-disable-keep-alive.global";

@Injectable()
export class GracefulShutdown implements OnApplicationShutdown {
  onApplicationShutdown(signal: string) {
    if (signal === "SIGINT") {
      GlobalService.isDisableKeepAlive = true;

      console.log("server closed");
    }
  }
}

pm2

// ecosystem.config.js
module.exports = {
  apps: [{
        name: "server", // 실행되는 프로세스 이름
        script: "dist/main.js", // 스크립트는 main.js 사용
        cwd: "/root/Web04-Fitory/server",
        instances: 0, // max와 동일
        exec_mode: "cluster", // 실행모드
        wait_ready: true,
        listen_timeout: 50000,
        kill_timeout: 5000
  }]
}

.env 설정 또한 pm2로 관리 가능 하지만 어려움

// .env 설정 부분
module.exports = {
  apps: [{
        name: "server",
        script: "dist/main.js",
        cwd: "/root/Web04-Fitory/server",
        env: {
            PORT: "8080",
        }
  }]
};

nginx

# www.fitory.ga 또는 fitory.ga로 들어올 경우 강제로 https로 redirect 해준다.
server {
        listen 80 default_server;
        listen [::]:80 default_server;

        server_name fitory.ga www.fitory.ga;

        if ($host = www.fitory.ga) {
                return 301 https://$host$request_uri;
        } # managed by Certbot


        if ($host = fitory.ga) {
                return 301 https://$host$request_uri;
        } # managed by Certbot

        return 404;

}

# https 설정 부분이다. letsencrypt와 certbot을 이용했다.
server {
        listen [::]:443 ssl ipv6only=on; # managed by Certbot
        listen 443 ssl; # managed by Certbot

        ssl_certificate /etc/letsencrypt/live/fitory.ga/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/fitory.ga/privkey.pem; # managed by Certbot

        include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

        root /var/www/html;

        charset utf-8;

        underscores_in_headers on;

        add_header Strict-Transport-Security "max-age=31536000" always;

        location / {
                proxy_pass http://localhost:3000/;

                proxy_http_version  1.1;
                proxy_cache_bypass  $http_upgrade;

                proxy_set_header Upgrade           $http_upgrade;
                proxy_set_header Connection        "upgrade";
                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-Proto $scheme;
                proxy_set_header X-Forwarded-Host  $host;
                proxy_set_header X-Forwarded-Port  $server_port;
        }

        location /api {
                proxy_pass http://localhost:8080/api;

                proxy_http_version  1.1;
                proxy_cache_bypass  $http_upgrade;

                proxy_set_header Upgrade           $http_upgrade;
                proxy_set_header Connection        "upgrade";
                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-Proto $scheme;
                proxy_set_header X-Forwarded-Host  $host;
                proxy_set_header X-Forwarded-Port  $server_port;
        }

        gzip on;
        gzip_comp_level 2;
        gzip_proxied any;
        gzip_min_length  1000;
        gzip_disable     "MSIE [1-6]\."
        gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

}
  • 포트 80 없이 3000번 포트로 자동 이동함, /api로 올 경우 pm2로 클러스트링된 서버들로 이동
  • 또한 1개의 프로세스가 처리하는 것이 아닌, 각 서버들이 번갈아가며 로직을 수행

무중단 배포 아키텍쳐

롤링 배포

  • 인스턴스 추가, 삭제(방법-1)
    • 인스턴스를 하나 추가하고, 새로운 버전을 실행.
    • 로드 밸런서애 해당 인스턴스를 연결하고 기존 구버전이 실행되는 인스턴스 하나를 줄인다.
    • 서버개수를 유연하게 조졸할 수 있는 AWS와 같은 클라우드 기반으로 서비스를 운영할 떄 적합
    • 하지만 인스턴스 추가, 삭제 하는 과정에서 비용 발생 -> docker를 이용해서 해결가능
  • 로드 밸런싱(방법-2)
    • 구버전이 실행되고 있는 서버 하나로 트래픽이 가지 않게 로드 밸런서에서 제외
    • 로드 밸런서에서 제외되서 트래픽이 가지 않는 상태에서 해당 서버를 업데이트
    • 이 과정을 반복해서 서버를 전부 업데이트
    • 클라우드 환경뿐만 아니라 물리적인 서버로 서비스를 운영하는 상황에서도 가능
  • 장점
    • 많은 서버 자원을 확보하지 않아도 무중단 배포 가능
    • 손쉽게 롤백 가능 -> 인스턴스마다 차례로 배포를 하기 때문에
  • 단점
    • 방식2와 같은 경우는 배포 도중 서비스중인 인스턴스의 수가 줄어들게 됨, 따라서 다른 각각의 서버가 부담하는 트래픽 양이 증가
    • 구버전과 신버전의 호환성 문제

참고

https://www.essential2189.dev/zero-downtime-deployment

profile
현재 블로그 : https://jasonsong97.tistory.com/

0개의 댓글