1. 为什么我坚持用 docker-compose 搭 ZooKeeper 与 Kafka 集群
单机跑一个 Kafka 做 Demo,一条docker run就够了;但只要涉及多副本、多 Broker、消费者组重平衡这些真实场景,单机环境立刻露怯。我最早接触消息队列那会儿,为了凑一个三节点集群,在三台虚拟机上手动装 JDK、解压 ZooKeeper、改myid、配server.x列表,光环境就折腾了一下午,第二天换台机器又得重来一遍。后来我把整套东西搬进 docker-compose,一个docker-compose up -d就能拉起一整套带协调服务的 Kafka 集群,删掉重来也就几秒钟的事。这篇内容就是把我这几年反复搭、反复拆、反复踩坑的经验整理出来,讲清楚docker-compose 部署 ZooKeeper + Kafka 集群这件事到底怎么做才稳、每个参数为什么这么填、出问题去哪儿找原因。
先说清楚这套东西是什么、能干什么。ZooKeeper 在传统 Kafka 架构里扮演的是"集群大脑"的角色,负责 Broker 注册、Controller 选举、元数据存储、Topic 分区 Leader 分配这些协调工作;Kafka 则是真正扛消息吞吐的那一层。两者组合起来,就是一套经典的消息中间件集群。用 docker-compose 编排的好处在于:所有节点配置以声明式文件固化下来,网络、卷、依赖关系一目了然,团队里谁拿到这份文件都能复现出完全一致的环境。这套方案适合几类人:一是刚学 Kafka、想在本机搭个多节点环境理解副本和选举机制的同学;二是做微服务开发、本地需要一套接近生产拓扑的中间件的工程师;三是需要快速给测试环境铺一套消息集群、又不想碰运维脚本的开发者。哪怕你之前只跑过单机 Kafka,跟着下面的步骤也能把三节点集群拉起来。
需要提前打个预防针:这套方案定位是开发、测试、学习环境,不是让你直接搬到生产。生产环境的磁盘、网络、安全认证、监控告警、跨机房容灾,每一项都比 compose 文件复杂得多。但反过来说,把这套 compose 玩透,你对 Kafka 集群内部怎么协作的理解会有一个质的飞跃,这恰恰是单机 Demo 给不了的。
2. 整体架构设计与方案选型拆解
2.1 集群拓扑怎么定:三节点是性价比最高的起点
Kafka 依赖 ZooKeeper 做协调,ZooKeeper 自身又是个需要多数派存活的分布式服务,所以节点数量不是随便定的。ZooKeeper 的选举和写操作要求超过半数节点在线才能正常工作,这叫 quorum 机制。1 个节点没有容错,2 个节点挂一个就失去多数派(2 的一半是 1,剩 1 个不构成"超过半数"),所以最小有意义的高可用配置是 3 个节点——挂掉 1 个还剩 2 个,2 > 1.5,集群继续服务。Kafka Broker 这边,副本因子(replication factor)最大不能超过 Broker 数量,想要体验replication-factor=3的完整副本机制,至少也得 3 个 Broker。
综合下来,3 个 ZooKeeper + 3 个 Kafka Broker是最经典的入门拓扑:既能演示真实的选举和副本同步,又不会把本机内存吃干。我实测过,每个 ZooKeeper 容器限制 512MB、每个 Kafka 容器限制 1GB,整套跑起来宿主机大概占用 4GB 左右内存,一台 16GB 的开发机完全扛得住。如果你机器紧张,也可以退而求其次用 1 个 ZooKeeper + 3 个 Kafka,但这样就失去了 ZooKeeper 层的高可用演示,选举相关的实验做不了,我不太推荐。
这里有个容易忽略的点:ZooKeeper 集群里每个节点需要一个唯一的myid(1 到 255 之间的整数),并且所有节点要通过server.N=host:port1:port2的格式互相认识。在容器环境里,host 不能写localhost,得写 compose 网络里其他容器的服务名,否则节点之间根本连不上。这是新手最容易翻车的地方,后面实操部分我会把配置逐行拆开讲。
2.2 为什么选 docker-compose 而不是别的编排方式
有人会问,既然要集群,为什么不上 Kubernetes?答案很现实:本地开发和测试场景,K8s 的复杂度是过度设计。你要维护一套集群、写一堆 YAML、处理 PVC 和 Service,为了跑三个 Kafka 容器付出的学习成本远超收益。docker-compose 的优势在于单文件、单命令、零外部依赖,一个docker-compose.yml就把网络、卷、环境变量、启动顺序全描述清楚了,docker-compose up -d一把梭,docker-compose down -v连数据卷一起清干净,特别适合反复实验。
另一个考量是可读性和可修改性。compose 文件是纯文本 YAML,改个端口、加个 Broker、调个 JVM 参数,直接编辑保存重启即可,不需要理解任何编排框架的概念。对于想搞懂 Kafka 配置项的人来说,能直接看到KAFKA_*环境变量怎么映射到server.properties,比藏在 Helm Chart 的 values 里直观太多。当然,compose 也有它的边界:它只管单机多容器,跨主机调度、自动扩缩容、滚动升级这些它做不了。但咱们的目标场景本来就不需要这些,够用就好。
2.3 镜像选型:官方镜像与版本对齐的坑
镜像这块我踩过最深的坑是版本不匹配。Kafka 和 ZooKeeper 之间有协议兼容性问题,Kafka 3.x 之后虽然开始支持 KRaft 模式(不再依赖 ZooKeeper),但传统架构下 Broker 和 ZK 的通信协议仍需版本大致对齐。我的建议是:ZooKeeper 用官方zookeeper:3.8或3.9,Kafka 用bitnami/kafka或confluentinc/cp-kafka这类维护活跃的镜像,并且把版本号写死,绝对不要用latest。latest今天拉下来能跑,下周重建环境可能就换了个大版本,配置项名字都变了,排查起来极其痛苦。
我个人的偏好是bitnami/kafka,原因是它的环境变量命名比较规整(KAFKA_CFG_*前缀统一映射到server.properties),文档也清楚。confluentinc/cp-kafka同样成熟,生态工具链更全,如果你后续想接 Schema Registry、Kafka Connect,用 Confluent 系会更顺。两者选其一即可,关键是全集群统一,别三个 Broker 混用两种镜像,否则配置项语义对不上,排查时你会怀疑人生。
3. 核心配置细节逐项拆解
3.1 ZooKeeper 集群配置:myid、server 列表与数据目录
ZooKeeper 的集群配置核心就三样东西:myid文件、zoo.cfg里的server.N列表、以及dataDir指向的数据目录。在容器化部署里,myid通常通过环境变量ZOO_MY_ID注入,官方镜像会自动把它写到数据目录下。server.N列表则通过ZOO_SERVERS环境变量传入,格式是server.1=zk1:2888:3888;2181 server.2=zk2:2888:3888;2181 ...。
这里每个端口的含义必须搞清楚,不然配错了连不上还找不到原因。2888是节点间数据同步端口(Follower 从 Leader 同步数据走这个口),3888是选举端口(Leader 选举时节点间通信走这个口),最后的2181是客户端连接端口(Kafka Broker 连 ZK 走这个口)。三个端口各司其职,缺一不可。我见过有人只写了server.1=zk1:2888:3888漏掉;2181,结果 ZK 集群自己内部能通,但 Kafka 死活连不上,查了半天才发现客户端端口没暴露。
dataDir建议挂一个命名卷(named volume),比如zk-data-1、zk-data-2、zk-data-3,每个节点独立一个卷。为什么不共用一个卷?因为每个节点的myid和数据快照必须隔离,共用卷会导致myid互相覆盖,集群直接起不来。这一点在单机多容器场景下尤其要注意,容器虽然隔离了文件系统,但如果你手动把宿主机同一个目录挂给三个容器,那就是自己给自己挖坑。
3.2 Kafka Broker 配置:listener、advertised 与 broker.id 三件套
Kafka 的配置项比 ZK 多得多,但真正决定集群能不能正常工作的,是listener 相关配置和broker.id。先说broker.id,集群内每个 Broker 必须唯一,通常用 1、2、3 这样的整数。在 bitnami 镜像里对应KAFKA_CFG_BROKER_ID。
然后是 listener 三兄弟,这是 Kafka 网络配置里最容易搞混的部分:
listeners:Broker 实际监听的地址和协议,容器内一般写PLAINTEXT://:9092(监听所有网卡的 9092)。advertised.listeners:Broker 告诉客户端"你应该用这个地址来找我"。这个值会注册到 ZooKeeper 里,客户端拿到后用它建立连接。容器环境下这里必须写宿主机能访问到的地址,比如PLAINTEXT://localhost:9092(对应映射到宿主机的端口),绝不能写容器内部 IP,否则宿主机上的客户端拿到一个容器网段地址,根本连不通。listener.security.protocol.map:协议映射,简单场景写PLAINTEXT:PLAINTEXT即可。
我重点强调advertised.listeners,因为这是 90% 的"本地能起、客户端连不上"问题的根源。举个具体场景:三个 Broker 分别映射到宿主机 9092、9093、9094,那么它们的advertised.listeners就得分别写PLAINTEXT://localhost:9092、PLAINTEXT://localhost:9093、PLAINTEXT://localhost:9094。客户端连上任意一个 Broker 后,Broker 返回的元数据里带的就是这些宿主机地址,客户端才能正确地跟其他 Broker 通信。如果三个都写成一样的端口,客户端拿到元数据后会全部连到同一个端口,另外两个 Broker 形同虚设。
3.3 关键参数对照表
为了让你一眼看清每个配置项的作用,我把核心参数整理成下面这张表。这张表是我反复搭环境后沉淀下来的,建议直接对照着填。
| 配置项 | 作用 | 示例值 | 备注 |
|---|---|---|---|
ZOO_MY_ID | ZK 节点唯一编号 | 1 / 2 / 3 | 集群内不可重复 |
ZOO_SERVERS | ZK 节点列表 | server.1=zk1:2888:3888;2181 ... | 三个端口含义不同 |
KAFKA_CFG_BROKER_ID | Broker 唯一编号 | 1 / 2 / 3 | 集群内不可重复 |
KAFKA_CFG_ZOOKEEPER_CONNECT | ZK 连接串 | zk1:2181,zk2:2181,zk3:2181 | 用服务名不用 IP |
KAFKA_CFG_LISTENERS | 监听地址 | PLAINTEXT://:9092 | 容器内监听 |
KAFKA_CFG_ADVERTISED_LISTENERS | 对外通告地址 | PLAINTEXT://localhost:9092 | 客户端实际连接用 |
KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR | 位移主题副本数 | 3 | 默认 1,集群必须调大 |
KAFKA_CFG_DEFAULT_REPLICATION_FACTOR | 默认副本因子 | 3 | 建 Topic 不指定时用 |
KAFKA_CFG_MIN_INSYNC_REPLICAS | 最小同步副本数 | 2 | 配合 acks=all 保证不丢消息 |
表里最后三个参数是很多人搭完集群后忽略的。Kafka 内部有个__consumer_offsets主题用来存消费者位移,它默认副本因子是 1,在集群环境下如果不调成 3,一旦某个 Broker 挂了,位移数据就可能丢,消费者组行为会变得诡异。min.insync.replicas设成 2 则是配合生产者acks=all使用的,保证至少两个副本确认才算写入成功,这是消息不丢的关键组合。
4. 完整实操过程与核心环节实现
4.1 环境准备与目录规划
动手之前先把地基打好。宿主机需要装好 Docker Engine 和 docker-compose(现在新版 Docker 自带docker compose子命令,两者都行)。我习惯先建一个项目目录,把 compose 文件和数据卷规划清楚:
mkdir -p ~/kafka-cluster && cd ~/kafka-cluster目录里最终只会有一个docker-compose.yml,数据全部交给命名卷管理,不往宿主机目录里乱写。这样做的好处是清理时docker-compose down -v一条命令把卷也删干净,不会在宿主机留下散落的快照文件。如果你确实想把数据落到宿主机某个目录方便查看,也可以改成 bind mount,但要记得给目录设好权限,否则容器内进程写不进去。
端口规划也要提前想好。我一般这样分配:ZK 客户端端口 2181、2182、2183(虽然 Kafka 走容器网络连 ZK,宿主机不一定需要,但暴露出来方便用zkCli调试);Kafka 端口 9092、9093、9094。端口冲突是本地环境的高频问题,起之前用lsof -i:9092或ss -lntp | grep 9092确认一下没被占用。
4.2 编写 docker-compose.yml 的完整思路
下面这份 compose 文件是我实际在用的精简版,三个 ZK 加三个 Kafka,网络用自定义 bridge,数据用命名卷。我把它拆成几段讲,你照着拼起来即可。
先看 ZooKeeper 部分。三个节点结构几乎一样,只有ZOO_MY_ID和卷名不同:
services: zk1: image: zookeeper:3.9 container_name: zk1 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 volumes: - zk-data-1:/data networks: - kafka-net ports: - "2181:2181"zk2、zk3照抄,把ZOO_MY_ID改成 2、3,卷名改成zk-data-2、zk-data-3,端口映射改成 2182、2183。注意ZOO_SERVERS三个节点写的完全一样,因为每个节点都需要知道全集群的成员列表,这是 ZK 的机制决定的,不是笔误。
再看 Kafka 部分,以 broker1 为例:
kafka1: image: bitnami/kafka:3.6 container_name: kafka1 depends_on: - zk1 - zk2 - zk3 environment: KAFKA_CFG_BROKER_ID: 1 KAFKA_CFG_ZOOKEEPER_CONNECT: zk1:2181,zk2:2181,zk3:2181 KAFKA_CFG_LISTENERS: PLAINTEXT://:9092 KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 KAFKA_CFG_DEFAULT_REPLICATION_FACTOR: 3 KAFKA_CFG_MIN_INSYNC_REPLICAS: 2 KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "false" volumes: - kafka-data-1:/bitnami/kafka networks: - kafka-net ports: - "9092:9092"kafka2、kafka3同理,BROKER_ID改 2、3,ADVERTISED_LISTENERS改成 9093、9094,端口映射同步改。这里depends_on只是保证启动顺序,不保证 ZK 已经就绪,所以 Kafka 启动时如果 ZK 还没准备好,可能会重试几次,这是正常现象,等一会儿就好。如果你追求更稳的启动,可以给 Kafka 加健康检查或者用restart: on-failure,让它失败后自动重试。
最后补上网络和卷的声明:
networks: kafka-net: driver: bridge volumes: zk-data-1: zk-data-2: zk-data-3: kafka-data-1: kafka-data-2: kafka-data-3:自定义 bridge 网络让所有容器能通过服务名互相解析,这是zk1:2181这种写法能生效的前提。命名卷则保证容器重建后数据还在,方便你反复实验而不丢 Topic。
4.3 启动、验证与第一个 Topic
文件写好,一条命令拉起:
docker-compose up -d然后docker-compose ps看状态,六个容器都应该是Up。ZK 集群是否成型,可以用四字命令验证:
docker exec zk1 bash -c "echo srvr | nc localhost 2181"输出里如果能看到Mode: leader或Mode: follower,说明 ZK 集群已经完成选举。三个节点里应该恰好有一个 leader、两个 follower,这是 quorum 机制的正常表现。
Kafka 这边,进任意一个 Broker 容器建个测试 Topic:
docker exec kafka1 kafka-topics.sh \ --bootstrap-server localhost:9092 \ --create --topic demo-topic \ --partitions 3 --replication-factor 3建完用--describe看一下分区分布:
docker exec kafka1 kafka-topics.sh \ --bootstrap-server localhost:9092 \ --describe --topic demo-topic正常输出里,3 个分区各自的 Leader 应该分散在不同的 Broker 上,每个分区有 3 个副本(1 个 Leader + 2 个 ISR)。如果看到Isr数量少于 3,说明有副本没同步上,通常是网络或advertised.listeners配错了,得回头查。
生产消费的连通性测试也顺手做一下。开一个生产者:
docker exec -it kafka1 kafka-console-producer.sh \ --bootstrap-server localhost:9092 --topic demo-topic敲几行消息回车,再开另一个终端跑消费者:
docker exec -it kafka2 kafka-console-consumer.sh \ --bootstrap-server localhost:9092 --topic demo-topic --from-beginning能收到刚才发的消息,说明整条链路通了。注意消费者连的是kafka2,但--bootstrap-server写的是localhost:9092,这正好验证了advertised.listeners配置正确——客户端通过 broker1 拿到元数据后,能正确路由到其他 Broker 拉取消息。
4.4 副本与选举的实测观察
集群搭起来不验证一下高可用,等于白搭。我常用的实验是:停掉一个 Broker,看集群是否还能正常读写。比如docker stop kafka3,然后往demo-topic继续发消息。因为副本因子是 3、min.insync.replicas是 2,停掉一个 Broker 后还剩 2 个副本,满足最小同步副本要求,生产消费应该照常工作。这时候再--describe看,原本 Leader 在 kafka3 上的分区会自动切换到其他 Broker,ISR 列表里 kafka3 消失。这个切换过程通常几秒内完成,你能直观看到 Kafka 的故障转移机制。
再把 kafka3 启回来docker start kafka3,等一会儿它会重新加入 ISR,数据自动追平。这一停一起,就把副本同步和 Leader 选举两个核心机制都演示了一遍。我建议每个搭这套环境的人都亲手做一次这个实验,比看十篇原理文章都管用。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
下面这张表是我这些年被问最多、自己也踩过的问题,按现象归类,方便你对照排查。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| Kafka 容器反复重启 | ZK 未就绪或连接串错误 | 看docker logs kafka1,确认ZOOKEEPER_CONNECT服务名正确 |
| 客户端连不上 Broker | advertised.listeners写了容器 IP | 改成宿主机可访问地址,如localhost:9092 |
| 建 Topic 报副本数超限 | Broker 数量少于副本因子 | 确认三个 Broker 都在线,或降低replication-factor |
| ISR 数量不足 | 副本同步失败 | 检查网络连通性、min.insync.replicas是否过高 |
| ZK 集群起不来 | myid重复或server列表不一致 | 确认每个节点ZOO_MY_ID唯一,ZOO_SERVERS完全一致 |
| 消费者收不到消息 | 位移主题副本问题或from-beginning没加 | 检查__consumer_offsets副本数,消费时加--from-beginning |
| 端口冲突启动失败 | 宿主机端口被占用 | lsof -i:9092查占用,改映射端口 |
5.2 三个我踩过的真实坑
第一个坑是advertised.listeners写成容器名。我早期图省事,三个 Broker 都写PLAINTEXT://kafka1:9092这种容器内地址,结果容器之间通信没问题,但宿主机上的 Java 客户端连上后拿到kafka1:9092这个地址,宿主机根本解析不了容器服务名,直接超时。后来才想明白,advertised.listeners是给集群外部客户端看的,必须写外部可达地址。这个坑的隐蔽之处在于,容器内测试一切正常,只有从宿主机连才出问题,很容易误判成客户端配置错误。
第二个坑是__consumer_offsets副本因子没调。有次搭完集群做消费者组测试,发现消费者组状态老是Dead或者位移提交失败,查了半天日志才发现位移主题副本因子还是默认的 1,而当时刚好有个 Broker 在重启,位移主题的 Leader 正好在那个 Broker 上,导致提交失败。把KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR设成 3 之后问题消失。这个参数在单机环境完全无感,一到集群就暴露,属于典型的"环境差异坑"。
第三个坑是数据卷没清理导致配置残留。有次改了 Broker 的broker.id重新up,结果新配置没生效,Broker 还是用旧 ID 注册,集群里出现两个相同 ID 的 Broker,直接乱套。原因是命名卷里存了旧的元数据,容器重建时又读了出来。解决办法是改broker.id这类身份相关配置时,先docker-compose down -v把卷清掉再起。这个教训告诉我:凡是涉及节点身份的配置变更,一定要连数据一起重置,否则旧数据会跟你作对。
5.3 日志排查的正确姿势
出问题时别急着改配置,先看日志。ZK 的日志用docker logs zk1,重点看有没有Cannot open channel to N at election address这类选举失败信息,通常指向server列表或网络问题。Kafka 的日志信息量更大,docker logs kafka1里搜ERROR和WARN,重点关注Connection to node -1 could not be established(连不上 ZK 或 Broker 间不通)和NOT_ENOUGH_REPLICAS(副本不足)这两类。我习惯在启动后先docker-compose logs -f盯一会儿,等所有节点稳定了再操作,很多问题在启动阶段就有征兆,早发现早处理。
还有个小技巧:ZK 的四字命令mntr能看到更详细的集群指标,比如zk_followers、zk_synced_followers、zk_pending_syncs,比srvr信息更全。Kafka 则可以用kafka-broker-api-versions.sh --bootstrap-server localhost:9092确认 Broker 是否正常响应 API 请求。这些工具在容器里都自带,不用额外装。
6. 一些让环境更好用的补充建议
搭完基础集群后,有几个小改进能让日常使用舒服很多。第一是给 Kafka 容器加个restart: unless-stopped,这样宿主机重启后集群自动恢复,不用每次手动up。第二是考虑加一个kafka-ui或者kafdrop容器,浏览器里就能看 Topic、分区、消费者组状态,比命令行直观。第三是如果你经常需要从宿主机跑客户端程序,可以把advertised.listeners配成双 listener 模式(一个PLAINTEXT给容器内,一个EXTERNAL给宿主机),这样内外都能连,不过配置会复杂一些,初学阶段单 listener 够用。
我个人在实际操作中的体会是,这套 compose 环境最大的价值不在于"能跑起来",而在于它让你可以低成本地破坏和重建。想验证某个参数的作用?改一行、重启、观察、再改回来,整个过程不到一分钟。这种快速反馈循环是理解分布式系统行为的最好方式。等你把这套环境玩到闭着眼睛都能配出正确的advertised.listeners,再去碰生产环境的 Kafka,心里就有底了。最后分享一个小习惯:每次改完 compose 文件,先docker-compose config校验一下语法,能省掉不少因为缩进或拼写错误导致的启动失败。