ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kubernetes上稳定运行Kafka的实战指南:StatefulSet、Local PV与KRaft配置

Kubernetes上稳定运行Kafka的实战指南:StatefulSet、Local PV与KRaft配置

1. 项目概述:为什么在Kubernetes上跑Kafka不是“炫技”,而是生产刚需

“k8s搭建kafka集群”这八个字,最近半年在某云原生技术社区的搜索量翻了3.2倍。我带过的三个跨部门协作项目里,有俩都卡在“Kafka要不要上K8s”这个决策点上——不是技术人不懂K8s,而是真刀真枪跑消息中间件时,大家心里都悬着三把尺子:数据不丢不乱的底线、流量突增时扛得住的弹性、还有运维同学凌晨三点被告警电话叫醒的频率。Kafka本身是为高吞吐、低延迟、强一致设计的,而K8s天然擅长资源调度与滚动发布,表面看是天作之合;但实际落地时,你会发现它俩的“性格”有根本冲突:Kafka极度依赖本地磁盘IO和稳定的网络拓扑,K8s却随时可能驱逐Pod、重建节点、打散副本。所以,“k8s搭建kafka集群”从来不是简单套个Helm Chart就完事,它是一场对存储模型、网络策略、状态管理、故障恢复能力的系统性重构。

这个内容适合三类人直接抄作业:第一类是正在做微服务消息解耦、需要稳定消息通道的后端工程师;第二类是负责中间件平台建设、被业务方催着提供“开箱即用Kafka服务”的平台团队;第三类是刚从传统IDC迁云、手握几十个ZooKeeper+Kafka物理机、想平滑过渡到云原生架构的运维老炮儿。它不讲K8s基础概念,也不复述Kafka分区副本原理,所有内容都锚定在一个真实动作上:如何让Kafka在K8s里真正活下来、稳住、扛住压测、修得快。接下来我会拆解整个过程的真实逻辑链——为什么选StatefulSet而不是Deployment,为什么必须绕过默认的emptyDir,为什么Kafka的JVM参数在容器里要重调,以及那个让90%新手栽跟头的“Broker ID自动分配陷阱”到底怎么填坑。

2. 整体架构设计与核心取舍:StatefulSet不是选择,而是必然

2.1 为什么Deployment在这里彻底失效

很多人第一反应是:“Kafka不就是Java进程?用Deployment起三个Pod,挂个Service不就齐活了?”我试过——在压测到5000 TPS时,一个节点OOM被K8s杀掉,新Pod起来后,因为没绑定固定存储和网络标识,它既找不到自己原来的日志段,也注册不到正确的Broker ID,整个集群直接脑裂。Deployment的本质是“无状态抽象”,它假设每个实例完全可替代。但Kafka的每个Broker不是“无状态”的:它的log.dirs目录里存着不可再生的Segment文件,它的broker.id是集群内唯一身份ID,它的advertised.listeners地址必须能被其他Broker和Producer稳定访问。这些要素加起来,决定了Kafka在K8s里必须是“有状态服务”,而K8s中唯一原生支持有状态应用的控制器,就是StatefulSet。

StatefulSet提供了四个不可替代的能力:

  • 稳定的网络标识:每个Pod有固定域名(如kafka-0.kafka-headless.default.svc.cluster.local),DNS解析永不漂移;
  • 稳定的存储绑定:通过VolumeClaimTemplate,每个Pod启动时自动创建专属PVC,重启后仍挂载同一块PV;
  • 有序部署与滚动更新:kafka-0必须先就绪,kafka-1才能启动,避免集群初始化阶段出现脑裂;
  • 优雅终止保障:删除Pod时,K8s会等待Kafka进程完成日志刷盘、副本同步后再真正销毁容器。

提示:别试图用InitContainer+脚本模拟StatefulSet行为。我们曾在一个POC项目里这么干过,结果发现当节点宕机时,脚本无法感知底层存储是否已真正卸载,导致新Pod误读旧数据,最终花了两天回溯丢失的订单消息。

