本文来自一个三节点 ZooKeeper 集群的资源治理。命名空间、节点、业务名称和容量已泛化;参数只能作为同量级负载的起点,不能直接复制到生产环境。系列导航:Kubernetes 重建迁移教程系列。

一个三节点 ZooKeeper 集群长期呈现以下资源状态:

指标节点 A节点 B节点 C
Pod 内存约 1.8 GiB约 1.8 GiB约 1.4 GiB
CPU约 10m约 9m约 11m
memory request2 GiB2 GiB2 GiB
memory limit3 GiB3 GiB3 GiB
重启次数000

乍一看很像内存泄漏: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 requests0没有排队
平均延迟低于 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 法定人数滚动

三节点集群需要始终保持至少两个健康成员。推荐流程:

  1. 备份 values、ZooKeeper 配置和关键数据。
  2. 确认三个成员角色、连接和法定人数正常。
  3. 一次只更新一个 follower。
  4. 等待该 Pod Ready,并通过 ruok、stat、mntr 验证。
  5. 观察同步、延迟、GC 和连接恢复。
  6. 更新另一个 follower。
  7. 最后处理 leader,或先确认 leader 已安全迁移。
  8. 跨过真实高峰后再进入第二阶段。

每一步都应等待 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 涉及不可变字段,通常需要单独设计迁移:

  1. 创建并验证备份。
  2. 确认现有 ensemble 健康。
  3. 准备带 PVC 的新 StatefulSet 或按 Chart 的升级要求重建。
  4. 一次迁移一个成员并等待重新同步。
  5. 验证快照、事务日志和故障恢复。
  6. 保留明确的回滚窗口。

内存收缩和存储迁移应该拆成两次变更,否则出现问题时无法快速判断是 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 才是更值得优先列入风险清单的问题:资源过配浪费容量,没有持久化则可能在故障时失去恢复基础。