본문 바로가기

Backend/Node.js

Cluster Module in Node.JS

이번 포스팅에선 Node.JS 의 내장 모듈인 cluster 에 대해서 알아보도록 하겠습니다.

 

Node.JS 는 기본 싱글 스레드입니다. Node.JS 어플리케이션은 하나의 core 에서 실행되므로 어플리케이션을 띄우는 환경이 멀티코어인 경우 나머지 CPU 를 활용하지 못하는 문제점이 있습니다. 흔히 사용하는 클라우드 서비스에서도 멀티코어 인스턴스를 사용하는 경우가 많으므로, 낭비없이 이를 최대한 활용하는 방법으로 Node.JS 에선 cluster 라는 내장 모듈을 제공하고 있습니다.

 

- Cluster

 

const cluster = require('cluster');
const os = require('os');
const process = require('process');
const cpus = os.cpus().length;

if (cluster.isPrimary) {
  console.info(`primary process ${process.pid} is running.`);

  for (let i = 0 ; i < cpus ; i++) {
    cluster.fork();
  }
} else {
  console.info(`worker process ${process.pid} - ${cluster.worker.id} is running.`);
}

/*
primary process 1649 is running.
worker process 1650 - 1 is running.
worker process 1651 - 2 is running.
worker process 1655 - 6 is running.
worker process 1657 - 8 is running.
worker process 1653 - 4 is running.
worker process 1652 - 3 is running.
worker process 1654 - 5 is running.
worker process 1656 - 7 is running.
worker process 1658 - 9 is running.
worker process 1659 - 10 is running.
worker process 1660 - 11 is running.
worker process 1661 - 12 is running.
*/

 

Node.JS 16 버전을 기준으로 작성된 코드입니다. isPrimary 는 현재 코드가 동작할 때 master process 인지 알려주는 프로퍼티이며, 14 버전까지는 isPrimary 가 아닌 isMaster 라는 프로퍼티였습니다.

먼저 os 모듈을 사용해 현재 사용중인 환경의 cpu 개수를 가져옵니다. 이후 cluster.fork 를 cpu 개수만큼 호출하게 되는데 이를 통해

하위 프로세스를 생성하게 되고, 이 하위 프로세스들은 다시 현재 코드를 재실행하게 됩니다.

fork 에 의해 생성된 프로세스는 master process 가 아닌 worker process 이므로 else 부분을 타서 위와 같은 실행 결과를 남기게 됩니다.

 

- web-server

 

위처럼 하위 프로세스를 fork 해서 console 만 찍는건 아무 의미가 없고, 당연히 일을 시켜야합니다.

가장 일반적으로는 하위 프로세스에서 web-server 를 띄워 http request 를 처리하게 할 수 있습니다.

 

// primary.js
const cluster = require('cluster');
const os = require('os');
const process = require('process');
const cpus = os.cpus().length;

if (cluster.isPrimary) {
  console.info(`primary process ${process.pid} is running.`);

  for (let i = 0 ; i < cpus ; i++) {
    cluster.fork();
  }
} else {
  require('./web-server').run();
}

// web-server.js
const express = require('express');

const run = () => {
  const app = express();
  const port = 3000;

  app.use('/', (req, res) => {
    res.send({ ok: 'ok' });
  });

  app.listen(port, () => {
    console.info(`server listening on port ${port}`);
  });
};

module.exports = {
  run,
};

/*
primary process 4180 is running.
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
server listening on port 3000
*/

 

간단한 예시 코드입니다. master process 에서 cpu 수만큼 하위 프로세스를 fork 하고, 각 worker 들에선 간단한 express 서버를 3000 포트로 열고 있습니다. 로컬에서 localhost:3000 으로 요청시 응답이 오는걸 확인할 수 있습니다.

 

이렇게 로드밸런싱을 해놨지만 로컬에서 요청을 여러번 보내보면, 대게 하나의 worker 에서만 요청이 처리될거라 이게 일을 제대로 시키는게 맞나? 하는 생각이 들 수 있습니다. 나름대로 일은 분배하고 있고, 공식 문서에 따르면 round-robin 방식이라고 나와있습니다.

 

근래에 Node.JS 의 cluster 모듈을 사용해 http 요청을 로드밸런싱 하는 것은, 오히려 코드를 더 복잡하게 만들어 좋지 않은 방법이라고 얘기합니다. 이 내장 모듈의 로드밸런싱이 생각보다 각 worker 들에 균등하게 요청을 나눠주지 않는 것 같다는 얘기도 있고, 어찌됐건 이렇게하면 어플리케이션 레벨에서 확장, 로드밸런싱까지 신경써야 하다보니 말이죠. (점점 역할을 분리하고 각자의 것에만 집중하는 트렌드에 익숙해진 것 같습니다)

 

- IPC

 

Node.JS 의 cluster 모듈은 master process 와 하위 process 간에 IPC 를 통해 메세지를 주고 받을 수 있습니다.

 

// primary.js
const cluster = require('cluster');
const os = require('os');
const process = require('process');
const cpus = os.cpus().length;

if (cluster.isPrimary) {
  console.info(`primary process ${process.pid} is running.`);

  for (let i = 0 ; i < cpus ; i++) {
    const worker = cluster.fork();
    const data = { message: `message-${i}` };
    worker.send(data);
  }
} else {
  const { workerHandler } = require('./cluster-handler');
  workerHandler();
}

