凌晨三点被电话叫醒,ZooKeeper集群磁盘告警,登录上去一查,df -h 显示 /data 已经 100%。这种场景只要是跑过 ZooKeeper 集群的运维,多少都经历过。ZooKeeper 本身是个轻量级协调服务,但它的日志机制却极容易变成磁盘杀手,而且不少人在清理时因为没搞懂底层原理,直接 rm 删文件,结果不仅没释放空间,甚至把集群搞到数据错乱。
这篇东西我憋了很久想写。网上聊 ZooKeeper 日志清理的文章不少,但大多停留在"改配置开 autopurge"这一步,遇到 autopurge 失效、日志文件删不掉、误删了当前活跃事务日志这类场景就抓瞎了。我打算从原理层到操作层,把这套东西完整捋一遍,顺便把我在生产环境里踩过的坑和处理过的故障过程拿出来复盘。
1. 先搞懂ZooKeeper日志为何成为磁盘杀手
1.1 两类日志的本质区别
很多人一说ZooKeeper日志就笼统地理解为"日志"两个字,实际上ZooKeeper目录下会出现两类性质完全不同的日志,不区分清楚就开始乱删,后患无穷。
第一类是运行日志,也就是程序自己打的日志。默认情况下输出到 zookeeper.out(用bin/zkServer.sh启动时),或者通过 log4j 输出到指定目录。这类日志记录的是连接建立、会话超时、选主过程、异常堆栈等等,对排查问题有用,删了不会影响数据一致性,顶多丢点排查线索。
第二类是事务日志(transaction log),这才是磁盘空间的真正大头。ZooKeeper 每次写操作(create、setData、delete 等)都会先把操作记录追加到事务日志里,然后才更新内存数据。这个日志文件命名很规律,是以写入第一个事务的 zxid(ZooKeeper Transaction ID,事务ID)十六进制命名的,比如 log.1c00000001。文件会持续写入,达到一定大小(默认 64MB)就滚动生成下一个文件。
同时,ZooKeeper 还会周期性做快照(snapshot),把内存中的全量数据写到 snapshot.xxxx 文件里。快照记录的是某个时刻的完整数据状态,而事务日志记录的是从快照点之后所有的增量变更。
这里有个关键逻辑:恢复数据时,ZooKeeper 会加载最新的快照,然后重放此后的所有事务日志。 所以,理论上只要某个事务日志已经被包含在更新的快照里,它就没有保留价值了。清理事务日志的核心就是:只保留从最新几个快照往后的事务日志,更早的全部可以删。
1.2 为什么日志增长速度远超预期
遇到过很多朋友在论坛问:我的 ZooKeeper 才存了几百个节点,为什么事务日志一天能写几个G?
原因在于写入频率,而不是数据量大小。ZooKeeper 的事务日志记录的是"每一次写操作",而不是"每个节点最终状态"。假设你的配置中心每秒推送一次配置变更,或者注册中心里服务实例频繁上下线,哪怕每次变更的 value 只有几十字节,事务日志也是按操作次数累加的。再加上 ZooKeeper 客户端如果开启了持久性监听或者会话频繁过期重连,产生的写事务数量会相当可观。
另外还有一个被忽略的点:ZooKeeper 默认配置下,事务日志和快照存储在同一个目录(dataDir)。如果你没有单独配置 dataLogDir,那么事务日志会跟快照文件、myid 文件混在一起。这样做的坏处不只是清理时容易误删,还有一个严重的性能问题:事务日志需要顺序写盘,如果磁盘 IO 被快照写入的随机IO干扰,整个集群的写延迟都会上升。
1.3 清理日志前必须知道的一个致命误区
很多人的第一反应是:日志文件增长快,那我写个 crontab 定期把旧日志文件删掉不就行了?
我特别想强调:直接删除事务日志文件,是 ZooKeeper 运维里最危险的操作之一。 原因有三:
第一,正在被 ZooKeeper 进程持有的文件句柄,你用 rm 删掉后磁盘空间并不会立刻释放,需要等进程重新打开文件或者进程重启后才释放。这就是经典的"删了文件但 df 还是满的"现象。
第二,如果你删掉了当前正在写入的事务日志文件,ZooKeeper 会因为找不到日志文件而无法继续同步,严重时直接触发数据恢复失败。
第三,也是最隐蔽的:事务日志需要"按序保留"。假设你有快照 A(对应事务日志到 1000)和快照 B(对应事务日志到 2000),如果你不理解这个对应关系,只是根据文件名时间戳随手删,删掉了 1500 到 2000 之间的日志,而最新的快照恰好是 A,那么数据恢复时只能恢复到 1000 的状态,之后所有变更全部丢失。
所以在执行任何清理动作之前,先把"快照对应哪个事务位置"和"日志文件从哪个事务开始"这两个概念刻在脑子里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事务日志的清理:从自动机制到手工兜底
2.1 靠配置实现自动清理,但需要知道它在什么条件下才生效
ZooKeeper 从 3.4.0 版本开始内置了自动清理机制,配置项是 autopurge.snapRetainCount 和 autopurge.purgeInterval。
ini复制# 保留最近 3 个快照及对应的事务日志
autopurge.snapRetainCount=3
# 每 1 小时执行一次清理
autopurge.purgeInterval=1
snapRetainCount 表示保留多少个快照,purgeInterval 表示清理周期,单位是小时。很多人以为配了这两个参数就高枕无忧了,其实这里有三个容易被忽略的细节:
一是 purgeInterval 默认是 0,表示关闭自动清理。很多发行版自带的 cfg 文件里这两个参数默认都是注释掉的,等于没开。
二是自动清理机制是在快照写入完成后再触发的。也就是说,它清理的依据是"当前已存在的有效快照"。如果 ZooKeeper 长时间没有触发快照(比如写入量非常低,没有达到 snapCount 阈值),那么即使旧的事务日志早就不需要了,也可能不会被清理。snapCount 默认是 100000,即每 100000 次事务做一次快照。
三是自动清理保留的快照数量少,不代表事务日志文件就只保留少量。它保留的是每个快照点之后产生的增量事务日志,如果业务写入频繁,snapRetainCount=3 也可能对应几十个事务日志文件。
我一个建议是:线上环境建议把 snapRetainCount 设置为 3 到 5 之间。设置太大会让磁盘占用上升,设置太小(比如 1)会增大快照恢复失败时丢失数据的风险,因为如果最新快照恰好损坏,你连次新的快照都没得回退。
2.2 自动清理失效时,用官方脚本手工触发
在一些老集群上,或者因为种种原因没开启自动清理的集群,磁盘满了需要立刻处理。这个时候最稳妥的方式是用 ZooKeeper 自带的清理脚本 zkCleanup.sh。这个脚本位于 bin/ 目录下,原理是遍历 dataDir(如果配置了 dataLogDir,还会遍历 dataLogDir),找到所有的快照文件,按它们的 zxid 排序,保留最近 N 个快照,其余的快照和对应事务日志都删掉。
用法很简单:
bash复制# 保留最近 5 个快照,并清除更早的快照与事务日志
bin/zkCleanup.sh /data/zookeeper/data 5 -n /data/zookeeper/logs
参数解释:
- 第一个参数是
dataDir路径 - 第二个参数是保留的快照数量
-n后面指定dataLogDir目录,如果不传,脚本默认只在dataDir下操作
执行前建议先用 -n 试跑,看脚本输出会删除哪些文件,确认无误后再正式执行。这个脚本在 ZooKeeper 3.4.6 及之后的版本里都有。
相比直接 rm 旧文件,zkCleanup.sh 最大的价值是它会根据 zxid 来匹配事务日志和快照的对应关系,避免误删当前还需要用于恢复的日志。但我实测发现,如果集群的数据目录非常庞大(几万个文件),这个脚本遍历文件的过程会比较慢,而且没办法直接释放正在被进程持有的句柄,所以有时候执行完 df 看空间没变化,需要结合后面的方案。
2.3 手工清理时的安全操作步骤
如果 zkCleanup.sh 因为版本问题没法用,或者你需要在不停机的情况下快速腾出空间,可以手工操作。这里我给出一个相对安全的顺序:
先确认当前最新的快照位置:
bash复制# 在 dataDir 下查看最新的快照文件
ls -lrt /data/zookeeper/data/snapshot.* | tail -5
每个快照文件名后面的数字是 zxid。比如 snapshot.1c000000a3,代表这个快照包含了 zxid 为 1c000000a3 及之前的所有变更。那么,在 dataLogDir 目录下,任何事务日志文件名的 zxid 小于等于这个快照 zxid 的,都是可以被安全删除的对象。
安全删除的方法是保留最新的那个事务日志文件(因为可能正在写入),然后删除这个文件之前的所有更早的 log.*:
bash复制# 先进入 dataLogDir
cd /data/zookeeper/logs
# 找到最新的 log 文件(这个是当前正在写入的,不能删)
ls -lt log.* | head -3
# 删除除了最新 2 个之外的所有 log 文件
ls -t log.* | tail -n +3 | xargs rm -f
注意这里的"最新"指的是 zxid 数字最大的,不要按文件修改时间去判断。原理层面,事务日志文件名就是第一个事务的 zxid,zxid 越大代表事务越新,当前正在写入的肯定是 zxid 最大的那个文件。
删除之前最好执行一下:
bash复制# 查看是否有进程持有已删除文件的句柄
lsof | grep deleted
如果发现有被删除但仍被持有的文件,可以这样处理:
bash复制# 找到 ZooKeeper 进程 PID
jps -l | grep QuorumPeerMain
对于进程已打开但逻辑上已被删除的事务日志文件,其实不需要太紧张,因为那通常是一个早已滚动完毕的旧文件,ZooKeeper 不会再写它。等下次进程重启或者日志滚动后,句柄会自动释放,空间会真正归还系统。但如果被持有的是一个当前正在写入的日志文件,那你就需要高度重视,因为此时 ZooKeeper 无法正常持久化,可能很快就会出问题。
2.4 不要在 ZooKeeper 进程运行时做危险操作
这里必须补一个我亲身踩过的坑。
有一次我在清理时,发现 dataDir 和 dataLogDir 没有分开配置,所有文件都堆在一个目录下。当时我按照文件名 mtime 把三天前的 log.* 文件重命名成 .bak,准备等确认没问题后再删掉。结果 ZooKeeper 进程发现日志目录里多了很多不认识的文件,并不理会,但问题出在我重命名的文件里恰好有一个是当前正在写入的日志文件。
那之后 ZooKeeper 直接重新创建了一个新的事务日志文件,并把配置变更等都写进了新文件。表面上看没出什么事,但我在这个过程中破坏了事务日志的连续性,后来在对集群做一次小版本升级时,数据恢复阶段一直报事务日志缺失,花了很长时间才修复到一致状态。
所以我的铁律是:在 ZooKeeper 进程运行时,绝对不 rename 或删除正在使用的事务日志文件。 如果确有必要,应该先通过滚动方式或者直接重启集群让进程重新打开文件句柄,再进行文件清理。如果是多节点集群,可以逐台节点滚动重启,不要同时重启所有节点,否则会触发长时间选主,影响上层业务。
3. 运行日志与zookeeper.out的处置方案
3.1 log4j 配置与滚动策略
说完事务日志,再来说运行日志。前面提到,运行日志不会影响数据一致性,但它的增长量同样不容小觑。尤其是在流量高峰或者网络抖动期间,ZooKeeper 会疯狂打印连接断开、重连、会话超时等日志,zookeeper.out 一天写几个 GB 也是有可能的。
ZooKeeper 的日志打印由 log4j 控制,配置文件在 conf/log4j.properties。默认情况下用的是 DailyRollingFileAppender,也就是按天滚动,但默认没有做文件大小限制。如果你的业务量很大,一天产生的日志量可能直接把磁盘打满。
推荐的做法是改成同时按大小和数量滚动,修改 conf/log4j.properties:
properties复制log4j.rootLogger=INFO, CONSOLE, ROLLINGFILE
log4j.appender.ROLLINGFILE=org.apache.log4j.RollingFileAppender
log4j.appender.ROLLINGFILE.File=/data/logs/zookeeper/zookeeper.log
log4j.appender.ROLLINGFILE.MaxFileSize=256MB
log4j.appender.ROLLINGFILE.MaxBackupIndex=10
log4j.appender.ROLLINGFILE.layout=org.apache.log4j.PatternLayout
log4j.appender.ROLLINGFILE.layout.ConversionPattern=%d{ISO8601} [%t] %-5p %c{1}:%L - %m%n
注意,修改 log4j 配置后需要重启 ZooKeeper 才能生效。等待低峰期逐台滚动重启即可。
MaxFileSize 和 MaxBackupIndex 的搭配要看你的日志保留需求。256MB × 10 个文件也就是 2.5GB 左右,对于运行日志来说足够保留几天的排障信息。如果公司有采集系统,可以适当减小保留份数。
ZooKeeper 3.5 及以上版本用的是 Logback 体系,配置文件是 conf/logback.xml,里面的滚动策略配置类似,但语法不同,用 Logback 的 RollingFileAppender 和 SizeAndTimeBasedRollingPolicy 即可。升级新版本的朋友注意别找错文件。
3.2 zookeeper.out 与系统日志的处理
zookeeper.out 是 zkServer.sh 启动脚本里通过 nohup 重定向的输出文件,记录的是 JVM 的标准输出和标准错误。这部分日志不受 log4j 配置控制,不能通过改配置文件来限制大小,只能通过外部轮转工具处理。
Linux 下我用的是 logrotate,配置示例如下:
bash复制/data/logs/zookeeper/zookeeper.out {
daily
rotate 7
copytruncate
compress
missingok
notifempty
}
copytruncate 这个选项很重要。它的作用是把当前日志文件复制一份后立刻清空原文件,而不是先改名再让进程创建新文件。因为 ZooKeeper 的 stdout 指向的是固定路径文件句柄,如果你用 rename 的方式轮转,ZooKeeper 会继续往已经改名后的文件写,新文件反而一直是空的。copytruncate 会存在极小概率丢失少量日志,但考虑这是 stdout 日志,可接受。
同样的方式也可以处理 gc 日志。ZooKeeper 默认在启动时可能会带上 GC 日志参数,日志文件同样会持续增长。可以在启动脚本的 ZOO_LOG4J_PROP 或 JVMFLAGS 里加上 GC 日志的路径,然后用 logrotate 做轮转,或者干脆放到固定目录定期清理。
3.3 目录规划是最容易忽略的长期策略
聊到运行日志就不得不提目录规划。很多生产事故其实是目录混用导致的:
dataDir里混着事务日志、快照、myid,一旦日志膨胀,连启动所需的快照都可能被误删。zookeeper.out默认产生在生产部署目录下,和二进制文件、配置放一起,清理时容易把配置文件一起误伤。
我现在的习惯是在部署 ZooKeeper 时就严格分目录:
bash复制/data/zookeeper/data # 快照数据
/data/zookeeper/datalog # 事务日志,dataLogDir 指向这里
/data/logs/zookeeper # 运行日志、gc 日志、zookeeper.out
/app/zookeeper # 二进制和 conf 目录
dataLogDir 在 zoo.cfg 里单独配置:
ini复制dataDir=/data/zookeeper/data
dataLogDir=/data/zookeeper/datalog
这样做的另一个好处是:事务日志的写入是顺序IO,如果能把数据目录和系统盘分离(比如挂载单独的 SSD 或者高性能云盘),还能显著改善 ZooKeeper 的写性能。快照文件放在普通磁盘上也不会有太大影响。
4. 完整排障链路:从磁盘报警到集群恢复的全过程
4.1 定位问题:谁占满了磁盘
上面说了很多理论,现在回到开头那个凌晨的场景,我把完整的排查步骤过一遍。
接到磁盘告警后,第一步不是急着删文件,而是先定位是谁占的空间。按以下顺序操作:
bash复制# 查看分区占用情况
df -h
# 进入 ZooKeeper 相关挂载点,定位大目录
du -sh /data/*
# 分别查看两类文件的占用
du -sh /data/zookeeper/datalog
du -sh /data/zookeeper/data
du -sh /data/logs/zookeeper
正常情况下,你会看到事务日志目录和运行日志目录占大头。如果事务日志目录下文件数量动辄几千个,那就基本锁定了。
接着检查配置,确认自动清理是否开启:
bash复制grep -E 'autopurge|dataDir|dataLogDir' /app/zookeeper/conf/zoo.cfg
如果 autopurge.purgeInterval 是 0 或者注释状态,那就说明问题根因在于自动清理未生效,这也是我们处理完本次故障后首先需要改进的配置项。
4.2 清理动作的执行顺序与验证
定位完成后,按照"先运行日志、后事务日志"的顺序处理。
先处理运行日志,因为这部分最安全。启动 logrotate 做一次强制轮转:
bash复制logrotate -f /etc/logrotate.d/zookeeper
再处理 zookeeper.out,如果文件很大,copytruncate 方式清空:
bash复制# 手动方式:备份 + 清空
cp /data/logs/zookeeper/zookeeper.out /tmp/zookeeper.out.bak
cat /dev/null > /data/logs/zookeeper/zookeeper.out
然后是事务日志。先看当前最新的快照对应到哪个 zxid,再安全清理更早的事务日志,并将保留数量控制在安全范围内。我用一个脚本配合 zkCleanup.sh 完成:
bash复制# 保留最近 3 个快照
bin/zkCleanup.sh /data/zookeeper/data 3 -n /data/zookeeper/datalog
执行完 df -h 看空间是否释放。如果空间没有明显变化,执行 lsof | grep deleted 查看是否有被进程持有的已删除文件。如果有,在业务低峰期重启该节点的 ZooKeeper 进程,让句柄释放。注意逐台重启,确认前一台恢复后再操作下一台。
4.3 清理后的健康检查清单
清理完日志不代表事情结束了,必须做一轮健康检查确认集群没有因为清理操作受到影响。
用四字命令最直观:
bash复制# 查看集群角色、节点数、延迟等信息
echo stat | nc localhost 2181
echo mntr | nc localhost 2181
重点看几个指标:
zk_server_state是否为 leader 或 followerzk_followers数量是否正常(对 leader 执行)zk_outstanding_requests是否接近 0,这个数值越大说明堆积的未处理请求越多zk_znode_count是否正常,如果数据节点数量明显变少,说明可能误删了数据文件,需要立即从备份恢复
再用 zkServer.sh status 查看所有节点的角色,确认没有发生不必要的选主切换。如果清理前是稳定状态的集群,清理后各节点角色应当保持不变。
另外一个容易忽略的点:检查 dataDir 下的 myid 文件是否完好,myid 文件里记录的是当前节点的编号(1-255),如果 myid 丢失,集群会无法启动。
4.4 这类故障的预防体系
故障处理完之后,我总会做一件事:从根上杜绝再次发生。
第一是打开自动清理。所有节点统一检查 zoo.cfg:
ini复制autopurge.snapRetainCount=3
autopurge.purgeInterval=1
第二是监控磁盘水位。ZooKeeper 日志的增长趋势虽然不是线性,但可以通过定时任务采集目录大小来判断。我一般用脚本每小时统计一次数据目录大小,超过阈值就告警:
bash复制#!/bin/bash
# 统计事务日志目录大小,超过 10GB 告警
SIZE=$(du -sm /data/zookeeper/datalog | awk '{print $1}')
if [ $SIZE -gt 10240 ]; then
echo "ZooKeeper datalog directory size ${SIZE}MB, greater than 10GB"
# 这里接告警通道,可以是短信、钉钉或者邮件
fi
第三是定期演练。每季度手动执行一次 zkCleanup.sh 的 dry-run,确认清理脚本还能正常工作。不要等到磁盘满了才去脚本。脚本可能和你升级 ZooKeeper 版本时不兼容,提前发现问题成本最低。
第四是调整 snapCount。如果你的业务写入量特别大,可以适当调小 snapCount,让快照生成频率更高,这样每次清理可以释放更多早期事务日志。但 snapCount 调小会增加快照写入频率,对 IO 有一定压力,需要权衡。
我自己始终保留的一个习惯是:每次执行 ZooKeeper 日志清理前,先在测试环境或者非关键节点跑一次,确认集群状态正常后再批量操作。日志清理这件事,看起来是个运维杂活,但一旦在数据一致性上出了纰漏,恢复成本远远超过你清理日志省下的那点时间。
