☰
Docker Compose 部署 ZooKeeper 与 Kafka 集群实战指南
2026/10/27 5:27:52 网站建设 项目流程

1. 为什么我最终选了 docker-compose 来搭 ZooKeeper 与 Kafka 集群

单机跑一个 Kafka 玩玩,下载个压缩包、改两行配置、bin/kafka-server-start.sh一敲就完事了。但只要你想搭一个真正意义上的集群——多个 broker、多个 ZooKeeper 节点、彼此能选主、能容错——手工部署的复杂度会陡然上升一个量级。我最早也是手动搞的,三台机器来回切,配置文件改到眼花,节点之间版本对不上、myid写错、防火墙端口没开,排查一圈下来半天没了。后来我把整套东西搬进了 docker-compose,一次编排、一条命令拉起、随时销毁重建,那种"环境即代码"的踏实感,是手工部署给不了的。

这篇内容聊的就是docker-compose 部署 ZooKeeper + Kafka 集群这件事本身。它解决的核心问题是:把一套多节点、有状态、节点间还要互相通信的分布式消息系统,用声明式的方式固化下来,让环境可复现、可版本管理、可快速迭代。适合谁看?我把它定位成三类人:一是刚接触 Kafka、想在本机或测试环境搭个像样集群练手的开发者;二是需要给团队提供一套标准化本地开发环境的技术负责人;三是已经会单机部署、但被集群配置折磨过、想找一套干净编排方案的中高级工程师。哪怕你之前没怎么写过 compose 文件,跟着思路走也能落地,因为我会把每个参数为什么这么写都讲清楚。

需要先说明一点:ZooKeeper 在 Kafka 新版本里正在被 KRaft 模式逐步替代,但大量存量项目、教程、以及很多公司的实际生产环境仍然跑在 ZooKeeper 之上,面试和排障场景里 ZK 依然是绕不开的知识点。所以这套编排方案在当下依然有很强的实用价值,尤其是学习和过渡阶段。下面我从整体设计思路开始,一层层拆到能直接抄作业的程度。

2. 整体架构设计与编排思路拆解

2.1 集群拓扑怎么定:节点数量与角色的取舍

动手写 compose 之前,先得把拓扑想清楚。ZooKeeper 集群有个硬性要求:节点数必须是奇数,常见的是 3 节点或 5 节点。原因在于 ZK 的选主依赖"过半原则"(quorum),3 节点能容忍 1 个挂掉,5 节点能容忍 2 个挂掉。2 节点反而比单节点更脆弱,因为挂一个就凑不齐多数派,整个集群不可用。所以哪怕只是本地练习,我也建议直接上 3 个 ZK 节点,既符合真实场景,又能让你亲眼看到选主过程。

Kafka broker 这边,节点数没有奇数限制,但要想体验分区副本、leader 选举这些特性,至少得 3 个 broker。因为默认副本因子是 3,broker 少于 3 个时你没法创建三副本的 topic,很多容错实验就做不了。我最终的拓扑是:3 个 ZooKeeper 节点 + 3 个 Kafka broker,全部跑在同一台宿主机上,通过 Docker 自定义网络互通。这个规模对一台 16G 内存的开发机来说完全扛得住,每个容器限制在 512M 到 1G 内存之间即可。

这里有个容易被忽略的点:ZooKeeper 之间需要互相通信做选主,Kafka broker 之间也需要互相通信做副本同步,同时 broker 还要连 ZK。所以网络设计必须让所有节点在同一个网段内、能用固定主机名互相解析。Docker 的自定义 bridge 网络天然满足这个条件——容器名就是主机名,Docker 内置 DNS 会自动解析。这也是我不用默认 bridge 网络、而是显式声明一个自定义网络的原因。

2.2 为什么用 compose 而不是 K8s 或纯 docker run

有人会问,都上集群了,为什么不用 Kubernetes?答案很现实:本地开发和测试场景,K8s 的复杂度是过度设计。你要维护一个集群、写一堆 YAML、处理存储卷和 Service,为了跑三个 Kafka 付出的运维成本远超收益。而纯docker run一条条敲命令,虽然简单,但节点一多就没法维护,改个配置要重新敲一遍,销毁重建更是灾难。compose 正好卡在中间:单文件声明、一条命令起停、支持依赖顺序、支持自定义网络和卷,学习成本低,可读性高,还能进 Git 做版本管理。

