在 EMR Serverless Spark 的 Ray 作业中使用 Checkpoint 断点续跑

0 阅读8分钟

批量推理、多模态预处理等长作业失败后,可以通过 Ray Data checkpoint 保留已提交的处理进度。重新提交同一个作业时,系统在读取输入后过滤已提交 ID,继续计算剩余记录。

这个机制要解决的问题很具体。Ray 自带的任务级重试能兜住偶发的 worker 抖动,但整个作业失败退出之后,重提就是整条管道从头再跑一遍——几小时的计算白做。checkpoint 记的是"哪些记录已经处理完"这件事,不是处理结果;结果仍然完整落在你指定的输出目录里。恢复时框架加载已提交的 ID 集合,在读取输入之后把它们过滤掉,只把剩余记录送进处理逻辑和写出环节。过滤发生在读取之后,所以恢复仍然要把输入完整读一遍,省下来的是后续的计算与写出。

本文适用于 EMR Serverless Spark 产品中的 Ray 作业(Ray 版本 >= err-1.2.0),使用包含 CheckpointConfig 和 OSS 适配的产品运行时。

1. 准备输入与存储路径

适合使用 checkpoint 的管道是:输入可重读,每条记录独立处理,输出保留稳定、非空且全局唯一的 id。例如,读取一批文本或图片,逐条预处理或推理,再写出结果。

这三个条件不是风格建议,各自对应恢复路径上的一环:输入可重读,是因为恢复轮仍然要扫一遍输入才能拿到待处理的记录;记录独立处理,是因为进度是按记录粒度提交的,"某条记录已完成"这种说法无法描述聚合或跨行依赖;ID 稳定且唯一,是因为整个恢复判断只依赖一件事——这个 ID 之前出现过没有。

为每个业务批次分配独立的输出和 checkpoint 路径,并确保执行节点具备 OSS 访问权限。以下用 run-001 表示同一个业务批次,失败重提时保持不变。

oss://<bucket>/snapshots/input-001/   输入快照
oss://<bucket>/jobs/run-001/output/   处理结果
oss://<bucket>/jobs/run-001/checkpoint/   已提交进度

image.png

主要节省后续计算与写出;恢复时仍需读取输入。 把一次失败重提拆开看,各阶段是这样:

执行阶段不启用 checkpoint启用后重提(假设上一轮已完成 70%)
读取输入全量仍为全量
逐条处理 / 推理全部重算只算剩余约 30%
结果写出全部重写只追加剩余结果
人工介入手工记录进度,或整批重跑原命令重提即可

如果处理逻辑本身不便宜,这张表就是 checkpoint 的价值所在;如果瓶颈在读输入,它帮不上忙,这一点后面还会展开。

2. 在处理代码中启用 Checkpoint

下面示例假设输入 Parquet 包含唯一的 id 和字符串类型的 text 两列,用文本长度计算展示完整的数据流。将 process_batch 替换为业务预处理或模型推理时,保留原始 ID。保存为 job.py:

import numpy as np
import ray
from ray.data.checkpoint import CheckpointConfig


def process_batch(batch):
    return {
        "id": batch["id"],
        "text_length": np.asarray(
            [len(text) for text in batch["text"]], dtype=np.int64
        ),
    }


ray.init(address="auto")
ctx = ray.data.DataContext.get_current()
ctx.checkpoint_config = CheckpointConfig(
    id_column="id",
    checkpoint_path="oss://<bucket>/jobs/run-001/checkpoint",
    delete_checkpoint_on_success=False,
)

ds = ray.data.read_parquet("oss://<bucket>/snapshots/input-001")
ds = ds.map_batches(process_batch, batch_format="numpy", batch_size=64)
ds.write_parquet("oss://<bucket>/jobs/run-001/output", mode="append")

这段脚本的四个动作值得逐个交代:ray.init(address=“auto”) 让脚本接入运行时已经拉起的 Ray 集群;CheckpointConfig 挂在全局的 DataContext 上,之后不需要在每个算子处重复声明;map_batches 里就是你的预处理或推理逻辑,示例用文本长度演示;最后 write_parquet 才真正触发执行——Ray Data 是惰性的,前面两步只是在拼 DAG。

三个配置决定了恢复方式:

配置建议
id_column使用输入已有的唯一 ID,处理过程中保留其值与类型。
checkpoint_path同一业务批次及其失败重提复用同一路径,不与其他批次共享。
delete_checkpoint_on_success=False成功后先保留进度,待结果确认和后续业务完成后再按保留策略清理。

