도커와 PM2를 이용한 Node.js scale-out 및 부하 테스트

date
Sep 30, 2022
thumbnail
slug
node-scale-out
author
status
Published
tags
Javascript
summary
싱글 스레드 기반의 Node.js의 성능을 위한 클러스터링 및 테스트
type
Post
updatedAt
Feb 1, 2023 09:17 AM

배경

최근 Node.js의 cluster 모듈, socket.io, Redis를 이용하여 통신을 하는 프로젝트를 진행했었는데, 시간이 얼마 없어서 테스트나 차후 어떻게 수평적으로 더 확장할 지에 대한 고민이 부족했다. 따라서 이번 포스트에서 PM2와 도커를 이용하여 멀티 코어 환경을 위한 Node.js 컨테이너를 만들고, 싱글 코어만 사용하는 경우와 멀티 코어를 사용하는 경우에 대한 각각의 부하테스트를 진행하여 성능을 비교해 보려 한다.

Node.js를 클러스터링 해야하는 이유

Node.js 구조
Node.js 구조
단일한 콜 스택을 갖기 때문에 멀티 스레드처럼 공유 자원의 경쟁 문제 등에서 자유롭고 좀 더 쉬운 프로그래밍이 가능하다. 동시성을 위해서는 이벤트 루프 구조를 이용한다. 현대 운영체제의 스케줄링 단위는 대개 스레드이므로, N개의 코어를 가진 환경에서는 적합한 스레드 N개를 선택하게 된다. 그리고 time slice가 지나거나 I/O로 인해 wait 상태로 변하게 되면 다음 스레드를 선택하게 될 것이다. 결론적으로 단일한 콜 스택, 싱글 스레드 이벤트 루프를 갖는 Node.js의 특성 상 멀티 코어 환경에서는 제약점이 있다. 따라서 프로세스를 여러 개 만들어서 클러스터링 할 필요가 있다.
비슷한 이유로 GIL을 기반으로 동작하는 Python앱도 I/O를 제외하고는 싱글 스레드처럼 동작하므로 WSGI를 이용해 클러스터링하곤 했다.

도구 선택 및 이유

PM2(Process Manager2)

notion image
PM2는 Node.js 어플리케이션을 쉽게 관리할 수 있게 해주는 Process Manager로, Node.js 어플리케이션을 cluster mode 로 실행시킨다거나, 메모리가 넘친다거나, 오류로 인해 프로세스가 종료되는 등의 상황에 직면했을 때 등 여러 상황들을 간편하게 처리할 수 있다. PM2를 선택한 이유는 아래와 같다.
  • 한 서버 내에서의 부하 분산은 Nginx로도 할 수 있고, Node.js의 cluster 모듈을 이용하여 할 수도 있다.
  • 사실 확장, 로드 밸런싱 같은 부분은 Devops의 문제라고 생각하기 때문에, Node.js 코드에서 cluster 모듈을 사용하여 Worker들을 생성하는 방법은 좋지 못하다고 생각했다.
  • 하지만 그렇다고 한 서버 내에서의 부하 분산을 위해 Nginx 설정을 하기에는 복잡성만 늘어나는 것 같다.
결론적으로 PM2를 사용하면, 싱글 코어 어플리케이션을 만들듯이 코드를 작성하고 PM2가 자체적으로 cluster 모듈을 사용하여 Worker를 생성해주므로 코드 상으로 클러스터링 하는 부분이 포함되지 않아서 최선의 선택이라고 생각했다.

PM2를 사용하지 않는 방식 - Cluster 모듈 사용

import cluster from 'cluster';
import { cpus } from 'os';
import process from 'process';
import express from "express";

const numCPUs = cpus().length;

if (cluster.isPrimary) {
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  const app = express();
	
	app.get("/", (req, res) => {
	  let variable = 0;
	  for (let i = 0; i < 1000000; i++) {
	    variable += i;
	  }
	  res.status(200).send(`result : ${variable}`);
	});
	
	app.listen(8080);
}

PM2를 사용하는 방식

import express from "express";

const app = express();
	
app.get("/", (req, res) => {
  let variable = 0;
  for (let i = 0; i < 1000000; i++) {
    variable += i;
  }
  res.status(200).send(`result : ${variable}`);
});

app.listen(8080);
클러스터링을 위한 코드를 따로 작성하지 않고, pm2 start-i max 옵션을 주면 된다.

Docker

notion image
도커(Docker)는 응용 프로그램들을 프로세스 격리 기술을 활용해 컨테이너로 실행하고 관리하는 오픈소스다.
사실 지금 포스팅하려는 내용 같이, 하나의 머신에서 Multiple Processes를 이용하기 위해서는 PM2만을 사용해도 충분하지만 추후에 로드밸런서를 통한 다른 머신까지의 scale-out을 위해서는 도커를 사용하는 편이 더 편리하다고 판단하였다.
예를 들면, 여러 서버 인스턴스들에서는 각각 도커 컨테이너들을 실행하고, 리버스 프록시 역할을 할 서버에는 Nginx를 설치하여 각 서버 인스턴스들을 연결해주면 된다.

Grafana K6

