Kafka 只有它连不上:advertised.listeners 广播地址配错排查复盘
引言
客户现场有一套自建的 Kafka,用 Docker Swarm 部署,单节点 KRaft 模式,全公司好几个业务系统都挂在上面收发消息。某天接到反馈:有一个应用一直连不上 Kafka,其他应用全都好好的。
这种”别人都没事,就它不行”的故障是最容易把人带沟里的。第一反应几乎必然是——是不是这个应用自己的配置写错了?是不是它跑的那台机器网络有问题?
最后的结论确实和 Kafka 配置有关,但根因不在那个应用身上,也不在其他任何一个应用身上,而在一个大多数人都配过、却很少有人真正理解其语义的参数:KAFKA_ADVERTISED_LISTENERS。
更准确地说:这个参数配错的故障表现,天生就是”选择性的“——它不会打死所有人,只会打死那些网络路径和它不匹配的客户端。这也正是它最迷惑人的地方。
一、故障现象
先交代一下环境拓扑,这是后面所有推理的基础:
- Kafka 跑在客户的 Swarm 集群里,通过一个统一的对外入口访问,下文把这个入口的域名脱敏为
pfscene.example.com(真实域名保留在客户环境里)。 - 大部分业务应用部署在内网,和 Kafka 处在可以互通的网段。
- 出问题的那个应用部署在另外一台独立机器上,它连不到 Kafka 的内网地址,只能走
pfscene.example.com这个对外入口。
故障表现的形态很典型。应用启动之后日志里反复刷:
1 | |
Python 客户端那边是另一个长相,但本质一样:
1 | |
这里有个细节值得先圈出来:报错里的地址是 192.168.0.196:9092,一个内网地址。而那个应用配置里写的连接地址明明是 pfscene.example.com。它从来没主动连过 192.168.0.196,这个地址是Kafka 主动告诉它的。
当时没人注意到这个细节,包括我在内。
二、第一轮排查:先证明它到底能不能连上
面对”连不上”的报障,第一步永远是把”连不上”这三个字拆开——是网络层不通,还是连上了但用不了?这两者的排查方向完全相反。
先从那台故障机器上做最朴素的端口探测:
1 | |
结果:通。
再让用户确认防火墙、安全组、出网策略,也都没有拦。也就是说:
网络是通的,应用能建立到 Kafka 的 TCP 连接,但它依然起不来。
这一步排除掉了一大半可能性(路由、ACL、防火墙、DNS 解析失败),同时把问题压缩到了一个很窄的范围里:连接建立得起来,但会话建立不起来。在 Kafka 的语境下,这几乎等价于一句话——客户端拿到的集群信息是错的。
同时,另一个事实也在反复敲打我们:其他应用全都正常。如果 Kafka 本身配置有问题,为什么它们没事?
说实话,到这里我的直觉也是偏向”那个应用自己的问题”——毕竟其他应用都正常这个反证太强了。这恰恰是这次排查里最大的思维陷阱:”其他人都正常”只能证明故障不是全局的,不能证明故障不在被怀疑的服务端。
三、关键一击:让 Kafka 自己交代它广播了什么
既然是”连上了但用不了”,那就要看客户端在握手之后拿到了什么。Kafka 的元数据是可以用命令行直接问出来的,这也是这次排查里最快的一击:
1 | |
输出大意如下(已脱敏):
1 | |
真相就在这里,一行字:
from broker -1: pfscene.example.com:19092/bootstrap——这一行是我主动连的地址,也就是 bootstrap 地址,Kafka 标记为broker -1,意思是”还没分配身份的入口”。broker 1 at 192.168.0.196:9092——这一行是 Kafka 告诉我”你应该来这儿找我” 的地址。
于是整个故障链条一下子闭合了:
- 应用连
pfscene.example.com:19092→ 成功(所以nc是通的)。 - Kafka 回了一句”我这个 broker 在
192.168.0.196:9092“。 - 应用老老实实去连
192.168.0.196:9092→ 那台独立机器根本不在这个内网里,路由不可达 → 超时、重试、再超时。
补一刀验证这个推断:
1 | |
再回到 broker 侧确认配置本身:
1 | |
现场的 compose 片段(缩进已整理):
1 | |
KAFKA_LISTENERS 里写 PLAINTEXT://:9092(即绑 0.0.0.0:9092)不是问题的根源——它决定了 broker 在哪张网卡上接客,是服务端视角的配置。真正捅娄子的是 KAFKA_ADVERTISED_LISTENERS,它是客户端视角的配置,含义是:
“客户端你好,请你用这个地址回来找我。”
而这个地址,被硬编码成了一个外部客户端永远够不着的内网 IP。
四、原理:bootstrap 只是”敲门砖”
如果要问 Kafka 网络配置里最容易踩、后果最隐蔽的一个坑是什么,我会毫不犹豫投给 advertised.listeners。它隐蔽的根本原因在于:Kafka 客户端不是只连一个地址,而是连两次。
一次完整的客户端接入过程是这样的:
1 | |
- 第一阶段(bootstrap):客户端拿
bootstrap.servers里的地址,随便挑一个连上去。这个地址的作用仅仅是敲门——它只要能带着客户端进入集群即可,可以是任何一个 broker。 - 第二阶段(metadata):连上之后客户端会请求一份集群元数据。broker 在响应里给出”分区 leader 在哪台 broker 上”,而这些 broker 的地址,就是它们各自
advertised.listeners里配的值,由 broker 原样广播出去。 - 第三阶段(真正干活):客户端丢弃 bootstrap 地址,改用 metadata 里返回的地址去连真正的 leader 收发消息。
关键点在于:Kafka 完全不关心它广播出去的地址对客户端是否可达。 它只是如实转述配置里写的东西。地址能不能通,是配置者的责任。
所以现场的现象不是巧合,而是必然:
| 客户端位置 | bootstrap 用的地址 | metadata 拿到的 broker 地址 | 结果 |
|---|---|---|---|
| 内网 / Swarm 内的应用 | kafka:9092 或内网 IP |
192.168.0.196:9092 |
可达 → 正常 |
| 那台独立机器上的应用 | pfscene.example.com:19092 |
192.168.0.196:9092 |
不可达 → 超时 |
这张表就是”其他应用都正常、只有它不行”的全部答案:同一个 broker、同一份配置,对不同网络路径的客户端产生了不同的结果。advertised 配错的故障面不是”全体下线”,而是”走到不可达路径的那部分客户端”——这就是它被称为选择性故障的原因。
顺带说一句,如果 advertised.listeners 干脆不配会怎样?Kafka 会退而使用 listeners 的值;而如果 listeners 里的 host 部分留空(就像上面那样写成 PLAINTEXT://:9092),Kafka 会拿 java.net.InetAddress.getCanonicalHostName() 去猜——在容器里通常得到的是容器主机名或容器 IP,那是一个比内网 IP 更不可达的地址。所以这个参数不但要配,还必须配对。
五、为什么”把那一行改掉”不是正确答案
知道了根因,最直觉的修复是:把广播地址改成对外入口不就行了?
1 | |
这个改法确实能让那台独立机器连上。但它会顺手打死另一批客户端。
别忘了,同一套服务里还躺着一个内部组件——Kafdrop(Kafka 的 Web 管理界面),它就在同一个 Swarm 网络里:
1 | |
改完之后会发生什么?
- Kafdrop 用
kafka:9092做 bootstrap → 依然连得上。 - broker 回它一句”broker 1 在
pfscene.example.com:19092“。 - Kafdrop 转身去连
pfscene.example.com→ 如果容器内的 DNS 解析不到这个名字,或者解析出来但回程路由不通(对外入口多半做了 NAT 甚至反向代理),它照样挂。
于是就成了按下葫芦浮起瓢:修好了外部的,打死了内部的。
问题的本质在于:advertised.listeners 是按 listener 广播的,而当时只有一个 listener,所以全天下所有客户端只能听到同一个地址。可现实里客户端来自两条不同的网络路径,一条走内部服务名,一条走对外入口。一个地址伺候不了两条路径。
六、正确解法:拆成内、外两个 listener
Kafka 早就为这种场景准备好了机制——多 listener:broker 在不同网卡/端口上监听,并针对每类客户端广播各自可达的地址。
改造后的完整 compose(Swarm stack 文件,可直接 docker stack deploy):
1 | |
和现场原配置逐项对比,实质改动其实只有四处:
| 配置项 | 改造前 | 改造后 |
|---|---|---|
KAFKA_LISTENERS |
PLAINTEXT://:9092,CONTROLLER://:9093 |
拆成 INTERNAL / EXTERNAL / CONTROLLER 三个 |
KAFKA_ADVERTISED_LISTENERS |
PLAINTEXT://192.168.0.196:9092 |
按 listener 分别广播内外两个地址 |
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP |
只登记 CONTROLLER / PLAINTEXT |
登记全部三个,并新增 KAFKA_INTER_BROKER_LISTENER_NAME |
ports |
只发布 9092 | 追加发布 19092(网关侧同步调整指向) |
其余(KRaft 单节点、副本因子、mode: host、外部网络 sf_net)都保持原样。顺带说明两处”看着像配置、实际不生效”的字段:container_name 和 depends_on 在 Swarm 模式下都会被忽略,前者由 <stack>_<service>.<序号>.<hash> 规则生成容器名,后者只剩语义表达——它们不影响功能,但排查时别拿容器名去 docker exec(下一节验证时会用到这一点)。
几个容易忽略的细节:
KAFKA_INTER_BROKER_LISTENER_NAME不能省。 单 listener 时它无所谓,一旦配了多个 listener,broker 就不知道集群内部通信该走哪一个,启动时会直接抛配置异常。这里指定INTERNAL,因为 broker 之间(当前是单节点自连)走内部网络最稳。advertised的端口允许和实际监听端口不一致。 上面EXTERNAL://pfscene.example.com:19092里的 19092 是”告诉客户端来连这个端口”,broker 自身则在EXTERNAL://:19092上监听。这是网关/NAT 场景下的合法用法(早年很多”端口映射后客户端连不上”的问题,根源就在于此)——代价是所有外部客户端都必须真的能从那个地址和端口进来。- 内部地址用服务名而不是宿主内网 IP。 原来写
192.168.0.196的问题是:集群迁到别的节点、宿主机换 IP,配置全废。改成 Swarm 服务名kafka:9092之后,内部客户端解析的是集群 DNS,跟宿主机 IP 解耦。 mode: host发布端口时,端口落在运行容器的那个节点上,不经过 Swarm 的 routing mesh。所以pfscene入口必须指向实际跑着 broker 的那个节点,别指望随便指一个节点都能转发。
改完 advertised.listeners 属于静态配置,必须重启 broker 才生效,不能靠动态配置下发:
1 | |
七、验证:用两个视角各问一次
修复之后不要急着宣告成功,用同一个命令从两条路径分别验证一次,比什么都直观:
1 | |
这里有一条可以刻进肌肉记忆的判据:
kcat -L输出里broker N at ...那一行的地址,必须和你的 bootstrap 地址属于同一条可达路径。
如果两者一个是内网 IP、一个是对外域名,那这个客户端迟早要出事——出事的时刻,就是它真正开始收发消息的时候。
顺带提醒两个行为:
- 修改生效后,客户端可能还抱着旧的 metadata 缓存不放。Java 客户端的
metadata.max.age.ms默认 5 分钟,急的话直接重启客户端验证。 - 如果客户端报的是
Topic xxx not present in metadata,别急着去查 topic 存不存在。这个报错的真实含义往往是”我连不上任何 leader”,元数据获取失败和 topic 不存在,在这里是同一个症状。
八、经验固化
这次故障的技术含量其实不高,但很值得记下来,因为它踩中了好几个经典的认知陷阱。
一、”其他人都正常”证明不了”服务端没问题”。
这个反证只能说明故障不是全局的。凡是”部分客户端异常”的故障,都要先问一句:这部分客户端有什么共同点? 这次答案是”它们在另一条网络路径上”——而这句话直接指向了 advertised 配置。
二、报错里出现的陌生 IP,是服务端主动告诉客户端的。
应用配置里明明写的是 pfscene.example.com,日志里却在连 192.168.0.196。这种”身份不明的地址”不要放过,它八成来自服务端的元数据广播,而不是客户端配置。Kafka 的这个地址就是 advertised.listeners。
三、”端口通”不等于”能连上”。nc -zv 通只验证了四层可达,Kafka 的会话能不能建立,还要看第二阶段拿到的地址对不对。凡是”端口通、应用不通”的中间件故障,都该往元数据/协商层去想。
四、配置来源要区分”服务端视角”和”客户端视角”。listeners 是服务端绑在哪里,advertised.listeners 是告诉客户端去哪里。混为一谈就会写出一个”服务端自己觉得没问题、客户端全都连不上”的配置。类似的还有 Redis 的 cluster announce、Elasticsearch 的 publish_address,套路如出一辙。
五、动手前先问一句”这个改动会打死谁”。
把 broadcast 地址改成对外域名,能救外部客户端,但会顺手打死内部客户端(比如 Kafdrop)。在只有一个 listener 的前提下,任何单点修改都是在两类客户端之间二选一——正确的动作不是选边,而是把 listener 拆开。
最后一句话收尾:这个参数的正确写法从来没有绝对答案,它取决于你的客户端从哪里来。想清楚有哪些网络路径,就配几个 listener,让每条路径上的客户端都听到自己够得着的地址——这才是 advertised.listeners 的正确打开方式。