这里显式使用 mode="append",让恢复轮继续追加剩余结果。保留 checkpoint 时,不要清空或覆盖原输出。

CheckpointConfig 还带有 override_filesystem、write_num_threads 等可选参数,前者用于传入自定义的 OSS 文件系统和凭证,后者控制写出进度记录的线程数,常规场景保持默认即可。另外,checkpoint 相关接口在 Ray 中仍标记为 beta,跨运行时大版本升级时,建议先在测试批次上回归验证一次。

3. 正常提交,失败后原样重提

按产品支持的方式提交 job.py。如果使用 Ray Jobs CLI,先按控制台调用信息配置网关及身份认证,再从脚本目录执行:

# ray job submit 前必须设置 ray address 和 headers
export RAY_ADDRESS='https://emr-spark-ray-gateway-cn-hangzhou.aliyuncs.com'
export RAY_JOB_HEADERS='{"ray-token": "<RAY_TOKEN>"}'

ray job submit --working-dir . -- python job.py

作业失败后,先处理导致失败的问题,并确认旧作业及写任务已经结束。保持输入快照、处理逻辑、模型版本、输出路径与 checkpoint 路径不变,重新执行同一条提交命令即可。读取进度和过滤已提交 ID 由框架完成,无需手工记录已完成行或增加业务侧恢复阶段。

若修复会改变输出语义,例如更换模型或修改处理逻辑,应作为新批次运行,使用新的输出与 checkpoint 路径,避免混用不同版本的结果。

4. 使用时注意这几件事

  • ID 稳定且唯一。 不在每次运行时随机生成 ID,不在处理中丢弃或改写 ID。聚合、跨行依赖及一对多输出需要单独设计恢复语义。

  • 路径按批次隔离。 同批次失败重提复用路径,新批次使用新路径;不要让多个作业同时写同一组输出和 checkpoint。

  • 成功后管理进度。 本例保留 checkpoint,以便完成后重提仍能过滤已提交 ID。清理后再向原输出目录追加,可能产生重复结果。API 默认自动清理对应 Dataset 执行成功,不代表整个业务流程完成。

  • 按实际规模评估资源。 恢复会加载已提交 ID;大规模 ID 集合需要预留内存。UDF 调用外部服务或写入数据库时,业务仍需自行保证幂等。

常见问题

1、checkpoint 目录里存的是什么?

已提交记录的 ID 和必要的进度元数据,不是处理结果——结果完整落在输出目录。所以它的空间占用主要随已完成记录数增长,通常远小于输入和输出数据本身。

2、为什么恢复时还要把输入读一遍?

因为过滤发生在读取之后:框架得先拿到记录,才知道它提交过没有。由此有两点推论:checkpoint 省不掉读取阶段的开销,如果瓶颈恰好在读输入(比如 OSS 上是海量小文件),得从输入的组织方式入手优化;"输入可重读"是前提而不是建议,输入被删除或覆盖之后,恢复就无从谈起。

3、重提之后结果会重复吗?

正常路径不会,已提交的记录不会再被处理——前提是 checkpoint 还在、输出目录没被清空、写入保持 append。反过来,手工清掉 checkpoint 又往原输出目录追加,重复几乎是必然的。另外,Ray Data 不保证输出顺序,checkpoint 也不改变这一点,需要有序结果的话把排序放到下游。

4、失败瞬间正在处理的那批记录会怎样?

会被重新处理。进度并非逐条实时落盘,最后一段尚未提交的记录在重提后会再算一次。这正是外部副作用必须幂等的原因:写数据库用 upsert、调用模型服务时设计好去重或重试策略,都能让这次重算变得无害。

5、只修了 bug,需要新开批次吗?

判断标准是输出语义有没有变化。让原本会崩溃的记录能正常算出结果、调整并发、加日志,这类改动可以沿用原路径恢复,已提交的部分不受影响。换了模型、改了预处理口径、调整了字段含义,则应新开批次——否则同一个输出目录里会混着两个版本的结果,而且没有任何字段能把它们区分开。

6、什么情况下不该用 checkpoint?

跑几分钟就结束的短作业,恢复的开销未必比直接重跑小。含跨行依赖的处理,比如按某个键聚合、join、一条输入产出多条输出,记录级的"已完成"无法表达正确的恢复语义,需要另行设计。如果处理本身很便宜、读取特别贵,checkpoint 能省下的那部分本来就很有限。