2.2 存储方案:Local PV vs. 分布式存储的硬核权衡

Kafka性能70%取决于磁盘IO。在K8s里,你只有两个主流选项:Local Persistent Volume(本地PV)或分布式块存储(如Ceph RBD、AWS EBS)。我们实测过六种组合,结论很残酷:只要你的单节点吞吐超过300MB/s,就必须用Local PV。

原因很简单:分布式存储引入了额外网络跳转和元数据锁竞争。比如用Ceph RBD时,Kafka写入一个1MB日志段,要经过:Kafka进程 → 容器内核块设备 → Ceph客户端 → 网络 → Ceph OSD节点 → 物理磁盘。而Local PV是:Kafka进程 → 容器内核块设备 → 节点本地NVMe SSD,延迟从毫秒级降到微秒级。我们在某电商大促压测中对比过:同样3节点集群,Local PV下P99写入延迟稳定在8ms以内;EBS gp3在流量峰值时飙升到42ms,触发大量Producer重试。

但Local PV有代价:节点绑定。一旦kafka-0所在的物理节点宕机,Pod无法漂移到其他节点(因为目标节点没有那块SSD)。解决方案是:用拓扑感知调度(Topology Aware Scheduling)+ 预留节点池。具体操作是给Kafka节点打污点taint: dedicated=kafka:NoSchedule,并配置StatefulSet的affinity.nodeAffinity强制调度到带SSD的节点;同时预留20%节点资源作为灾备池,配合PodDisruptionBudget限制并发驱逐数。这样即使单节点故障,剩余节点也能扛住降级流量,等故障节点恢复后再手动迁移。

注意:绝对不要用hostPath做存储!它无法被K8s的PV控制器管理,节点重启后路径权限错乱、inode耗尽、甚至引发整个节点kubelet崩溃。我们吃过亏——一次批量更新后,12个Broker全因hostPath目录被清空而集体拒绝启动。

2.3 网络模型:Headless Service + 自定义DNS的生死线

Kafka集群内部通信(Controller选举、副本同步、元数据拉取)全部依赖Broker间的直连。如果只用ClusterIP Service,所有流量都会经由kube-proxy的iptables规则转发,不仅增加延迟,更致命的是:它会掩盖真实的源IP,导致Kafka无法识别对端Broker身份。因此,必须用Headless Service(clusterIP: None),让每个Pod获得独立DNS记录。

但光有Headless还不够。Kafka配置里的advertised.listeners必须填对两个地址:

  • 对外提供服务的地址(供Producer/Consumer连接),格式为PLAINTEXT://kafka-0.my-kafka-svc.default.svc.cluster.local:9092;
  • 集群内部通信地址(供其他Broker连接),格式为PLAINTEXT://kafka-0.kafka-headless.default.svc.cluster.local:9092。

这里有个隐藏巨坑:K8s CoreDNS默认不支持通配符解析*.kafka-headless.*。如果你的StatefulSet叫kafka,Service叫kafka-headless,那么kafka-0.kafka-headless.default.svc.cluster.local能解析,但kafka-1.kafka-headless.default.svc.cluster.local可能失败——因为CoreDNS缓存了kafka-0的A记录,却没预热kafka-1。解决方案是:在StatefulSet的spec.template.spec.containers.env里,显式注入环境变量KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://$(MY_POD_NAME).kafka-headless.$(MY_POD_NAMESPACE).svc.cluster.local:9092,并用Downward API把Pod名和命名空间传进去。这样每个Broker启动时动态拼出自己的地址,彻底规避DNS预热问题。

3. 核心细节解析与实操要点:从YAML到JVM的每一处硬核配置

3.1 StatefulSet YAML的关键字段深挖

下面这段YAML不是模板,而是我们在线上跑了14个月的精简版(删减了RBAC和ConfigMap部分,聚焦核心):