另一个关键考量是启动顺序。Kafka broker 启动时必须能连上 ZooKeeper,否则会报连接超时甚至直接退出。compose 的depends_on能控制启动顺序,但要注意它只保证"容器启动"的先后,不保证"服务就绪"。ZK 容器起来了不代表它已经能接受连接。所以我在实际编排里会配合健康检查(healthcheck)和重启策略,让 Kafka 在 ZK 真正就绪后再启动,这一点后面实操部分会详细展开。

2.3 数据持久化与配置外置的设计

有状态服务最怕的就是容器一删数据全没。ZooKeeper 存的是集群元数据(broker 注册信息、topic 配置、ACL 等),Kafka 存的是实际的消息日志。这两块都必须挂载到宿主机卷上,否则每次docker-compose down之后集群状态清零,之前建的 topic、发的消息全丢,调试起来非常痛苦。

我的做法是给每个节点单独挂一个数据目录,用 compose 的命名卷或者宿主机绑定挂载都行。命名卷的好处是 Docker 统一管理、跨平台一致;绑定挂载的好处是你能直接在宿主机上看到数据文件、方便排查。我倾向于绑定挂载到项目目录下的data/子目录,结构一目了然,删起来也干净。配置方面,ZooKeeper 的zoo.cfg和 Kafka 的server.properties我都外置成独立文件,通过 volume 挂进容器,而不是塞在 compose 的environment里。原因很简单:Kafka 的配置项太多太杂,塞进环境变量既难读又容易出错,外置成文件后改起来直观,也方便对照官方文档。

3. 核心配置细节逐项拆解

3.1 ZooKeeper 的 myid 与集群成员配置

ZooKeeper 集群配置里最容易翻车的就是myid。每个 ZK 节点在数据目录下都要有一个名为myid的文件,里面写一个 1 到 255 之间的整数,这个数字必须和zoo.cfg里server.N的 N 对应上。比如节点 1 的myid写1,那zoo.cfg里就得有server.1=zk1:2888:3888。这个映射关系错了,节点就起不来或者选主异常,而且报错信息往往很隐晦,新手很容易卡在这里。

zoo.cfg里两个端口的含义也要搞清楚:2888是 follower 连 leader 做数据同步用的,3888是选主时节点间通信用的。这两个端口只在集群内部用,不需要暴露给宿主机。真正需要对外的是2181,也就是客户端连接端口,Kafka broker 就是连这个端口。我在编排里只把 2181 映射到宿主机(而且只映射其中一两个节点方便本地调试),2888 和 3888 留在容器网络内部,减少端口冲突。

还有一个参数值得单独说:tickTime。它是 ZK 的时间基准单位,默认 2000 毫秒。initLimit是 follower 初始连接 leader 允许的 tick 数,syncLimit是 follower 与 leader 之间同步允许的 tick 数。本地环境网络延迟低,用默认值就行;但如果你的机器负载很高、容器启动慢,可以适当调大initLimit,否则可能出现"follower 还没连上 leader 就被判定超时"的情况。我实测在资源紧张的机器上把initLimit从 10 调到 20 会稳不少。

3.2 Kafka broker 的 id、监听器与 advertised.listeners

Kafka 的server.properties里,集群部署必须改的几项我列一下。首先是broker.id,每个 broker 必须唯一,通常就用 1、2、3。然后是zookeeper.connect,要写成所有 ZK 节点的地址加端口,用逗号分隔,比如zk1:2181,zk2:2181,zk3:2181。这里有个小技巧:可以在末尾加上chroot路径,比如zk1:2181,zk2:2181,zk3:2181/kafka,这样 Kafka 的元数据会存在 ZK 的一个子节点下,多个 Kafka 集群共用一个 ZK 时不会互相干扰。

监听器配置是另一个重灾区。listeners是 broker 实际绑定的地址,advertised.listeners是 broker 告诉客户端"你应该用这个地址来找我"的地址。这两个如果配错,典型症状是:客户端能连上 broker 拿到元数据,但真正收发消息时就超时,因为它按advertised.listeners里的地址去连,结果连不上。在 Docker 环境里,我通常让listeners绑定0.0.0.0:9092(容器内所有网卡),advertised.listeners写成容器主机名加端口,比如PLAINTEXT://kafka1:9092。这样容器网络内的其他 broker 和客户端都能通过主机名解析到它。

