Node 运行时健康
区分「进程活着吗」与「Node 是否在劣化」—— 用容器 healthcheck + 运行时指标 + process.report 侦测内存泄漏、GC 压力、事件循环阻塞、句柄泄漏,指标到问题的对照表、各方案的性能开销、告警规则与升级信号
这份文档回答什么
健康检查只回答一件事:进程还能应答吗(活着 / 死了,二值)。它回答不了另一类更隐蔽的问题 ——
进程还活着,但 Node 已经病了:内存在泄漏、GC 在 thrash(频繁且长时间停顿)、事件循环 (Event Loop)
被拖慢、句柄 (Handle) 在泄漏。这些情况下 /health 照样返回 200,服务却在持续劣化,直到某刻 OOM 或超时雪崩。
这份文档讲运行时健康 (Runtime Health) —— 怎么侦测「Node 是否存在问题」,而不只是「是否活着」。方案与平台无关,Railway 与 自部署 EC2 都适用;它接在你已有的 pino → Alloy → Loki → Grafana 管道上,不另起炉灶。
两个层次:健康 ≠ 运行状态
| 层次 | 问题 | 手段 | 发现得了内存泄漏吗 |
|---|---|---|---|
| Liveness | 进程活着吗 | /health 探针 + 容器 healthcheck | 否 |
| 运行状态 | Node 在劣化吗 | 运行时指标 + 诊断报告 + profiling | 是 |
/health 现在只返回 { ok: true } —— 这是故意的:liveness 探针要极轻、不查 DB,否则 DB 抖一下就会让负载均衡
(Load Balancer) 把所有实例判死、全部重启。侦测劣化是另一套机制,下面展开。
指标 → 问题对照表
「Node 是否存在问题」不是一个布尔值,而是几条趋势线。每条对应一种具体故障:
| 指标 | 异常形态 | 说明的问题 |
|---|---|---|
heapUsed / rss | 跨多次重启单调上涨、从不回落 | 内存泄漏 |
| GC 暂停时长 / 频率 | 持续上升 | GC 压力(agent-loop 的已知隐患) |
| 事件循环延迟 (Event Loop Lag) | p99 > 50–100ms 持续 | 有东西阻塞了事件循环(同步 IO / 巨型 JSON / 紧循环) |
activeHandles / activeRequests | 单调上升 | 句柄 / 连接泄漏(socket、文件、定时器没释放) |
rss 逼近 mem_limit 后容器 exit 137 | 突然重启 | OOM(cgroup 内存超限被内核杀) |
| 容器重启计数 | > 0 | 崩溃循环 —— 结合日志里的 uncaughtException 定位 |
建模原则:把这几条当成运行状态的 primary signal,其余都是围绕它们的下钻。
方案分层
按「侦测 Node 问题」的收益 / 成本从低到高:
第 0 层 —— 容器 healthcheck + 内存边界
已落地 ——
docker-compose.yaml里 server 走/health、worker 走心跳文件的 healthcheck;内存边界在docker-compose.prod.yaml(mem_limit+NODE_OPTIONS=--max-old-space-size,值是 t3.large 的起步点,按实测调)。
给 server 加 healthcheck(打现成的 /health),docker 会重启卡死但没崩的进程(restart: unless-stopped
只兜崩溃,兜不了卡死)。给每个服务设 mem_limit,并把 Node 的 --max-old-space-size 对齐到略低于它 —— V8
在 cgroup OOM-kill 之前就先 GC,内存问题从「神秘重启」变成「日志里可见的 GC 压力」。
worker 没有 HTTP 端点,healthcheck 用心跳文件:每轮 loop touch 一个文件,healthcheck 检查其 mtime 是否新鲜。
alpine 镜像没有
curl,healthcheck 命令用wget -qO- ...或node -e "..."代替。
第 1 层 —— 运行时心跳日志(零新基建)
已落地 ——
apps/server/src/lib/runtime-heartbeat.ts,server + worker 均挂载;生产默认开启,本地 dev 用RUNTIME_HEARTBEAT=true开启(默认关,免终端噪音)。worker 的心跳同时 stamp 一个 liveness 文件供第 0 层的 healthcheck 读。
一个小模块,默认每 30s emit 一条 runtime.heartbeat,带
{ rssMb, heapUsedMb, heapTotalMb, eventLoopLagP99Ms, activeResources, uptimeS }(process.memoryUsage() +
perf_hooks.monitorEventLoopDelay())。它走你已有的 pino → Loki 管道,Grafana 里
{event="runtime.heartbeat"} | json | unwrap heapUsed 就能出内存 / 延迟趋势图。event 是低基数
label、数值是字段 —— 正好符合 cardinality 铁律。立刻拿到内存趋势 + 事件循环延迟可见性,成本近乎为零。
第 2 层 —— prom-client 默认指标(主力)
未落地(下一步升级) —— 要真·时序 + 声明式告警时再上;第 0/1/3 层已够日常侦测。
prom-client 的 collectDefaultMetrics() 开箱把上面对照表里的信号全给你 ——
nodejs_heap_size_used_bytes、nodejs_gc_duration_seconds、nodejs_eventloop_lag_seconds、nodejs_active_handles、
process_resident_memory_bytes、process_cpu_seconds_total。应用暴露 /metrics,Alloy 用 prometheus.scrape
拉取推到 Grafana Cloud metrics(免费 10K series)。这是「Node 是否存在问题」的核心方案 —— 一次性拿全泄漏 / GC /
事件循环 / 句柄四类信号,且正是 overview 文档
已写好的升级路径:不用换 Alloy。
// config.alloy 追加
prometheus.scrape "zapvol" {
targets = [{ __address__ = "server:8001", __metrics_path__ = "/metrics" }]
forward_to = [prometheus.remote_write.cloud.receiver]
}
prometheus.remote_write "cloud" {
endpoint {
url = "https://prometheus-prod-XX.grafana.net/api/prom/push"
basic_auth {
username = env("GRAFANA_CLOUD_PROM_USER")
password = env("GRAFANA_CLOUD_PROM_TOKEN")
}
}
}
第 3 层 —— process.report(事后取证,内置)
已落地 —— compose 的
command已带--report-on-fatalerror --report-on-signal --report-directory=/app/reports,报告写入reports_data卷。
Node 自带诊断报告:用 --report-on-fatalerror --report-on-signal --report-directory=/app/reports
启动。一旦致命错误、或收到 SIGUSR2,自动 dump 一份 JSON —— 完整堆状态、所有活跃句柄、libuv 事件循环状态、原生栈、环境变量。
不用挂调试器就能回答「它出问题的那一刻,Node 内部到底什么样」。稳态零开销(仅触发时写一次)。把 report-dir 挂到卷上,事故后捞出来分析。
可选深化
- Pyroscope(Grafana 家、可自托管)—— 指标告诉你「内存在涨」,Pyroscope 的连续火焰图告诉你「涨在哪个函数」。定位泄漏 / CPU 热点用它。临时排查也可
--heap-prof/--cpu-prof出单次快照。 - Sentry —— 你现在
uncaughtException进了 Loki,但没有分组 / 告警 / source-map 还原栈。Sentry(慷慨免费档、可自托管)补这块,和 Loki 互补,不替代。
性能开销
推荐常开的前五项开销都可忽略,是生产标配;唯一需要收敛的是持续 profiling。
| 方案 | 稳态开销 | 说明 |
|---|---|---|
| 容器 healthcheck | 可忽略 | 每 ~30s 起一个探针进程;alpine 用 wget / node -e 代 curl |
mem_limit + --max-old-space-size | 可忽略(GC 略增) | 逼近上限时 V8 提前 GC —— 正是目的:用可控 GC 换不可控 OOM |
runtime.heartbeat 日志 | 可忽略 | 每 30–60s 一次 memoryUsage() + 一行日志;延迟测量是 libuv C++ 计时器 |
| prom-client 默认指标 | < 1% CPU / 几 MB | 低频采样 + scrape 时序列化几 KB;GC 走 PerformanceObserver。全球生产标配 |
process.report | 0(仅触发时) | 只在致命错误 / 收到信号那一刻 dump,稳态零开销 |
| Pyroscope 持续 profiling | 1–5% CPU | 唯一非平凡开销;按采样率调,或只在排查时临时开 |
| Sentry | 错误捕获近零;tracing 随采样率 | 纯错误模式忽略;tracesSampleRate 调低即可 |
所以选型上:第 0–3 层放心常开,profiling 按需开。
告警规则示例
有了指标,让「劣化」主动通知你,而不是等你翻仪表盘(Grafana Alerting,over Prometheus 指标):
| 告警 | 表达式(示意) | 说的问题 |
|---|---|---|
| 堆逼近上限 | nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes > 0.9(持续 10min) | 内存泄漏 / 压力 |
| 事件循环卡顿 | nodejs_eventloop_lag_p99_seconds > 0.1(持续 5min) | 事件循环阻塞 |
| 句柄泄漏 | deriv(nodejs_active_handles[30m]) > 0 长期为正 | 句柄泄漏 |
| 崩溃循环 | increase(process_start_time_seconds[15m]) 变化 > 2 次 | 反复重启 |
没上 Prometheus 之前,前两条也能用 LogQL over runtime.heartbeat 近似(unwrap + 阈值)。
何时上哪一层
沿用 overview 的升级哲学:
- 起步:第 0 层(healthcheck + mem 边界)+ 第 3 层(process.report)—— 纯配置,先兜住卡死与取证。
- 要看内存趋势:加第 1 层心跳日志 —— 零基建,当天见效。
- 要真·时序 + 声明式告警:上第 2 层 prom-client + Alloy scrape —— 这才是完整的「Node 是否存在问题」方案。
- 需要定位到函数级:再上 Pyroscope。
延伸阅读
- 可观测栈总览 —— pino → Alloy → Loki → Grafana 管道与 Prometheus 升级路径
- 可观测面板 —— 已内置的 4 个业务 Dashboard
- AWS EC2 自部署 —— healthcheck / mem_limit 在 compose 里落到哪