- Published on
Flink Checkpoint HDFS 自动清理实践:从误删风险到安全灰度上线
- Authors

- Name
- Charles Chen
这是之前 《Flink Checkpoint 生命周期管理》(ckpt lifecycle)博客的优化篇。 上一篇介绍了历史运行目录为什么会累积,以及如何通过 Python 和 DolphinScheduler 管理它们;这一篇继续记录方案落地时遇到的目录误判、HA 访问、调度启动和元数据扫描问题。
在长时间运行的 Flink 集群中,作业取消后重新发布、创建新的运行实例,可能留下多个历史 Checkpoint 目录。它们既占用存储,也增加 NameNode 的元数据负担。并非每次故障重启都会产生新的运行目录,实际情况取决于部署方式和 Checkpoint 路径配置。
最初的需求很简单:每个逻辑 Job 保留最新的一份运行目录,其余历史目录超过指定时间后清理。但真正落地后,一个目录层级判断错误,就可能把整个 Job 的 Checkpoint 根目录当成历史批次删除。
本文记录提供的实践过程和增强设计,不附新版 Python 完整实现。上一篇附录中的旧脚本不支持这里新增的 --exclude-job,也未实现本文全部安全检查;不能直接把新参数套到旧脚本上。 --list-contents 是后文提出的进一步优化接口。上线前应核对实际部署版本及其行为。
一、清理目标与适用前提
假设目录结构固定为:
hdfs:///flink/checkpoints/dws/
├── flink-job-a/
│ ├── run-001/
│ ├── run-002/
│ └── run-003/
└── flink-job-b/
├── run-001/
└── run-002/
其中 ROOT → JOB → GENERATION 是本方案的部署约定,不是所有 Flink 集群天然遵循的目录规范。目标是在每个 Job 下保留按修改时间排序的最新目录:
flink-job-a/
├── run-003/ KEEP
├── run-002/ 达到年龄阈值后,成为删除候选
└── run-001/ 达到年龄阈值后,成为删除候选
例如设置 --min-age-hours 144,表示候选目录的修改时间距离扫描时刻至少 144 小时,也就是 6 天。
这里必须区分三个概念:目录修改时间、运行实例启动时间、最近一次成功 Checkpoint 时间并不等价。 深层文件发生变化,不意味着祖先目录的修改时间一定随之更新。因此年龄阈值只能作为候选过滤条件,不能证明目录已经停止使用。
真正删除前,还必须确认旧实例已经退出,目录不被当前作业、恢复任务或发布回滚流程引用。最新目录也不一定包含可恢复的成功 Checkpoint,不能仅因为出现了新目录,就删除唯一可用的旧恢复点。Flink 增量 Checkpoint 可能共享状态文件,清理前必须考虑快照所有权和恢复依赖。Flink 官方说明
二、基本逻辑:默认只生成计划
脚本按如下流程处理:
扫描 ROOT
↓
找到一级 JOB 目录
↓
扫描 JOB 下的运行目录
↓
按修改时间排序,最新目录 KEEP
↓
历史目录检查年龄、保护规则和恢复依赖
↓
生成删除候选
核心规则可以概括为:
ordered[0] # KEEP
ordered[1:] # 历史目录,仍需经过安全检查
修改时间相同不能证明哪个实例更新。如果排序结果影响恢复点保留,应依赖运行记录消除歧义;无法确认时保留这些目录或停止执行。
默认只输出:
WOULD-DELETE ...
只有显式传入 --execute,才调用:
hdfs dfs -rm -r "<generation-path>"
删除命令不使用 -skipTrash。但“不跳过 Trash”不等于“必然可恢复”:必须确认实际执行用户和集群的 Trash 配置有效,验证恢复操作,并了解保留期限。进入 Trash 的数据通常仍占用空间,不会立即释放容量。Hadoop FileSystem Shell
三、最大的风险:Checkpoint Root 存在嵌套
实际目录可能是:
hdfs:///flink/checkpoints/dws/
├── flink-job-a/
├── flink-job-b/
└── dgraph/
├── flink-job-c/
└── flink-job-d/
dgraph/ 本身也是一个 Checkpoint Root,需要单独治理。如果父任务直接扫描 dws/,只理解两层结构的脚本会把 dgraph 当作普通 Job,再把 flink-job-c 和 flink-job-d 当作两个 generation。
于是可能生成这样的错误计划:
KEEP dws/dgraph/flink-job-d
WOULD-DELETE dws/dgraph/flink-job-c
一旦开启执行,删除的就是整个 Job 的 Checkpoint 根目录,而不是一个旧运行批次。
四、用 --exclude-job 隔离父子 Root
父 Root 必须明确排除嵌套 Root:
/bin/python3 flink_checkpoint_lifecycle.py \
--root hdfs:///flink/checkpoints/dws/ \
--exclude-job dgraph
子 Root 单独处理:
/bin/python3 flink_checkpoint_lifecycle.py \
--root hdfs:///flink/checkpoints/dws/dgraph/
这样职责就清楚了:父任务只处理 flink-job-a、flink-job-b,不进入 dgraph;子任务专门处理 dgraph 下的 Job。
多个排除项可以重复传入:
--exclude-job testone \
--exclude-job testone2 \
--exclude-job dgraph
排除项按一级目录名精确匹配。--exclude-job test 不会自动排除 testone 或 testtwo,避免模糊匹配扩大范围。
五、排除目录写错时必须失败
对于删除任务,采用 Fail Closed:关键安全信息不完整或不一致时,停止执行。
例如把 dgraph 写成 dgrahp,不能静默忽略,否则操作人员会误以为保护已经生效。安全版应报错并返回非零状态码:
ERROR: excluded job directories were not found: ['dgrahp']
校验必须针对本次实际扫描到的目录,且在任何删除之前完成。保护路径拼错、选定 Job 不存在,也应遵循相同规则。
六、自动检测疑似嵌套 Root,先扫描再删除
仅依赖人工配置仍可能遗漏。正常运行目录可能呈现如下结构:
ROOT
└── JOB
└── RUN
├── chk-123
├── shared
└── taskowned
如果扫描看到的是:
ROOT
└── dgraph
└── JOB
└── RUN
└── chk-123
说明 dgraph 很可能是另一个 Root。检测到这种异常后,应停止整个 Root 的扫描,不执行删除。
不过,目录特征只能用于发现疑点,不能作为安全证明。空目录、尚未完成 Checkpoint 的实例,以及不同版本或存储布局,都可能缺少这些特征。明确的 Root 清单和层级约定仍是基础;探测应限制深度和调用规模,无法确认的结构停止处理。
整个过程分成四个阶段:
Inventory:扫描全部待处理目录
↓
Validate:校验排除项、保护项、层级和目录边界
↓
Plan:生成完整删除计划
↓
Execute:全部通过后才执行 rm
不能边扫描边删除,等扫描到 dgraph 才发现前面的数据已经删除。这里的“全部”首先指单次 Python 调用处理的 Root;后文 Shell 逐 Root 调用,不提供跨 Root 的原子性。若要求任何 Root 校验失败时所有 Root 都不删除,需要先完成所有 Root 的全局预检。
同样,执行阶段也不是事务:第一个删除成功、第二个失败时,前者不会自动回滚。日志必须区分计划删除、实际成功和失败项。
七、执行前再检查目录边界与并发变化
被删除目录必须是当前 Job 的直接子目录。例如:
JOB: hdfs:///flink/checkpoints/dws/job-a
DELETE: hdfs:///flink/checkpoints/dws/job-a/run-001
下面的路径则必须拒绝:
DELETE: hdfs:///flink/checkpoints/dws/other-job
概念上是:
dirname(delete_path) == job_path
实现时不能只做字符串前缀比较。需要解析和规范化 URI,确认 scheme、nameservice 和路径边界一致,拒绝根目录、父目录、通配符以及含义不明确的路径,再以独立参数传给 HDFS 命令。
还要考虑 Inventory 与 Execute 之间的变化:扫描之后,作业可能重新启动、恢复任务可能引用旧目录。正式执行应再次确认候选状态,并通过调度互斥和发布流程协调避免竞争。只加本地锁不能防止另一台 Worker 启动相同任务;重新检查也不能消除与并发写入之间的全部时间窗口。无法协调时,应保持 Dry Run。
八、DolphinScheduler:解释器与脚本路径都要明确
部署到调度环境后,曾遇到:
/bin/python3: can't find '__main__' module in ''
这个错误提示应先排查 Python 实际接收到的入口参数:可能是空值、目录路径或资源定位错误,不能仅凭这一行断定唯一原因,也不能认为改成绝对解释器路径就能修复。
在明确使用 /bin/python3 的环境中,可以固定解释器:
PYTHON_BIN="/bin/python3"
"$PYTHON_BIN" /path/flink_checkpoint_lifecycle.py
同时检查解释器可执行、脚本是可读文件、资源已经绑定并下载到 Worker。不要只依赖 #!/usr/bin/env python3 来保证调度环境的版本一致性。
如果通过工作目录查找脚本,必须处理“找不到”和“同名文件不止一个”两种情况;直接 find ... -print -quit 会隐藏重复文件,可能选到旧版本。
九、HDFS HA:不要写死 NameNode IP
另一次失败日志是:
ERROR: command failed (hdfs dfs -ls -t hdfs://172.30.50.12:8020/...)
Operation category READ is not supported in state standby
这说明请求落到了不接受该操作的 Standby NameNode。把地址手工换成当前 Active 只能临时解决,下次 HA 切换后还会失败。
应在实际执行任务的 Worker 环境中检查:
hdfs getconf -confKey fs.defaultFS
如果返回 hdfs://nameservice1,使用逻辑 nameservice URI:
hdfs://nameservice1/flink/checkpoints/dws/
也可以在默认 HDFS 配置正确时使用:
hdfs:///flink/checkpoints/dws/
逻辑 URI 需要客户端加载对应的 NameNode 地址和 failover provider 配置,不能只改 URI 字符串。应在 DolphinScheduler 的执行用户、配置目录和认证环境下验证访问。Hadoop HDFS HA 配置说明
十、HDFS 输出异常时立即停止
运行过程中还遇到过:
ERROR: unexpected 'hdfs dfs -ls' output: 'testtwo1'
脚本预期的数据行类似:
drwxr-xr-x - hdfs supergroup 0 2026-09-07 18:14 hdfs:///xxx/testtwo1
但拿到的却只有 testtwo1。此时不能简单 continue:程序已经无法可靠理解目录清单,就不应继续推导哪个目录可以删除。
解析器需要识别正常的列表头,分离标准输出与错误输出,检查命令退出状态;对无法解释的数据行立即失败。建议记录完整上下文:
ERROR: unexpected HDFS ls output
parent=hdfs:///xxx/flinkjob1
command=hdfs dfs -ls -t hdfs:///xxx/flinkjob1
line='testtwo1'
本方案约定此类错误返回 exit code 2。这属于脚本的错误处理约定,并不是所有 HDFS 错误天然使用同一退出码。
十一、多个 Root 的统一调度示例
下面保留此次实践中的路径清单,默认全部 Dry Run。使用前需要确认每条路径确实遵循 ROOT/JOB/GENERATION,不能因为名字包含 checkpoints 就套用两层规则。尤其是看起来已经细分到某个业务或 Job 的路径,要核对是否多传或少传了一层。
脚本通过零分隔读取查找结果,发现同名文件不止一个时失败,避免随机选择旧版本:
#!/bin/bash
set -euo pipefail
PYTHON_BIN="/bin/python3"
SLEEP_SECONDS=60
if [ ! -x "$PYTHON_BIN" ]; then
echo "ERROR: Python interpreter is not executable: $PYTHON_BIN" >&2
exit 2
fi
RESOURCE_LIST="$(mktemp)"
trap 'rm -f "$RESOURCE_LIST"' EXIT
find "$PWD" -type f -name 'flink_checkpoint_lifecycle.py' -print0 > "$RESOURCE_LIST"
SCRIPT_PATH=""
SCRIPT_COUNT=0
while IFS= read -r -d '' candidate; do
SCRIPT_PATH="$candidate"
SCRIPT_COUNT=$((SCRIPT_COUNT + 1))
done < "$RESOURCE_LIST"
if [ "$SCRIPT_COUNT" -ne 1 ] || [ ! -r "$SCRIPT_PATH" ]; then
echo "ERROR: expected exactly one readable cleanup script; found $SCRIPT_COUNT" >&2
exit 2
fi
"$PYTHON_BIN" --version
echo "SCRIPT_PATH: $SCRIPT_PATH"
run_cleanup() {
local ckpt_root="$1"
shift
echo "CKPT_ROOT: $ckpt_root"
echo "START_TIME: $(date '+%F %T')"
"$PYTHON_BIN" "$SCRIPT_PATH" \
--root "$ckpt_root" \
--min-age-hours 144 \
--max-list-lines 1000 \
"$@"
echo "END_TIME: $(date '+%F %T')"
sleep "$SLEEP_SECONDS"
}
run_cleanup "hdfs:///flink/checkpoints/dws/dgraph/"
run_cleanup "hdfs:///flink/checkpoints/dim/"
run_cleanup "hdfs:///flink/checkpoints/ods/"
run_cleanup "hdfs:///flink/checkpoints/dwd/"
run_cleanup "hdfs:///flink/checkpoints/dws/" --exclude-job dgraph
run_cleanup "hdfs:///dolphinscheduler/hdfs/resources/flink-checkpoints/dws/"
run_cleanup "hdfs:///flink-checkpoints/data-bill-early-warning-result/"
run_cleanup "hdfs:///flink/dinky01/checkpoints/receive-data/"
run_cleanup "hdfs:///flink/checkpoints/receive-data/ods/"
run_cleanup "hdfs:///flink/checkpoints/receiver/"
echo "ALL CKPT ROOTS FINISHED"
echo "TIME: $(date '+%F %T')"
这份清单里的 DolphinScheduler 资源中心路径是历史治理对象,不是推荐的新目录布局。新 Checkpoint 应与调度资源目录分离;原有资源中心条目还要遵循对应版本的资源管理流程,具体背景见上一篇。
观察期间不添加 --execute。只有安全条件和灰度验收通过后,才在选定范围的 Python 调用中显式增加该参数。这个 Shell 的串行调用和失败退出只覆盖当前进程,仍需在调度器中防止周期重叠和多 Worker 重复执行。
十二、控制 NameNode 压力
清理任务不能为了释放空间,反过来制造集中的元数据压力。这次采用三种调度策略:
- Root 串行执行。 不同时启动 dws、ods、dim、dwd 等清理任务。
- Root 之间增加间隔。 示例为 60 秒,必要时可调整为 120 秒或 300 秒。
- 在业务低峰运行。 按存储增长速度选择每天或每周执行,无需默认每小时扫描。
这些间隔是调度起点,不是经过容量验证的通用安全值。应观察 NameNode RPC 延迟、队列和 GC,以及同期 Checkpoint 时长和失败率,再决定频率与批次大小。
Root 间休眠只能缓解 Root 之间的连续压力,不能限制一个巨大 Root 内部的扫描或删除突发。必要时还要限制每批候选数量、设置操作间隔和超时;大目录删除本身也有成本。
十三、--max-list-lines 只限制日志量
上一篇把这个参数描述为“限制单次 HDFS 列表输出规模”,容易让人误以为它限制了扫描规模。结合旧脚本的实现,更准确的说法是:它只限制候选目录的递归内容打印行数。
如果实现是:
lines = client.list_recursive(target.path)
shown = lines[:1000]
而 list_recursive() 内部调用 hdfs dfs -ls -R,即使目录有 500000 个文件,HDFS 仍需要完成整个遍历。最终只打印 1000 行,不会把扫描规模降到 1000。
因此,它减少的是 DolphinScheduler 日志量,不是 NameNode 元数据访问量;如果完整输出先进入内存,客户端内存开销也不会因打印截断而消失。
十四、默认不要递归展示候选内容
删除旧实例前,再执行一次:
hdfs dfs -ls -R "<generation-path>"
可能遍历成千上万个文件。常规审计通常只需要记录保留对象、候选路径、判断依据和实际执行结果:
JOB hdfs:///flink/checkpoints/dws/job-a
KEEP hdfs:///flink/checkpoints/dws/job-a/run-003
WOULD-DELETE hdfs:///flink/checkpoints/dws/job-a/run-001
WOULD-DELETE hdfs:///flink/checkpoints/dws/job-a/run-002
建议进一步增加 --list-contents:默认不递归展示内容,只有排查时才显式开启。该改动必须落在 Python 实现中,单纯不传参数无法改变上一篇旧脚本始终递归列举候选的行为。
这能减少一次不必要的完整遍历。用于安全校验的有限深度探测、必要清单读取和删除操作仍然存在,不能把“不打印内容”误解成“无需访问元数据”。
十五、从单目录 Dry Run 到受控删除
此次上线采用分阶段灰度:
| 阶段 | 范围 | 模式 | 重点检查 |
|---|---|---|---|
| 第一阶段 | 单个已确认结构的 Root | Dry Run | KEEP、候选、年龄判断、排除项和层级 |
| 第二阶段 | 扩大 Root 范围 | Dry Run | 父子隔离、HA 配置、任务耗时与元数据压力 |
| 第三阶段 | 先限定 Root 和删除批次,再逐步扩大 | 显式 --execute | 实际删除结果、恢复验证、失败退出与告警 |
第一阶段的任务可以命名为“CKPT 清理脚本单目录试运行观察”,连续观察数个周期。验收不能只看任务退出码为零,还要检查保留目录是否满足恢复要求、候选是否已经停止使用,以及排除和保护规则是否覆盖真实路径。
正式执行继续保持串行、Root 间休眠、低峰运行、最小年龄保护、有效 Trash、嵌套目录检测和严格校验。发生异常时暂停后续删除并审查计划,不盲目重试。
这里描述的是灰度流程和上线条件,未提供集群实测指标或恢复演练记录,因此不能据此声称某个扫描频率已经通过生产容量验证。
十六、最终安全原则
经过这次改造,Checkpoint 自动清理至少需要守住以下边界:
- 默认 Dry Run,真正删除必须显式启用。
- 最新 generation 始终保留,但不把“最新”当作“可恢复”的证明。
- 最小年龄只过滤候选,活跃实例、恢复依赖和回滚保护要单独确认。
- 父子 Root 显式隔离,排除项和保护项未匹配时失败。
- 异常目录层级、无法解析的输出和不完整清单都不能进入删除阶段。
- 先完成清单与安全校验,再生成计划和执行;明确单 Root 与全局范围的区别。
- 执行前复核 URI 边界和候选状态,并协调调度、发布与恢复操作。
- HA 使用正确配置的 nameservice,执行环境固定解释器并验证脚本资源。
- 多 Root 串行,避免任务重叠,减少无意义的递归遍历。
- 保留可验证的恢复能力和执行审计,承认部分删除失败无法自动回滚。
最初只是想“每个 Job 留一份,清理历史目录”,最终需要处理的却是目录所有权、嵌套 Root、恢复依赖、HA、调度环境和元数据压力。
生产环境中的删除脚本,最重要的能力是知道什么时候必须停止。 真正可靠的自动化,需要把人工操作中的谨慎判断转化为可验证的约束,而不只是把 hdfs dfs -rm -r 放进定时任务。