- 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.
· 9 min read
On this page
- First: should you cluster inside a container at all?
- Problem 1: os.cpus() doesn't know about your CPU limit
- Problem 2: Node as PID 1 and signals
- Problem 3: only the primary hears docker stop
- The complete server
- Memory limits: divide by your workers
- The Dockerfile
- Compose: putting it together
- What about PM2 inside Docker?
- Don't forget the database
- Checklist
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:
- Node counts the host's CPUs, not your container's limit, so a container limited to 2 CPUs can fork 32 workers.
- Your app runs as PID 1, which changes how signals work, and
docker stopmay wait 10 seconds and then kill everything. - 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:
docker run --rm --cpus=2 node:22-alpine node -e "console.log(require('os').cpus().length)"
# 16os.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:
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,
SIGTERMterminates it by default. For PID 1, a signal without a registered handler is ignored. If your code doesn't callprocess.on("SIGTERM", …),docker stopsendsSIGTERM, nothing happens, Docker waits out its grace period (10 seconds by default), then sendsSIGKILL. 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:
# ✅ 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#
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:
- Docker sends
SIGTERM→ tini → primary. - The primary calls
worker.disconnect()on every worker. Each worker stops accepting new connections and finishes the requests it's handling. - Workers close their database pools and exit. When the last one is gone, the primary exits with code
0. - 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:
// 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#
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 unprivilegednodeuser. 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#
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 countTest the shutdown path before you trust it:
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 apiYou 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:
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_CONCURRENCYoverride - ✅ Run with
--init/init: trueand exec-formCMD ["node", "server.js"] - ✅ Primary handles
SIGTERMand drains workers withworker.disconnect() - ✅ Shutdown timeout shorter than the container's stop grace period
- ✅ Per-worker
--max-old-space-sizethat fits inside the container memory limit - ✅ Non-root user,
HEALTHCHECK, and a testeddocker 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.