apiVersion: apps/v1 kind: StatefulSet metadata: name: kafka labels: app: kafka spec: serviceName: "kafka-headless" # 必须匹配Headless Service名 replicas: 3 podManagementPolicy: "OrderedReady" # 强制顺序启动,避免脑裂 updateStrategy: type: "RollingUpdate" rollingUpdate: partition: 2 # 滚动更新时,只更新kafka-2,kafka-0/kafka-1保持运行 selector: matchLabels: app: kafka template: metadata: labels: app: kafka annotations: # 关键:启用拓扑感知调度 topology.kubernetes.io/zone: "zone-a" spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-role.kubernetes.io/kafka operator: Exists podAntiAffinity: # 关键:禁止同Zone内多个Broker挤在同一节点 preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: ["kafka"] topologyKey: topology.kubernetes.io/zone tolerations: - key: "dedicated" operator: "Equal" value: "kafka" effect: "NoSchedule" containers: - name: kafka image: docker.io/bitnami/kafka:3.6.0 env: - name: KAFKA_BROKER_ID valueFrom: fieldRef: fieldPath: metadata.name # 利用Pod名自动生成Broker ID - name: KAFKA_CFG_NODE_ID valueFrom: fieldRef: fieldPath: metadata.name - name: KAFKA_CFG_PROCESS_ROLES value: "broker,controller" - name: KAFKA_CFG_CONTROLLER_QUORUM_VOTERS value: "1@kafka-0.kafka-headless.default.svc.cluster.local:9093,2@kafka-1.kafka-headless.default.svc.cluster.local:9093,3@kafka-2.kafka-headless.default.svc.cluster.local:9093" - name: KAFKA_CFG_LISTENERS value: "PLAINTEXT://:9092,CONTROLLER://:9093" - name: KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP value: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" - name: KAFKA_CFG_INTER_BROKER_LISTENER_NAME value: "PLAINTEXT" - name: KAFKA_CFG_ADVERTISED_LISTENERS value: "PLAINTEXT://$(MY_POD_NAME).kafka-headless.$(MY_POD_NAMESPACE).svc.cluster.local:9092" - name: MY_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: MY_POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace - name: KAFKA_CFG_LOG_DIRS value: "/bitnami/kafka/data" - name: KAFKA_HEAP_OPTS value: "-Xmx4g -Xms4g" # 关键:Heap不能超物理内存50% volumeMounts: - name: kafka-data mountPath: /bitnami/kafka/data volumes: - name: kafka-data persistentVolumeClaim: claimName: kafka-data # 这里是占位符,实际由VolumeClaimTemplate生成 volumeClaimTemplates: - metadata: name: kafka-data annotations: # 关键:绑定到Local PV的StorageClass volume.beta.kubernetes.io/storage-class: "local-ssd" spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 500Gi

重点解释三个易错字段:

  • podManagementPolicy: "OrderedReady":这是StatefulSet的默认值,但必须显式声明。它确保kafka-0的Readiness Probe返回true后,kafka-1才开始创建。如果删掉这行,K8s会并行创建所有Pod,kafka-1可能在kafka-0还没完成ZooKeeper注册前就尝试连接,导致初始化失败。
  • rollingUpdate.partition: 2:滚动更新时,只更新序号≥2的Pod(即kafka-2),kafka-0和kafka-1保持运行。这是为了保证Controller Quorum(至少2个投票者)始终在线。Kafka 3.3+启用了KRaft模式,Controller和Broker角色分离,所以必须严格控制更新节奏。
  • KAFKA_CFG_CONTROLLER_QUORUM_VOTERS:这个字符串必须手敲,不能用$(...)变量替换。因为Controller Voter列表在集群启动时就固化,如果用变量,每个Pod会解析出自己的地址,导致Quorum无法形成。我们曾因复制粘贴漏掉一个逗号,三个Broker互相认为对方不是Voter,死锁了47分钟。

