1. 为什么要在 K8s 里折腾 Kafka 集群
把 Kafka 搬进 Kubernetes,这件事在消息中间件圈子里讨论了好多年,到现在依然是个高频话题。我接触过的团队里,大致分两派:一派觉得 Kafka 天生是有状态的重型组件,放 K8s 里纯属给自己找麻烦;另一派则坚持统一调度、统一运维的收益远大于折腾成本。我自己属于后者,但也不得不承认,前者的顾虑是有道理的。
先说清楚这个项目到底在做什么。核心目标是在 K8s 集群里部署一套Kafka + ZooKeeper 集群,并且这套集群要能被集群外部的客户端正常访问——也就是所谓的“可外部通信”。这个需求非常现实:很多公司的业务应用还跑在虚拟机或者物理机上,消息队列先上云原生,但不可能要求所有生产者消费者一夜之间全部容器化。所以“内外互通”不是锦上添花,而是落地的前提条件。
适合谁来参考这篇内容?如果你已经对 Docker 和 K8s 的基本概念(Pod、Service、StatefulSet、PV/PVC)有了解,能看懂 YAML,但还没真正在生产或准生产环境里跑过 Kafka 集群,那这篇就是写给你的。如果你是完全的新手,建议先把 K8s 的 Service 类型和存储卷机制搞明白再往下看,否则后面讲外部通信的部分会有点吃力。
我先把结论性的判断放在前面:K8s 部署 Kafka 的难点从来不在“能不能跑起来”,而在于稳定的网络标识、持久化存储的绑定、以及外部访问的暴露方式这三件事。跑起来一个单节点 Demo 半小时就够,但要让它像一个正经集群那样工作,坑基本都集中在这三块。下面我会按照设计思路、核心细节、实操过程、问题排查的顺序,把整个方案拆开讲。
2. 整体架构设计与方案选型拆解
2.1 为什么用 StatefulSet 而不是 Deployment
Kafka 的每个 Broker 都是有状态的,它需要固定的网络标识(broker.id 和主机名的对应关系一旦确定就不能乱变),还需要独占的持久化存储。Deployment 管理的 Pod 名字是随机的,重启后主机名会变,这对 Kafka 来说是灾难——Broker 之间靠advertised.listeners里配置的主机名互相通信,主机名一变,集群内部的元数据就对不上了。
StatefulSet 恰好解决这个问题:Pod 名字是稳定的(比如kafka-0、kafka-1、kafka-2),每个 Pod 有固定的 DNS 记录,配合 Headless Service 就能让 Broker 之间通过稳定的域名互相找到。存储方面,StatefulSet 配合volumeClaimTemplates能给每个 Pod 绑定独立的 PVC,Pod 重建后还能挂回原来的卷,数据不丢。这是选型的第一个关键决策,没有太多商量余地。
ZooKeeper 同理,它作为 Kafka 的元数据协调组件,同样需要稳定的集群身份,所以也是 StatefulSet。这里要说明一下,较新版本的 Kafka 已经在推 KRaft 模式(去掉 ZooKeeper 依赖),但考虑到大量存量环境和客户端兼容性,ZooKeeper 模式依然是当前最稳妥的选择,这个项目就按 ZooKeeper 模式来做。
2.2 外部通信的三种暴露方式怎么选
这是整个项目里最需要动脑子的地方。K8s 里让外部访问 Pod,常见的有 NodePort、LoadBalancer、Ingress 三种路子,但对 Kafka 来说,选择逻辑和普通 Web 服务完全不同。
Kafka 客户端连接 Broker 的流程是这样的:客户端先连上一个 Bootstrap 地址,Broker 返回集群元数据,元数据里包含每个 Broker 的advertised.listeners地址,然后客户端直接去连那些地址。这意味着客户端拿到的地址必须是它自己能访问到的地址。如果 Broker 在集群内通告的是 Pod IP 或者集群内部域名,外部客户端拿到后根本连不上,连接就会卡在元数据阶段。
所以方案的核心是:给每个 Broker 单独暴露一个外部可达的地址,并且把这个地址写进advertised.listeners。具体做法上,NodePort 是最直接的选择——给每个 Broker 的 Pod 配一个独立的 NodePort Service,外部客户端通过“节点 IP + NodePort”访问。缺点是端口是随机分配的一大段,管理起来不够优雅,但胜在简单可靠,不依赖云厂商的负载均衡器。
LoadBalancer 类型在云环境里更优雅,每个 Broker 一个 LB 实例,地址固定。但自建机房或者没有 LB 能力的集群就用不了,而且成本也高。Ingress 对 Kafka 基本不适用,因为 Kafka 走的是 TCP 协议不是 HTTP,Ingress 控制器主要处理七层流量。所以这个项目我选NodePort + 固定端口规划的组合,兼顾通用性和可控性。
2.3 存储方案与容量估算
Kafka 对磁盘的要求是“能持久、够快、够大”。PV 的供给方式取决于你的集群环境:云上可以用 StorageClass 动态供给,自建集群一般用 Local PV 或者 NFS。这个项目为了通用性,我用 StorageClass 动态供给的方式演示,实际替换成你环境里可用的 StorageClass 即可。
容量怎么估?一个粗略的经验公式:单 Broker 磁盘容量 ≈ 日均消息量 × 保留天数 × 副本因子 ÷ Broker 数量 × 安全系数。举个例子,日均写入 500GB,保留 3 天,副本因子 3,3 个 Broker,安全系数取 1.3,那么单 Broker 需要 500 × 3 × 3 ÷ 3 × 1.3 ≈ 1950GB,取整配 2TB。这个估算很粗,但足够用来申请资源。要注意 Kafka 的磁盘不要用满,留 20% 到 30% 余量,否则日志段滚动和清理会出问题。
3. 核心细节解析与实操要点
3.1 关键配置项逐个说清楚
Kafka 的配置文件里,和 K8s 部署强相关的就那么几个,但每一个配错都可能导致集群起不来或者外部连不上。我把它们列成表格,方便对照。
| 配置项 | 作用 | 本项目取值示例 | 说明 |
|---|---|---|---|
broker.id | Broker 唯一标识 | 0/1/2 | 必须集群内唯一,StatefulSet 里用序号生成 |
zookeeper.connect | ZK 连接串 | zk-0.zk-headless:2181,... | 用 Headless Service 域名 |
listeners | Broker 监听地址 | PLAINTEXT://0.0.0.0:9092 | 监听所有网卡 |
advertised.listeners | 对外通告地址 | 见下文 | 内外通信的核心 |
log.dirs | 数据目录 | /var/lib/kafka/data | 挂载 PVC 的路径 |
num.partitions | 默认分区数 | 6 | 按吞吐和消费者数权衡 |
advertised.listeners是重中之重。在“可外部通信”的场景下,它必须配置成外部客户端能访问的地址。如果集群内外都要访问,可以配置多个 listener,比如INTERNAL://...和EXTERNAL://...,分别对应内部和外部。但多 listener 会增加复杂度,这个项目为了清晰,统一用外部可达地址通告,内部客户端也走这个地址(前提是内部也能路由到节点 IP)。
3.2 Headless Service 与 DNS 解析机制
ZooKeeper 和 Kafka 的集群内部通信都依赖稳定的 DNS。做法是给每个 StatefulSet 配一个 Headless Service(clusterIP: None),这样每个 Pod 会得到形如kafka-0.kafka-headless.default.svc.cluster.local的域名。Broker 之间、Broker 连 ZK 都用这些域名,而不是 IP。
为什么不用普通 Service?普通 Service 会给一组 Pod 做负载均衡,返回的是一个虚拟 IP,客户端连上去不知道具体落到哪个 Pod。而 Kafka 和 ZK 需要精确地连到某一个具体实例,Headless Service 正好提供“每个 Pod 一个 DNS 记录”的能力。这个机制是整套方案能稳定运行的地基,理解它比记住 YAML 更重要。
3.3 端口规划与 NodePort 分配
NodePort 默认范围是 30000 到 32767。三个 Broker 需要三个端口,我习惯提前规划好,比如 30092、30093、30094,避免和集群里其他服务冲突。规划好之后,在 Service 的 YAML 里显式指定nodePort,不要让它随机分配,否则每次重建端口变了,外部客户端的配置就得跟着改,非常痛苦。
注意:NodePort 端口一旦被占用,Service 创建会失败。部署前先用
kubectl get svc -A扫一遍现有端口,或者直接查节点上ss -lntp看端口占用情况。
3.4 资源限制与 JVM 参数
Kafka 是 JVM 应用,容器里跑 JVM 有个经典坑:JVM 默认会按宿主机内存来算堆大小,容器限制的内存它看不见,结果就是堆开得太大被 OOM Kill。解决办法是显式设置KAFKA_HEAP_OPTS,比如-Xmx4G -Xms4G,同时给容器配resources.limits.memory,两者要匹配。一般堆内存占容器内存的 50% 到 60% 比较稳妥,剩下的留给页缓存——Kafka 大量依赖操作系统的页缓存来提升读写性能,堆不是越大越好。
4. 完整实操过程与核心环节实现
4.1 前置准备:命名空间与存储类确认
先建一个独立命名空间,把 Kafka 相关资源都放进去,方便统一管理。
kubectl create namespace kafka-demo kubectl get storageclass确认集群里有可用的 StorageClass,记下它的名字,后面 PVC 模板里要用。如果没有动态供给能力,就得提前手动创建 PV,这里假设有。
4.2 部署 ZooKeeper 集群
先写 ZooKeeper 的 Headless Service 和 StatefulSet。ZK 集群需要每个节点知道其他节点的存在,通过环境变量或者配置文件里的 server 列表实现。我用一个 ConfigMap 存zoo.cfg,里面列出三个 ZK 节点的地址。
apiVersion: v1 kind: Service metadata: name: zk-headless namespace: kafka-demo spec: clusterIP: None selector: app: zookeeper ports: - port: 2181 name: client - port: 2888 name: peer - port: 3888 name: electionStatefulSet 部分,副本数设 3,serviceName指向zk-headless,volumeClaimTemplates申请每节点 10GB 存储。容器镜像用官方zookeeper镜像,通过环境变量ZOO_MY_ID和ZOO_SERVERS配置集群。这里有个细节:ZOO_MY_ID要能根据 Pod 序号动态生成,官方镜像支持从文件读取,但更简单的做法是用一个启动脚本,从 Pod 主机名里截取序号。我实测下来,用command覆盖启动命令、在脚本里解析HOSTNAME末尾数字的方式最稳。
部署完成后验证:
kubectl get pods -n kafka-demo -l app=zookeeper kubectl exec -n kafka-demo zk-0 -- zkServer.sh status三个 Pod 都 Running,且zkServer.sh status能看出一个是 leader 两个是 follower,ZK 集群就算成了。
4.3 部署 Kafka 集群
Kafka 的 Headless Service 和 ZK 类似,端口是 9092。StatefulSet 里副本数 3,每个 Pod 的broker.id用序号生成。这里我用一个启动脚本,从HOSTNAME里取出序号赋给broker.id,同时拼出advertised.listeners。
advertised.listeners的拼装逻辑是:PLAINTEXT://<节点IP>:<对应NodePort>。节点 IP 怎么来?可以在脚本里通过环境变量注入,或者用 Downward API 把节点 IP 传进容器。我用 Downward API 的status.hostIP字段,这是最干净的方式。
env: - name: NODE_IP valueFrom: fieldRef: fieldPath: status.hostIP然后启动脚本里:
#!/bin/bash ORDINAL=${HOSTNAME##*-} export KAFKA_BROKER_ID=$ORDINAL export KAFKA_ADVERTISED_LISTENERS="PLAINTEXT://${NODE_IP}:$((30092 + ORDINAL))" exec /etc/kafka/docker/runNodePort 的分配要和这个公式对上:kafka-0 对应 30092,kafka-1 对应 30093,kafka-2 对应 30094。Service 那边也要按序号一一对应地创建三个 NodePort Service,每个 Service 的 selector 精确指向单个 Pod(用statefulset.kubernetes.io/pod-name标签),这样每个 Service 只暴露一个 Broker,端口固定。
apiVersion: v1 kind: Service metadata: name: kafka-external-0 namespace: kafka-demo spec: type: NodePort selector: statefulset.kubernetes.io/pod-name: kafka-0 ports: - port: 9092 targetPort: 9092 nodePort: 30092另外再建一个普通的 ClusterIP Service 作为内部 Bootstrap 地址,方便集群内客户端使用。
4.4 验证外部通信
部署完成后,从集群外部的一台机器上测试。先确认能连通节点 IP 和 NodePort:
nc -zv <节点IP> 30092然后用 Kafka 自带的命令行工具创建 topic 并生产消费:
kafka-topics.sh --bootstrap-server <节点IP>:30092 \ --create --topic test-topic --partitions 3 --replication-factor 3 kafka-console-producer.sh --bootstrap-server <节点IP>:30092 --topic test-topic能正常创建 topic、能生产消息、能消费到消息,说明外部通信链路打通了。这里的关键验证点是:客户端拿到元数据后,能成功连上另外两个 Broker 的 NodePort。如果只能连上 Bootstrap 那个,说明advertised.listeners配错了,客户端拿到的还是内部地址。
5. 常见问题与排查技巧实录
5.1 集群起不来:ZK 连不上或选主失败
最常见的现象是 Kafka Pod 一直重启,日志里报Connection refused连 ZK 失败。排查顺序:先看 ZK 三个 Pod 是否都 Running 且选主成功,再看 Kafka 配置里的zookeeper.connect域名是否拼对。ZK 的 Headless Service 域名格式是<pod名>.<service名>.<namespace>.svc.cluster.local,少一段都解析不了。我踩过一次坑,把 namespace 漏了,结果只在同 namespace 内能解析,跨 namespace 就挂。
5.2 外部客户端连上就断:advertised.listeners 陷阱
这个前面提过,但值得单独强调。现象是客户端能连上 Bootstrap,但一操作就超时或者报LeaderNotAvailable。根因是 Broker 通告了客户端不可达的地址。排查方法:在客户端机器上用kafka-broker-api-versions.sh --bootstrap-server <地址>看返回的 Broker 列表里的地址,如果出现 Pod IP 或者集群内部域名,就是配置问题。解决就是确保advertised.listeners里是外部可达的节点 IP 加 NodePort。
5.3 数据丢失或 Pod 重建后数据不见
多半是 PVC 没绑对,或者log.dirs路径和挂载路径不一致。检查kubectl describe pod里的 Volumes 挂载点,再进容器ls一下数据目录。还有一种情况是用了emptyDir而不是 PVC,Pod 一删数据就没了。StatefulSet 的volumeClaimTemplates一定要配,别图省事。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| Kafka Pod 反复重启 | ZK 不可达 / 内存超限 | 看日志、查 ZK 状态、核对堆参数 |
| 外部连上即断 | advertised.listeners 错误 | 用 api-versions 工具看通告地址 |
| 数据重建后丢失 | PVC 未绑定 / 路径错 | describe pod 看挂载、进容器确认 |
| 端口冲突创建失败 | NodePort 被占用 | 扫现有 Service 和节点端口 |
| 消费延迟高 | 分区数不足 / 磁盘慢 | 看分区分布、查磁盘 IO |
5.5 几条踩坑心得
第一,别用 latest 标签的镜像。Kafka 和 ZK 的版本兼容性有讲究,镜像版本漂移可能导致启动参数不兼容,务必锁定具体版本号。第二,NodePort 端口提前规划并写进文档,团队里换个人接手,看到一堆随机端口会崩溃。第三,先小规模验证再扩副本,三个 Broker 跑通再考虑加节点,扩容时新 Pod 的 broker.id 和端口映射要同步更新,别只改副本数忘了改 Service。第四,监控磁盘和 ISR,Kafka 的UnderReplicatedPartitions指标一旦持续大于零,说明有副本同步跟不上,早发现早处理,别等磁盘写满才慌。
这套方案我在几个不同规模的环境里跑过,三个 Broker 的配置应付中等吞吐的业务完全够用。真正需要花心思的不是部署本身,而是后续的容量规划和监控告警。部署只是起点,让它稳定跑上一年半载,靠的是对这些配置细节的理解和日常的巡检习惯。