用 flamegraph 排查 Workers 生产 CPU/内存:从 cf CLI 抓 profile 到 R2 递归调用优化
CPU 或内存 profile 能直接指出应用在哪一行函数上吃 CPU、在哪分配内存。Workers 和 Durable Objects 现在支持按需 CPU / 内存 profiling:在 Workers Observability 页面发起对活跃 Worker 的按需采样,用交互式 flamegraph 查看,并下载 profile 文件。理解一个应用最好的方式,就是在生产环境里 profile 它。
发起一次 profile
先用 cf 包:
cf workers versions profile latest \
--worker-id "$WORKER_ID_OR_NAME" \
--duration-ms 5000 \
--profile-type cpu > worker-cpu.pprof
Dashboard 路径:Build -> Compute -> Workers & Pages,选中 Worker,进入 Observability 标签页,下拉选择 "Flamegraph"。CPU 和内存 profile 都能发起。Duration 决定 profiler 运行多久;还可以选择要采样的版本——Worker 需要有足够的流量才能成功采样,所以挑一个有流量的版本。
点击 Capture Profile 后渲染出 flamegraph。每个矩形代表一次函数调用,宽度代表消耗的 CPU 时间或内存。点函数聚焦,悬停看详情。多抓几次,找最宽的函数;表视图可以看出哪些函数出现得最多。如果 Worker 是 TypeScript,要开启 source maps,否则函数名会被混淆。
CPU profile:R2 binding 里的两处热点
对一个使用 R2 binding 的 Worker 采样 50 秒。最宽的框吃 CPU 最多:decryptBlock 是 R2 在输出对象数据时做解密,fillResponse 只是在搬字节,这两处没有明显的优化空间。
按 Samples 排序的表视图指向 genericR2JsonReplacer:占超过 5% 的 CPU 时间,而且它在递归调用自己。虽然 replacer 是被 JSON.stringify 在每个节点上调用的,但 replacer 本身也在遍历树——一个嵌套五层的值被处理了五次。修掉之后,genericR2JsonReplacer 快了 2.7 倍。
第二个候选是重复调用 metrics:
function ship() {
if (!registry.metrics().length) {
return;
}
// ... more code here ...
return Response(registry.metrics());
}
metrics 调用足够重,一次就占了 1% 的 CPU 时间。把第一次调用的结果存进变量、不再调第二次,就省下了这部分 CPU。
内存 profile:修掉 128 MB 上限附近的 P999 抖动
内部有一个 Worker 的 P999 内存接近 133 MB,而 Worker 内存上限是 128 MB,导致频繁被驱逐并报 "Exceeded Memory"。团队对生产 Worker 抓了 heap profile,用 pprof 打开,发现 Prometheus 相关代码占了大约 66.7% 的分配。这段代码本该被禁用,但仍有相当一部分在跑;它埋点覆盖了大量代码路径,占用大量内存,即使采集到的数据从未离开 Worker。代码被认为是禁用状态,实际只是部分禁用。团队彻底移除了这条 Prometheus 代码路径:
| 百分位 | 之前 (MB) | 之后 (MB) |
|---|---|---|
| P50 | 70 | 54 |
| P90 | 94 | 79 |
| P99 | 113 | 97 |
| P999 | 133 | 118 |
P999 因此比 128 MB 上限低出约 10 MB 的余量。
一次 profile 请求是怎么落到边缘的
此前 Workers 只能通过 Chrome DevTools 本地 profiling,生产 Worker 不行。请求会被路由到最近的数据中心;Worker 会复制到多个数据中心和机器上,一个接收大量请求的 Worker 可能在同一台机器上被复制多份。Durable Objects 又多了一层间接:它们可以被动态放置,也可以迁移。
在开始采样前要先回答几个问题:采哪个版本;哪个数据中心最近跑过这个版本;isolate 是否还加载在那里;isolate 是否只属于该账号;对于 Durable Object,活跃的 primary actor 在哪里。
一个 API 请求示例:
curl 'https://api.cloudflare.com/client/v4/accounts//workers/workers//versions/latest/profile' \
--data-raw '{"duration_ms":5000,"profile_type":"cpu"}'
采样不会为了出结果而新建一个 isolate;如果 Worker 流量很小,可能很难采到。
不停止 Worker 完成 isolate 采样
Workers Runtime 只在 profiler 生命周期操作期间持有 isolate 锁。一次 CPU profile 的流程是:获取 isolate 及其锁;创建 V8 CPU profiler,以 1 毫秒间隔开始采样;释放锁,让正常请求继续跑;等待请求的时长;重新获取锁并停止采样;释放锁,在临界区之外序列化结果。如果整个采样时长都占着锁,JavaScript 就没法执行,profile 也就没用了。
Durable Objects 是有状态的,并且有名字。给 Durable Object 做 profile 时,可以按名字选择要采样哪个对象,runtime 会把请求路由到拥有该 actor 的那台机器。
局限
采样会话必须显式启动,因此可能错过重要的时间段。内存 profiler 只显示采样窗口内的分配,启动阶段的分配看不到。官方正在做 continuous profiling。