Node.js 多核扩展
问题所在
JavaScript 运行时在每个进程的单线程上执行事件循环。一个 node app.js(或 bun/deno)进程会占满一个 CPU 核心,无论主机容量如何——无论是处理 HTTP 请求、处理队列、运行定时任务,还是执行 CPU 密集型工作。
这适用于所有主要的 JS 运行时:
| 运行时 | 引擎 | 单线程事件循环 |
|---|---|---|
| Node.js | V8 | 是 |
| Bun | JavaScriptCore | 是 |
| Deno | V8 | 是 |
这是运行时的特性,而非平台限制——无论是裸机、Docker、Kubernetes 还是任何 PaaS 平台,都受此约束。
Coolify 容器无 CPU 限制
默认情况下,Coolify 不会限制容器 CPU,因此所有主机核心均可用——你只需告知运行时使用它们即可。
解决方案
在容器内运行多个工作进程。每种运行时都有其首选的机制:
| 运行时 | 方案 | 代码改动 | 说明 |
|---|---|---|---|
| Node.js | PM2 集群模式 | 无 | 最简单;包装你现有的启动命令 |
| Node.js | node:cluster 模块 | 应用层 | 内置,无需额外依赖 |
| Bun | Bun.serve({ reusePort: true }) | 一行代码改动 | 通过 SO_REUSEPORT 由内核负载均衡 |
| Deno | Deno.serve({ reusePort: true }) | 一行代码改动 | 与 Bun 相同的内核机制 |
本指南涵盖 PM2 集群模式(Node.js)和 reusePort(Bun、Deno),并提供 Dockerfile 和 Nixpacks 构建包的示例。
技术背景
- V8(及 JavaScriptCore)的事件循环是每个进程单线程的。
- libuv 的线程池(Node 中默认为 4 个线程)会卸载部分 I/O 工作,但你的 JavaScript 代码仍运行在单个核心上。
- 要使用多个核心,你需要在容器内运行多个进程,或者使用支持多进程监听器的运行时(Bun 和 Deno 通过
reusePort实现)。
Dockerfile 示例(PM2 集群模式)
当你的应用使用 Dockerfile 构建包 构建时,请使用此配置。
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm install -g pm2
COPY . .
EXPOSE 3000
CMD ["pm2-runtime", "-i", "max", "dist/index.js"]关键点:
pm2-runtime -i max会为每个可用的 CPU 核心派生一个工作进程,并使 PM2 保持在前台运行(这是必需的,因为 Docker 的 PID 1 进程不能退出)。- 将
dist/index.js替换为你的实际入口文件。 - 在你的平台网络设置中暴露应用监听的端口(此处为
3000)——在 Coolify 中,这是 Ports Exposes 字段。
Nixpacks 示例
当你的应用使用 Nixpacks 构建包 构建时,请使用此配置。
选项 A — 将 NIXPACKS_START_CMD 设置为环境变量
在你的应用中设置以下环境变量(在 Coolify 中:打开你的应用 → 环境变量):
NIXPACKS_START_CMD=pm2-runtime -i max dist/index.js然后将 pm2 添加到 package.json 的 dependencies 部分,以便 Nixpacks 在构建期间安装它。
选项 B — 在仓库根目录放置 nixpacks.toml
[phases.setup]
nixPkgs = ["nodejs", "pm2"]
[start]
cmd = "pm2-runtime -i max dist/index.js"这将使多核配置保留在版本控制中,并适用于本地和远程构建。
Bun 替代方案(一行代码实现多核)
Bun 的 HTTP 服务器可以使用 SO_REUSEPORT 在多个进程中绑定同一端口。运行 N 个 Bun 进程,内核会在它们之间对传入连接进行负载均衡。
应用代码
Bun.serve({
port: 3000,
reusePort: true,
fetch(req) {
return new Response("Hello from Bun");
},
});Dockerfile
FROM oven/bun:alpine
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --production
COPY . .
EXPOSE 3000
# Spawn one Bun process per available core.
CMD ["sh", "-c", "for i in $(seq 1 $(nproc)); do bun run server.js & done; wait"]Nixpacks (nixpacks.toml)
[phases.setup]
nixPkgs = ["bun"]
[start]
cmd = "sh -c 'for i in $(seq 1 $(nproc)); do bun run server.js & done; wait'"说明:
reusePort: true需要 Linux 内核 ≥ 3.9(所有现代发行版均支持)。- 每个 Bun 进程都是独立的——没有主/从进程间的 IPC,因此请使用 Redis 或数据库来管理共享状态。
- 比 PM2 更简单,但没有内置的每个工作进程自动重启功能;建议配合 Docker 的
restart: unless-stopped(Coolify 的默认设置)使用,以便在父 Shell 崩溃时进行恢复。
Deno 替代方案(一行代码实现多核)
Deno 的 Deno.serve 接受相同的 reusePort 标志,因此你可以派生 N 个进程并让它们绑定同一端口,由内核分配连接。
应用代码
Deno.serve({ port: 3000, reusePort: true }, (_req) => {
return new Response("Hello from Deno");
});Dockerfile
FROM denoland/deno:alpine
WORKDIR /app
COPY . .
RUN deno cache server.ts
EXPOSE 3000
# Spawn one Deno process per available core.
CMD ["sh", "-c", "for i in $(seq 1 $(nproc)); do deno run --allow-net server.ts & done; wait"]Nixpacks (nixpacks.toml)
[phases.setup]
nixPkgs = ["deno"]
[start]
cmd = "sh -c 'for i in $(seq 1 $(nproc)); do deno run --allow-net server.ts & done; wait'"与 Bun 相同的注意事项:进程间无 IPC,无内置的每个工作进程自动重启功能。
注意事项
进程内状态无法扩展
工作进程不共享进程内内存。会话、内存缓存、速率限制计数器和 WebSocket 连接映射必须迁移到 Redis 或其他外部存储。
- **WebSocket:**连接会分散到各个工作进程中。请在代理层使用粘性会话,或将发布/订阅迁移到 Redis,以便任何工作进程都能投递消息。
- **优雅关闭:**在每个工作进程中处理
SIGTERM信号。PM2 会自动处理此问题。 - **内存:**大致呈线性扩展——N 个工作进程 ≈ N × 单进程 RSS。请据此调整服务器规格。
- **日志输出:**PM2 会合并跨工作进程的日志;而使用 Bun 的
reusePort方案时,每个进程独立写入,日志可能会交错。建议使用结构化的 JSON 日志。
验证多核使用情况
部署后,SSH 登录到运行容器的宿主机并检查进程:
docker exec -it <container> top你应该能看到多个 node、bun 或 deno 进程。在负载下访问应用(例如使用 autocannon -c 200 http://<host>:3000/),并确认 htop/top 显示负载分散在所有主机核心上,而不是集中在某一个核心上。