如果你的客户端在宿主机上、不在容器网络里,那就得把advertised.listeners改成宿主机能访问的地址,比如PLAINTEXT://localhost:9092,同时把端口映射出来。但这里有个坑:三个 broker 如果都映射到宿主机的 9092 会冲突,得分别映射成 9092、9093、9094,然后每个 broker 的advertised.listeners对应写自己的映射端口。这种"内外地址不一致"的配置是本地调试时最常见的困惑来源,我建议初学时干脆把客户端也放进容器网络里跑,省去这层麻烦。

3.3 副本与分区相关的默认参数

集群搭起来只是第一步,能不能发挥集群价值,取决于副本和分区配置。default.replication.factor控制自动创建 topic 时的默认副本数,集群有三个 broker 就设成 3。min.insync.replicas建议设成 2,配合生产者的acks=all,能保证至少两个副本写入成功才算成功,兼顾可靠性和可用性。num.partitions是默认分区数,本地练习设 3 就够,生产环境要根据吞吐量算。

还有一个offsets.topic.replication.factor,这是 Kafka 内部存储消费者位移的 topic 的副本数,默认是 3。如果你的 broker 少于 3 个又没改这个值,消费者组功能会出问题——内部 topic 创建失败,消费者起不来。这也是我坚持至少 3 个 broker 的原因之一。另外transaction.state.log.replication.factor和transaction.state.log.min.isr也建议一并设成 3 和 2,为事务消息留好底子。

4. 完整实操过程与关键环节实现

4.1 目录结构与 compose 文件骨架

先把项目目录搭起来,结构清晰后面才好维护。我的习惯是这样:

zk-kafka-cluster/ ├── docker-compose.yml ├── zookeeper/ │ ├── zk1/ │ │ ├── zoo.cfg │ │ └── myid │ ├── zk2/ │ └── zk3/ ├── kafka/ │ ├── kafka1/ │ │ └── server.properties │ ├── kafka2/ │ └── kafka3/ └── data/

每个 ZK 节点一个目录,放自己的zoo.cfg和myid;每个 broker 一个目录放server.properties。data/用来挂载运行时数据。这种"一节点一目录"的布局虽然文件多了点,但每个节点的差异一目了然,改配置时不会互相干扰。

compose 文件我用的是 3.8 版本语法,先声明一个自定义网络:

networks: kafka-net: driver: bridge

然后 ZK 节点部分,以 zk1 为例:

services: zk1: image: zookeeper:3.8 container_name: zk1 hostname: zk1 restart: unless-stopped networks: - kafka-net ports: - "2181:2181" volumes: - ./zookeeper/zk1/zoo.cfg:/conf/zoo.cfg - ./zookeeper/zk1/myid:/data/myid - ./data/zk1:/data 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

这里我用官方zookeeper镜像,它支持通过环境变量ZOO_MY_ID和ZOO_SERVERS自动生成配置,比手动挂zoo.cfg更省事。但如果你要精细控制tickTime、initLimit这些参数,还是挂自己的zoo.cfg更灵活。两种方式可以二选一,我实际用下来,环境变量方式对新手最友好,不容易因为路径或格式问题翻车。

4.2 启动顺序与健康检查的配合

Kafka 依赖 ZK,所以 broker 的depends_on要写上三个 ZK 节点。但前面说过,depends_on只管启动顺序不管就绪状态。我的做法是给 ZK 加健康检查,用nc或者echo ruok | nc探测 2181 端口是否可连:

healthcheck: test: ["CMD", "bash", "-c", "echo ruok | nc localhost 2181 | grep imok"] interval: 10s timeout: 5s retries: 5

然后在 Kafka 的depends_on里用长语法声明"等 ZK 健康后再启动":

depends_on: zk1: condition: service_healthy zk2: condition: service_healthy zk3: condition: service_healthy

