Skip to main content
All posts
  • Node.js
  • Docker
  • DevOps
  • Scaling

Node.js Clustering Inside Docker: CPU Limits, PID 1 and Graceful Shutdown

Running the Node.js cluster module in a container has traps a VM doesn't — os.cpus() lies about your CPU limit, Node as PID 1 ignores signals, and docker stop kills workers mid-request. Here's how to do it right.

SK
Santosh Kumar

· 9 min read

On this page

In the Node.js clustering guide we forked one worker per CPU core so a single server could use the whole machine. That works on a VM. Put the same code in a Docker container and three things quietly go wrong:

  1. Node counts the host's CPUs, not your container's limit, so a container limited to 2 CPUs can fork 32 workers.
  2. Your app runs as PID 1, which changes how signals work, and docker stop may wait 10 seconds and then kill everything.
  3. Only the primary receives the stop signal, so workers get killed mid-request unless the primary shuts them down.

This post fixes all three, and starts with the question you should ask first.

First: should you cluster inside a container at all?#

The "cloud native" answer is one process per container: run a single Node process in each container and let the orchestrator (Kubernetes, ECS, Docker Swarm) run more containers when you need more capacity. That gives you:

  • Health checks, restarts and scaling handled by the platform
  • Clean resource accounting (one container = one process = one memory limit)
  • No primary process to get wrong

So when does clustering inside a container make sense?

Situation Recommendation
Kubernetes or ECS with autoscaling One process per container. Scale replicas, not workers.
One big VM running docker compose Cluster inside the container, so one container uses all its CPUs
ECS on EC2 with a few large tasks Clustering is reasonable; match workers to the task's CPU units
Platform charges per container, you're given multi-core containers Cluster to use the cores you're paying for

If you're in the first row, stop here and keep your containers single-process. For the rest, read on.

Problem 1: os.cpus() doesn't know about your CPU limit#

Run this inside a container started with docker run --cpus=2 on a 16-core host:

Terminal
docker run --rm --cpus=2 node:22-alpine node -e "console.log(require('os').cpus().length)"
# 16

os.cpus() reports the host's cores. Docker's --cpus flag doesn't hide cores; it limits CPU time through a Linux control group (cgroup) quota. Your container can run on all 16 cores but only gets 2 cores' worth of time in total.

Fork 16 workers under a 2-CPU quota and they all compete for that time: more context switching, 8× the memory, and worse latency than 2 workers would give you.

os.availableParallelism() is better (it respects CPU pinning with --cpuset-cpus), but don't rely on it to understand a --cpus quota across Node versions. The reliable approach is to read the cgroup limit yourself:

cpu-limit.js
import { readFileSync } from "node:fs";
import { availableParallelism } from "node:os";

