Pm2 cluster 모드에서 env 설정하기 (with multi service)

이수인·2026년 3월 5일

inhu

목록 보기
4/4
post-thumbnail


안녕하세요 백엔드 개발자 이수인입니다! 오늘은 pm2 cluster 모드에서 env 설정하는 방법에 대해서 정리해보려 합니다. 오랜 구글링과 pm2 issue를 여러 개 읽어봐도 자료가 부족해서 해결이 되지 않았던 나름 어려운 문제였습니다. 오픈소스 코드를 읽어본 끝에 해결을 했는데, 다른 분들은 저처럼 고생하지 않았으면 하는 마음에 이 글을 적어봅니다. 저처럼 cluster 모드를 사용하고 multi service에, 각 service 마다 다른 환경변수 파일을 설정하시려는 분들께 도움이 될 것이라 생각합니다.

글은 아래 순서로 적어보려합니다.

  • pm2 cluster를 사용하게 된 계기
  • 문제 상황
  • 간략한 코드 구성
  • 문제 해결 과정
  • 원인
  • 해결 방법
  • 결론

pm2 cluster를 사용하게 된 계기

먼저 pm2 를 사용한 이유는 다들 같겠지만, 저희 Node.js 프로세스를 백그라운드 환경에서 실행하고 싶었기 때문입니다. 그런데 문제가 하나 있었습니다. 여지껏 프로세스를 띄울 때 아래 명령어로 띄웠습니다.

pm2 start npx dotenv -e .env.admin -- node dist/apps/admin-server/main.js" --name inhu-backend-admin-dev

fork 모드이며, 별다른 옵션이 없죠. 이런 방식의 문제가 무중단 배포가 되지 않습니다. 그러면 배포를 할 때마다 한 번쯤 봤을 502 error 가 뜨게되죠.

왜 502 error 가 나는지는 아래 블로그에서 정말 잘 설명해주기 때문에 꼭 한 번 읽어보시는 걸 추천드립니다.

https://engineering.linecorp.com/ko/blog/pm2-nodejs

결과적으로 위 글에서 pm2 무중단 배포를 위해서 cluser 모드를 사용해야 함을 설명해줍니다. 저 또한 무중단 배포를 위해 cluster 모드를 사용했습니다. 하지만 위 글은 ExpressJS 기준이라서 main 파일에서 NestJS는 조금 다르게 해야 됩니다. 아래처럼 Graceful shutdown 을 위한 설정과 pm2 를 위한 설정을 담아주면 됩니다.

// main.ts

/**
import
**/

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableShutdownHooks(); // Graceful shutdown

  /**
  기타 코드
  **/

  await app.listen(process.env.PORT ?? 3000);

  // pm2 setting
  if (process.send) {
    process.send('ready');
  }
}
bootstrap();

문제 상황

그런데 정말 큰 문제가 발생했습니다!!! fork 모드일 때는 .env 안의 환경변수들이 잘 읽어지는데 cluster 모드일 때는 읽어지지 않았습니다. 환경변수에 PORT, DB URL 등이 있었는데 이게 없으니 404, 500, 502 에러가 나고 난리도 아니었습니다...

간략한 코드 구성

원활한 설명을 위해서 간략한 코드 구성을 소개하려 합니다.

1. pm2 process 구성


저희 서비스는 총 3개가 있습니다. 그래서 일단 프로세스는 3개 띄운 상태이고요. 또한 각각의 환경변수 파일도 달라서 .env.user, .env.admin, .env.batch 총 3개 존재합니다.

2. pm2는 ecosystem.config.js 로 관리

원래는 서비스마다 명령어를 치면서 관리했는데 옵션도 늘어나고 매번 명령어 어디있나 찾기 귀찮아서 ecosystem.config.js 로 관리하기로 했습니다. 문제가 됐던 설정 파일은 아래와 같습니다.

module.exports = {
  apps: [
    {
      name: 'inhu-backend-user-dev',
      script: 'dist/apps/user-server/main.js',
      node_args: '-r dotenv/config',
      env: {
	      DOTENV_CONFIG_PATH: ".env.user"
      },
      instances: 1,
      exec_mode: 'cluster',
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
    },
    {
      name: 'inhu-backend-admin-dev',
      script: 'dist/apps/admin-server/main.js',
      node_args: '-r dotenv/config',
      env: {
              DOTENV_CONFIG_PATH: ".env.admin"
      },
      instances: 1,
      exec_mode: 'cluster',
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
    },
    {
      name: 'inhu-backend-batch-dev',
      script: 'dist/apps/batch-server/main.js',
      node_args: '-r dotenv/config',
      env: {
              DOTENV_CONFIG_PATH: ".env.batch"
      },
    },
  ],
};