3.2 JVM参数调优:容器内存限制下的生存法则

Kafka是Java应用,但在容器里调JVM参数和物理机完全不同。K8s的resources.limits.memory是cgroup硬限制,一旦JVM堆外内存(Direct Buffer、Code Cache、Metaspace)超限,容器会被OOMKilled,且不会触发JVM的GC日志。我们线上踩过的最痛的坑是:给Pod设了8Gi内存limit,JVM-Xmx4g,结果压测时Direct Buffer占用3.5Gi,总内存超8Gi,Pod被杀,而JVM日志里连一行GC记录都没有。

正确姿势是:Heap + Off-Heap ≤ 0.7 × Container Memory Limit。计算公式如下:

  • 假设容器memory limit = 8Gi = 8192Mi
  • Heap设为-Xmx4g(4096Mi)
  • Off-Heap安全余量 = 8192 × 0.3 = 2457Mi
  • 其中Direct Buffer上限设为-XX:MaxDirectMemorySize=1500m
  • Metaspace上限设为-XX:MaxMetaspaceSize=512m
  • Code Cache保留默认(240m)

同时必须加两个关键参数:

  • -XX:+UseG1GC -XX:MaxGCPauseMillis=20:G1垃圾收集器对低延迟更友好;
  • -XX:+ExitOnOutOfMemoryError:一旦OOM立即退出,避免僵尸进程占用端口。

实操心得:用kubectl exec -it kafka-0 -- jstat -gc $(pgrep java)实时监控GC。如果G1OldGen使用率持续>85%,说明Heap太小;如果CCUsed(Compressed Class Space)增长过快,说明加载了太多动态代理类,要检查Spring Boot的AOP配置。

3.3 Kafka核心参数校准:脱离ZooKeeper后的全新平衡

Kafka 3.3+默认启用KRaft(Kafka Raft Metadata mode),彻底移除ZooKeeper依赖。这意味着所有元数据(Topic、Partition、ACL)都存在Kafka自己的日志里,配置项全面重构。最关键的三个参数必须手调:

参数名推荐值为什么必须改
process.rolesbroker,controller单节点同时承担Broker和Controller角色,简化架构;若分拆,需额外部署Controller-only节点
node.id与broker.id一致(如kafka-0→1)KRaft要求每个节点有唯一数字ID,必须和StatefulSet序号映射
controller.quorum.voters"1@kafka-0:9093,2@kafka-1:9093,3@kafka-2:9093"Controller Quorum必须显式列出所有投票者,地址必须是Pod内部可解析的域名

特别注意listeners和advertised.listeners的分离:

  • listeners是Kafka监听的本地地址,填:9092即可(绑定所有网卡);
  • advertised.listeners是告诉外部客户端“你该连谁”,必须填完整域名。我们曾把advertised.listeners错写成PLAINTEXT://localhost:9092,结果Consumer连上来后,Kafka返回的元数据里全是localhost,Consumer又去连自己本地的127.0.0.1,彻底断联。

4. 实操过程与核心环节实现:从零部署到压测验证的完整流水线

4.1 环境准备:四步筑基,缺一不可

第一步:准备Local PV存储池
在每台Kafka专用节点上执行:

# 创建SSD挂载点 mkdir -p /mnt/ssd-kafka # 格式化(假设设备是/dev/nvme0n1) mkfs.xfs -f /dev/nvme0n1 # 挂载并设置开机自启 echo '/dev/nvme0n1 /mnt/ssd-kafka xfs defaults 0 0' >> /etc/fstab mount -a # 创建Local PV对象(以节点kafka-node-0为例) cat <<EOF | kubectl apply -f - apiVersion: v1 kind: PersistentVolume metadata: name: local-pv-kafka-0 labels: type: local spec: capacity: storage: 500Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: local-ssd local: path: /mnt/ssd-kafka nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - kafka-node-0 EOF