/** CPU quota for this container in cores (e.g. 2 or 1.5), or Infinity if unlimited. */
function cgroupCpuLimit() {
  // cgroup v2 (modern Docker / Kubernetes): file contains "<quota> <period>" or "max <period>"
  try {
    const [quota, period] = readFileSync("/sys/fs/cgroup/cpu.max", "utf8").trim().split(" ");
    if (quota !== "max") return Number(quota) / Number(period);
  } catch {}

  // cgroup v1 (older hosts): quota is -1 when unlimited
  try {
    const quota = Number(readFileSync("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "utf8"));
    const period = Number(readFileSync("/sys/fs/cgroup/cpu/cpu.cfs_period_us", "utf8"));
    if (quota > 0) return quota / period;
  } catch {}

  return Infinity; // no limit, or not running in a container
}

export function workerCount() {
  // An explicit setting always wins; useful when you know better than the heuristic
  const fromEnv = Number(process.env.WEB_CONCURRENCY);
  if (fromEnv > 0) return fromEnv;

  const limit = Math.floor(cgroupCpuLimit()); // 1.5 CPUs → 1 worker
  return Math.max(1, Math.min(availableParallelism(), limit));
}

With --cpus=2, /sys/fs/cgroup/cpu.max contains 200000 100000 (200 ms of CPU time per 100 ms period), so workerCount() returns 2. With no limit set, it falls back to the cores you can actually use.

WEB_CONCURRENCY is a widely used convention for "how many worker processes," so it's worth supporting: you can tune workers per environment without rebuilding the image.

Problem 2: Node as PID 1 and signals#

Inside a container, the first process gets PID 1, which Linux treats specially:

  • PID 1 gets no default signal handlers. For any other process, SIGTERM terminates it by default. For PID 1, a signal without a registered handler is ignored. If your code doesn't call process.on("SIGTERM", …), docker stop sends SIGTERM, nothing happens, Docker waits out its grace period (10 seconds by default), then sends SIGKILL. Every stop takes 10 seconds, and in-flight requests are killed.
  • PID 1 is responsible for reaping zombie processes. Orphaned child processes get re-parented to PID 1, which is supposed to clean them up when they exit. Node doesn't do this.

Two fixes, use both:

1. Run a tiny init process as PID 1. Docker ships one (tini). Add --init to docker run, or init: true in Compose. tini becomes PID 1, forwards signals to your Node process and reaps zombies.

2. Start Node directly with the exec form of CMD:

dockerfile
# ✅ exec form: Node is the process that receives signals
CMD ["node", "server.js"]

# ❌ shell form: runs /bin/sh -c "node server.js"; the shell receives the signal, not Node
CMD node server.js

# ❌ avoid npm start in containers: an extra process layer between Docker and your app
CMD ["npm", "start"]

Problem 3: only the primary hears docker stop#

docker stop sends SIGTERM to the container's main process. With tini, that signal is forwarded to your primary, not to the workers. So the primary has to drive the shutdown: tell every worker to stop accepting connections, let in-flight requests finish, then exit, all before Docker's grace period runs out and SIGKILL arrives.

The complete server#

server.js
import cluster from "node:cluster";
import process from "node:process";
import { workerCount } from "./cpu-limit.js";

const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS ?? 8_000);

if (cluster.isPrimary) {
  const count = workerCount();
  console.log(`Primary ${process.pid}: starting ${count} workers`);
  for (let i = 0; i < count; i++) cluster.fork();

  let shuttingDown = false;

  cluster.on("exit", (worker, code, signal) => {
    if (shuttingDown) {
      if (Object.keys(cluster.workers).length === 0) {
        console.log("All workers stopped. Bye.");
        process.exit(0);
      }
      return;
    }
    console.warn(`Worker ${worker.process.pid} died (${signal || code}). Restarting.`);
    cluster.fork();
  });

  const shutdown = (sig) => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log(`${sig} received: draining ${Object.keys(cluster.workers).length} workers`);

    for (const worker of Object.values(cluster.workers)) worker.disconnect();

    // Must finish before Docker's stop timeout sends SIGKILL
    setTimeout(() => {
      console.error("Drain timed out. Exiting with workers still running.");
      process.exit(1);
    }, SHUTDOWN_TIMEOUT_MS).unref();
  };

  process.on("SIGTERM", () => shutdown("SIGTERM"));
  process.on("SIGINT", () => shutdown("SIGINT"));
} else {
  // app.js exports start() (listens on PORT) and stop() (closes DB pools, queues, etc.)
  const { start, stop } = await import("./app.js");
  await start();

  // Worker side of the drain: the primary's disconnect() already closes our HTTP
  // server; release everything else so the event loop empties and the process exits
  process.on("disconnect", () => stop());
}

(Top-level await needs ES modules: "type": "module" in package.json, or name the file server.mjs.)

How a docker stop plays out with this code:

  1. Docker sends SIGTERM → tini → primary.
  2. The primary calls worker.disconnect() on every worker. Each worker stops accepting new connections and finishes the requests it's handling.
  3. Workers close their database pools and exit. When the last one is gone, the primary exits with code 0.
  4. If anything hangs, the primary gives up after 8 seconds, safely inside Docker's 10-second default.