문제 해결 과정

1. 환경변수 파일 경로가 안 들어간 것인가?

먼저 이 생각이 들었습니다. 그래서 잘 설정이 된 inhh-backend-batch-dev 와 잘 설정이 안 된 inhu-backend-user-dev를 비교해보기로 했습니다. env 설정을 보는 방법은 pm2 env <process-name>을 하면 볼 수 있습니다.


두 개 모두 잘 설정이 됐습니다. 그래서 경로 문제는 아니라고 생각을 했습니다.

2. cluster 모드는 환경변수 설정이 안 되는 건가?

조금 극단적이고 상식적으로 생각해보면 당연히 돼야 하는 거지만 확인해보고 싶었습니다. 그래서 ecosystem.config.jsenv 필드에 직접 환경변수를 넣어봤습니다. 아래처럼 말이죠.

module.exports = {
  apps: [
    /**
    user
    **/
    {
      name: 'inhu-backend-admin-dev',
      script: 'dist/apps/admin-server/main.js',
      node_args: '-r dotenv/config',
      env: {
        ADMIN_SERVER_PORT: 3001, // 포드 직접 설정
        DOTENV_CONFIG_PATH: ".env.admin"
      },
      instances: 1,
      exec_mode: 'cluster',
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
    },
    /**
    batch
    **/
  ],
};

이렇게 하고 환경변수를 확인해보니, 아래 사진처럼 정상적으로 환경변수가 들어갔습니다.

그래서 이렇게 환경변수들을 모두 ecosystem.config.js에 옮길까 했지만, 그러면 ecosystem.config.jspm2 설정 + 환경변수 가 있는 것이 너무 더럽다고 생각했습니다. 그래서 환경변수 파일은 별도로 분리하고 fork 모드처럼 파일을 부르는 방식으로 설정해보려 했습니다.

3. pm2 demon이 깨끗하지 않아서?

pm2 issue 를 읽어보면서 깨끗한 pm2 demon에서는 된다는 의견을 봤습니다. 그래서 pm2 kill 을 한 후에 start를 했지만 여전히 환경변수가 잘 안 읽혔습니다.

https://github.com/Unitech/pm2/issues/5766

4. 환경변수를 읽는 과정에서 문제?

누구는 잘 된다, 누구는 잘 안 된다. 정말 다양한 의견들이 나왔습니다. 애초에 환경변수를 읽는 과정에서 cluster 모드는 조금 다르다고 생각을 했습니다. 그런데 구글링을 해도 좋은 글이 나오지 않아서 pm2 오픈소스를 읽는 게 차라리 빠르다고 생각했습니다.

원인

결론부터 말하면 cluster 모드와 fork 모드의 env 전달 타이밍이 다르기때문에 발생하는 문제입니다. 여기부터 조금 복잡해지니 용어부터 정리하고 가겠습니다.

용어 정리

  • app.env: ecosystem의 env: {...}
  • pm2_env: pm2 내부 실행 설정 객체
  • process.env: 런타임에서 앱이 실제로 읽는 환경변수
    ※ 여기서 process.env는 문맥에 따라 두 가지입니다.
    • daemon process.env: pm2 daemon(부모) 프로세스의 환경변수
    • worker process.env: 실제 앱 worker(자식) 프로세스의 환경변수

우리가 실제로 runtime에 읽는 환경변수는 worker process.env에 담기게 됩니다. 그래서 worker process.env에 원하는 값이 없게 되거나(혹은 늦게 들어오면) 환경변수를 의도대로 못 읽게 되죠. 그럼 이번엔 fork 모드와 cluster 모드의 동작 과정에 대해서 알아보겠습니다. 그 전에 먼저 두 모드의 공통 로직부터 알아보겠습니다.

공통 로직

  1. pm2가 daemon process.env + app.env를 합쳐 앱 실행 env를 준비
  2. 실행 직전 pm2_env 형태로 확정
  3. fork / cluster 분기