注意:persistentVolumeReclaimPolicy: Retain是关键!如果设为Delete,Pod删除后PV会被自动清理,下次StatefulSet重建时PVC无法绑定到原有PV,数据全丢。

第二步:创建Headless Service

apiVersion: v1 kind: Service metadata: name: kafka-headless labels: app: kafka spec: clusterIP: None # Headless核心标志 ports: - port: 9092 name: client - port: 9093 name: controller selector: app: kafka

第三步:配置ConfigMap存Kafka Server.properties

apiVersion: v1 kind: ConfigMap metadata: name: kafka-config data: server.properties: | # 基础配置 broker.id=0 process.roles=broker,controller node.id=0 controller.quorum.voters=1@kafka-0.kafka-headless.default.svc.cluster.local:9093,2@kafka-1.kafka-headless.default.svc.cluster.local:9093,3@kafka-2.kafka-headless.default.svc.cluster.local:9093 listeners=PLAINTEXT://:9092,CONTROLLER://:9093 listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT inter.broker.listener.name=PLAINTEXT # 性能调优 num.network.threads=8 num.io.threads=16 socket.send.buffer.bytes=102400 socket.receive.buffer.bytes=102400 log.flush.interval.messages=10000 log.retention.hours=168 # KRaft专用 group.initial.rebalance.delay.ms=3000

提示:log.flush.interval.messages设为10000而非默认的1000000,是为了在磁盘IO波动时更快刷盘,避免Producer因NOT_ENOUGH_REPLICAS错误超时。

第四步:打节点污点并部署

# 给三台节点打污点 kubectl taint nodes kafka-node-0 dedicated=kafka:NoSchedule kubectl taint nodes kafka-node-1 dedicated=kafka:NoSchedule kubectl taint nodes kafka-node-2 dedicated=kafka:NoSchedule # 部署StatefulSet、Service、ConfigMap kubectl apply -f kafka-headless-svc.yaml kubectl apply -f kafka-configmap.yaml kubectl apply -f kafka-statefulset.yaml

4.2 启动验证:五层检查法,拒绝“假成功”

部署完成后,别急着发消息,按顺序执行五层检查:

第一层:Pod状态与事件

kubectl get pods -l app=kafka # 应看到 kafka-0 1/1 Running, kafka-1 1/1 Running, kafka-2 1/1 Running kubectl describe pod kafka-0 # 检查Events里是否有Warning,特别是"FailedScheduling"或"FailedMount"

第二层:存储绑定

kubectl get pvc # 应看到 kafka-data-kafka-0, kafka-data-kafka-1, kafka-data-kafka-2,STATUS为Bound kubectl exec -it kafka-0 -- df -h /bitnami/kafka/data # 应显示挂载的是500Gi Local PV,而非tmpfs或rootfs

第三层:DNS解析

kubectl exec -it kafka-0 -- nslookup kafka-1.kafka-headless.default.svc.cluster.local # 必须返回10.244.x.x的IP,且kafka-1/kafka-2/kafka-0三者互ping通 kubectl exec -it kafka-0 -- ping -c 2 kafka-1.kafka-headless.default.svc.cluster.local

第四层:Kafka内部健康

# 进入kafka-0,用自带脚本检查集群 kubectl exec -it kafka-0 -- /opt/bitnami/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 # 应返回各API版本,无ERROR kubectl exec -it kafka-0 -- /opt/bitnami/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe # 应显示quorum状态为"Ready",voters包含1,2,3

第五层:端口连通性

# 从集群外节点测试(假设NodePort暴露了9092) telnet kafka-0.my-kafka-svc.default.svc.cluster.local 9092 # 必须能连通,且nc -v命令返回"Connected"

常见失败点:kafka-metadata-quorum.sh describe返回Error while executing metadata quorum command。90%是因为controller.quorum.voters里的域名无法被Pod内部解析。此时用kubectl exec -it kafka-0 -- cat /etc/resolv.conf检查nameserver是否指向CoreDNS,再用nslookup逐个测试voter域名。

