TT Lab
博客 开始
学习 学习路径 课程

Node.js 后端 — 框架藏起来的东西

运维默认值与优雅关闭 — 用真实套接字测量

在 TT Lab 中继续学习

目标

逐步完善一个 /root/work/srv/server.mjs,用真正的 socket 测量在前端后面工作的服务器必须自己决定的五件事——输入范围、错误响应、 请求体大小、超时、keep-alive——以及收到 SIGTERM 时的结束顺序。最后一步是事件。

为什么重要

处理函数的测试没有 socket,所以遇不到慢客户端、大请求体、前端的连接复用和部署信号。然而生产环境中的 502、内存暴涨、“只在部署时出现的错误”,全都出自这道缝隙。而且这些设置写下的值和实际生效的情况并不相同 ——因检查周期而迟迟才断开的超时、对已完成响应的连接置之不理的 close(),都会在本实验中亲自测量。

评分器会用空闲端口(PORT 环境变量)直接启动你的 server.mjs 进行测试,结束后回收。即使你启动着的服务器运行的是旧代码, 也不影响评分。自己尝试时,用 PORT=8081 node /root/work/srv/server.mjs & 启动,用 kill %1 回收。

步骤

  1. 在 PORT 上监听,响应 /healthz、/work(只接受 0–5000 的整数)和 404
  2. /boom、/boom-async 抛出错误时也返回 500 JSON,正文中没有机密和堆栈,进程继续存活
  3. POST /echo 的请求体上限为 64KiB——先看请求头,chunked 则边接收边计数,返回 413
  4. 拖延请求头 5 秒、拖延请求体 10 秒时断开,但不断开 3 秒的正常请求
  5. 让 keepAliveTimeout 比前端的空闲时间(60 秒)更长
  6. SIGTERM——停止监听,关闭空闲连接,进行中的请求处理到底,以 0 退出,截止 4 秒
  7. 事件 nd-deploy——每次部署都出现 502,每个服务的原因不同

参考

在 PORT 上监听并过滤输入的服务器

创建 /root/work/srv/server.mjs。从环境变量 PORT 读取端口,在 127.0.0.1 上监听(评分器会选择空闲端口直接启动)。GET /healthz 返回 200 和 {"status":"ok"},GET /work?ms=<n> 等待 n 毫秒后返回 200 和 {"done": n}。如果 ms 不是 0–5000 的整数(负数、小数、字母、空值),返回 400;不存在的路径返回 JSON 404。所有响应都是 JSON。

方向:服务器收到的值全部来自外部——如果原样等待 ms=99999999,一个请求就会拖住服务器。 工具:node:http 的 createServer 与 listen(port, host)、new URL(req.url, 'http://local').searchParams、process.env.PORT。 具体:把 writeHead(status, {content-type, content-length}) 和 end(body)封装进同一个函数,后面的步骤要多加一个请求头就很容易。想自己启动试试,就先 PORT=8081 node server.mjs &,再 curl -s 127.0.0.1:8081/work?ms=300。

抛出的错误既不能带走进程,也不能泄露机密

添加两个故意出错的路径。GET /boom 在处理函数中 throw new Error('db password=hunter2 failed at /srv/app/db.js'),GET /boom-async 在一次 await 之后抛出同样形式的错误。两个请求都必须以 500 和 {"error": "..."} JSON 响应,响应正文中不能有错误消息、堆栈或文件路径。这些信息改为留在标准错误(console.error)中,进程必须继续工作。

方向:请求处理器返回的 promise 被拒绝时,必须有人接收——如果没人接收,Node 22 会结束进程。 工具:route(req, res).catch(...)、res.headersSent、console.error(err.stack)。 具体:如果在 catch 中发现请求头已经发出,就无法再修改响应,此时切断连接(res.destroy());否则发送措辞固定的 500。一旦把 err.message 放进响应,这一步就会失败——消息里含有密码。

在接收之前、接收之中限制请求体大小

添加 POST /echo。接收 JSON 请求体,以 200 和 {"echo": <received-json>}(即收到的内容)返回,请求体上限为 64KiB(65536 字节)。请求头中的 Content-Length 超过上限时,不等待请求体直接返回 413;不声明大小的 Transfer-Encoding: chunked 请求体,边接收边统计字节数,一超过上限就返回 413;损坏的 JSON 返回 400。60KB 的请求体必须能够接收。

方向:拒绝得越早越便宜。只看请求头就能拒绝的东西,如果等收完请求体再拒绝,内存和时间就已经付出去了。 工具:req.headers['content-length']、for await (const chunk of req),抛出携带状态码的错误类(例如 HttpError),在第 02 步的 catch 中区分。 具体:发送 413 时要加上 Connection: close——剩余的请求体还会通过同一个连接继续涌入,这个连接不能再用于下一个请求。测试:curl -s -H 'Content-Length: 10485760' -d '{}' ... 会被 curl 自动纠正请求头,所以请像评分器那样用 python3 的 socket 直接写请求头。

不让慢客户端占住连接