// cluster-handler.js
const cluster = require('cluster');

const workerHandler = () => {
  process.on('message', data => {
    const { message } = data;
    console.info(`${cluster.worker.id}-worker get message, ${message}`);
  });
};

module.exports = {
  workerHandler,
};


/*
primary process 4475 is running.
1-worker get message, message-0
2-worker get message, message-1
3-worker get message, message-2
4-worker get message, message-3
6-worker get message, message-5
5-worker get message, message-4
7-worker get message, message-6
8-worker get message, message-7
9-worker get message, message-8
10-worker get message, message-9
11-worker get message, message-10
12-worker get message, message-11
*/

 

간단하게 master 에서 각 worker 에 메세지를 보낸 예시입니다. 예시 코드에서 볼 수 있듯이 plain object 도 주고받을 수 있습니다.

알아서 serialize - deserialize 를 해주는데, 메서드는 그 대상이 아니므로 어떠한 객체를 보낸다면 그 객체가 갖는 프로퍼티만 주고 받는걸 확인하실 수 있습니다.

 

주의할 점은 주로 저는 배치 작업에서 IPC 를 활용해 master -> worker 로 데이터를 보내는 경우 이런 문제를 겪었었는데, 데이터를 보낼 때 그만큼 데이터 복사를 하게 됩니다. 그 과정에서 너무 많은 worker 에 사이즈가 큰 데이터를 보내게 될 경우 그만큼 메모리를 순간적으로 많이 사용하게 되면서 process 가 비정상 종료될 수 있으니 주의하시기 바랍니다.

 

- exit

 

이렇게 cluster 모듈을 사용해 띄운 master process, worker process 들을 exit 하는 이벤트 핸들러도 제공하고 있습니다.

 

// primary.js
const cluster = require('cluster');
const os = require('os');
const process = require('process');
const cpus = os.cpus().length;

if (cluster.isPrimary) {
  console.info(`primary process ${process.pid} is running.`);

  for (let i = 0 ; i < cpus ; i++) {
    const worker = cluster.fork();
    const data = { message: `message-${i}` };
    worker.send(data);
  }

  const { primaryHandler } = require('./cluster-handler');
  primaryHandler();
} else {
  const { workerHandler } = require('./cluster-handler');
  workerHandler();
}

// cluster-handler.js
const cluster = require('cluster');

const primaryHandler = () => {
  cluster.on('exit', (worker, code, signal) => {
    console.info(worker.id, code, signal);
  });
};

const workerHandler = () => {
  process.on('message', data => {
    if (cluster.worker.id > 6) {
      process.exit(1);
    } else {
      process.exit(0);
    }
  });
};

module.exports = {
  primaryHandler,
  workerHandler,
};

/*
1 0 null
2 0 null
3 0 null
4 0 null
5 0 null
6 0 null
7 1 null
8 1 null
9 1 null
10 1 null
11 1 null
12 1 null
*/

 

위 예시는 좀 이상한 코드긴하지만, master process 에서 fork 후 데이터를 보냈을 때 worker 에서 데이터를 받으면 일부는 process.exit(0) 을 통해 정상 종료를, 다른 나머지 프로세스들은 process.exit(1) 을 통해 비정상 종료를 하고 있습니다.

그렇게 worker 로 부터 발생한 exit 이벤트를 master process 에서 cluster.on('exit') 를 통해 받을 수 있습니다.

 

signal 은 위의 경우 null 로 찍히는데, 이는 worker 가 받은 종료 시그널을 의미하고 process.exit 코드 없이 worker 들을 띄워놓은 상태에서 kill 명령어를 통해 특정 worker 에 SIGTERM 신호를 보내보면 12 null SIGTERM 과 같은 로그가 찍히는걸 확인할 수 있습니다.

 

이는 꽤 여러 가지로 활용 가능한데, 일반적인 웹서버 형태에서 cluster 모듈을 사용했다면 graceful exit 를 구현하거나 하나의 worker 에서 비정상 종료가 발생했을 경우 master 에서 그 이벤트를 받아 다시 fork 를 수행해 worker 프로세스를 다시 띄우는 용도로도 사용이 가능합니다.

 

위에서도 언급했던 것 처럼 cluster 모듈을 통해 어플리케이션 코드 레벨에서 로드밸런싱을 하는 것은 선호되지 않는 방법일 수 있습니다.

저도 현재의 팀에서 관리하는 일부 서버의 경우엔 cluster 모듈을 통해 어플리케이션 코드를 복잡하게 만들기보단 pm2 를 활용하여 프로세스를 관리하기도 합니다. pm2 를 통한 프로세스 관리 및 IPC 에 대해서도 추후 포스팅을 올려봐야겠네요.

 

오히려 근래엔 배치잡에서 cluster 모듈을 활용해 하나의 process 가 처리해야 할 일의 양을 하위 process 로 나누어 처리하도록 하여
배치잡이 돌아가는 인스턴스의 cpu 를 최대로 활용하고 각 process 가 가져야 할 메모리 부담을 줄여주는 용도로 사용을 하고 있습니다.

'Backend > Node.js' 카테고리의 다른 글

lockfile 주의점  (0) 2022.07.16
Node.JS - Reactor 패턴  (0) 2022.07.09
SharedArrayBuffer & Atomics (SharedMemory)  (0) 2022.04.03
package-lock.json 이란?  (0) 2022.01.22
axios configs 정리  (0) 2021.04.18