4.3 压测与调优:用kafka-producer-perf-test实测吞吐瓶颈

部署通过后,用官方压测工具验证真实性能:

步骤1:创建测试Topic

kubectl exec -it kafka-0 -- /opt/bitnami/kafka/bin/kafka-topics.sh \ --create \ --bootstrap-server localhost:9092 \ --topic perf-test \ --partitions 12 \ --replication-factor 3 \ --config retention.ms=3600000

注意:--partitions 12不是随便写的。我们按经验公式:分区数 = max(生产者线程数, 消费者线程数) × 2。12个分区能充分利用3节点集群的并行度。

步骤2:启动Producer压测

kubectl exec -it kafka-0 -- /opt/bitnami/kafka/bin/kafka-producer-perf-test.sh \ --topic perf-test \ --num-records 5000000 \ --record-size 1024 \ --throughput -1 \ --producer-props bootstrap.servers=kafka-0.kafka-headless.default.svc.cluster.local:9092 \ --print-metrics

观察输出中的Records sent per second和Max latency ms。我们线上标准是:

  • 3节点集群,12分区,500MB/s吞吐下,P99延迟<15ms;
  • 若延迟>30ms,优先检查磁盘IO:kubectl exec -it kafka-0 -- iostat -x 1 5,看%util是否持续>95%;
  • 若%util正常但延迟高,检查网络:kubectl exec -it kafka-0 -- tcpping kafka-1.kafka-headless.default.svc.cluster.local 9092,看是否有丢包。

步骤3:模拟故障验证容灾

# 手动删除kafka-1 Pod kubectl delete pod kafka-1 # 观察kafka-1是否在30秒内重建,并加入集群 kubectl exec -it kafka-0 -- /opt/bitnami/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe # 应显示voter 2的状态从"Offline"变为"Ready" # 同时检查Producer压测是否中断:若中断时间>10秒,说明Controller切换太慢,需调`controller.quorum.election.timeout.ms`(默认10000,可降至5000)

5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训

5.1 “Broker ID冲突”问题:自动分配的甜蜜陷阱

现象:kafka-0启动正常,kafka-1日志报错ERROR [KafkaServer] Fatal error during KafkaServer startup. Prepare to shutdown (kafka.server.KafkaServer),接着是java.lang.IllegalArgumentException: Broker id 1 is already registered。

原因:很多人用KAFKA_BROKER_ID: "0"这种静态值,但StatefulSet的volumeClaimTemplates会为每个Pod创建独立PVC,而Kafka首次启动时会在log.dirs下生成meta.properties文件,里面固化了broker.id。当kafka-1用broker.id=0启动,发现/bitnami/kafka/data/meta.properties里已有broker.id=0,直接拒绝。

解决方案:用Pod名动态生成Broker ID。在StatefulSet的env里加:

- name: KAFKA_BROKER_ID valueFrom: fieldRef: fieldPath: metadata.name

然后在entrypoint脚本里(或用initContainer)把kafka-0转成数字1:

# 在容器启动脚本中 BROKER_ID=$(echo $MY_POD_NAME | sed 's/kafka-//') echo "broker.id=$BROKER_ID" >> /opt/bitnami/kafka/conf/server.properties

这样kafka-0→1,kafka-1→2,完美匹配KRaft的node.id。

5.2 “Controller选举失败”问题:Quorum投票的隐形门槛

现象:三个Pod都Running,但kafka-metadata-quorum.sh describe显示voter 1: Offline,voter 2: Offline,只有voter 3: Ready,集群无法创建Topic。

原因:Controller Quorum要求多数派(majority)在线。3节点集群,至少2个voter必须就绪。而voter 1离线,往往是因为它启动时controller.quorum.voters里的kafka-0域名解析失败,导致无法连接其他voter。