不把请求头发完、一直拖延的连接,服务器必须在 5 秒(headersTimeout)左右断开;发完请求头却拖延请求体的连接,必须在整个请求 10 秒(requestTimeout)左右断开。评分器每 0.5 秒发送一行请求头或一个字节的请求体,测量何时被断开,同时检查 GET /work?ms=3000 是否以 200 结束(生成响应的时间不是客户端的责任)。拖延请求头必须在 3–7.5 秒时断开,拖延请求体必须在 8–13 秒时断开。

方向:如果设了值却仍没有按时断开,要怀疑的不是值,而是“什么时候检查”。 工具:http.createServer({ headersTimeout, requestTimeout, connectionsCheckingInterval }, handler),用 python3 的 socket 一行一行发送请求头,并用 time.time() 计时。 具体:Node 每隔 connectionsCheckingInterval(默认 30000ms)才检查一次这两个时间。5 秒的限制配上 30 秒的周期,最长可以撑到 35 秒——把周期设为 1 秒左右。

keep-alive 要比前端更长

这个服务器前面有一个负载均衡器,其空闲连接时间为 60 秒。把服务器的 keepAliveTimeout 设得比它更长(65000ms 以上)。评分器会读取实际接收请求的服务器对象上的值,并在连接闲置 7 秒后通过同一连接发送第二个请求(如果是默认值 5 秒,连接早已关闭)。前一步的超时设置(headersTimeout 5 秒、requestTimeout 10 秒)必须保持不变,平时的响应不能加上 Connection: close。

方向:先确定应该由哪一方关闭连接——服务器关闭连接的那一刻,前端并不知道。 工具:server.keepAliveTimeout、响应头中的 Keep-Alive: timeout=,用 curl -v 观察同一连接的复用(Re-using existing connection)。 具体:keepAliveTimeout 和 headersTimeout 是两个不同的时钟——前者是响应之后的空闲时间,后者是接收新请求头的时间。不要因为调大了一个就放宽另一个。

收到 SIGTERM 时优雅地结束

收到 SIGTERM 后,不再接受新连接,立即关闭空闲的 keep-alive 连接,对进行中的请求响应到底后也关闭该连接,全部结束后自行以退出码 0 结束。如果有请求在 4 秒内没有结束,就切断剩余连接并结束(3.5–5.2 秒)。评分器会挂着一个空闲连接和一个 1.5 秒的请求发送 SIGTERM,并用一个拖延请求体(不会自行结束)的上传来检查截止时间。

方向:亲自测量 server.close() 关闭了什么、留下了什么——关键在于回调何时到来。 工具:process.on('SIGTERM', ...)、server.close(cb)、server.closeIdleConnections()、server.closeAllConnections()、响应头 Connection: close、setTimeout(...).unref()。 具体:close() 只关闭那一刻空闲的连接。当时正在工作的 keep-alive 连接在响应后仍然开着,回调不会到来——关闭过程中,请在响应上加 Connection: close,让连接随响应一起关闭。截止定时器要 unref,进程才不会只因为它而留下。

事件——每次部署都出现 502

运行 incident nd-deploy 就会发生事件。前端(ndproxy,127.0.0.1:8080)后面的三个服务 catalog、checkout、profile 各自以两个实例(-a、-b)运行,每次部署(ndctl deploy <service>)时前端都会返回 502。请针对每个服务修改 /srv/nd-deploy/<service>/server.mjs 的 SIGTERM 处理并重新启动(ndctl restart <instance>),让三个服务在部署过程中都不再出现 502。不要改动前端的配置和进程,保持 keep-alive。在 /root/incident/nd-deploy/report.txt 中写下 catalog: <code>、checkout: <code>、profile: <code> 三行,以及每个服务一行能说明其原因的记录(/var/log/nd/deploy.log、proxy.log),分别写成 evidence-catalog: <line>、evidence-checkout: <line>、evidence-profile: <line>。原因按服务各自确定,两个服务的原因也可能相同。如果用不同的原因代码答错三次,事件会被回收,需要重新触发。代码是以下之一:drain-too-short(截止时间比进行中的请求短)、exit-on-sigterm(收到 SIGTERM 立即结束)、idle-left-open(让已完成响应的连接保持打开)、keepalive-shorter(keep-alive 比前端短)。

方向:三种 502 行看起来都一样。区分它们的是旧进程在 SIGTERM 之后何时、以何种方式结束——立即、稍后、还是始终不结束而被强制结束。 工具:/var/log/nd/deploy.log 中的实例行(exit=、after、SIGKILL)、proxy.log 中的 reused=、实例日志、服务代码中 process.on('SIGTERM' 部分,修改后用 ndctl deploy <service> 重新测量。 具体:把第 06 步建立的关闭顺序与每个服务逐一对照,找出缺少的部分。部署工具的慢请求是 800ms(评分时会按实例改为 0.5 秒和 1.3 秒,以识别“等待固定时间后结束”的做法),SIGTERM 之后 3 秒发送 SIGKILL。关闭 keep-alive 或在每个响应上加 Connection: close 来回避,不算修复(评分器会检查前端的连接复用)。