本章目标:把「出问题了怎么查」变成一套可执行的流程。 包含四层可观测性(状态/日志/指标/追踪)、每层的工具与坑, 一份按症状索引的排查手册,以及一个真实事故的复盘。
11.1 四层可观测性
⚠️ 这张表里"追踪"那一行有个名实不符的地方,本书第四轮修订专门纠正了:
上面列的三个工具(timeline / task events / Distributed Debugger)
都不是"追踪" —— 它们是"看单点"的工具。
真正回答「一次请求跨了 5 个 actor,慢在哪一段」的,
是分布式追踪(OpenTelemetry / Jaeger 那一套)——
而本书前三轮没有任何一章真正讲过它
(⚠️ 第五轮纠正:早先这里写的是"OpenTelemetry 在前 35 章命中数是 0",
那是自我否证的 —— 这一行本身就算一处命中。改成定性表述)。
那一层现在在第 36 章,包括:opentelemetry.context 为什么跨不过
ray.remote、怎么把 context 当参数传、以及怎么把 Ray 的 ID 写进 span
来和第 11 章的 State API 对上。
什么时候去第 36 章:当你的问题是"这条请求为什么慢", 而不是"整体哪里不对"时。两者是不同的问题(第 36 章 §36.1 有分工表)。
使用顺序建议:状态 → 日志 → 指标 → 追踪。 很多人一上来就翻日志,结果被几万行输出淹没;先看状态能把范围缩小 90%。
这四层的分工,一句话记: 指标告诉你"出问题了",追踪告诉你"问题在哪一段",日志告诉你"那一段里发生了什么"。 (timeline 是第四个维度 —— 机器视角,不是追踪的替代品。)
11.2 State API:最直接的一层
⚠️ 官方标注 State API 为 alpha,需要 ray[default]。
from ray.util import state
# 聚合视图(先看这个)
state.summarize_tasks() # 各状态个数 / 重试数 / 耗时统计
state.summarize_objects() # 对象数 / 内存占用 / 溢出与驱逐计数
state.summarize_actors() # actor 状态分布
# 列表视图
state.list_tasks() # 每个任务:状态/耗时/重试/节点/worker
state.list_objects() # 每个对象:object_size/所在节点/reference_type/call_site
state.list_actors() # 每个 actor:状态/节点/重启次数
state.list_nodes() # 节点:资源总量/可用量/标签
state.list_workers() # worker 进程:pid/类型/已执行任务数
state.list_placement_groups()
state.list_runtime_envs() # 每个 runtime_env 的状态(装好了没、耗时多久)
state.list_jobs() # 提交过的 job(配合 Jobs API,见第 4 章 §4.7)
state.list_logs() # 日志文件清单(哪个节点/哪个文件)
state.list_cluster_events() # 集群事件:节点加入/退出、autoscaler 决策
# 单个对象
state.get_task(task_id) / state.get_actor(actor_id) / state.get_objects(...)
三个容易被忽略但很关键的函数:
- list_runtime_envs() —— 当任务卡在 PENDING 且你怀疑是依赖没装好时,
这是唯一能直接看到「runtime_env 装到哪一步、失败没有」的接口,
比翻日志快得多(配合附录 F 的 runtime_env 排错);
- list_cluster_events() —— 「节点为什么突然没了 / autoscaler 为什么缩容了」
的答案在这里,而不是在任务列表里;
- list_logs() —— 先列出有哪些日志文件,再去读。 ray logs 的目标名(actor id / worker id / 节点 ip)经常写错,
先用 list_logs() 确认有什么,能省很多时间。
命令行版本(生产环境更好用,不用写代码):
ray list tasks # 列任务(可加 -f state=PENDING 这类过滤)
ray summary tasks # 聚合视图(按状态/类型分组计数)
ray summary objects # 对象存储的聚合视图(查内存先看这个)
ray list objects # 列对象
ray get tasks <task_id> # 看单个实体的详情(⚠️ 复数:ray get tasks)
ray status # 集群资源与自动扩缩状态
⚠️ 三处最容易记错的拼写(第 33 章 §33.3 有完整对照):
① ray get 后面的资源名是复数(ray get tasks <id>,不是 ray get task);
② ray list logs 不存在 —— 对应的 Python API 是
ray.util.state.list_logs(),CLI 侧走 ray logs cluster;
③ 对象的字段是 object_size 不是 size(见下面 §11.4 的字段表)。
⚠️ ray logs 是自成一体的子命令组(ray logs [OPTIONS] COMMAND),
老文档里的 ray logs --actor-id=... 这种扁平写法早已不可用 ——
子命令形态自 Ray 2.3(PR #30422)就存在,不是 2.58 的新变更:
ray logs cluster raylet.out --tail 500 # 集群级:看某个节点上的某个日志文件
ray logs actor --id <actor_id> --follow # 某个 actor 的日志
ray logs task --id <task_id> --err # 某个 task 的日志(--err 看 stderr)
ray logs worker --pid <PID> # ⚠️ worker 只认 --pid;actor 两种都认
ray logs job --id <job_id> # 某个 job 的日志
Commands 一共五个:actor / cluster / job / task / worker。
⚠️ 注意 CLI 的形状:ray list <resource> / ray summary <resource> /
ray get <resource> <id> 是三组子命令,没有 ray state list 这个命令
(那是 Python 侧 ray.util.state.list_* 的写法)。
而 ray logs 自成一组子命令组,与 ray list 体系并列 ——
命名不一致是 Ray CLI 的历史包袱(worker 用 --pid、其余用 --id 就是例子),
别试图找规律,用 --help 确认。
⚠️ 另外记住:ray logs 只能看到「还活着的节点」上的日志
(官方原文 "only the logs from alive nodes are available")。
节点已经没了,日志就得靠集中式日志系统去捞(见下文)。
实战:三个最常用的查询
# ① 哪些任务卡住了?卡在哪一步?
from ray.util import state
stuck = [t for t in state.list_tasks() if t["state"].startswith("PENDING")]
for t in stuck[:10]:
print(t["state"], t["name"], t["task_id"])
# ② 哪些对象最占内存?谁创建的?
# 字段名是 object_size(不是 size);call_site 需要先开记录开关,见下
objs = sorted(state.list_objects(), key=lambda o: -o.get("object_size", 0))[:10]
for o in objs:
print(o["object_size"] / 1e6, "MB", o["object_id"], o.get("call_site"))
# ③ 哪些任务重试过?(环境抖动的信号)
# 字段名是 attempt_number(不是 num_attempts —— 那是 mini-ray 的叫法)
retried = [t for t in state.list_tasks() if t.get("attempt_number", 1) > 1]
print(f"{len(retried)} 个任务被重试过")
⚠️ call_site 默认是空的。 引用创建点默认不记录 ——
必须用 RAY_record_ref_creation_sites=1 启动 Ray,否则所有 call_site
都会显示成 disabled,你会以为"排查不出泄漏",其实是开关没开。
这是"内存一直涨却找不到谁创建"最常见的原因
(见第 07 章 §7.7 的「四类内存事故」)。
还有两个必须知道的使用约束(否则大集群上会既慢又不准):
- State API 不是快照:官方明确说返回结果可能
过期(stale)、不完整(partial)、被截断(truncated) ——
单次查询默认上限 100 条(DEFAULT_LIMIT)、超时 30 秒,
十万级实体以上可能拿不全,已结束的资源还可能已被 GC 掉。
所以务必带过滤条件,别拉全量:
state.list_tasks(filters=[("state", "=", "PENDING")], limit=50, timeout=10)
- Log API 只能拿存活节点的日志。节点一旦被回收,它的历史日志就取不到了 ——
生产上要靠集中的日志系统(Loki/ES)或 KubeRay 的 History Server,
而不是指望事后用 ray logs 去捞。
11.3 Dashboard:好看,但有代价
ray[default] 自带 Dashboard(默认 http://localhost:8265),包含:
Jobs(提交与查看 job)、Cluster(节点与资源)、Actors、Tasks、Logs、
Metrics、Ray Data / Ray Train / RLlib 的专用面板(部分仍标 beta)、
以及 Ray Distributed Debugger 的入口。
三个必须知道的坑:
- 默认没有鉴权 —— 这是被实际利用过的攻击面(CVE-2025-62593, CVSS 9.4:DNS rebinding + User-Agent 绕过,浏览器打开恶意页面即可在 本机执行命令;2026-08-17 被 CISA 列入已知被利用漏洞目录)。 不要把 8265 暴露到不可信网络;
- 它在 head 节点上跑,会消耗 head 节点的资源与网络带宽;
- 不要把它当监控系统:长期指标请接 Prometheus(见 11.5)。
# 关闭 dashboard(安全要求严格的环境)
ray start --head --include-dashboard=false
# 或 ray.init(include_dashboard=False)
11.4 日志:worker 的日志去哪了
Ray 的日志模型:
driver 进程 ────────────▶ 你的终端(直接打印)
worker 进程 ── 转发 ──▶ driver 的终端(带 (pid) 前缀)
└────────▶ 节点上的日志文件(session 目录下)
相关配置:
ray logs worker --pid=<PID> # 按 worker 进程查(注意是 --pid,不是 --id)
ray logs actor --id=<ACTOR_ID> # 按 actor 查
ray logs task --id=<TASK_ID> # 按 task 查
ray logs job --id=<SUBMISSION_ID> # 按 job 查
ray logs cluster <filename> --node-id=<NODE_ID> # 按节点捞整个文件
ray logs cluster <filename> --follow # 跟随
⚠️ --pid 的用法和别的不一样,别试图找规律:task / job 用 --id;
worker 用 --pid(没有 --id);而 actor 两者都接受
(--id 按 ActorID、--pid 按进程号,见第 33 章 §33.5 的对照表)。
敲之前用 --help 确认一下最省事。
扁平写法 ray logs <worker_id> 早已不可用 —— 子命令形态自 Ray 2.3
(PR #30422)就存在了,不是 2.58 的新变更。
最常见的困惑:「我的 worker 里 print 的消息去哪了?」
默认会转发到 driver(所以终端里能看到,只是带 pid 前缀且可能交错)。
如果设了 RAY_LOG_TO_DRIVER=false,就去节点的日志目录里找。
大集群建议:把日志导到集中式系统(Loki/ELK),别指望 Dashboard 的日志面板。
⚠️ 2.58 引入了一个迁移开关,但默认是关的:
RAY_enable_task_events_to_dashboard_head(ray_config_def.h 里默认 false)。
打开后,事件由 aggregator 从 GCS 热路径导出到 head 上的内存存储,
state API / timeline 改从 head 读 —— 目的是降低大集群的控制面开销。
升级到 2.58 后如果发现 task events 变少或字段缺失,先查这个开关;
反过来,如果你没开它,就不要以为"2.58 已经默认搬家了"。
11.5 指标:接 Prometheus
Ray 自带 metrics exporter:
ray.init(..., _metrics_export_port=8080) # 或 ray start --metrics-export-port=8080
Prometheus ── scrape ──▶ <node>:8080/metrics
关键指标族(按用途):
两个必须知道的现实问题:
- 2.52–2.56.1 的 OTel metrics recorder 死锁(2.57 修复): 该 bug 会冻结 head 节点的 dashboard agent,导致提交的 job 永远不启动。 如果你的版本在这一区间,升级到 2.57+;
- 指标缺失也是 bug:Ray Data 有多个背压策略,
但从不暴露「是哪个策略阻塞了 operator」(官方 issue #65607 自认),
所有情况看起来都是 [backpressure],只能靠翻 executor 日志手工追 ——
这是可观测性上的真实缺口,遇到时不要怀疑自己。
应用自定义指标:ray.util.metrics
上面这些 ray_* 指标都是系统级的。你自己的业务指标(队列长度、
推理延迟、缓存命中率)要接进同一个 Prometheus 端点,用的是 ray.util.metrics——
这是整个 Ray 里最实用、却最少被讲到的 API 之一:
from ray.util.metrics import Counter, Gauge, Histogram
# ⚠️ 在模块顶层创建(或 ray.init 之后),不要放进任务函数里反复创建
req_total = Counter(
"app_requests_total",
description="处理的请求总数",
tag_keys=("model", "status"), # ← 标签必须先在这里声明
)
queue_depth = Gauge("app_queue_depth", description="当前排队深度")
latency = Histogram(
"app_latency_seconds",
description="推理延迟",
boundaries=[0.01, 0.05, 0.1, 0.5, 1.0, 5.0], # 自定义分桶
)
@ray.remote
def infer(batch):
queue_depth.set(len(batch))
start = time.perf_counter()
result = model(batch)
latency.observe(time.perf_counter() - start) # 手动计时
req_total.inc(1, tags={"model": "bert", "status": "ok"})
return result
⚠️ 真实 Ray 的 Histogram 只有 .observe(value, tags=...),没有 .timer()。
网上(包括一些官方示例的早期版本)流传的 with hist.timer(): 会 AttributeError。
计时器要自己写 —— 就上面那三行。
本教程配套的 mini-ray 额外提供了 .timer() 上下文管理器
(mini-ray/miniray/util/metrics.py),那是为了演示方便加的教学扩展,
不是 Ray 的 API。用真实 Ray 时不要照抄。
三种类型的语义:
四个必须知道的约束(不知道就会白折腾):
- 指标是「每个进程一份」的。 每个 worker 进程都有自己的指标注册表,
Prometheus 从这个节点的 --metrics-export-port 上把它们汇总抓走。
所以你在 driver 里创建的 Counter,不会自动出现在 worker 里 ——
想统计 worker 内的事件,就得让 worker 里也有一份(通常放在模块顶层,
被 import 时创建)。
- 标签必须先声明。 tag_keys 里没写的键,inc(tags={...}) 时会报错。
这是故意的:Prometheus 的标签基数(cardinality)一旦爆炸,
整个监控系统都会挂 —— 所以 Ray 强制你显式列出。
- 绝不要用高基数标签。 把 user_id、request_id、object_ref 当标签,
会瞬间产生百万级时间序列,把 Prometheus 打挂。
高基数的东西该进日志或 trace,不该进指标。
- 创建时机:在 ray.init() 之后(或模块顶层)创建。
在任务函数内部每次创建会重复注册,行为依版本而异(未确认)。
验证它接上了:
curl -s localhost:8080/metrics | grep app_ # 应该能看到你的指标
为什么值得单独讲:绝大多数人止步于「Ray 有 Dashboard」,
于是业务指标只能靠 print 和日志。而 ray.util.metrics 让你用几行代码
就把业务指标接进和系统指标同一个 Prometheus —— 后面接 Grafana 告警时,
「GPU 利用率」和「我的队列深度」就能画在同一张图上,这是排查
「到底是资源不够还是我的代码慢」的关键。
mini-ray 也实现了这一层 —— miniray/util/metrics.py 提供了
Counter / Gauge / Histogram 三件套,语义与上面一致
(含 tag_keys 校验、桶插值分位数、重复注册报错)。
⚠️ 唯一的超集是它额外提供了 .timer() 上下文管理器 ——
真实 Ray 没有这个 API,别照抄。对照见 §11.10。
11.6 时间线与追踪
⚠️ 先开开关,否则拿到空文件
这是「timeline 用不了」的头号原因:Ray 默认不记录任务级的 profiling 事件。
RAY_PROFILING=1 ray start --head ... # 启动集群时就带上
# 或
RAY_PROFILING=1 python my_script.py
没设这个变量时,Ray 不会抛异常,只会打一条 warning,然后把文件照写出来:
WARNING ... No profiling events found. Ray profiling must be enabled by
setting RAY_PROFILING=1, and make sure RAY_task_events_report_interval_ms=0.
紧接着它仍然执行 open(filename, "w") + json.dump([]) ——
于是你拿到一个内容为 [] 的文件,"导出成功"的假象是这里最容易误判的地方。
📌 本条的目的地是提醒两个前提,不是一个(2.58 源码 state.py 的
chrome_tracing_dump():if not all_events: logger.warning(...),
之后无条件写文件):
① RAY_PROFILING=1;② RAY_task_events_report_interval_ms=0。
只设了 ① 而没设 ②,事件仍可能停在上报队列里没进 GCS,
你同样会得到一个空文件。
⚠️ 本书第四轮曾把这里写成"通常会直接报错",那是错的 ——
报错只来自"Ray 还没 init"(RuntimeError: Ray has not been started yet),
与 profiling 无关。第五轮对着 2.58 源码改回"warning + 空文件",
并补上了第二个环境变量。
所以排查顺序是:先 echo $RAY_PROFILING 确认它是 1,
再确认 RAY_task_events_report_interval_ms=0,然后才怀疑别的 ——
这一步能省掉大量瞎猜。
导出与查看
import ray
ray.timeline("timeline.json") # Chrome Trace Format
# 拖进 chrome://tracing、Perfetto、或 speedscope 看
能直接回答的问题:
- 任务之间的空隙在哪(调度延迟 / 排队);
- 并行度够不够(CPU 是否有空转);
- 某个阶段为什么慢(是计算还是等待)。
三个概念别混
timeline 适合回答「这段为什么慢」(看空闲缝隙),
task events 适合回答「这个任务卡在哪一步」(看状态与耗时),
两者互补。注意 timeline 是采样/缓冲的,超大集群上可能不完整
(未确认:缓冲上限与丢弃策略在版本间有调整)。
11.7 调试工具链
⚠️ local_mode 已经没有用了 —— 这是一个常见过时信息。
替代它的调试路径有两条,别混(这是本书第四轮写得最含糊的一处,
第五轮对着 2.58 的 scripts.py 与官方文档拆开):
🔴 RAY_DEBUG_POST_MORTEM 两套调试器都支持 —— 这条本书改错过两次。
- 第四轮写的是"只在 legacy 模式下有效";
- 第五轮改成"只属于新版,用之前要先去掉 legacy 旗标";
- 第八轮回源码核对后的结论:两个分支都有。
python/ray/util/rpdb.py 的 _post_mortem() 是按 RAY_DEBUG 分支的:
def _post_mortem():
if os.environ.get("RAY_DEBUG", "1") == "1":
return ray.util.ray_debugpy._post_mortem() # 新版(debugpy)
rdb = _connect_ray_pdb(...) # legacy(终端 pdb)
rdb.post_mortem()
而唯一的调用侧判据(python/ray/_raylet.pyx,任务抛未捕获异常时)
只看 RAY_DEBUG_POST_MORTEM,不看 RAY_DEBUG ——
所以两条路都生效。官方 legacy 调试器文档的示例也正是
{"RAY_DEBUG": "legacy", "RAY_DEBUG_POST_MORTEM": "1"} 一起设。
前两轮之所以各对一半,是因为都只查了一个分支就下了全局结论 —— 同一个错误形状,第三次出现(详见第 39 章 §39.5)。
(📌 相关读取点:python/ray/util/rpdb.py:337 的
_is_ray_debugger_post_mortem_enabled(),读的就是 RAY_DEBUG_POST_MORTEM,
默认 "0"(关闭);另一处消费点在 ray/data/exceptions.py。
⚠️ 它不在 worker.py / scripts.py / ray_config_def.h 里 ——
只 grep 那三个文件会落空,这也是本书早先把它误标成"未在源码中定位到"的原因。)
📌 最容易搞错的一点:ray debug 不是新版的入口,它是 legacy 的。
2.58 的 ray debug 在不设 RAY_DEBUG=legacy 时会先打印
"The distributed debugger … is now the default … If you want to keep using
'ray debug' please set RAY_DEBUG=legacy"。
RAY_DEBUG=1 + ray debug 这个组合是把两条路径拼错了 ——
RAY_DEBUG 的合法取值里,只有 legacy 才表示"用旧的"。
为什么不能再用 local_mode? 因为它在 Ray Core 层已经不可用了 ——
⚠️ 但要说准:形参还在签名里(ray/_private/worker.py 的
def init(..., local_mode: bool = False, ...)),只是传 True 会直接抛:
ray.init(local_mode=True)
# RuntimeError: `local_mode` is no longer supported. For interactive debugging
# consider using the Ray distributed debugger.
(源码:ray/_private/worker.py 的 if local_mode: raise RuntimeError(...)。
移除发生在 Ray Core 层,连带 RLlib / Tune 一起 —— PR #60647。)
那怎么获得"单进程调试"的体验? 三条现实路径:
- 把任务体抽成普通函数,先脱离 Ray 单测通过,再接回 @ray.remote ——
这是最有效也最被低估的一条;
- 用 Ray Distributed Debugger(在 remote 函数里写 breakpoint(),
再用 VS Code 扩展 attach),它能在真正的 worker 进程里停下断点;
- 在 mini-ray 里用 local_mode=True —— 见下方"与 mini-ray 的关系",
它是本教程的简化实现,保留了 local_mode。
11.7.1 分布式进度条:ray.experimental.tqdm_ray
它解决什么问题:普通 tqdm 把进度条画在当前进程的终端上。Ray 里同时跑
8 个 worker,每个都 import tqdm 自己画一条 —— 8 条进度条会互相覆盖、
刷新时把彼此刷花,最后你往往只能看到其中一条(还停在 100%)。
tqdm_ray 的做法是:worker 只上报状态,进度条统一由 driver 渲染,
driver 侧按 (ip, pid) 给每个 worker 分配互不重叠的 position
(_BarManager 里的注释把它比作"虚拟内存管理器")。
from ray.experimental.tqdm_ray import tqd
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。