直接上结论:docker-compose单机部署一整套 Kafka 环境非常简单,很多人第一步都是这么干——一个 YAML 文件把 Zookeeper、Kafka 全塞进同一台机器,端口映射出来,测试完事。但一旦把同样的套路搬到多台服务器上,你就会发现各种连不上、leader 选举失败、client 读写超时。这套东西坑非常多,而且大多数坑不在 Kafka 本身,而是在网络拓扑、hostname 解析、advertised.listeners 这些平时单机环境根本不会暴露的细节上。这篇文章我会把多主机(跨物理机/虚拟机)环境用 docker-compose 部署 Zookeeper 集群和 Kafka 集群的完整过程拆开讲,包括架构设计、compose 文件编写、环境变量含义、启动验证顺序,以及我在实际部署中踩过的坑和排查思路。适合已经会写单机 Kafka compose、但对多机集群还处于“大概知道要改 IP,但不知道还要改什么”阶段的人。1. 部署前必须想清楚的四件事1.1 这不是“复制三份”那么简单单机 compose 里,所有容器跑在同一个 docker bridge 网络,容器之间通过服务名互相访问,比如zookeeper:2181、kafka:9092,Docker 自带的 DNS 自动完成解析。你根本不需要关心容器 IP 是多少、对外暴露什么端口,反正面试官也不会问。多主机环境完全不同。三台机器上的容器不在同一个网络命名空间,Docker 默认的网络模型在这里是行不通的。你要么选择 overlay 网络(docker swarm 或手动创建 attachable overlay network),要么选择最简单直接的host 网络模式,让容器直接使用宿主机网络栈。我在这篇文章里推荐的是 host 模式。原因很简单:Kafka 和 Zookeeper 对网络延迟和端口映射的敏感度很高,host 模式没有 NAT 转发,性能损耗最小,而且端口、IP 全部透明,排查问题的时候心理压力小很多。代价是你要自己管理端口冲突,以及手动维护各节点之间的 hostname 解析。1.2 Kafka 与 Zookeeper 的版本关联部署之前先确认版本组合。旧版 Kafka 依赖 Zookeeper 做元数据存储、Controller 选举、broker 注册。虽然 Kafka 3.x 开始引入 KRaft 模式,可以去掉 Zookeeper,但生产环境里大量存量集群还是基于 Zookeeper 架构,而且很多组件(比如某些消息中间件、数据同步工具、历史运维脚本)仍然假设 Zookeeper 存在。所以这篇内容完全没说老黄历,反而是很多团队迁移 KRaft 之前必须掌握的基础功。版本选型上,我建议用稳定组合:Zookeeper:zookeeper:3.6.3或3.8.xKafka:apache/kafka:3.4.0或3.5.x,注意apache/kafka:3.x官方镜像和旧的wurstmeister/kafka镜像环境变量不完全一致用官方镜像而不是某个第三方封装镜像,主要原因是可维护性。第三方镜像停更多年、环境变量和安装路径不同,出了问题你连文档都不好找。后面所有配置我都基于官方镜像来写。1.3 主机规划与域名解析是大前提为了让三台机器上的容器互相用 hostname 访问,你需要在每台宿主机上把主机名写到 hosts 文件里。这个操作如果不做,后面 Kafka 的 broker 注册、ZK 的 quorum 通信全会出问题,而且报错信息非常不直观。我习惯的规划是三台机器跑一个 ZK 节点加一个 Kafka broker,一套 3 节点集群,既能容错(允许挂一台),又不需要太多资源。当然你也可以把 ZK 和 Kafka 拆开部署到 6 台机器,但那至少需要 6 台机器,很多测试环境其实没这条件。ZK 单独 3 台 + Kafka 单独 3 台更适合生产环境,本文方案重点在讲清原理,你完全可以根据资源调整。1.4 数据持久化:不能全依赖容器层容器是一次性的,这不用我说你也知道。但很多人部署集群时图省事,不给 ZK 和 Kafka 挂数据卷,结果容器重启一下,元数据直接丢了,Zookeeper 里的事务日志没了,Kafka 里的消息全没了。你哪怕是在做测试,也应该挂 volume。数据目录最好是放在宿主机独立磁盘或远程存储上,配置层面我下面会给出卷映射方式。生产环境建议用 NFS 或多副本存储,但那是基础设施的事,不是这篇文章的重点。2. 整体架构设计与节点规划2.1 推荐的三节点拓扑我这次部署的规划如下:宿主机器hostname 对应名称角色内网 IP机器 Azk1 / kafka1Zookeeper + Kafka192.168.1.11机器 Bzk2 / kafka2Zookeeper + Kafka192.168.1.12机器 Czk3 / kafka3Zookeeper + Kafka192.168.1.13注意:这里zk1、kafka1不是宿主机 hostname,而是将来容器启动时指定的 hostname。因为 Kafka 和 ZK 都要监听外网,advertised 地址会用到它。每台机器的 hosts 文件都要加上三行:192.168.1.11 zk1 kafka1 192.168.1.12 zk2 kafka2 192.168.1.13 zk3 kafka3为什么要写两个名字?因为 ZK 集群内部通信用的是zk1、zk2、zk3,Kafka broker 之间以及客户端访问用的是kafka1、kafka2、kafka3。虽然在这里它们指向同一台机器,但职责不同,分开命名方便你后续某一天把 Kafka 横向扩展成独立节点而不用大改。2.2 端口规划host 模式下容器的监听端口就是宿主机的监听端口,所以端口不能冲突。需要开放的端口如下:组件端口用途Zookeeper client2181客户端连接 ZKZookeeper 选举通信2888follower 与 leader 通信Zookeeper 投票端口3888leader 选举投票Kafka client9092客户端生产/消费本质上只需要开放 9092 和 2181 给外部客户端,2888 和 3888 只需要在三台机器之间互通。如果中间有防火墙,自己心里要有数。2.3 关于“到底是 Kafka 连 ZK,还是客户端先连 ZK?”这是一个常见混淆点。客户端连的是 Kafka 的 9092,不直接连 ZK(除了老式 admin 工具偶尔会直接查 ZK)。Kafka broker 自己启动时会连接 ZK 完成注册和元数据同步。所以你在 compose 里配置 ZK 地址,是给 Kafka broker 用的,不是给外部客户端用的。3. compose 文件编写与逐项拆解3.1 目录结构建议每台机器上使用相同的目录结构,方便维护:/opt/cluster/ ├── zk/ │ ├── data/ # ZK 数据目录 │ └── datalog/ # ZK 事务日志目录 ├── kafka/ │ └── data/ # Kafka 日志目录 └── docker-compose.yml三台机器上的 compose 文件,除节点编号、hostname 之外,其余内容完全一致。3.2 Zookeeper 的 compose 配置以机器 A 为例,zk服务的配置应该是这样的:services: zk: image: zookeeper:3.8.1 container_name: zk hostname: zk1 restart: unless-stopped network_mode: host environment: ZOO_MY_ID: 1 ZOO_SERVERS: server.1=zk1:2888:3888;2181 server.2=zk2:2888:3888;2181 server.3=zk3:2888:3888;2181 ZOO_4LW_COMMANDS_WHITELIST: "*" volumes: - /opt/cluster/zk/data:/data - /opt/cluster/zk/datalog:/datalog机器 B 只需要把ZOO_MY_ID改成 2,hostname改成zk2,机器 C 同理。这里有几个必须解释清楚的地方:ZOO_SERVERS的格式:空格分隔多个server.X=host:2888:3888;2181,host必须能解析成 IP。如果解析不了,ZK 集群之间连不上,日志会一直刷Cannot open channel to X at election address。ZOO_MY_ID为什么不能乱:ZK 通过 myid 区分每个节点,三台机器的 myid 必须分别是 1、2、3,不能重复。写错了会出现两个节点认为自己是同一个成员,日志疯狂报错。ZOO_4LW_COMMANDS_WHITELIST: "*":这个是给后续用zkCli.sh或运维监控四字命令(ruok、mntr)准备的。不开白名单,echo ruok | nc localhost 2181会直接失败。3.3 Kafka 的 compose 配置Kafka 服务配置如下(机器 A):kafka: image: apache/kafka:3.4.0 container_name: kafka hostname: kafka1 restart: unless-stopped network_mode: host environment: KAFKA_BROKER_ID: 1 KAFKA_LISTENERS: PLAINTEXT://:9092 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:9092 KAFKA_ZOOKEEPER_CONNECT: zk1:2181,zk2:2181,zk3:2181 KAFKA_NUM_PARTITIONS: 3 KAFKA_DEFAULT_REPLICATION_FACTOR: 2 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 2 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 KAFKA_MIN_INSYNC_REPLICAS: 2 KAFKA_LOG_DIRS: /var/lib/kafka/data volumes: - /opt/cluster/kafka/data:/var/lib/kafka/data机器 B 改KAFKA_BROKER_ID: 2、hostname 为kafka2,机器 C 改成 3。3.4 最重要的一行:advertised.listeners如果你的集群只需要接收本机 localhost 请求,那KAFKA_ADVERTISED_LISTENERS不配也没事。但只要你希望从其他机器(无论客户端还是其他 broker)访问,这一行就决定生死。这里面的原理是:listeners:broker 监听哪个网卡和端口。advertised.listeners:broker 对外宣告的地址,客户端拿到这个地址之后才会去连接。单机部署时,容器里经常被设成PLAINTEXT://localhost:9092,你在本机测试完全正常。一旦换到多机环境,别的机器按 localhost 来找 Kafka,结果找到自己机器上去了,当然连不上。所以必须把 advertised 地址写成其他机器能解析到的主机名或 IP。注意一个细节:advertised.listeners里面写的是kafka1:9092,不是192.168.1.11:9092。我在前面要求 hosts 文件里做了解析,所以这里用主机名完全没问题。如果直接写 IP 也可以,但一旦机器迁移或换网段,配置就要全部跟着改,主机名的可维护性好很多。3.5 镜像环境变量的命名习惯apache/kafka官方镜像内部会把KAFKA_前缀的环境变量自动转换成配置文件里的同名属性。所以:KAFKA_BROKER_ID对应broker.idKAFKA_ADVERTISED_LISTENERS对应advertised.listenersKAFKA_ZOOKEEPER_CONNECT对应zookeeper.connect如果你看到网上有些文章用KAFKA_BROKER_ID=1 BROKER_ID_COMMAND或者需要写-e KAFKA_offset_topic_replication_factor之类,那多半是旧版第三方镜像的写法。官方镜像直接驼峰写就好,省心很多。4. 启动顺序、验证流程与常见操作4.1 启动顺序:先 ZK,后 Kafka这个顺序是必须遵守的。Kafka broker 启动时要向 ZK 注册,如果 ZK 还没就绪,Kafka 启动会反复重试,日志里报SessionExpiredException或连接超时,虽然不一定崩,但会让你误以为配置出问题。启动动作如下:# 在三台机器上都执行:先启动 zk docker compose up -d zk # 等 20 秒,让 zk 完成 leader 选举 # 再启动 kafka docker compose up -d kafka怎么确认 ZK 集群起来了?在任意一台机器上执行:for i in 1 2 3; do echo "server.${i}: $(echo mntr | nc zk${i} 2181)" done如果输出里有zk_server_state leader或follower,说明集群已经选主完成。如果三个节点全部显示standalone,说明三台机器之间没连起来,问题大概率出在 hosts 解析或防火墙。当然,前提是你已经配置了ZOO_4LW_COMMANDS_WHITELIST: "*",否则四字命令会被拒绝。4.2 验证 Kafka broker 是否注册ZK 起来后,检查 Kafka 是否成功注册。连到任意一台 ZK,执行:docker exec -it zk zkCli.sh -server 127.0.0.1:2181 ls /brokers/ids正常会输出[1, 2, 3]。如果只有部分 id,说明那台 broker 没起来或没注册成功,去对应机器的 Kafka 日志看报错。注意看日志位置。官方镜像的日志默认输出到 stdout,docker logs kafka就能看到。报错信息里如果出现Connection to node -1 could not be established. Broker may not be available.,通常不是 broker 之间连不上,而是客户端或测试工具能解析到的 hostname 不对,这个后面专门讲。4.3 创建 topic 并测试读写用一个能访问任意一台机器的环境(最好不是你部署集群的三台机器,而是另一台客户端机器),安装kafka_2.13-3.4.0.tgz或者直接使用 docker 镜像里的命令行工具。这里用官方镜像跑命令是最快的:# 创建 3 分区 3 副本的 topic docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --create \ --topic test-multi-host \ --partitions 3 \ --replication-factor 3 \ --bootstrap-server kafka1:9092注意:--bootstrap-server只需要写一个 broker 地址,客户端会通过 metadata 请求拿到所有 broker 的地址列表。但实际连接时,它会依据每个 broker 的 advertised.listeners 地址去连,所以你必须保证客户端机器能解析并访问kafka1、kafka2、kafka3这 3 个名字对应的 IP。验证 topic 状态:docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --describe \ --topic test-multi-host \ --bootstrap-server kafka1:9092理想情况是看到每个 partition 有 3 个副本,且所有副本都处于Isr状态(在 ISR 列表里)。只要有一个分区显示Replicas: 1,2之类只有两个副本,说明第三台机器的 broker 不可用或没注册上。生产消费验证我一般这样测:# 终端 1:消费 docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh \ --topic test-multi-host \ --bootstrap-server kafka2:9092 \ --from-beginning # 终端 2:生产 docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \ --topic test-multi-host \ --bootstrap-server kafka3:9092如果能正常收发,客户端链路就通了。先把这条链路走通,再谈生产环境优化。4.4 日常重启与滚动升级多主机集群维护时,不要三台机器一起重启。推荐滚动方式:先重启一台,确认 ZK 重新选主正常、Kafka broker 重新注册、ISR 收缩又恢复之后再操作下一台。Kafka 支持滚动重启,是因为分区副本在其它 broker 上有同步副本,短暂下线会影响延迟但不会丢数据(前提是min.insync.replicas设置合理)。ZK 因为集群本身有 3 节点,允许挂一个节点完成选主。5. 我踩过的几个坑与排查思路5.1 broker 启动超慢,日志里一直刷 ZK 连接超时这个问题我出现过好多次。先说结论:90% 是KAFKA_ZOOKEEPER_CONNECT里写的 hostname 解析不了。Kafka 容器使用 host 网络模式时,它会在宿主机的/etc/hosts里查找zk1、zk2、zk3。如果你只在其中一台机器写了 hosts,其他机器没写,那么那台机器上的 Kafka 就会连不上其它机器上的 ZK。排查命令很简单:docker exec -it kafka getent hosts zk1 docker exec -it kafka getent hosts zk2 docker exec -it kafka getent hosts zk3任何一个失败就说明 hosts 没配对。别再浪费时间看 Kafka 日志了,先解决解析问题。5.2 客户端能连上 ZK,但生产/消费时一直报 timeout典型报错:org.apache.kafka.common.errors.TimeoutException: Topic test-multi-host not present in metadata after 60000 ms.或者:Connection to node -1 could not be established. Broker may not be available.这不是 broker 挂了,而是你的客户端拿到了 broker 的 advertised 地址,但无法连接。最常见的原因是:你把KAFKA_ADVERTISED_LISTENERS配成了PLAINTEXT://localhost:9092,或者写成了容器内网 IP,比如172.17.0.2。客户端解析后连接了一个它自己机器上的不存在的端口,自然失败。正确写法是PLAINTEXT://kafka1:9092(对应 kafka2、kafka3),并确保客户端机器也能解析这三个 hostname。如果客户端机器不在同一个 DNS 环境,直接在客户端机器的 hosts 文件里加同样的映射也行。5.3 ZK 三节点互相看不到,用mntr全是 standalone别看程序单机,要看节点间通信。三台机器全部显示 standalone 说明它们之间就没有建立 quorum 连接。检查思路按顺序走:三台机器的/etc/hosts是否都写全了映射。三台机器防火墙是否对 2888、3888 端口放行。看 ZK 日志,搜Cannot open channel to,里面会显示它尝试连接哪个 IP、失败原因。确认ZOO_SERVERS里写的 host 不是localhost。这里有镜像默认值问题,官方镜像默认ZOO_SERVERS指向zookeeper:2888:3888,如果你没配这个环境变量,它默认连接一个叫zookeeper的 host,当然找不到。5.4 host 模式下容器内的 hostname 为什么老变?有些版本下,即使你在 compose 里指定了hostname: kafka1,容器内/etc/hosts依然会附加容器 ID 和宿主机 hostname 的映射。这不影响主要功能,只要 Kafka 进程解析PLAINTEXT://:9092时监听的是0.0.0.0就行。但有个隐性问题:如果宿主机名也叫kafka1,然后你又在另一台机器上把它的宿主机名也改成了kafka1,那 hosts 解析会重叠,出现逻辑混乱。所以我再三建议:部署这种多机容器集群之前,把宿主机命名为中性的名字,不要和容器 hostname、service 名混用。例如宿主机器分别叫node1、node2、node3,容器内部 hostname 才用集群角色名。5.5 Kafka 的offsets内部 topic 副本数不设置会怎样?如果KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR不设,单机默认就是 1,你三节点的集群里__consumer_offsets只有 1 个副本。一旦存放 offset 的那个 broker 挂了,整个消费组都会出问题。我习惯在三台机器的 compose 里统一设成 2 或 3。同理,transaction.state.log.replication.factor也建议设置,不然使用事务 API 时会出现NotEnoughReplicasException。这里有个小技巧:这些参数虽然是 compose 里靠环境变量设置的,但只能在broker 首次启动创建内部 topic 时生效。如果第一次启动没设置,内部 topic 已经创建了,你再改环境变量重启也不会重建,需要手动删除内部 topic 并重启,或者用kafka-configs.sh去改 topic 级别配置。所以我建议第一次启动的时候就全部配好,省得后面还要做手术。5.6 误将单机 compose 的 network 配置复制到多机有些开发者在单机的时候上了自定义 bridge network,并且给 Kafka 配了固定 IP(比如 static IPs),到多机部署时就想继续沿用。声明确实能配置,但跨主机的 bridge 网络默认是不互通的,要么你搭 overlay,要么老老实实用 host。折腾 overlay 对测试环境意义不大,我还是那句话:能用 host 就 host。6. 生产环境增强建议6.1 增加健康检查compose 的healthcheck在容器编排场景里非常有用。ZK 和 Kafka 都可以通过命令检查存活状态。ZK 加:healthcheck: test: ["CMD", "zkServer.sh", "status"] interval: 10s timeout: 5s retries: 3注意 ZK 容器里zkServer.sh status在没有写4lw.commands.whitelist时会失败,所以前面提到的白名单一定不能漏。Kafka 健康检查稍微麻烦一点,因为它没有直接暴露 health 端点,通常用kafka-broker-api-versions.sh拉一下 broker 版本号来判断:healthcheck: test: ["CMD", "/opt/kafka/bin/kafka-broker-api-versions.sh", "--bootstrap-server", "kafka1:9092"] interval: 15s timeout: 10s retries: 5这个健康检查适合 compose 管理场景,真实生产建议用更完善的监控接入,有条件的直接上 JMX exporter + Prometheus。6.2 资源限制Kafka 是吃内存大户,尤其 page cache 用得狠。compose 里建议限制:deploy: resources: limits: memory: 4g reservations: memory: 1gZK 相对轻量,1g 限制足够。不过docker compose的deploy字段需要 compose 文件版本 3 以上才支持。没有跨主机的编排需求时可以加,加了也就在单机维度限制容器内存,至少比不管要好。6.3 存储选择Zookeeper 和 Kafka 都是极端依赖磁盘顺序读写的组件。Kafka 的 log 段文件如果落在普通磁盘上,吞吐和延迟都会非常难看。建议:Kafka 数据目录放到 SSD,或者至少独立于系统盘。Zookeeper 的 data 和 datalog 目录也放到独立磁盘,数据量虽小,但事务日志的 fsync 性能直接影响整个集群稳定性。真实生产环境必须做数据备份和灾备,这里容器化部署并不能帮你免掉这些功课。我自己测试时遇到过一个问题:数据卷挂载的是机械硬盘,Kafka 消费延迟忽高忽低,ISR 频繁收缩又恢复。当时排查了半天的网络问题,最后才发现是磁盘 IO 跟不上。所以如果你是做性能相关的压测,先把磁盘性能基准测一下再上集群。7. 实际操作中我最想留给你的一句话如果你问我在多次多机部署里最深的体会是什么,那就是:多主机 Kafka 集群 90% 的问题都不是 Kafka 本身,而是网络语义没有理清。单机环境隔离了太多细节,让很多人误以为 compose 部署集群只是多复制几段 YAML。实际上你要处理的是机器间解析、端口开放、广播地址选择、副本因子设置,这些都是分布式系统的基础功课,只是平时单机环境帮你隐藏了。真正把三台机器的 compose 都写好、启动一次、看到 topic 正常同步之后,这套架构带来的收益会非常直接:以后任何节点挂了,集群依然可以对外提供服务;重新拉起一台也不会有太多心智负担,因为配置是声明式的,一遍写对,终身受益。最后再分享一个我个人的小习惯:每次改完 compose 先跑一遍docker compose config,确认 YAML 语法和环境变量没有问题,再执行docker compose up -d。这个习惯帮我拦截了无数次因为 YAML 缩进或变量名拼写错误导致的无效启动。多主机环境里,改一个文件要在三台机器上同步,如果你不借助配置管理工具,手动复制粘贴的时候更要小心,漏改一个 broker.id 可能就是一次集群脑裂事故。
直接上结论:docker-compose单机部署一整套 Kafka 环境非常简单,很多人第一步都是这么干——一个 YAML 文件把 Zookeeper、Kafka 全塞进同一台机器,端口映射出来,测试完事。但一旦把同样的套路搬到多台服务器上,你就会发现各种连不上、leader 选举失败、client 读写超时。这套东西坑非常多,而且大多数坑不在 Kafka 本身,而是在网络拓扑、hostname 解析、advertised.listeners 这些平时单机环境根本不会暴露的细节上。这篇文章我会把多主机(跨物理机/虚拟机)环境用 docker-compose 部署 Zookeeper 集群和 Kafka 集群的完整过程拆开讲,包括架构设计、compose 文件编写、环境变量含义、启动验证顺序,以及我在实际部署中踩过的坑和排查思路。适合已经会写单机 Kafka compose、但对多机集群还处于“大概知道要改 IP,但不知道还要改什么”阶段的人。1. 部署前必须想清楚的四件事1.1 这不是“复制三份”那么简单单机 compose 里,所有容器跑在同一个 docker bridge 网络,容器之间通过服务名互相访问,比如zookeeper:2181、kafka:9092,Docker 自带的 DNS 自动完成解析。你根本不需要关心容器 IP 是多少、对外暴露什么端口,反正面试官也不会问。多主机环境完全不同。三台机器上的容器不在同一个网络命名空间,Docker 默认的网络模型在这里是行不通的。你要么选择 overlay 网络(docker swarm 或手动创建 attachable overlay network),要么选择最简单直接的host 网络模式,让容器直接使用宿主机网络栈。我在这篇文章里推荐的是 host 模式。原因很简单:Kafka 和 Zookeeper 对网络延迟和端口映射的敏感度很高,host 模式没有 NAT 转发,性能损耗最小,而且端口、IP 全部透明,排查问题的时候心理压力小很多。代价是你要自己管理端口冲突,以及手动维护各节点之间的 hostname 解析。1.2 Kafka 与 Zookeeper 的版本关联部署之前先确认版本组合。旧版 Kafka 依赖 Zookeeper 做元数据存储、Controller 选举、broker 注册。虽然 Kafka 3.x 开始引入 KRaft 模式,可以去掉 Zookeeper,但生产环境里大量存量集群还是基于 Zookeeper 架构,而且很多组件(比如某些消息中间件、数据同步工具、历史运维脚本)仍然假设 Zookeeper 存在。所以这篇内容完全没说老黄历,反而是很多团队迁移 KRaft 之前必须掌握的基础功。版本选型上,我建议用稳定组合:Zookeeper:zookeeper:3.6.3或3.8.xKafka:apache/kafka:3.4.0或3.5.x,注意apache/kafka:3.x官方镜像和旧的wurstmeister/kafka镜像环境变量不完全一致用官方镜像而不是某个第三方封装镜像,主要原因是可维护性。第三方镜像停更多年、环境变量和安装路径不同,出了问题你连文档都不好找。后面所有配置我都基于官方镜像来写。1.3 主机规划与域名解析是大前提为了让三台机器上的容器互相用 hostname 访问,你需要在每台宿主机上把主机名写到 hosts 文件里。这个操作如果不做,后面 Kafka 的 broker 注册、ZK 的 quorum 通信全会出问题,而且报错信息非常不直观。我习惯的规划是三台机器跑一个 ZK 节点加一个 Kafka broker,一套 3 节点集群,既能容错(允许挂一台),又不需要太多资源。当然你也可以把 ZK 和 Kafka 拆开部署到 6 台机器,但那至少需要 6 台机器,很多测试环境其实没这条件。ZK 单独 3 台 + Kafka 单独 3 台更适合生产环境,本文方案重点在讲清原理,你完全可以根据资源调整。1.4 数据持久化:不能全依赖容器层容器是一次性的,这不用我说你也知道。但很多人部署集群时图省事,不给 ZK 和 Kafka 挂数据卷,结果容器重启一下,元数据直接丢了,Zookeeper 里的事务日志没了,Kafka 里的消息全没了。你哪怕是在做测试,也应该挂 volume。数据目录最好是放在宿主机独立磁盘或远程存储上,配置层面我下面会给出卷映射方式。生产环境建议用 NFS 或多副本存储,但那是基础设施的事,不是这篇文章的重点。2. 整体架构设计与节点规划2.1 推荐的三节点拓扑我这次部署的规划如下:宿主机器hostname 对应名称角色内网 IP机器 Azk1 / kafka1Zookeeper + Kafka192.168.1.11机器 Bzk2 / kafka2Zookeeper + Kafka192.168.1.12机器 Czk3 / kafka3Zookeeper + Kafka192.168.1.13注意:这里zk1、kafka1不是宿主机 hostname,而是将来容器启动时指定的 hostname。因为 Kafka 和 ZK 都要监听外网,advertised 地址会用到它。每台机器的 hosts 文件都要加上三行:192.168.1.11 zk1 kafka1 192.168.1.12 zk2 kafka2 192.168.1.13 zk3 kafka3为什么要写两个名字?因为 ZK 集群内部通信用的是zk1、zk2、zk3,Kafka broker 之间以及客户端访问用的是kafka1、kafka2、kafka3。虽然在这里它们指向同一台机器,但职责不同,分开命名方便你后续某一天把 Kafka 横向扩展成独立节点而不用大改。2.2 端口规划host 模式下容器的监听端口就是宿主机的监听端口,所以端口不能冲突。需要开放的端口如下:组件端口用途Zookeeper client2181客户端连接 ZKZookeeper 选举通信2888follower 与 leader 通信Zookeeper 投票端口3888leader 选举投票Kafka client9092客户端生产/消费本质上只需要开放 9092 和 2181 给外部客户端,2888 和 3888 只需要在三台机器之间互通。如果中间有防火墙,自己心里要有数。2.3 关于“到底是 Kafka 连 ZK,还是客户端先连 ZK?”这是一个常见混淆点。客户端连的是 Kafka 的 9092,不直接连 ZK(除了老式 admin 工具偶尔会直接查 ZK)。Kafka broker 自己启动时会连接 ZK 完成注册和元数据同步。所以你在 compose 里配置 ZK 地址,是给 Kafka broker 用的,不是给外部客户端用的。3. compose 文件编写与逐项拆解3.1 目录结构建议每台机器上使用相同的目录结构,方便维护:/opt/cluster/ ├── zk/ │ ├── data/ # ZK 数据目录 │ └── datalog/ # ZK 事务日志目录 ├── kafka/ │ └── data/ # Kafka 日志目录 └── docker-compose.yml三台机器上的 compose 文件,除节点编号、hostname 之外,其余内容完全一致。3.2 Zookeeper 的 compose 配置以机器 A 为例,zk服务的配置应该是这样的:services: zk: image: zookeeper:3.8.1 container_name: zk hostname: zk1 restart: unless-stopped network_mode: host environment: ZOO_MY_ID: 1 ZOO_SERVERS: server.1=zk1:2888:3888;2181 server.2=zk2:2888:3888;2181 server.3=zk3:2888:3888;2181 ZOO_4LW_COMMANDS_WHITELIST: "*" volumes: - /opt/cluster/zk/data:/data - /opt/cluster/zk/datalog:/datalog机器 B 只需要把ZOO_MY_ID改成 2,hostname改成zk2,机器 C 同理。这里有几个必须解释清楚的地方:ZOO_SERVERS的格式:空格分隔多个server.X=host:2888:3888;2181,host必须能解析成 IP。如果解析不了,ZK 集群之间连不上,日志会一直刷Cannot open channel to X at election address。ZOO_MY_ID为什么不能乱:ZK 通过 myid 区分每个节点,三台机器的 myid 必须分别是 1、2、3,不能重复。写错了会出现两个节点认为自己是同一个成员,日志疯狂报错。ZOO_4LW_COMMANDS_WHITELIST: "*":这个是给后续用zkCli.sh或运维监控四字命令(ruok、mntr)准备的。不开白名单,echo ruok | nc localhost 2181会直接失败。3.3 Kafka 的 compose 配置Kafka 服务配置如下(机器 A):kafka: image: apache/kafka:3.4.0 container_name: kafka hostname: kafka1 restart: unless-stopped network_mode: host environment: KAFKA_BROKER_ID: 1 KAFKA_LISTENERS: PLAINTEXT://:9092 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:9092 KAFKA_ZOOKEEPER_CONNECT: zk1:2181,zk2:2181,zk3:2181 KAFKA_NUM_PARTITIONS: 3 KAFKA_DEFAULT_REPLICATION_FACTOR: 2 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 2 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 KAFKA_MIN_INSYNC_REPLICAS: 2 KAFKA_LOG_DIRS: /var/lib/kafka/data volumes: - /opt/cluster/kafka/data:/var/lib/kafka/data机器 B 改KAFKA_BROKER_ID: 2、hostname 为kafka2,机器 C 改成 3。3.4 最重要的一行:advertised.listeners如果你的集群只需要接收本机 localhost 请求,那KAFKA_ADVERTISED_LISTENERS不配也没事。但只要你希望从其他机器(无论客户端还是其他 broker)访问,这一行就决定生死。这里面的原理是:listeners:broker 监听哪个网卡和端口。advertised.listeners:broker 对外宣告的地址,客户端拿到这个地址之后才会去连接。单机部署时,容器里经常被设成PLAINTEXT://localhost:9092,你在本机测试完全正常。一旦换到多机环境,别的机器按 localhost 来找 Kafka,结果找到自己机器上去了,当然连不上。所以必须把 advertised 地址写成其他机器能解析到的主机名或 IP。注意一个细节:advertised.listeners里面写的是kafka1:9092,不是192.168.1.11:9092。我在前面要求 hosts 文件里做了解析,所以这里用主机名完全没问题。如果直接写 IP 也可以,但一旦机器迁移或换网段,配置就要全部跟着改,主机名的可维护性好很多。3.5 镜像环境变量的命名习惯apache/kafka官方镜像内部会把KAFKA_前缀的环境变量自动转换成配置文件里的同名属性。所以:KAFKA_BROKER_ID对应broker.idKAFKA_ADVERTISED_LISTENERS对应advertised.listenersKAFKA_ZOOKEEPER_CONNECT对应zookeeper.connect如果你看到网上有些文章用KAFKA_BROKER_ID=1 BROKER_ID_COMMAND或者需要写-e KAFKA_offset_topic_replication_factor之类,那多半是旧版第三方镜像的写法。官方镜像直接驼峰写就好,省心很多。4. 启动顺序、验证流程与常见操作4.1 启动顺序:先 ZK,后 Kafka这个顺序是必须遵守的。Kafka broker 启动时要向 ZK 注册,如果 ZK 还没就绪,Kafka 启动会反复重试,日志里报SessionExpiredException或连接超时,虽然不一定崩,但会让你误以为配置出问题。启动动作如下:# 在三台机器上都执行:先启动 zk docker compose up -d zk # 等 20 秒,让 zk 完成 leader 选举 # 再启动 kafka docker compose up -d kafka怎么确认 ZK 集群起来了?在任意一台机器上执行:for i in 1 2 3; do echo "server.${i}: $(echo mntr | nc zk${i} 2181)" done如果输出里有zk_server_state leader或follower,说明集群已经选主完成。如果三个节点全部显示standalone,说明三台机器之间没连起来,问题大概率出在 hosts 解析或防火墙。当然,前提是你已经配置了ZOO_4LW_COMMANDS_WHITELIST: "*",否则四字命令会被拒绝。4.2 验证 Kafka broker 是否注册ZK 起来后,检查 Kafka 是否成功注册。连到任意一台 ZK,执行:docker exec -it zk zkCli.sh -server 127.0.0.1:2181 ls /brokers/ids正常会输出[1, 2, 3]。如果只有部分 id,说明那台 broker 没起来或没注册成功,去对应机器的 Kafka 日志看报错。注意看日志位置。官方镜像的日志默认输出到 stdout,docker logs kafka就能看到。报错信息里如果出现Connection to node -1 could not be established. Broker may not be available.,通常不是 broker 之间连不上,而是客户端或测试工具能解析到的 hostname 不对,这个后面专门讲。4.3 创建 topic 并测试读写用一个能访问任意一台机器的环境(最好不是你部署集群的三台机器,而是另一台客户端机器),安装kafka_2.13-3.4.0.tgz或者直接使用 docker 镜像里的命令行工具。这里用官方镜像跑命令是最快的:# 创建 3 分区 3 副本的 topic docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --create \ --topic test-multi-host \ --partitions 3 \ --replication-factor 3 \ --bootstrap-server kafka1:9092注意:--bootstrap-server只需要写一个 broker 地址,客户端会通过 metadata 请求拿到所有 broker 的地址列表。但实际连接时,它会依据每个 broker 的 advertised.listeners 地址去连,所以你必须保证客户端机器能解析并访问kafka1、kafka2、kafka3这 3 个名字对应的 IP。验证 topic 状态:docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --describe \ --topic test-multi-host \ --bootstrap-server kafka1:9092理想情况是看到每个 partition 有 3 个副本,且所有副本都处于Isr状态(在 ISR 列表里)。只要有一个分区显示Replicas: 1,2之类只有两个副本,说明第三台机器的 broker 不可用或没注册上。生产消费验证我一般这样测:# 终端 1:消费 docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh \ --topic test-multi-host \ --bootstrap-server kafka2:9092 \ --from-beginning # 终端 2:生产 docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \ --topic test-multi-host \ --bootstrap-server kafka3:9092如果能正常收发,客户端链路就通了。先把这条链路走通,再谈生产环境优化。4.4 日常重启与滚动升级多主机集群维护时,不要三台机器一起重启。推荐滚动方式:先重启一台,确认 ZK 重新选主正常、Kafka broker 重新注册、ISR 收缩又恢复之后再操作下一台。Kafka 支持滚动重启,是因为分区副本在其它 broker 上有同步副本,短暂下线会影响延迟但不会丢数据(前提是min.insync.replicas设置合理)。ZK 因为集群本身有 3 节点,允许挂一个节点完成选主。5. 我踩过的几个坑与排查思路5.1 broker 启动超慢,日志里一直刷 ZK 连接超时这个问题我出现过好多次。先说结论:90% 是KAFKA_ZOOKEEPER_CONNECT里写的 hostname 解析不了。Kafka 容器使用 host 网络模式时,它会在宿主机的/etc/hosts里查找zk1、zk2、zk3。如果你只在其中一台机器写了 hosts,其他机器没写,那么那台机器上的 Kafka 就会连不上其它机器上的 ZK。排查命令很简单:docker exec -it kafka getent hosts zk1 docker exec -it kafka getent hosts zk2 docker exec -it kafka getent hosts zk3任何一个失败就说明 hosts 没配对。别再浪费时间看 Kafka 日志了,先解决解析问题。5.2 客户端能连上 ZK,但生产/消费时一直报 timeout典型报错:org.apache.kafka.common.errors.TimeoutException: Topic test-multi-host not present in metadata after 60000 ms.或者:Connection to node -1 could not be established. Broker may not be available.这不是 broker 挂了,而是你的客户端拿到了 broker 的 advertised 地址,但无法连接。最常见的原因是:你把KAFKA_ADVERTISED_LISTENERS配成了PLAINTEXT://localhost:9092,或者写成了容器内网 IP,比如172.17.0.2。客户端解析后连接了一个它自己机器上的不存在的端口,自然失败。正确写法是PLAINTEXT://kafka1:9092(对应 kafka2、kafka3),并确保客户端机器也能解析这三个 hostname。如果客户端机器不在同一个 DNS 环境,直接在客户端机器的 hosts 文件里加同样的映射也行。5.3 ZK 三节点互相看不到,用mntr全是 standalone别看程序单机,要看节点间通信。三台机器全部显示 standalone 说明它们之间就没有建立 quorum 连接。检查思路按顺序走:三台机器的/etc/hosts是否都写全了映射。三台机器防火墙是否对 2888、3888 端口放行。看 ZK 日志,搜Cannot open channel to,里面会显示它尝试连接哪个 IP、失败原因。确认ZOO_SERVERS里写的 host 不是localhost。这里有镜像默认值问题,官方镜像默认ZOO_SERVERS指向zookeeper:2888:3888,如果你没配这个环境变量,它默认连接一个叫zookeeper的 host,当然找不到。5.4 host 模式下容器内的 hostname 为什么老变?有些版本下,即使你在 compose 里指定了hostname: kafka1,容器内/etc/hosts依然会附加容器 ID 和宿主机 hostname 的映射。这不影响主要功能,只要 Kafka 进程解析PLAINTEXT://:9092时监听的是0.0.0.0就行。但有个隐性问题:如果宿主机名也叫kafka1,然后你又在另一台机器上把它的宿主机名也改成了kafka1,那 hosts 解析会重叠,出现逻辑混乱。所以我再三建议:部署这种多机容器集群之前,把宿主机命名为中性的名字,不要和容器 hostname、service 名混用。例如宿主机器分别叫node1、node2、node3,容器内部 hostname 才用集群角色名。5.5 Kafka 的offsets内部 topic 副本数不设置会怎样?如果KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR不设,单机默认就是 1,你三节点的集群里__consumer_offsets只有 1 个副本。一旦存放 offset 的那个 broker 挂了,整个消费组都会出问题。我习惯在三台机器的 compose 里统一设成 2 或 3。同理,transaction.state.log.replication.factor也建议设置,不然使用事务 API 时会出现NotEnoughReplicasException。这里有个小技巧:这些参数虽然是 compose 里靠环境变量设置的,但只能在broker 首次启动创建内部 topic 时生效。如果第一次启动没设置,内部 topic 已经创建了,你再改环境变量重启也不会重建,需要手动删除内部 topic 并重启,或者用kafka-configs.sh去改 topic 级别配置。所以我建议第一次启动的时候就全部配好,省得后面还要做手术。5.6 误将单机 compose 的 network 配置复制到多机有些开发者在单机的时候上了自定义 bridge network,并且给 Kafka 配了固定 IP(比如 static IPs),到多机部署时就想继续沿用。声明确实能配置,但跨主机的 bridge 网络默认是不互通的,要么你搭 overlay,要么老老实实用 host。折腾 overlay 对测试环境意义不大,我还是那句话:能用 host 就 host。6. 生产环境增强建议6.1 增加健康检查compose 的healthcheck在容器编排场景里非常有用。ZK 和 Kafka 都可以通过命令检查存活状态。ZK 加:healthcheck: test: ["CMD", "zkServer.sh", "status"] interval: 10s timeout: 5s retries: 3注意 ZK 容器里zkServer.sh status在没有写4lw.commands.whitelist时会失败,所以前面提到的白名单一定不能漏。Kafka 健康检查稍微麻烦一点,因为它没有直接暴露 health 端点,通常用kafka-broker-api-versions.sh拉一下 broker 版本号来判断:healthcheck: test: ["CMD", "/opt/kafka/bin/kafka-broker-api-versions.sh", "--bootstrap-server", "kafka1:9092"] interval: 15s timeout: 10s retries: 5这个健康检查适合 compose 管理场景,真实生产建议用更完善的监控接入,有条件的直接上 JMX exporter + Prometheus。6.2 资源限制Kafka 是吃内存大户,尤其 page cache 用得狠。compose 里建议限制:deploy: resources: limits: memory: 4g reservations: memory: 1gZK 相对轻量,1g 限制足够。不过docker compose的deploy字段需要 compose 文件版本 3 以上才支持。没有跨主机的编排需求时可以加,加了也就在单机维度限制容器内存,至少比不管要好。6.3 存储选择Zookeeper 和 Kafka 都是极端依赖磁盘顺序读写的组件。Kafka 的 log 段文件如果落在普通磁盘上,吞吐和延迟都会非常难看。建议:Kafka 数据目录放到 SSD,或者至少独立于系统盘。Zookeeper 的 data 和 datalog 目录也放到独立磁盘,数据量虽小,但事务日志的 fsync 性能直接影响整个集群稳定性。真实生产环境必须做数据备份和灾备,这里容器化部署并不能帮你免掉这些功课。我自己测试时遇到过一个问题:数据卷挂载的是机械硬盘,Kafka 消费延迟忽高忽低,ISR 频繁收缩又恢复。当时排查了半天的网络问题,最后才发现是磁盘 IO 跟不上。所以如果你是做性能相关的压测,先把磁盘性能基准测一下再上集群。7. 实际操作中我最想留给你的一句话如果你问我在多次多机部署里最深的体会是什么,那就是:多主机 Kafka 集群 90% 的问题都不是 Kafka 本身,而是网络语义没有理清。单机环境隔离了太多细节,让很多人误以为 compose 部署集群只是多复制几段 YAML。实际上你要处理的是机器间解析、端口开放、广播地址选择、副本因子设置,这些都是分布式系统的基础功课,只是平时单机环境帮你隐藏了。真正把三台机器的 compose 都写好、启动一次、看到 topic 正常同步之后,这套架构带来的收益会非常直接:以后任何节点挂了,集群依然可以对外提供服务;重新拉起一台也不会有太多心智负担,因为配置是声明式的,一遍写对,终身受益。最后再分享一个我个人的小习惯:每次改完 compose 先跑一遍docker compose config,确认 YAML 语法和环境变量没有问题,再执行docker compose up -d。这个习惯帮我拦截了无数次因为 YAML 缩进或变量名拼写错误导致的无效启动。多主机环境里,改一个文件要在三台机器上同步,如果你不借助配置管理工具,手动复制粘贴的时候更要小心,漏改一个 broker.id 可能就是一次集群脑裂事故。