这样能避免 Kafka 在 ZK 还没就绪时启动失败。不过要注意,condition: service_healthy需要 compose 文件版本支持,老版本可能不认。如果你用的 compose 版本较老,退而求其次的办法是给 Kafka 加restart: on-failure,让它启动失败后自动重试,等 ZK 就绪了自然就起来了。我两种都用过,健康检查更优雅,重启策略更省心,看你的环境选。

4.3 Kafka broker 的完整配置与启动验证

以 kafka1 为例,server.properties的核心几行:

broker.id=1 listeners=PLAINTEXT://0.0.0.0:9092 advertised.listeners=PLAINTEXT://kafka1:9092 zookeeper.connect=zk1:2181,zk2:2181,zk3:2181 log.dirs=/kafka-logs num.partitions=3 default.replication.factor=3 min.insync.replicas=2 offsets.topic.replication.factor=3 transaction.state.log.replication.factor=3 transaction.state.log.min.isr=2

compose 里对应写:

kafka1: image: bitnami/kafka:3.6 container_name: kafka1 hostname: kafka1 restart: unless-stopped networks: - kafka-net ports: - "9092:9092" volumes: - ./kafka/kafka1/server.properties:/opt/bitnami/kafka/config/server.properties - ./data/kafka1:/kafka-logs depends_on: zk1: condition: service_healthy zk2: condition: service_healthy zk3: condition: service_healthy

kafka2、kafka3 同理,只改broker.id、hostname、端口映射和挂载路径。全部写好后,docker-compose up -d一把拉起。验证集群是否正常,我一般分三步走:先docker-compose ps看所有容器是不是 Up 状态;再进任意一个 broker 容器执行kafka-topics.sh --bootstrap-server kafka1:9092 --list,能正常返回说明 broker 和 ZK 通信没问题;最后创建一个三副本 topic,--replication-factor 3 --partitions 3,再用--describe看每个分区的 leader 和 ISR 是否分布在三个 broker 上。如果 ISR 里只有一两个副本,说明有 broker 没同步上,得回去查网络和advertised.listeners。

4.4 一次完整的消息收发验证

光看 topic 元数据还不够,得实际发消息验证。我习惯用容器自带的命令行工具,在 kafka1 里开一个生产者:

kafka-console-producer.sh --bootstrap-server kafka1:9092 --topic test-topic

另开一个终端进 kafka2 起消费者:

kafka-console-consumer.sh --bootstrap-server kafka2:9092 --topic test-topic --from-beginning

生产者敲几行字,消费者那边能实时收到,说明整条链路通了。这一步看着简单,但能一次性验证 broker 间通信、副本同步、客户端路由三件事。如果消费者收不到,先看消费者是不是连的另一个 broker(这恰好能验证跨 broker 路由),再看 topic 的 ISR 是否完整。我踩过一次坑:消费者连 kafka2 收不到消息,查了半天发现 kafka2 的advertised.listeners写成了localhost,导致客户端拿到元数据后去连 localhost 而不是 kafka2,自然连不上。改成主机名后立刻正常。

5. 常见问题排查与避坑经验实录

5.1 集群起不来?先查这五个地方

部署这套东西,九成的问题集中在几个固定位置。我整理成一张速查表,出问题时按顺序过一遍,基本能定位:

现象可能原因排查动作
ZK 容器反复重启myid 与 server.N 不匹配检查 myid 内容和 zoo.cfg 的 server 列表
Kafka 启动即退出连不上 ZK进容器nc zk1 2181测连通性
客户端收发超时advertised.listeners 配错核对客户端能否解析并访问该地址
消费者组起不来offsets topic 副本数大于 broker 数检查offsets.topic.replication.factor
分区 ISR 不全broker 间网络不通或配置不一致看 broker 日志里的副本同步报错

这张表是我从多次翻车里总结出来的,尤其是第一行和第三行,几乎每次新手部署都会撞上。myid 的问题特别隐蔽,因为 ZK 启动日志里不一定直接说"myid 错了",而是报一些选主相关的异常,得结合zoo.cfg一起看。

5.2 内存与资源限制的实操心得

