编程 Nginx + systemd 双实例摘流部署:Node.js 发布丢包从 2524 个到 0,含实测脚本

2026-11-13 21:32:24

Ubuntu 24.04 上 Node.js 零停机部署:同时重启丢 2524 个请求,先摘流后为 0

环境:干净的 Ubuntu 24.04.5,1 vCPU / 2 GB 内存 / 无 swap;Node.js 26.13.0(NodeSource),nginx 1.24.0,systemd 255.4-1ubuntu8.18。全部步骤一遍跑通约 12 分钟。两点前提:压测客户端 hey 0.1.4 跑在同一台机器上,不是跨网络;测试应用 38 行,listen 前故意等 3 秒,模拟真实应用加载配置、开数据库连接。

实测汇总:

  • 装 Node + nginx:28.88 s
  • 不部署,70 秒 150 req/s:0 失败 / 10495;最慢 1% GET ≥35.7 ms
  • 同时重启两个实例:2524 / 2605 / 808 失败(三次,各 4500 请求),中位 13.26 s
  • 一次一个、等 /health、不动 nginx:674 / 422 / 3 失败,中位 16.42 s
  • 用脚本(先摘流→重启→健康检查→放回):0/0/0 失败,中位 10.53 s
  • 回滚(同一脚本指向上一版):0/0/0 失败,中位 10.45 s
  • 故意做成启动即崩的版本,同样负载下部署:0 失败 /4500;27.89 s 后自动回到旧版
  • 100 GET/s 下 kill -9 一个实例:0 失败 /1500;该实例 4.27 s 后重新应答
  • SIGTERM 后旧进程退出耗时:nginx 仍在给它流量时约 10 s(12 次里 8 次);摘出 nginx 后不到 12 ms(15/15)
  • 内存:每个 Node 实例空闲 23.1 / 25.7 MB,负载下 28.6 MB;nginx 3.7 MB

1. 安装 Node.js 与 nginx

curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt-get install -y nodejs nginx
node -v

Ubuntu 24.04 自带的 Node 是 18.19.1,所以用 NodeSource。当天它的 LTS 脚本配的是 node_26.x,装到 nodejs 26.13.0-1nodesource1。nginx 1.24.0 来自 Ubuntu archive。

2. 应用:健康检查 URL + 干净退出

零停机部署依赖应用自己做到两件事:能说出「我准备好了」和「我在退出中」,并且收到停止信号时把手上的请求做完。下面是测试应用全文,你自己的会更大,要抄的是 /health 和 SIGTERM 处理:

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

const VERSION = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
const PORT = Number(process.env.PORT);
const WARMUP_MS = Number(process.env.WARMUP_MS || 0); // stands in for loading config, DB pools, caches
let draining = false;

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(draining ? 503 : 200, { 'content-type': 'text/plain' });
    return res.end(draining ? 'draining\n' : `ok ${VERSION}\n`);
  }
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    setTimeout(() => { // 20 ms of work per request
      res.writeHead(200, { 'content-type': 'text/plain' });
      res.end(`${VERSION} on ${PORT}\n`);
    }, 20);
  });
});

// Longer than nginx's upstream keepalive_timeout (60s), so nginx never reuses a socket Node just closed
server.keepAliveTimeout = 65000;
server.headersTimeout = 66000;

process.on('SIGTERM', () => {
  draining = true;
  server.close(() => process.exit(0)); // finish in-flight requests, accept no new connections
  server.closeIdleConnections();
  setTimeout(() => process.exit(1), 10000).unref(); // hard stop after 10 s
});

setTimeout(() => {
  server.listen(PORT, '127.0.0.1', () => console.log(`${VERSION} listening on ${PORT}`));
}, WARMUP_MS);

要点:/health 在服务可用时返回 200 和版本号,接到停止指令后返回 503;SIGTERM 时 server.close() 停止接受新连接并等待已有连接结束,closeIdleConnections() 丢掉没有请求的 keep-alive 连接;10 秒后无论如何退出。keepAliveTimeout 设 65 秒,因为 nginx 默认把到上游的空闲连接留 60 秒——如果 Node 先关掉空闲连接,nginx 可能把新请求送进一个已经消失的连接。WARMUP_MS 是假的启动延迟,你的应用里不要有。