fork 모드

  1. pm2 daemon이 자식 프로세스를 만들 때 pm2_env를 자식의 worker process.env로 직접 전달
  2. 그래서 자식 시작 시점부터 worker process.env에 앱별 app.env 값이 들어있음

cluster 모드

  1. pm2 daemon이 worker를 만들 때 pm2_env를 JSON 문자열 형태로 전달
  2. 이 JSON 문자열은 먼저 worker process.env.pm2_env에 들어감
  3. Node preload(-r dotenv/config)가 먼저 실행될 수 있음 (이 시점에는 앱별 app.env 값이 worker process.env에 아직 완전히 복원되기 전일 수 있음)
  4. 그 다음 worker에서 pm2_env JSON을 파싱해 worker process.env로 복원

fork 모드와 cluster 모드의 차이점이 보이시나요? fork는 값으로 환경변수를 전달하지만 cluster는 JSON으로 넘기고 뒤늦게 파싱해서 worker process.env에 값을 복사하죠. 그러면 여기서 의문인 점은 값으로 넘기는 거랑 JSON으로 넘기는 게 왜 중요하냐? 입니다. 저희가 ecosystem.config.js 옵션으로

node_args: '-r dotenv/config'
env: {
  DOTENV_CONFIG_PATH: ".env.batch",
},

를 적었죠. 이 명령어는 preload 단계에서 실행되고, 이때 process.env.DOTENV_CONFIG_PATH를 읽어 해당 파일을 로드한 뒤 worker process.env에 값을 주입합니다.

그런데 중요한 점은 이 명령어는 preload, 즉 초기 1회 실행이라는 점입니다. Node.js 공식 문서에서도 가장 먼저 실행된다고 나와있죠.

그래서 fork 모드일 때는 자식 생성 시점부터 worker process.env에 값이 존재하니 문제가 없었고, cluster 모드일 때는 JSON 파싱/복원 전에 preload가 먼저 실행되면서, daemon 기준 값으로 파일을 선택해 worker에 의도한 환경변수가 반영되지 않는 상황이 생겼던 것입니다.

해결방법

순서가 꼬여서 발생한 것을 알았으니 순서만 고쳐주면 해결이 됩니다. -r dotenv/config.env 파일 선택을 맡기지 않고 ecosystem.config.js에서 .env.user/.env.admin을 미리 파싱해 각 앱의 app.env에 주입을 합니다.
즉 런타임 preload 타이밍 문제를 피해, pm2가 프로세스를 띄우기 전에 앱별 app.env를 확정하는 방식으로 해결했습니다.

const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');

function loadEnvFile(fileName) {
  const filePath = path.join(__dirname, fileName);
  try {
    return dotenv.parse(fs.readFileSync(filePath));
  } catch (err) {
    console.error(`Error loading ${fileName}:`, err);
    return {};
  }
}

module.exports = {
  apps: [
    {
      name: 'inhu-backend',
      script: 'dist/apps/user-server/main.js',
      env: {
        ...loadEnvFile('.env.user'),
      },
      instances: 1,
      exec_mode: 'cluster',
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
    },
    {
      name: 'inhu-backend-admin',
      script: 'dist/apps/admin-server/main.js',
      env: {
              ...loadEnvFile('.env.admin'),
      },
      instances: 1,
      exec_mode: 'cluster',
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
    },
    {
      name: 'inhu-backend-batch',
      script: 'dist/apps/batch-server/main.js',
      node_args: ['-r', 'dotenv/config'],
      env: {
              DOTENV_CONFIG_PATH: ".env.batch",
      },
    },
  ]
}

결론

이번 기회에 오픈소스 코드를 보면서 cluster 환경에서 env 설정이 안 되는 문제를 해결해봤습니다. 코드가 장황해서 동작 과정을 이해하는 데 꽤 고생을 했습니다. 그걸 다시 글로 적으려고 하니 괜히 헷갈려서 힘들었지만 가치있던 경험이었습니다.
이 글이 도움이 되었으면 좋겠네요. 그럼 저는 다른 주제로 다시 찾아오겠습니다!

참고자료
https://engineering.linecorp.com/ko/blog/pm2-nodejs
https://github.com/Unitech/pm2/issues/5766
https://github.com/Unitech/pm2/issues/4718
https://github.com/Unitech/pm2
https://nodejs.org/api/cli.html#r-require-module
https://docs.nestjs.com/fundamentals/lifecycle-events

0개의 댓글