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.yamlmem_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-clientcollectDefaultMetrics() 开箱把上面对照表里的信号全给你 —— nodejs_heap_size_used_bytesnodejs_gc_duration_secondsnodejs_eventloop_lag_secondsnodejs_active_handlesprocess_resident_memory_bytesprocess_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 -ecurl
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.report0(仅触发时)只在致命错误 / 收到信号那一刻 dump,稳态零开销
Pyroscope 持续 profiling1–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。

延伸阅读

这页有帮助吗?