每个版本放独立目录,current 符号链接指向要跑的那个。应用用它自己的用户跑,只能读代码不能改:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin app
sudo mkdir -p /opt/app/releases/v1
sudo cp server.js /opt/app/releases/v1/
echo v1 | sudo tee /opt/app/releases/v1/VERSION
sudo chown -R root:root /opt/app
sudo ln -sfn /opt/app/releases/v1 /opt/app/current

3. 一个 unit 文件,两个实例

保存为 /etc/systemd/system/app@.service。@ 让它成为模板单元:app@3001 和 app@3002 由同一个文件生成两个服务,%i 是 @ 后面的内容,这里是端口。

[Unit]
Description=Node app instance on port %i
After=network.target

[Service]
User=app
Environment=PORT=%i WARMUP_MS=3000 NODE_ENV=production
WorkingDirectory=/opt/app/current
ExecStart=/usr/bin/node /opt/app/current/server.js
Restart=always
RestartSec=1
KillSignal=SIGTERM
TimeoutStopSec=15

[Install]
WantedBy=multi-user.target

Restart=always + RestartSec=1 让崩掉的实例 1 秒后回来。TimeoutStopSec=15 给应用自己的 10 秒硬超时留出余地,之后 systemd 才强杀。ExecStart 走 current 符号链接,重启即拿到链接指向的那个版本。WARMUP_MS 还是那个假延迟,删掉。

sudo systemd-analyze verify /etc/systemd/system/app@.service
sudo systemctl daemon-reload
sudo systemctl enable --now app@3001 app@3002
curl -s http://127.0.0.1:3001/health
systemctl is-active app@3001 app@3002

systemd-analyze verify 没有报错。3001 在启动 3.24 秒后应答 ok v1(其中 3 秒是假延迟),两个服务都是 active。

4. nginx 放在两者前面

两个文件。实例列表单独放 /etc/nginx/app-upstream.conf,这样部署脚本只改这两行:

server 127.0.0.1:3001 max_fails=1 fail_timeout=5s;
server 127.0.0.1:3002 max_fails=1 fail_timeout=5s;

站点文件 /etc/nginx/sites-available/app:

upstream app {
    include /etc/nginx/app-upstream.conf;
    keepalive 16;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 2s;
        proxy_next_upstream error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
    }
}

keepalive 16 配合 proxy_http_version 1.1 和空的 Connection 头,让 nginx 复用连接。max_fails=1 fail_timeout=5s 表示一次失败就把实例摘出轮转 5 秒。proxy_next_upstream 把在一个实例上失败的请求转给另一个,一次。这里有个后面会咬人的例外:nginx 文档说明,POST 这类非幂等方法一旦已发给第一个服务器,就不会再转下一个。

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sfn /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo nginx -t && sudo systemctl reload nginx
for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1/; done | sort | uniq -c

输出:3 v1 on 3001 / 3 v1 on 3002。六个请求两边各三。

5. 部署脚本

保存为 /usr/local/bin/deploy-app,接受一个参数:要跑的版本目录。

#!/bin/bash
# deploy-app RELEASE_DIR: switch /opt/app/current, then for each instance: take it out of nginx,
# let in-flight requests finish, restart it, wait for /health, put it back. Rolls back if /health fails.
set -u
NEW=$(readlink -f "$1"); OLD=$(readlink -f /opt/app/current)
PORTS="3001 3002"; UP=/etc/nginx/app-upstream.conf; DRAIN_S=${DRAIN_S:-2}; HEALTH_S=${HEALTH_S:-20}
[ -f "$NEW/server.js" ] || { echo "no server.js in $NEW"; exit 2; }