notion image
Grafana k6는 오픈소스 부하 테스팅 툴이다.
구글에서 여러 부하 테스트 도구들을 찾아보았는데, K6가 간편하게 사용하기에 가장 괜찮게 느껴졌다. 이유는 아래와 같았다.
  • 도커 컨테이너로 실행이 가능하므로 따로 설치가 필요 없다.
  • 무엇보다 javascript로 테스트용 스크립트를 작성할 수 있다.
  • CLI 환경으로 큰 설정 없이 바로 사용하기 편리하다.

테스트 환경

테스트 환경은 아래와 같다.
CPU : AMD Ryzen 5 5600H (3.30 GHz) - 6코어 12스레드RAM : 16GB

예상 아키텍처

notion image
좌측의 Single Container Multiple Workers를 PM2와 도커를 이용하여 구성한다.
컨테이너를 싱글 코어만 사용하도록 실행하는 경우와 클러스터링을 이용하여 멀티 코어를 사용하도록 실행하는 경우 각각에 대하여 성능 테스트를 진행한다.
우측과 같이 여러 컨테이너를 로드 밸런싱하는 작업은 이번 포스팅이 아니라 추후에 진행한다.

PM2 도커라이징 작업

// app.js
import express from "express";

const app = express();

app.get("/", (req, res) => {
  let variable = 0;
  for (let i = 0; i < 1000000; i++) {
    variable += i;
  }
  res.status(200).send(`result : ${variable}`);
});

app.listen(8080);
테스트를 위한 Express 서버를 간단하게 위와 같이 구축하였다. 인덱스 페이지를 HTTP로 호출하면 100만번의 연산 이후, 결과를 반환하도록 하였다.
# node 16 LTS 버전
FROM node:16 

# 앱 디렉토리
WORKDIR /usr/src/app

# 의존성 설치
COPY package*.json ./

RUN npm install
RUN npm install pm2 -g

# 앱 소스 추가
COPY . .

EXPOSE 8080

# pm2를 foreground로 실행 및 run 시 클러스터 인자 받기
ENTRYPOINT ["pm2-runtime","start","app.js"]
그리고 위와 같이 도커 파일을 작성하였다. 추후 컨테이너 실행 시, PM2 클러스터링 관련한 인자를 넘겨서 실행하도록 하기 위해 ENTRYPOINT만 정의해놓았다. 이후 docker build를 통해 이미지를 만든다.

Trouble Shooting

이상하게 이미지 빌드 후 컨테이너를 실행하니 곧 바로 종료되었다.이 포스팅에서 해결책을 찾을 수 있었고, 결론적으로 PM2를 foreground로 실행해주지 않아서였다. pm2-runtime을 통해 PM2를 foreground로 실행할 수 있다.예전에도 도커로 Nginx를 띄울 때 foreground로 실행하지 않아서 같은 문제를 겪었었는데, 간만에 도커를 사용하니 잊고 있었다.

테스트 수행

K6 설정

먼저 부하테스트를 위해, docker pull grafana/k6를 통해 K6 도커 이미지를 가져온다.
// script.js
import http from "k6/http";
import { sleep } from "k6";

export const options = {
  vus: 1000, // 가상 유저 수
  duration: "30s", // 테스트 시간
};

export default function () {
  const url = "http://localhost:8000";
  http.get(url);
  sleep(1);
}
부하테스트를 위해 작성한 K6 스크립트이다. 가상 요청자 수를 1000명으로, 부하시간을 30초로 설정하였다.

클러스터링을 수행하지 않은 경우의 부하테스트

docket run -d -p 8000:8080 이미지이름과 같이 아무 인자를 넘겨주지 않으면 클러스터링 되지 않도록 도커파일을 작성했었다.
notion image
실행된 컨테이너를 보면 위와 같이 싱글 프로세스만 동작하고 있는 상태이다.
클러스터링하지 않은 결과
클러스터링하지 않은 결과
docker run --rm -i grafana/k6 run -<script.js 명령으로 K6를 통해 부하 테스트를 한 결과이다.

클러스터링을 수행한 경우의 부하테스트

notion image
docket run -d -p 8000:8080 이미지이름 -i max과 같이 -i max옵션을 주면, 현재 CPU 코어 수에 맞게 Worker가 생성된다.실행된 컨테이너를 보면 위와 같이 프로세스들이 클러스터링 되어 실행되고 있다.
클러스터링한 결과
클러스터링한 결과
마찬가지로 docker run --rm -i grafana/k6 run -<script.js 명령으로 부하 테스트를 한 결과이다.

테스트 결과 비교

  • HTTP 요청 처리량은 7428개에서 28600개로 약 285% 증가
  • HTTP 요청에 대한 대기 시간은 3.18s에서 40.98ms로 약 98% 단축
  • 초당 데이터 수신량은 1.8MB에서 7.1MB로 약 294% 증가
  • 초당 데이터 송신량은 602KB에서 2.3MB로 약 282% 증가

결론

단일한 콜스택을 갖는 Node.js환경의 한계를 PM2를 이용한 클러스터링을 구축하여 해결 가능함을 볼 수 있었다.
나중에는 실제로 Nginx를 이용하여, 여러 서버 인스턴스들에서 위에서 사용한 이미지를 실행 후 로드밸런싱을 통해 새로운 성능 평가도 진행해봐야 할 듯하다.

참고문헌