本文来自一个三节点 ZooKeeper 集群的资源治理。命名空间、节点、业务名称和容量已泛化;参数只能作为同量级负载的起点,不能直接复制到生产环境。系列导航:Kubernetes 重建迁移教程系列。
一个三节点 ZooKeeper 集群长期呈现以下资源状态:
| 指标 | 节点 A | 节点 B | 节点 C |
|---|---|---|---|
| Pod 内存 | 约 1.8 GiB | 约 1.8 GiB | 约 1.4 GiB |
| CPU | 约 10m | 约 9m | 约 11m |
| memory request | 2 GiB | 2 GiB | 2 GiB |
| memory limit | 3 GiB | 3 GiB | 3 GiB |
| 重启次数 | 0 | 0 | 0 |
乍一看很像内存泄漏:CPU 很低,三个 Pod 却占用了接近 5 GiB。但继续检查发现,业务数据只有约 1.2 MiB,连接数和 watch 数也很低。
真正的问题不是 ZooKeeper 保存了几 GiB 数据,而是 JVM 被配置为固定的 2 GiB 堆,Chart 又通过资源预设为每个 Pod 申请了 2 GiB 内存。
先建立判断链路
flowchart TD
A["kubectl top: Pod 接近 2G"] --> B["读取真实 Java 命令行"]
B --> C["确认 Xms / Xmx 与重复参数"]
C --> D["采集 mntr、stat、快照和磁盘"]
D --> E{"存活数据和连接压力高吗"}
E -- "高" --> F["按峰值容量重新建模"]
E -- "低" --> G["分阶段缩小 Heap 与 requests"]
G --> H["逐 Pod 滚动并观察 GC / 延迟"]
H --> I["再处理 emptyDir 持久化风险"]
这里最重要的是先分清“堆上限大”“堆已提交”和“业务存活对象多”。仅凭 kubectl top 无法区分三者。
第 1 步:读取运行中的真实 JVM 参数
不要只看 Helm values。先检查容器内 Java 进程:
kubectl -n infra exec zookeeper-0 -- \
sh -c 'tr "\0" " " < /proc/1/cmdline'
这次看到的关键片段是:
-Xmx1000m -Xmx2048m -Xms2048m
重复的 -Xmx 不会相加,HotSpot 通常采用后出现的值。因此有效配置是:
Xms = 2048 MiB
Xmx = 2048 MiB
前一个 -Xmx1000m 来自镜像脚本默认值,后一个 -Xmx2048m 和 -Xms2048m 来自 Chart 的 heapSize。这说明:
- 默认值没有真正限制住堆。
- 堆从启动时就按 2 GiB 的固定大小提交。
- 三节点的理论固定堆预算已经达到 6 GiB。
重复参数本身不会形成 3 GiB 堆,也不是泄漏证据,但会让只检查环境变量的人得出错误结论。
第 2 步:区分 RSS、Committed Heap 和 Live Data
ZooKeeper Pod 的内存可以粗略理解为:
Pod working set
≈ JVM committed heap
+ Metaspace / Code Cache / GC structures
+ thread stacks / direct buffers / native libraries
+ process working-set file pages
Xms=2g 不代表进程启动瞬间一定占满 2 GiB RSS,但 JVM 在运行和 GC 过程中会逐步触碰已提交的内存页。即使存活对象很少,操作系统看到的工作集也可能长期接近固定堆大小。
因此,下面两个判断都不成立:
Pod RSS 接近 2 GiB,所以 ZooKeeper 一定存了 2 GiB 数据
数据只有 1 MiB,所以 Pod RSS 应该只有几十 MiB
业务数据规模是决定堆需求的重要输入,但不能直接等同于 Pod 总内存。
第 3 步:用 mntr 判断真实负载
ZooKeeper 四字命令需要在服务端白名单中放行。启用前应评估安全边界,避免把管理接口暴露到不可信网络。ZooKeeper 管理指南已经建议新系统优先使用 AdminServer;这里保留 mntr,是为了与现有集群的采集方式保持一致。
在允许 mntr 的环境中,可以执行:
kubectl -n infra exec zookeeper-0 -- \
sh -c 'printf "mntr\n" | nc 127.0.0.1 2181'
重点采集:
zk_server_state
zk_znode_count
zk_approximate_data_size
zk_num_alive_connections
zk_outstanding_requests
zk_avg_latency
zk_max_latency
zk_watch_count
zk_ephemerals_count
zk_open_file_descriptor_count
zk_max_file_descriptor_count
本次脱敏后的数据量级是:
| 指标 | 观测值 | 解释 |
|---|---|---|
| znode 数 | 约 22,000 | 规模较小 |
| approximate data size | 约 1.2 MiB | 业务数据远小于固定堆 |
| 全局 Session | 约 40 | 客户端压力低 |
| 单节点存活连接 | 10–20 | 连接压力低 |
| watch 数 | 10–20 | 不存在 watch 风暴 |
| outstanding requests | 0 | 没有排队 |
| 平均延迟 | 低于 1 ms | 当前性能余量充足 |
| 单个快照 | 约 3 MiB | 与数据量级一致 |
这些证据更支持“堆配置过大”,而不是“业务对象正在无限增长”。
第 4 步:检查事务日志和快照
继续检查 ZooKeeper 数据目录:
kubectl -n infra exec zookeeper-0 -- \
sh -c 'du -sh /bitnami/zookeeper/data /bitnami/zookeeper/logs 2>/dev/null'
事务日志可能使用预分配文件。看到几十 MiB 的日志文件,不代表其中每个字节都是有效业务数据,也不能直接作为堆大小的依据。
同时确认:
snapCount。autopurge.snapRetainCount。autopurge.purgeInterval。- 数据目录是否位于 PVC。
- 三个节点的快照和事务日志是否持续增长。
本次自动清理已经启用,快照也很小,因此没有证据支持立刻调整 snapCount 或清理频率。性能调优只应修改有证据支持的参数。
第 5 步:把 Chart 资源预设改成显式预算
当前配置类似:
heapSize: 2048
resourcesPreset: large
resources: {}
resourcesPreset 适合快速部署,但不适合长期容量治理。它隐藏了实际 request 和 limit,也很难表达“CPU 很低、内存需要保留 JVM 余量”的真实模型。Bitnami ZooKeeper Chart同样建议生产工作负载按实际场景调整 resources。
不要在没有监控的情况下直接从 2 GiB 堆砍到 512 MiB。更稳妥的是两阶段收缩。
第一阶段:先降到 1536 MiB
heapSize: 1536
resourcesPreset: none
resources:
requests:
cpu: 250m
memory: 1792Mi
ephemeral-storage: 256Mi
limits:
cpu: 1500m
memory: 2560Mi
ephemeral-storage: 1Gi
这一阶段保留约 1 GiB 的容器级余量,用来容纳 JVM 非堆、线程、直接内存、共享库和工作集波动。
第二阶段:稳定后再评估 1024 MiB
如果至少跨过一个完整业务高峰,且 GC、延迟、连接和选举都稳定,可以继续评估:
heapSize: 1024
resourcesPreset: none
resources:
requests:
cpu: 250m
memory: 1280Mi
ephemeral-storage: 256Mi
limits:
cpu: 1500m
memory: 2Gi
ephemeral-storage: 1Gi
三个副本在第一阶段的 memory request 合计为 5.25 GiB;进入第二阶段后,合计为 3.75 GiB。CPU request 则从原来的 3 核降到 750m。实际节省应以调度器看到的 request 为准,而不是只看瞬时 kubectl top。
为什么 Heap 不能顶到容器 limit
即使 ZooKeeper 的主要数据在 Java Heap 中,也必须给这些区域留空间:
- Metaspace 和 Compressed Class Space。
- JIT Code Cache。
- GC remembered set 和其他本地结构。
- NIO Direct Buffer。
- 线程栈。
- JNI 与压缩库。
- 文件页和进程运行时。
如果设置:
Xmx = container memory limit
JVM 堆尚未 OOM,容器就可能先被 cgroup 杀死。对 1 GiB Heap 给出 2 GiB limit 不是浪费,而是先建立可验证的安全边界;观察稳定后再决定是否继续压缩。
第 6 步:缩容前先补可观测性
原配置如果关闭了 metrics,资源缩小后只能通过 kubectl top 猜测,风险很高。建议先开启 ZooKeeper metrics exporter,并至少监控:
- Pod working set 与 RSS。
- JVM heap used、committed、max。
- GC pause、次数和分配速率。
zk_outstanding_requests。- avg/max latency。
- alive connections、watch、znode、ephemeral 数。
- leader 变化和选举时间。
- 容器重启、OOMKilled 与探针失败。
告警不应只使用“内存超过 80%”。更有意义的是组合判断:
heap 持续高位
+ GC 频率升高
+ 请求延迟或 outstanding requests 增长
固定堆场景下,RSS 长期高位本来就可能是预期行为。
第 7 步:按 ZooKeeper 法定人数滚动
三节点集群需要始终保持至少两个健康成员。推荐流程:
- 备份 values、ZooKeeper 配置和关键数据。
- 确认三个成员角色、连接和法定人数正常。
- 一次只更新一个 follower。
- 等待该 Pod Ready,并通过
ruok、stat、mntr验证。 - 观察同步、延迟、GC 和连接恢复。
- 更新另一个 follower。
- 最后处理 leader,或先确认 leader 已安全迁移。
- 跨过真实高峰后再进入第二阶段。
每一步都应等待 StatefulSet 就绪:
kubectl -n infra rollout status statefulset/zookeeper --timeout=15m
kubectl -n infra get pod -l app.kubernetes.io/name=zookeeper -o wide
发生以下任一情况,应立即停止继续滚动:
- 成员无法加入 quorum。
- 选举频繁发生。
- GC 暂停明显上升。
- outstanding requests 持续非零并增长。
- 业务端 Session 大量重建或超时。
比内存更大的风险:数据目录使用 emptyDir
本次检查还发现,StatefulSet 没有 volumeClaimTemplates,持久化开关关闭,数据目录实际使用 emptyDir。Bitnami Chart 的参数说明也明确指出:persistence.enabled=false 时使用 emptyDir。
这意味着:
- 容器重启但 Pod 未重建时,目录可能还在。
- Pod 被重新调度、删除或节点故障时,本地快照和事务日志会丢失。
- 单节点可从其他成员重新同步,但不代表三节点同时丢失后仍能恢复。
三副本不能替代持久化。副本解决运行时可用性,PVC 和备份解决可恢复性。
ZooKeeper 更适合使用低延迟块存储,例如 SSD StorageClass,而不是把高频事务日志放到通用 NFS 上。一个保守起点是每个 Pod 10 GiB PVC,再按事务量和保留策略调整:
persistence:
enabled: true
storageClass: ssd
size: 10Gi
但这里不能直接做普通滚动更新。StatefulSet 的 volumeClaimTemplates 涉及不可变字段,通常需要单独设计迁移:
- 创建并验证备份。
- 确认现有 ensemble 健康。
- 准备带 PVC 的新 StatefulSet 或按 Chart 的升级要求重建。
- 一次迁移一个成员并等待重新同步。
- 验证快照、事务日志和故障恢复。
- 保留明确的回滚窗口。
内存收缩和存储迁移应该拆成两次变更,否则出现问题时无法快速判断是 JVM 容量还是数据目录导致的。
不应该顺手修改的参数
当前证据没有指向以下参数,因此不应为了“优化”一起修改:
maxClientCnxns:当前连接数远未达到限制。snapCount:当前快照与事务压力不高。- 自动清理周期:现有磁盘增长正常。
- Tick、Session timeout:没有 Session 抖动证据。
- 副本数:三节点是生产环境常用的最小 quorum。
一次只改变一个容量变量,才能在指标变化后判断因果关系。
完成定义
ZooKeeper 内存优化完成,至少要满足:
- JVM 命令行中不存在无法解释的重复 Heap 参数。
- Heap、request 和 limit 形成明确的三层预算。
- 三个成员在滚动期间始终保持 quorum。
- 至少经过一个业务高峰,没有明显 GC 或延迟退化。
- znode、Session、watch、连接和请求积压均有监控。
- OOMKilled、频繁选举和 Session 重建有告警。
- emptyDir 风险已有独立的 SSD PVC 迁移计划和恢复演练。
最终结论
ZooKeeper Pod 接近 2 GiB 不等于内存泄漏。固定 Xms=Xmx=2g、Chart 的大规格资源预设,以及 JVM 已提交页被逐步触碰,足以在很小的数据量下形成稳定的高工作集。
正确做法不是看到 CPU 低就一次性把内存砍半,而是先读取真实命令行,用 mntr 和快照证明负载规模,显式声明 requests/limits,再通过 1536 MiB 到 1024 MiB 的两阶段滚动验证,逐步释放资源。
而检查中暴露的 emptyDir 才是更值得优先列入风险清单的问题:资源过配浪费容量,没有持久化则可能在故障时失去恢复基础。
DISCUSSION
讨论与反馈