upstream() { # write the upstream list with port $1 marked down ("" = all up), then reload nginx
  local p
  : > "$UP.tmp"
  for p in $PORTS; do
    if [ "$p" = "$1" ]; then echo "server 127.0.0.1:$p down;"
    else echo "server 127.0.0.1:$p max_fails=1 fail_timeout=5s;"; fi >> "$UP.tmp"
  done
  mv "$UP.tmp" "$UP" && nginx -t -q && nginx -s reload
}
healthy() { # up to HEALTH_S seconds for /health to answer 200
  local i
  for i in $(seq 1 $((HEALTH_S * 10))); do
    [ "$(curl -s -o /dev/null -w '%{http_code}' -m 1 "http://127.0.0.1:$1/health")" = 200 ] && return 0
    sleep 0.1
  done
  return 1
}
switch() { ln -sfn "$1" /opt/app/current.tmp && mv -T /opt/app/current.tmp /opt/app/current; }

switch "$NEW"
for p in $PORTS; do
  upstream "$p"; sleep "$DRAIN_S"
  systemctl restart "app@$p"
  if ! healthy "$p"; then
    echo "app@$p failed /health on $NEW: rolling back to $OLD"
    switch "$OLD"; systemctl reset-failed "app@$p"; systemctl restart "app@$p"
    healthy "$p" && upstream "" && echo "app@$p back on $(cat "$OLD/VERSION")"
    exit 1
  fi
  upstream ""
done
echo "deployed $(cat /opt/app/current/VERSION) to $PORTS"

它先把 current 指向新版本,然后对每个实例依次做五件事:在上游文件里标记 down 并 reload nginx、等 2 秒让在途请求结束、重启、轮询 /health 直到 200、放回。第二个实例在第一个缺席时承担全部流量,反之亦然。若 /health 20 秒不应答,脚本把 current 指回旧版本、用旧版本重启该实例、放回 nginx,并以错误退出。

那 2 秒等待还有第二个作用:nginx -s reload 立刻返回,但旧 worker 会继续跑到手上请求结束,所以 reload 之后的一小段时间里仍有请求按旧列表走。别把这个等待砍成 0。

sudo chmod 755 /usr/local/bin/deploy-app
bash -n /usr/local/bin/deploy-app && echo "syntax ok"
sudo mkdir -p /opt/app/releases/v2
sudo cp server.js /opt/app/releases/v2/
echo v2 | sudo tee /opt/app/releases/v2/VERSION
sudo deploy-app /opt/app/releases/v2
for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1/; done | sort | uniq -c

脚本最后打印 deployed v2 to 3001 3002,六个请求变成 3 v2 on 3001 / 3 v2 on 3002。每次部署后都跑最后这行——它能抓出文末那个 bug。

6. 三种部署方式实测对比

测试方法:每次部署都在同样负载下进行,100 GET/s 加 50 POST/s 跑 30 秒共 4500 请求,部署在第 5 秒开始。两个 hey 客户端都跑在服务器本机。非 200 即算失败。每种方式跑三次,在 v1 与 v2 之间来回切。

hey -z 30s -c 10 -q 10 http://127.0.0.1/
hey -z 30s -c 5 -q 10 -m POST -T application/json -d '{"order":1}' http://127.0.0.1/orders

对照组:同样负载 70 秒不部署,10495 个请求 0 失败,最慢 1% GET ≥35.7 ms(每请求 20 ms 假工作)。所以下面每个失败都来自部署。所有失败都是 nginx 的 502。第三次滚动运行的请求数偏少,是因为部分请求耗时远超平常。

同时重启两个实例

为什么同时重启丢几千个:两个实例都停着时 nginx 无处可发,直接 502,日志里叫 no live upstreams,第一次跑了 2524 次。空窗是停止耗时加启动耗时,而停止耗时是意外:前两次运行每个进程花了 10 秒才停,退出码 1,是我们自己的硬超时定时器干的。server.close() 等所有打开的连接结束,而 nginx 只要还认为实例活着就一直复用 keep-alive 连接,连接就永远安静不下来。朴素与滚动两种方式共 12 次停止里有 8 次是这样结束的。第三次两个进程碰巧同时停下,整个部署 3.34 秒,仍丢了 808 个请求,约 5.4 秒的流量——比部署本身还长。没细查,但 max_fails=1 fail_timeout=5s 会在一次连接被拒后把实例摘出长达 5 秒,数字对得上。

一次一个、等 /health、不动 nginx

为什么一次一个还丢几百个:这是没预料到的结果。滚动部署从没有两个实例同时停着,却丢了 674/422/3 个。nginx 日志说明了原因:16 次连接被拒,然后 674 次 no live upstreams。nginx 从没被告知某个实例要走了,它是靠发请求失败才发现的,随后把该实例摘出 5 秒(fail_timeout)。与此同时脚本看到 3001 健康就立刻动手 3002。于是有几秒钟 nginx 不信任第一个、又够不到第二个。第三次只丢 3 个,是因为第二个实例硬撑满 10 秒才停,给了 nginx 重新信任第一个的时间——那是运气不是设计,而且部署花了 26.46 秒。那次日志里还有 8 个请求在旧进程硬停的瞬间被切断:GET 0 失败、POST 3 失败,正好对上 nginx 文档(被切断的 GET 会转给另一个实例,POST 不会)。结论:部署脚本里加健康检查不够,代理必须在重启之前就知道,而不是靠失败请求才发现。

先摘流再重启

为什么先摘出 nginx 就不丢:实例标记 down 且 nginx reload 之后,新请求不会再到达它,在途请求在 2 秒等待内结束(我们的是 20 ms)。于是停止时没有要等的东西:脚本做的 15 次停止里旧进程都在 12 ms 内消失,而 nginx 还在送流量时约 10 秒。这也是三种方式里最快的,10.53 秒——两次 2 秒等待加一次 3 秒启动。等请求的人几乎感觉不到:三次脚本部署里最慢 1% GET 分别是 47.4 / 43.1 / 30.5 ms,对照是 35.7 ms。一个实例缺席时另一个要承担全部流量,所以服务器得有余量。

7. 回滚与启动失败

回滚就是同一命令指向上一版目录 sudo deploy-app /opt/app/releases/v1,三次都 0 失败,中位 10.45 秒。旧版本目录留到你不打算再回滚为止。

故障场景我们做了个第一行就抛 DATABASE_URL is not set 的版本,同样负载下部署。脚本把 3001 摘出 nginx 并重启,systemd 试了 5 次后放弃,报 Start request repeated too quickly。20 秒等不到 /health 后脚本打印:

app@3001 failed /health on /opt/app/releases/v3-broken: rolling back to /opt/app/releases/v1
app@3001 back on v1

4500 个请求 0 失败。从开始部署到两个实例都回到 v1 用了 27.89 秒,全程 3002 独自承担。脚本里的 systemctl reset-failed 就是为了这个 start limit:不加它 systemd 会拒绝再启动该实例。脚本以状态 1 退出,CI 任务因此也会失败。

8. 进程自挂

100 GET/s 跑 15 秒,第 3 秒对其中一个实例 kill -9。1500 个请求 0 失败。nginx 日志显示 5 个请求被切断,都转给了另一个实例。systemd 记录 Failed with result 'signal',1 秒 RestartSec 后拉起,4.27 秒后该实例重新应答 /health。这个测试里没有 POST;按上面滚动运行的表现,被 kill 的进程里正在处理的 POST 会失败。

9. 资源开销

内存是各进程 PSS。空闲是静置 2 分钟后读的,负载是 150 req/s 的对照组,CPU 取 60 秒平均占单核比例。Node 实例内存空闲 23.1 / 25.7 MB,负载 28.6 MB;nginx 全部进程 3.7 MB;负载下 Node 各 1.6% CPU,nginx 1.9%。这是无依赖测试应用的数字,当地板看。这套方案真实的成本很简单:多一个实例就是应用内存翻倍,nginx 几乎不占。

10. 脚本里的 local p bug

上面的数字是第二次运行的结果。第一次脚本部署三次每次都丢 495 个请求,而且结束后 3001 还在跑 v1、3002 跑 v2,脚本却打印了 deployed v2 to 3001 3002 并退出 0。原因是一个词:upstream 函数用变量 p 遍历端口,主循环也用它。函数里没有 local p,bash 只有一个 p:函数的循环把它留在 3002,于是脚本把 3001 标记为 down,然后重启了 3002——nginx 唯一还在用的那个实例。加上 local p 就是第 5 步的版本。教训两条:bash 里每个函数的变量都要 local;部署脚本自己打印的最后一行不能信,部署完要逐个实例问它现在跑哪个版本。第一次运行还显示 nginx reload 有延迟:systemctl reload nginx 之后 1 秒内发的 6 个请求里有 2 个还是旧配置答的。

11. 未测项

  • 压测客户端在另一台机器上(即客户端到 nginx 之间没有真实网络)
  • 真实应用(3 秒启动和每请求 20 ms 都是编的,朴素与滚动的数字会随应用启动时间变化)
  • 长于 2 秒等待的请求,比如上传、长轮询、WebSocket(把 DRAIN_S 调到比最长请求更长,否则会被切断)
  • 两个以上实例
  • 新旧版本都要兼容的数据库迁移
  • 一个实例在另一个部署期间崩掉(脚本在第一次重启前就切了 current,systemd 会把崩掉的实例按新版本拉起)
  • 前面的 HTTPS 与 HTTP/2
  • nginx 的 health_check 指令(商业版才有,开源版只能靠失败请求得知实例已死)

参考

复制全文 生成海报 Nginx systemd 部署 运维

推荐文章

程序员茄子在线接单