The timeout ordering matters: your shutdown timeout must be shorter than the container's stop grace period. If requests can legitimately take longer (file uploads, reports), raise both: stop_grace_period in Compose, or terminationGracePeriodSeconds in Kubernetes.

Memory limits: divide by your workers#

A container's memory limit applies to all processes together. With --memory=1g and 4 workers, each worker effectively has about 250 MB, minus the primary and some headroom.

Node's default heap limit doesn't know it's sharing that budget with other workers, so one worker can grow until the kernel's OOM killer ends the whole container. Set an explicit heap ceiling per worker:

JavaScript
// In the primary, before forking
cluster.setupPrimary({
  execArgv: [...process.execArgv, "--max-old-space-size=200"], // MB, per worker
});

Rule of thumb: (container memory − ~100 MB) ÷ workers, then leave some margin below that, because the V8 heap isn't the only memory a process uses (buffers and native modules live outside it). A worker that hits its heap limit crashes and gets restarted by the primary; that's much better than the entire container being OOM-killed.

The Dockerfile#

Dockerfile
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --chown=node:node . .
USER node

EXPOSE 3000

# Uses Node's built-in fetch, so no curl/wget needed in the image
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["node", "server.js"]
  • USER node: the official image includes an unprivileged node user. Don't run your app as root.
  • npm ci --omit=dev: reproducible installs from the lockfile, without dev dependencies.
  • HEALTHCHECK: requests hit whichever worker accepts the connection, which is fine. If workers are crash-looping, the check fails and your orchestrator can act.

Compose: putting it together#

docker-compose.yml
services:
  api:
    build: .
    init: true              # tini as PID 1: forwards signals, reaps zombies
    ports:
      - "3000:3000"
    cpus: "2"               # cgroup CPU quota → 2 workers via cpu-limit.js
    mem_limit: 1g
    stop_grace_period: 15s  # longer than SHUTDOWN_TIMEOUT_MS
    environment:
      SHUTDOWN_TIMEOUT_MS: "12000"
      # WEB_CONCURRENCY: "2"  # uncomment to override the detected worker count

Test the shutdown path before you trust it:

Terminal
docker compose up -d
# Start a long request, then stop the container while it's running
curl http://localhost:3000/slow & sleep 1 && time docker compose stop api

You should see the drain messages in docker compose logs api, the slow request should complete, and docker compose stop should return well before the grace period, not after exactly 10 seconds.

What about PM2 inside Docker?#

PM2 ships a container-friendly runner, pm2-runtime, which does the clustering, restarts and signal handling described here:

dockerfile
RUN npm install -g pm2
CMD ["pm2-runtime", "ecosystem.config.js"]

It's a valid choice, especially if you already use PM2 on VMs (that setup is here). Just remember to set instances explicitly to match the container's CPU limit instead of max, for the same reason os.cpus() misleads you.

Don't forget the database#

Every worker in every container opens its own connection pool. Four containers × four workers × a pool of 10 is 160 connections, and MySQL's default max_connections is 151. Size pools for the total, not per process; the math is in MySQL connection pooling in Node.js.

Checklist#

  • ✅ Decide first: replicas (one process per container) or cluster inside the container
  • ✅ Derive worker count from the cgroup CPU quota, with a WEB_CONCURRENCY override
  • ✅ Run with --init / init: true and exec-form CMD ["node", "server.js"]
  • ✅ Primary handles SIGTERM and drains workers with worker.disconnect()
  • ✅ Shutdown timeout shorter than the container's stop grace period
  • ✅ Per-worker --max-old-space-size that fits inside the container memory limit
  • ✅ Non-root user, HEALTHCHECK, and a tested docker stop

Clustering in Docker is the same cluster module with a different environment around it. Respect the container's limits, own the signals, and it behaves exactly like it does on a VM, just more portable.