排查步骤:

  1. 进入kafka-0,检查/opt/bitnami/kafka/logs/controller.log,搜索Failed to resolve;
  2. 执行nslookup kafka-1.kafka-headless.default.svc.cluster.local,确认是否超时;
  3. 检查CoreDNS日志:kubectl logs -n kube-system deployment/coredns,看是否有SERVFAIL;
  4. 最终发现是kafka-1的Pod IP被其他服务占用,导致CoreDNS缓存了错误A记录。

解决:强制刷新CoreDNS缓存(kubectl rollout restart deployment coredns -n kube-system),并给StatefulSet加spec.podManagementPolicy: "Parallel"临时加速启动(仅调试用,生产环境必须切回OrderedReady)。

5.3 “Producer连接超时”问题:advertised.listeners的终极迷局

现象:Producer代码里写bootstrap.servers=kafka-0.my-kafka-svc.default.svc.cluster.local:9092能连上,但发消息时报org.apache.kafka.common.errors.TimeoutException: Failed to update metadata after 60000 ms。

原因:Producer连上kafka-0后,kafka-0返回的元数据里,kafka-1和kafka-2的地址是advertised.listeners配置的值。如果kafka-0的advertised.listeners填的是PLAINTEXT://kafka-0.kafka-headless.default.svc.cluster.local:9092,那Producer拿到的就是这个域名;但如果Producer不在K8s集群内(比如本地开发机),这个域名根本无法解析。

解决方案:双advertised地址。在ConfigMap里配置:

advertised.listeners=PLAINTEXT://kafka-0.my-kafka-svc.default.svc.cluster.local:9092,INTERNAL://kafka-0.kafka-headless.default.svc.cluster.local:9092 listener.security.protocol.map=PLAINTEXT:PLAINTEXT,INTERNAL:PLAINTEXT inter.broker.listener.name=INTERNAL

然后Producer连接时指定bootstrap.servers=kafka-0.my-kafka-svc.default.svc.cluster.local:9092,Kafka会根据客户端IP自动返回对应地址——集群内客户端收到INTERNAL地址,集群外收到PLAINTEXT地址。

5.4 “磁盘空间告警”问题:日志清理的定时炸弹

现象:运行一周后,kubectl df -h显示/bitnami/kafka/data使用率98%,但kafka-log-dirs.sh --describe显示所有Topic的size总和只有200Gi,远小于500Gi PVC。

原因:Kafka的log.retention.hours只控制日志段(segment)的删除,但Controller日志、Transaction日志、Delegation Token日志不受此参数影响,它们默认永不清除,会持续增长。

解决方案:在server.properties里显式配置:

# Controller日志保留7天 controller.log.retention.hours=168 # Transaction日志保留24小时 transaction.state.log.retention.hours=24 # Delegation Token日志保留7天 delegation.token.expiry.time.ms=604800000

并添加CronJob定期清理:

apiVersion: batch/v1 kind: CronJob metadata: name: kafka-log-cleaner spec: schedule: "0 2 * * *" jobTemplate: spec: template: spec: containers: - name: cleaner image: bitnami/kafka:3.6.0 command: ["/bin/sh", "-c"] args: - "find /bitnami/kafka/data -name '*-controller-*' -mtime +7 -delete && find /bitnami/kafka/data -name '*-transaction-*' -mtime +1 -delete" volumeMounts: - name: kafka-data mountPath: /bitnami/kafka/data volumes: - name: kafka-data persistentVolumeClaim: claimName: kafka-data-kafka-0 # 此处需为每个Pod单独写

我个人在实际操作中的体会是:K8s上跑Kafka,70%的精力花在存储和网络,30%在参数调优。每次上线前,我必做三件事:用iostat盯10分钟磁盘IO,用nslookup测10次DNS解析,用kafka-metadata-quorum.sh查5次Quorum状态。这三招看起来笨,但能避开80%的线上事故。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进