本地跑六个容器(3 ZK + 3 Kafka),内存是绕不开的话题。默认情况下 JVM 会按宿主机内存的一定比例申请堆,容器里如果没限制,可能每个 Kafka 都想要 1G 以上,六个加起来直接把机器拖垮。我的做法是给每个容器设mem_limit,ZK 给 512M,Kafka 给 1G,同时通过环境变量把 JVM 堆固定住,比如KAFKA_HEAP_OPTS=-Xmx512m -Xms512m。固定堆的好处是避免 JVM 动态调整带来的抖动,本地环境稳定优先。

还有一个细节:Kafka 的日志清理和刷盘策略在本地可以放宽。log.flush.interval.messages默认很大,本地练习不用改;但log.retention.hours可以调小到 24 小时,避免测试数据把磁盘塞满。我见过有人本地跑了一周,data/目录涨到几十 G,最后发现是没设保留策略。这些参数不影响功能验证,但影响你机器的寿命。

5.3 销毁重建时如何不丢关键数据

docker-compose down默认会删容器和网络,但命名卷和绑定挂载的数据还在。所以用绑定挂载的好处这时候就体现出来了:down 之后data/目录原封不动,再up起来集群状态还在,之前建的 topic 和消息都还在。但如果你改了broker.id或者 ZK 的myid,那数据目录里的元数据可能对不上,这时候就得手动清空对应节点的data/再重建。我的经验是:改配置前先想清楚要不要保留数据,纯练习环境直接down -v加清空data/最干净,避免旧状态干扰新配置。

另外提醒一句,ZooKeeper 的数据目录里有个version-2子目录存快照和事务日志,这个不能随便删。如果 ZK 起不来又怀疑是数据损坏,可以先把整个data/备份再清空重来,别直接删了了事。Kafka 的log.dirs里除了消息日志还有meta.properties,记录了 broker 的 cluster id,如果 cluster id 和 ZK 里存的对不上,broker 会拒绝启动。遇到这种情况,要么清空 Kafka 数据目录重新注册,要么把 ZK 里的元数据也清掉,两者必须一致。

6. 从能跑到好用:几个进阶调整

6.1 用环境变量模板减少重复配置

三个 broker 的server.properties有大量重复内容,只有broker.id、advertised.listeners、log.dirs不同。手工维护三份文件容易改漏。我的做法是写一份模板,用 compose 的environment覆盖差异项,或者干脆用脚本在启动前生成配置文件。bitnami 镜像支持大量KAFKA_CFG_*环境变量,比如KAFKA_CFG_BROKER_ID、KAFKA_CFG_ADVERTISED_LISTENERS,完全可以把配置全塞进 compose 的 environment 里,省掉挂载配置文件。这种方式适合配置相对固定的场景,改起来集中,缺点是配置项多了以后 compose 文件会很长,可读性下降。两种方式各有取舍,我一般项目初期用环境变量快速跑通,稳定后再抽成配置文件。

6.2 监控与日志的初步接入

集群跑起来之后,你迟早会想知道每个 broker 的负载、每个分区的消息堆积情况。本地环境不用上重型监控,但至少把日志看明白。Kafka 的日志在容器里默认输出到 stdout,docker-compose logs -f kafka1就能实时看。重点关注ERROR和WARN级别的行,尤其是副本同步相关的。如果想看更细的指标,可以启用 JMX,但本地练习阶段我觉得没必要,先把功能跑通、把常见故障排查熟,比堆监控更有价值。

6.3 这套编排还能怎么扩展

这套骨架的扩展性其实不错。想加 broker,复制一份 kafka 配置改 id 和端口就行;想换 KRaft 模式,把 ZK 部分去掉、给每个 broker 配controller.quorum.voters即可,网络和卷的设计可以复用。我后来就在这套基础上加了一个 Kafka UI 容器,通过环境变量指向三个 broker,浏览器里就能看 topic、分区、消费者组,调试效率提升明显。这些扩展都不影响核心编排,属于锦上添花。

我个人在实际操作中的体会是,docker-compose 搭集群最大的价值不在于"省事",而在于"可重复"。手工部署时每次环境都有细微差异,出了问题很难复现;而 compose 把环境固化成一个文件,谁拉下来跑都是同一套东西,排查问题时变量少了一大半。踩过几次 myid 和 advertised.listeners 的坑之后,我现在搭任何有状态集群,第一件事就是把配置外置、把数据挂载、把健康检查加上,这三件事做到位,后面能省掉大量莫名其妙的故障排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询