1. 为什么要在 Kubernetes 上自建 Kafka 集群
把 Kafka 搬进 Kubernetes,这件事在圈子里一直有争议。一派认为消息中间件属于有状态服务,跑在容器里纯属给自己找麻烦;另一派则认为统一调度、弹性伸缩、声明式运维带来的收益远大于踩坑成本。我个人的立场偏后者,但前提是你得把存储、网络、外部可达性这三件事想清楚,否则上线第二天就会收到一堆"消息丢了""连不上"的告警。
这篇内容聊的是k8s 部署 kafka + zookeeper 集群,并且保证集群外部的客户端能够正常收发消息。关键词有三个:Kafka、ZooKeeper、可外部通信。前两个是组件,第三个才是真正的难点。很多人照着教程把 StatefulSet 一 apply,Pod 全 Running,就以为大功告成,结果本地写个 producer 一连就超时——因为 Kafka 的通信模型决定了它对网络标识极其敏感,容器网络里的地址和集群外的地址完全是两套体系。
适合谁来读?如果你已经能独立写 Deployment、Service、ConfigMap,对 Kafka 的基本概念(topic、partition、broker、offset)不陌生,但还没在生产环境里完整搭过一套可对外服务的集群,那这篇就是给你准备的。我会把每一步的选择理由讲透,包括那些教程里通常不写、但实际部署时一定会撞上的细节。全程用一套虚构的模拟项目来演示,参数和命名你可以直接替换成自己的。
先说清楚整体思路,避免一上来就陷进 YAML 里。Kafka 集群的核心矛盾在于:broker 之间互相通信用的地址,和外部客户端连接用的地址,往往不是同一个。在物理机时代这不是问题,因为机器 IP 就是对外 IP。但在 k8s 里,Pod IP 是集群内部的、会变的,Service 的 ClusterIP 是虚拟的、外部不可达。所以我们必须为每个 broker 提供一个"外部可解析、且稳定不变"的地址,并且让 broker 在向客户端返回元数据时,返回的是这个外部地址而不是 Pod IP。这就是整个部署方案的设计主线,后面所有的配置都是围绕它展开的。
2. 整体架构设计与关键选型拆解
2.1 组件拓扑与通信链路梳理
先把拓扑画在脑子里。一套最小可用的集群,我建议 3 个 ZooKeeper 节点 + 3 个 Kafka broker。ZooKeeper 负责元数据协调、controller 选举,Kafka 负责消息存储与分发。为什么都是 3 个?因为这两者都依赖多数派机制,3 节点允许挂 1 个还能正常工作,2 节点挂 1 个就失去多数派,等于没做高可用。生产环境如果预算允许可以上 5 节点,但 3 节点是性价比最高的起点。
通信链路有三条,必须分清楚:
- ZooKeeper 集群内部:节点之间通过 2888(数据同步)和 3888(选举)端口通信,这条走集群内部网络即可。
- Kafka broker 与 ZooKeeper:broker 通过 2181 端口连接 ZK 获取元数据,同样走内部网络。
- Kafka broker 之间:副本同步、controller 通信,走内部网络。
- 外部客户端与 broker:这是唯一需要暴露到集群外的链路,也是配置的重灾区。
关键点在于最后一条。Kafka 客户端连接时,先连上一个 bootstrap 地址,broker 返回集群元数据,里面包含每个 broker 的advertised.listeners地址,客户端随后直接连这些地址。如果advertised.listeners填的是 Pod IP 或 ClusterIP,外部客户端拿到后根本连不上。所以每个 broker 必须有一个外部可达的、固定的地址,写进advertised.listeners。
2.2 为什么用 StatefulSet 而不是 Deployment
有状态服务用 StatefulSet,这是常识,但我想把"为什么"讲透,因为理解了才能应对变体。Kafka broker 需要满足三个稳定性要求:稳定的网络标识(每个 broker 有固定主机名)、稳定的存储(重启后挂回原来的盘)、有序的启停(broker ID 与存储绑定,乱序会导致元数据错乱)。
Deployment 的 Pod 名字是随机后缀,重启后换一个,存储也只能用共享的 PVC 模板,无法保证"这个 Pod 永远挂这块盘"。StatefulSet 则提供<statefulset-name>-<ordinal>的固定命名,配合volumeClaimTemplates为每个副本生成独立的 PVC,Pod 重建后依然绑定原来的 PVC。Kafka 的broker.id和log.dirs里的数据是强绑定的,如果 Pod 换了盘,broker 会以为自己是新节点,触发大量副本重分配,严重时数据直接不可用。所以 StatefulSet 不是"更优雅",而是"必须"。
2.3 外部通信方案的三种取舍
让外部访问 Kafka,常见有三条路,我把它们的优劣摆出来,你可以按自己的环境选。
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| NodePort | 每个 broker 映射一个宿主机端口 | 配置简单,不依赖额外组件 | 端口范围受限,节点 IP 变动需改配置,暴露面大 | 测试环境、小规模 |
| LoadBalancer | 每个 broker 一个 LB 实例 | 地址稳定,运维省心 | 云上按实例计费,成本高 | 云环境生产 |
| 独立 Service + 固定外部地址 | 每个 broker 一个 Service,配合外部可解析域名 | 灵活,成本可控 | 需要自己维护 DNS 或 hosts | 自建机房、混合环境 |
我这次演示选第三种思路的简化版:为每个 broker 单独创建一个 Service,类型用 NodePort,同时把节点的固定地址写进advertised.listeners。这样既不需要云厂商的 LB,又能保证地址稳定(前提是节点 IP 固定)。如果你在云上,把 Service 类型换成 LoadBalancer,把advertised.listeners换成 LB 的地址即可,其余逻辑完全一致。这个方案的好处是逻辑透明,出问题好排查,不像某些 operator 把细节全封装了,一旦连不上你根本不知道从哪下手。
提示:无论选哪种方案,核心原则不变——
advertised.listeners里填的地址,必须是客户端能直接访问到的地址,且每个 broker 各不相同。
3. 存储、网络与配置的前置准备
3.1 命名空间与存储类的落地
先建命名空间,把 Kafka 相关资源隔离出来,避免和别的服务抢资源、混日志。存储这块,StatefulSet 的volumeClaimTemplates需要一个 StorageClass。自建环境常用本地盘或 NFS,云上用云盘。这里有个容易被忽略的点:Kafka 对磁盘 IO 很敏感,不要用网络存储跑高吞吐场景,NFS 的延迟抖动会让 ISR 频繁收缩。如果只是测试,NFS 能跑通,但生产务必用本地 SSD 或云的高性能盘。
我演示里假设已经有一个名为fast-ssd的 StorageClass 可用。每个 broker 分配 20Gi,这个数字按你的消息保留策略算:假设每天写入 50GB、保留 3 天、副本数 3,那单 broker 至少需要 50×3×3÷3 = 150GB,再留 30% 余量。别拍脑袋填,算一遍再定。
apiVersion: v1 kind: Namespace metadata: name: kafka-demo3.2 每个 broker 的稳定网络标识设计
这是整个方案的心脏。我们要为 3 个 broker 各准备一个 Service,命名规则统一为kafka-<ordinal>-external。Service 用 NodePort,并显式指定 nodePort,避免每次重建端口变化导致advertised.listeners失效。端口规划如下:内部通信统一 9092,外部映射从 30092 开始,依次 30092、30093、30094。
为什么内部和外部要用不同端口?因为 Kafka 支持多 listener,内部 listener 走 9092 供 broker 间通信,外部 listener 走另一个端口供客户端连接。这样职责清晰,安全组也好配——内部端口不对公网开放,只开外部端口。
apiVersion: v1 kind: Service metadata: name: kafka-0-external namespace: kafka-demo spec: type: NodePort selector: statefulset.kubernetes.io/pod-name: kafka-0 ports: - name: external port: 9094 targetPort: 9094 nodePort: 30092注意 selector 用的是statefulset.kubernetes.io/pod-name这个由 StatefulSet 自动打的标签,精确指向单个 Pod。三个 broker 就是三份这样的 Service,只改名字、端口和 nodePort。这一步做完,外部客户端就能通过<节点IP>:30092访问到 kafka-0 了。
3.3 ZooKeeper 集群的配置要点
ZooKeeper 相对简单,它不需要对外暴露,全部走内部通信。用 StatefulSet 部署 3 副本,每个节点通过ZOO_MY_ID标识自己,通过ZOO_SERVERS列出全部节点。这里有个坑:ZOO_SERVERS里的主机名必须用 StatefulSet 的 Pod DNS 格式,即zk-0.zk-headless.kafka-demo.svc.cluster.local,所以必须先建一个 headless Service(clusterIP: None)给 ZK 用。
apiVersion: v1 kind: Service metadata: name: zk-headless namespace: kafka-demo spec: clusterIP: None selector: app: zookeeper ports: - name: client port: 2181 - name: server port: 2888 - name: election port: 3888ZK 的持久化同样用volumeClaimTemplates,每个节点 10Gi 足够,因为 ZK 只存元数据不存消息。资源请求给 0.5 核 1Gi,限制 1 核 2Gi,这个量级跑中小集群绰绰有余。别给太小,ZK 的 JVM 堆如果被容器内存限制挤压,会频繁 Full GC 导致会话超时,进而触发不必要的选举。
4. Kafka 集群的完整部署实操
4.1 ZooKeeper StatefulSet 的编写与启动
先把 ZK 拉起来,因为 Kafka 启动时要连它。StatefulSet 的serviceName必须指向刚才的 headless Service,这样 Pod 才有稳定的 DNS。镜像选官方或社区维护的稳定版本,环境变量里ZOO_MY_ID用metadata.name的序号不好直接取,通常用command脚本从主机名截取,或者干脆为每个副本写死——但 StatefulSet 只有一个模板,所以用启动脚本解析主机名后缀是最通用的做法。
apiVersion: apps/v1 kind: StatefulSet metadata: name: zk namespace: kafka-demo spec: serviceName: zk-headless replicas: 3 selector: matchLabels: app: zookeeper template: metadata: labels: app: zookeeper spec: containers: - name: zookeeper image: zookeeper:3.8 ports: - containerPort: 2181 - containerPort: 2888 - containerPort: 3888 env: - name: ZOO_SERVERS value: "server.0=zk-0.zk-headless.kafka-demo.svc.cluster.local:2888:3888;2181 server.1=zk-1.zk-headless.kafka-demo.svc.cluster.local:2888:3888;2181 server.2=zk-2.zk-headless.kafka-demo.svc.cluster.local:2888:3888;2181" - name: ZOO_MY_ID valueFrom: fieldRef: fieldPath: metadata.name volumeMounts: - name: data mountPath: /data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast-ssd resources: requests: storage: 10GiZOO_MY_ID这里用metadata.name直接赋值其实不对,因为拿到的是zk-0这种字符串,而 ZK 要求是数字。实际做法是在容器启动命令里用 shell 截取末尾数字,或者用 initContainer 处理。我演示里为了简洁省略了这段脚本,你落地时记得补上,否则 ZK 起不来。这是很多教程含糊带过的地方,实测必踩。
启动后验证:kubectl exec -it zk-0 -n kafka-demo -- zkServer.sh status,应该看到一个 leader 两个 follower。三个都确认后再往下走。
4.2 Kafka broker 的 advertised.listeners 配置详解
这是全文最核心的一段配置,值得单独拎出来讲。Kafka 的 listener 配置有三组参数要配合:listeners、advertised.listeners、listener.security.protocol.map。
listeners是 broker 实际监听的地址和端口,容器里监听0.0.0.0即可。advertised.listeners是 broker 告诉客户端的地址,必须是对外可访问的。我们设计两个 listener:INTERNAL://0.0.0.0:9092供 broker 间通信,EXTERNAL://0.0.0.0:9094供外部客户端。但advertised.listeners里,INTERNAL 用 Pod DNS,EXTERNAL 用节点 IP + NodePort。
问题来了:StatefulSet 只有一个模板,三个 broker 的节点 IP 和 nodePort 都不同,怎么在一个模板里区分?答案是用启动脚本动态生成配置。思路是:从主机名解析出序号,查一张映射表得到本 broker 的外部地址,然后写进server.properties再启动 Kafka。这张映射表可以放在 ConfigMap 里,或者直接用环境变量注入。
#!/bin/bash ORDINAL=${HOSTNAME##*-} case $ORDINAL in 0) EXT_ADDR="192.168.1.10:30092" ;; 1) EXT_ADDR="192.168.1.11:30093" ;; 2) EXT_ADDR="192.168.1.12:30094" ;; esac cat >> /opt/kafka/config/server.properties <<EOF broker.id=${ORDINAL} listeners=INTERNAL://0.0.0.0:9092,EXTERNAL://0.0.0.0:9094 advertised.listeners=INTERNAL://kafka-${ORDINAL}.kafka-headless.kafka-demo.svc.cluster.local:9092,EXTERNAL://${EXT_ADDR} listener.security.protocol.map=INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT inter.broker.listener.name=INTERNAL zookeeper.connect=zk-0.zk-headless.kafka-demo.svc.cluster.local:2181,zk-1.zk-headless.kafka-demo.svc.cluster.local:2181,zk-2.zk-headless.kafka-demo.svc.cluster.local:2181 EOF这段脚本是整个方案的灵魂。inter.broker.listener.name=INTERNAL明确告诉 broker 之间用内部 listener 通信,避免它们互相用外部地址绕一圈。broker.id直接用序号,保证与存储绑定一致。外部地址写死节点 IP 是简化处理,如果你的节点 IP 会变,就得换成域名,并在集群外维护 DNS 解析——这是自建环境的固有成本,绕不开。
注意:
advertised.listeners里的主机名部分,客户端必须能解析。如果外部客户端没有集群内的 DNS,INTERNAL 那条对它无意义,它只用 EXTERNAL。但 broker 返回元数据时会把两条都带上,客户端会挑能连的用,所以两条都要写对。
4.3 StatefulSet 编排与 PVC 绑定验证
Kafka 的 StatefulSet 结构和 ZK 类似,但要多挂一个 ConfigMap 存启动脚本,还要暴露 9092 和 9094 两个端口。serviceName指向一个 headless Servicekafka-headless,供 broker 间通过 DNS 互相发现。
apiVersion: apps/v1 kind: StatefulSet metadata: name: kafka namespace: kafka-demo spec: serviceName: kafka-headless replicas: 3 selector: matchLabels: app: kafka template: metadata: labels: app: kafka spec: containers: - name: kafka image: bitnami/kafka:3.6 command: ["/bin/bash", "/scripts/start.sh"] ports: - containerPort: 9092 - containerPort: 9094 volumeMounts: - name: data mountPath: /bitnami/kafka - name: scripts mountPath: /scripts volumes: - name: scripts configMap: name: kafka-start-script defaultMode: 0755 volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast-ssd resources: requests: storage: 20Giapply 之后,用kubectl get pvc -n kafka-demo确认三个 PVC 都 Bound,再kubectl get pods看三个 broker 是否 Running。这里有个验证技巧:进到 kafka-0 里执行cat /bitnami/kafka/config/server.properties | grep advertised,确认生成的外部地址是本 broker 的、不是别人的。我见过因为脚本里序号解析错误,三个 broker 全写成同一个地址,结果客户端连上后所有请求都打到一台机器上,负载严重不均。
5. 外部连通性验证与常见故障排查
5.1 从集群外收发消息的完整验证
部署完不算完,必须从集群外真实收发一次。找一台集群外的机器,确认能 ping 通节点 IP、能 telnet 通 30092。然后准备一个client.properties,bootstrap.servers填三个外部地址:192.168.1.10:30092,192.168.1.11:30093,192.168.1.12:30094。
创建 topic 时要注意,副本数不能超过 broker 数,3 副本正好。replication.factor=3、min.insync.replicas=2,这样允许挂一台还能写。用官方脚本验证:
kafka-topics.sh --bootstrap-server 192.168.1.10:30092 \ --create --topic demo-topic --partitions 3 --replication-factor 3 kafka-console-producer.sh --bootstrap-server 192.168.1.10:30092 \ --topic demo-topic敲几行消息回车,另开一个消费者窗口kafka-console-consumer.sh --bootstrap-server ... --topic demo-topic --from-beginning,能看到消息就说明外部链路通了。这一步能过,基本就成功了八成。
5.2 连接超时与元数据错误的排查路径
外部连不上,九成出在advertised.listeners。排查顺序我总结成一张表,照着走能快速定位。
| 现象 | 可能原因 | 排查命令 | 解决 |
|---|---|---|---|
| 客户端连 bootstrap 就超时 | NodePort 未生效或防火墙拦截 | telnet 节点IP 30092 | 检查 Service、安全组 |
| 连上后报 "Connection to node -1 could not be established" | advertised 地址是 Pod IP | 进 Pod 看 server.properties | 修正启动脚本 |
| 部分分区收发失败 | 某 broker 的 advertised 地址错 | 看客户端日志里的目标地址 | 逐个核对 broker 配置 |
| 元数据里出现无法解析的主机名 | 用了集群内 DNS 给外部 | nslookup该主机名 | 外部 listener 改用 IP 或公网域名 |
最隐蔽的一种是:bootstrap 连上了,客户端也拿到了元数据,但真正发消息时超时。这通常是因为advertised.listeners里 INTERNAL 和 EXTERNAL 写反了,或者inter.broker.listener.name没配对,导致 broker 之间用外部地址通信,绕了远路甚至被防火墙挡。遇到这种,先看 broker 日志里副本同步是否正常,再看客户端实际连的目标地址。
5.3 副本同步异常与 ISR 收缩处理
集群跑起来后,kafka-topics.sh --describe看分区状态,如果 ISR 数量小于副本数,说明有副本没跟上。常见原因有三个:磁盘 IO 跟不上、网络延迟高、broker 负载不均。先看kubectl top pod确认资源,再看 broker 日志里的Shrinking ISR记录。
如果是磁盘慢,换存储;如果是网络,检查节点间带宽;如果是负载不均,多半是前面说的 advertised 地址写错导致流量集中。还有一种情况是min.insync.replicas设得比实际 ISR 高,导致生产者报NOT_ENOUGH_REPLICAS,这时候要么等副本追上,要么临时调低参数救急,但根因还是要解决同步问题。
提示:生产环境务必配
unclean.leader.election.enable=false,宁可短暂不可用,也不要让落后太多的副本当 leader 造成数据丢失。这个参数默认值在不同版本有差异,显式写死最稳妥。
6. 生产化改造与运维经验沉淀
6.1 资源限制与 JVM 参数的匹配
容器里跑 JVM,最容易犯的错是堆内存设得比容器限制还大,结果被 OOMKilled。Kafka 的堆不用太大,6-8Gi 足够支撑中等吞吐,剩下的内存留给页缓存——Kafka 的高性能很大程度靠操作系统的页缓存,堆给太大反而浪费。假设容器限制 8Gi,堆设-Xmx6g -Xms6g,留 2Gi 给堆外和系统。
资源请求和限制要合理。CPU 请求给 2 核、限制 4 核,磁盘 IO 才是瓶颈,CPU 通常不是。如果用本地盘,记得给 Pod 加nodeAffinity绑定到有盘的节点,否则调度到没盘的机器上 PVC 起不来。
6.2 优雅停机与数据安全
Kafka broker 停机前要让它把 leader 转移走、把数据刷盘,否则可能触发不必要的选举甚至数据不一致。StatefulSet 删除 Pod 时默认发 SIGTERM,Kafka 收到后会执行受控关闭,但terminationGracePeriodSeconds默认 30 秒可能不够,大集群刷盘要更久,建议调到 120 秒。
滚动升级时,StatefulSet 默认从最大序号倒序逐个更新,这个顺序对 Kafka 是友好的,因为 controller 通常在序号小的节点上,最后才动它。但升级前最好手动确认没有正在进行的副本重分配,否则升级过程中集群状态会很乱。
6.3 监控指标与容量规划建议
至少要监控四类指标:broker 的UnderReplicatedPartitions(大于 0 就要警惕)、ActiveControllerCount(集群里必须恰好为 1)、磁盘使用率(超过 80% 就该扩容或调保留策略)、请求延迟的 P99。这些通过 JMX 暴露,用 Prometheus 抓取即可。
容量规划上,我个人的经验是磁盘留 30% 余量、网络留 50% 余量。Kafka 的写入是顺序的,但副本同步会放大网络流量,3 副本意味着每写一份数据要走两遍网络。如果单 broker 网卡是千兆,实际可用吞吐要按三分之一估算,别按理论值满打满算。
我在实际部署这套方案时踩过最深的坑,是早期图省事把三个 broker 的advertised.listeners全写成同一个节点地址,测试时因为流量小没暴露,上线后所有生产流量压到一台机器,磁盘直接打满。后来改成每个 broker 独立地址,并在启动脚本里加了断言——如果解析出的外部地址和预期不符就拒绝启动,宁可起不来也不要带病运行。这个断言脚本后来救了我好几次,建议你也加上。另外一个小技巧:把advertised.listeners的生成逻辑做成幂等的,脚本重复执行不会追加重复配置,避免 Pod 重启后配置越滚越长。