Skip to content
Go back

使用 Docker 本地单机部署 Kafka(KRaft 模式)并通过 Tailscale 接入监控和可视化界面

| 0 Views Edit page

前言

之前的数据一直是直接走 PostgreSQL 批量写入的,随着爬虫节点越来越多,高峰期的写入冲突和堆积开始影响到其他业务。
这次打算引入 Kafka 作为中间的消息缓冲层,在家里的一台内网虚拟机上单机跑起来,所有外部访问(生产者、消费者、Prometheus + Grafana、公网 Kafka UI)统一走 Tailscale kafka.ts.net 私网域名接入。


方案概述

  1. 使用 Docker 单机部署 Kafka(KRaft 模式)
  2. 创建业务所需的 Topic
    1. 大数据量的主要数据管道
    2. 小数据量的辅助消息管道
  3. 连通性与性能测试
  4. 针对 4H 8G 的资源进行性能调优
  5. 使用 Prometheus + GrafanaKafka 进行监控
    1. Kafka 容器挂载 JMX Exporter 抓取 broker / JVM 指标
    2. 部署 kafka-exporter 抓取 Topic 和 Consumer Lag 指标
    3. 配置 Prometheus 抓取两套指标
    4. Grafana 中导入 Dashboard
  6. 在公网服务器上部署 Kafka UI 并通过 Tailscale 反向连接
    1. 编写 Kafka UIdocker-compose.yml
    2. 验证能通过 Tailscale 连接到内网 Kafka
    3. 配置 Caddy 反向代理以公网访问

部署架构

这次涉及到 Kafka 部署节点、已有的监控节点以及公网 UI 入口三处,统一列一下(所有节点都已加入 Tailscale):

角色主机名公网 IP内网 IPTailscale 域名备注
Kafka 单机节点kafka-192.168.2.10kafka.ts.net4H 8G 400GB SSD,运行 Kafka 容器、JMX Exporter、kafka-exporter

统一走 Tailscale 域名的好处是:无论客户端物理上在哪个网络、哪个机房,Kafka 的连接串都只有一个,后续机器搬家、网段变更、甚至整套环境迁移都不用改任何客户端配置。

我的 Tailscale 是通过自建的 Headscale 管理的,部署过程参考:部署 Headscale 和 DERP 以通过 Tailscale 实现无公网 IP 服务器之间的组网

业务上一开始只规划 2 个 Topic,以后有需要再加:

  • main-stream:主要数据管道,峰值 6 万条/s,单条消息很小只有几百字节。
  • message-stream:辅助消息管道,峰值 100 条/s,单条消息较大平均 10 KB,主要用来传输一些日志、错误信息等。

消费者数量暂定为 3 ~ 5 个,Topic 的分区数要 ≥ 消费者数量,否则多出来的消费者会空转。


操作步骤

一、在 pve-cz-kafka 上使用 Docker 单机部署 Kafka

Docker 的安装可以参考我的另一篇文章:Ubuntu 20.04 从官方源安装最新的 Docker

Kafka 3.3 开始,KRaft 模式成为生产可用;而 Kafka 4.0 更是彻底移除了 Zookeeper,官方镜像也已经默认以 KRaft 模式启动。
部署前,需要先生成一个用于标识这个 KRaft 集群的唯一 ID:

docker run --rm apache/kafka:latest /opt/kafka/bin/kafka-storage.sh random-uuid

输出类似:

MkU3OEVBNTcwNTJENDM2Qk

如果一时拿不到镜像,也可以任意生成一个 22 位的 Base64 字符串,例如:

openssl rand -hex 16 | head -c 22

创建工作目录并准备持久化目录:

mkdir -vp /opt/kafka/data      # 持久化 Kafka 的日志数据
chown -R 1000:1000 /opt/kafka/data
cd /opt/kafka
nano docker-compose.yml

不设置目录权限的话,Kafka 容器内的 kafka 用户(UID 1000)是没有权限写入数据目录的,启动后会一直重启失败:

kafka | Bootstrap metadata: BootstrapMetadata(records=[ApiMessageAndVersion(FeatureLevelRecord(name=‘metadata.version’, featureLevel=29) at version 0), ApiMessageAndVersion(FeatureLevelRecord(name=‘eligible.leader.replicas.version’, featureLevel=1) at version 0), ApiMessageAndVersion(FeatureLevelRecord(name=‘group.version’, featureLevel=1) at version 0), ApiMessageAndVersion(FeatureLevelRecord(name=‘share.version’, featureLevel=1) at version 0), ApiMessageAndVersion(FeatureLevelRecord(name=‘streams.version’, featureLevel=1) at version 0), ApiMessageAndVersion(FeatureLevelRecord(name=‘transaction.version’, featureLevel=2) at version 0)], metadataVersionLevel=29, source=format command) Formatting metadata directory /var/lib/kafka/data with metadata.version 4.2-IV1. Error while writing meta.properties file /var/lib/kafka/data: java.nio.file.AccessDeniedException: /var/lib/kafka/data/bootstrap.checkpoint.tmp

version: "3.8"

services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    restart: always
    ports:
      # 9092 绑到 0.0.0.0 后,Tailscale 接口自然也能访问
      # 所有外部客户端(生产者 / 消费者 / Prometheus / Kafka UI)全部走 Tailscale 域名 + 9092
      - "9092:9092"
    volumes:
      - /opt/kafka/data:/var/lib/kafka/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment:
      # ====== KRaft 模式核心配置 ======
      # 当前节点既是 broker 又是 controller,单机部署必须这样配
      KAFKA_PROCESS_ROLES: "broker,controller"
      KAFKA_NODE_ID: 1
      # 把上一步生成的集群 ID 填到这里
      CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk"
      # 由于是单机,controller quorum 里只有自己
      KAFKA_CONTROLLER_QUORUM_VOTERS: "1@localhost:9093"
      KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"

      # ====== 监听器 ======
      # EXTERNAL 暴露在 :9092,广播为 Tailscale 域名,供所有外部客户端接入
      # DOCKER   仅 docker 容器内网络使用(主要是同一 compose 里的 kafka-exporter),不对宿主机暴露端口
      # CONTROLLER 仅容器内使用
      KAFKA_LISTENERS: "EXTERNAL://0.0.0.0:9092,DOCKER://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093"
      KAFKA_ADVERTISED_LISTENERS: "EXTERNAL://kafka.ts.net:9092,DOCKER://kafka:29092"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT,CONTROLLER:PLAINTEXT"
      # 单机部署没有实际的 broker 间通信,随便指一个已经存在的即可
      KAFKA_INTER_BROKER_LISTENER_NAME: "DOCKER"

      # ====== 数据目录 ======
      KAFKA_LOG_DIRS: "/var/lib/kafka/data"

      # ====== 单机必备的几个默认值 ======
      # 单节点不可能有 3 副本,全部降到 1
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_DEFAULT_REPLICATION_FACTOR: 1
      KAFKA_MIN_INSYNC_REPLICAS: 1
      # 自动创建 Topic 关掉,Topic 都由我们手动精确建
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
      # 默认数据保留 7 天
      KAFKA_LOG_RETENTION_HOURS: 168

      # ====== 针对 4H 8G 的 JVM 堆限制 ======
      # 默认是 1G,这里给到 3G
      # 剩余留给页缓存(Kafka 的读写大量依赖 Linux page cache)
      KAFKA_HEAP_OPTS: "-Xms3g -Xmx3g"

启动:

docker compose up -d
docker compose logs -f kafka

看到类似 Kafka Server started 的输出,就说明启动成功了。

关于 advertised.listeners 的坑:Kafka 客户端首次连接 broker 时拿到的是 broker 广播出来的地址,而不是你连的那个地址
所以 EXTERNAL 监听必须广播为 kafka.ts.net:9092,否则客户端第一次握手拿到的会是 kafka:29092 这种容器内地址从而连不上。

进入容器内,先列一下有没有 Topic,应该是空的:

docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --list

再看下集群元信息:

docker exec -it kafka /opt/kafka/bin/kafka-metadata-quorum.sh \
    --bootstrap-server localhost:9092 describe --status

只要能打印出 LeaderId: 1 之类的信息,说明 KRaft 控制器已经正常选举完毕。

二、创建业务所需的 Topic

所有命令都在宿主机上直接 docker exec 进去执行即可。

1、大数据量的主要数据管道

这是流量大头,6 万条每秒,必须让分区数 ≥ 消费者最大数量,这里直接开到 6 个分区(对应最多 6 个消费者并发消费):

docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --create \
    --topic main-stream \
    --partitions 6 \
    --replication-factor 1 \
    --config retention.ms=259200000 \
    --config segment.bytes=1073741824 \
    --config compression.type=lz4

一些参数说明:

  • retention.ms=259200000:保留 3 天。按 6 万条每秒、每条 512 字节估算,3 天约 7.5TB,显然不现实。
    这里只是给一个上限,真正靠下面的 retention.bytessegment.bytes 滚动删除来控制磁盘占用。
  • segment.bytes=1073741824:每 1GB 一个 segment,便于滚动清理。
  • compression.type=lz4:价格数据一般是重复字段多的 JSON,lz4 压缩比和速度平衡最好,实测能压到原始的 30% 左右。

再单独设置一下磁盘保留上限(总共给这个 Topic 最多 200GB,分到 6 个分区就是每分区约为 33GB):

docker exec -it kafka /opt/kafka/bin/kafka-configs.sh \
    --bootstrap-server localhost:9092 \
    --alter \
    --entity-type topics --entity-name main-stream \
    --add-config retention.bytes=35433480192

2、小数据量的辅助消息管道

这个管道流量很低,一天也就几万条,开 2 个分区就够了:

docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --create \
    --topic message-stream \
    --partitions 2 \
    --replication-factor 1 \
    --config retention.ms=604800000 \
    --config compression.type=lz4

保留 7 天,流量小就不设置 retention.bytes 了。

创建完确认下:

docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --describe

可以看到每个 Topic 的分区、副本、ISR 情况。

三、连通性与性能测试

从 Tailscale 私网内其他机器上测试生产和消费,验证 Kafka 的吞吐和稳定性,同时也能测试一下 Tailscale 链路的延迟和稳定性。
Kafka 自带压测工具,验证一下 6 万条每秒的峰值是不是真的能抗住:

docker run --rm apache/kafka:latest /opt/kafka/bin/kafka-producer-perf-test.sh \
    --topic main-stream \
    --num-records 600000 \
    --record-size 512 \
    --throughput 60000 \
    --producer-props bootstrap.servers=kafka.ts.net:9092 acks=1 compression.type=lz4

观察输出中的 records/secavg latency,如果能稳定达到 60000 条/s 且平均延迟在几十毫秒以内,就完全满足需求。

我实测下来,在这台 4H 8G 400GB SSD 上稳定跑 60000 条/s、单条 512B、lz4 压缩,发送吞吐量稳定在 29.25 MB/sec,CPU 和磁盘利用率完全在安全区间内。
走 Tailscale 链路的整体平均延迟仅为 7.86ms(其中 P50 中位数延迟低至 2ms,P95 延迟约 60ms),这个网络性能和直连内网几乎没有体感差异,对于消息队列这种异步场景来说表现非常优秀。
测试结果

四、针对 4H 8G 的资源进行性能调优

上面已经在 docker-compose.yml 里把 JVM 堆直接写到了 3g。除此之外,再补充两个容易被忽略的点。

1、预留充足的 page cache

Kafka 的高吞吐本质上依赖操作系统的 page cache,而不是 JVM 堆。
4H 8G 机器,3G 给 JVM 堆,剩下的 5G 一定要留给系统做 page cache,尽量避免在这台机器上跑其他占用内存的服务。

free -h

available 列至少要有 3G 以上,才算合格。

2、调大文件描述符上限

Kafka 每个分区都会对应多个文件(segment、index 等),默认 1024ulimit 很容易被打满。
编辑 /etc/security/limits.conf

sudo nano /etc/security/limits.conf
*   soft   nofile   65535
*   hard   nofile   65535

之后断开 当前 SSH 会话重新登录,并重启 Docker 服务让改动生效:

sudo systemctl restart docker

五、使用 Prometheus + Grafana 对 Kafka 进行监控

搭建 Prometheus + Grafana 监控平台可以参考:搭建 Prometheus + Grafana 监控平台并使用 Node Exporter 监测服务器状态

监控 Kafka 的指标主要分两块,缺一不可

  • Broker 自身的 JVM / 吞吐 / 副本等底层指标 → 通过 JMX Exporter(以 javaagent 方式注入到 Kafka 进程里)
  • Topic / 消费者组 / Consumer Lag 等业务指标 → 通过 kafka-exporter(独立容器,以普通 Kafka 客户端身份拉取)

1、为 Kafka 容器挂载 JMX Exporter

先在宿主机上下载 JMX Exporter 的 javaagent jar 和 Kafka 专用配置(jmx_exporter Releases):

# 这里的 jmx-exporter 是个 jar 包,就不按照我放置 Prometheus Exporter 的习惯(/usr/local/bin)放了,直接放在 Kafka 的目录里,挂载到容器里更方便
mkdir -vp /opt/kafka/jmx-exporter
cd /opt/kafka/jmx-exporter

# 截止我写文章的时间点最新版本是 1.5.0
wget https://github.com/prometheus/jmx_exporter/releases/download/1.5.0/jmx_prometheus_javaagent-1.5.0.jar -O jmx_prometheus_javaagent.jar

# 官方维护的 Kafka 抓取规则
wget https://raw.githubusercontent.com/prometheus/jmx_exporter/refs/heads/main/examples/kafka-2_0_0.yml -O kafka.yml

然后修改 /opt/kafka/docker-compose.yml,在 kafka 服务下新增挂载、端口和 KAFKA_OPTS

    ports:
      - "9092:9092"
      - "9101:9101"   # 新增:JMX Exporter 暴露的 metrics 端口(供 Prometheus 通过 Tailscale 抓取)
    volumes:
      - /opt/kafka/data:/var/lib/kafka/data
      - /opt/kafka/jmx-exporter:/opt/jmx-exporter:ro   # 新增:挂载 javaagent
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment:
      # ... 原有配置保持不变 ...
      # 新增:以 javaagent 方式注入 JMX Exporter,直接把指标暴露在 :9101
      KAFKA_OPTS: "-javaagent:/opt/jmx-exporter/jmx_prometheus_javaagent.jar=9101:/opt/jmx-exporter/kafka.yml"

重启 Kafka 容器让 javaagent 生效:

cd /opt/kafka
docker compose up -d

等容器完全启动后,验证指标端口:

curl -s http://localhost:9101/metrics | grep kafka_server

看到一堆 kafka_server_brokertopicmetrics_... 开头的指标就说明 broker 指标已经暴露成功:
指标暴露成功

2、部署 kafka-exporter 抓取 Topic 和 Consumer Lag 指标

在同一个 docker-compose.yml 里追加 kafka-exporter 服务。
它和 Kafka 在同一个 docker 网络,不需要走 Tailscale:

  kafka-exporter:
    image: danielqsj/kafka-exporter:latest
    container_name: kafka-exporter
    restart: always
    depends_on:
      - kafka
    command:
      # 走容器网络内的 DOCKER listener 连 Kafka,零延迟、零损耗
      - --kafka.server=kafka:29092
      # Kafka 4.x 的 API 版本,默认是旧版本会有兼容性提示
      - --kafka.version=3.7.0
    ports:
      - "9308:9308"

启动并验证:

docker compose up -d kafka-exporter
curl -s http://localhost:9308/metrics | grep kafka_topic_partitions

能看到 main-streammessage-stream 两个 Topic 的分区指标,说明 kafka-exporter 已经连上 Kafka 并在正常抓取。

3、配置 Prometheus 抓取两套指标

编辑 Prometheus 配置文件,增加两个抓取 job,目标地址统一用 Tailscale 域名:

nano /opt/prometheus/prometheus.yml
  - job_name: 'kafka-jmx'
    static_configs:
      - targets:
        - 'kafka.ts.net:9101'

  - job_name: 'kafka-exporter'
    static_configs:
      - targets:
        - 'kafka.ts.net:9308'

重载配置生效:

curl -X POST http://localhost:9090/-/reload

4、在 Grafana 中导入 Dashboard

在 2026/04/24 的时候,社区里还没有完全适配 Kafka 4.x 的 Dashboard,所以暂时用 Kafka Dashboard 顶一下。

Kafka Dashboard

社区常用的下面 2 个 Dashboard 已经不兼容新版本的查询语句了:

打开会显示无数据,所以不选用。

六、在公网服务器上部署 Kafka UI 并通过 Tailscale 反向连接

UI 我选了社区使用人数最多的 provectus/kafka-ui,部署在另一台已经加入 Tailscale 网络的公网服务器 tencent-gz-master 上。

1、编写 Kafka UI 的 docker-compose.yml

mkdir -vp /opt/kafka-ui
cd /opt/kafka-ui
nano docker-compose.yml
version: "3.8"

services:
  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: kafka-ui
    restart: always
    ports:
      # 只绑到本地环回,完全由 Caddy 反代出去,不直接暴露到公网
      - "127.0.0.1:8080:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: pve-cz-kafka
      # ===== 关键:走 Tailscale 域名反向连接内网 Kafka 的 EXTERNAL listener =====
      # 和生产者 / 消费者 / Prometheus 用的是完全同一个接入点
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka.ts.net:9092

      # 顺带把 JMX 指标也接入 UI,在 UI 上直接看到 broker 性能图表
      KAFKA_CLUSTERS_0_METRICS_PORT: 9101
      KAFKA_CLUSTERS_0_METRICS_TYPE: PROMETHEUS

      # 如果担心 UI 被误操作删 Topic,可以打开只读模式
      # KAFKA_CLUSTERS_0_READONLY: "true"

      # ===== 启用简单的登录认证,避免公网被扫到后直接裸奔 =====
      AUTH_TYPE: LOGIN_FORM
      SPRING_SECURITY_USER_NAME: admin
      SPRING_SECURITY_USER_PASSWORD: <your_strong_password>

启动:

docker compose up -d

2、验证能通过 Tailscale 连接到内网 Kafka

没有持续报 Connection to node -1 could not be established 之类的错误,就说明 UI 已经连上后端 Kafka:

docker logs -f kafka-ui

3、配置 Caddy 反向代理以公网访问

/etc/caddy/Caddyfile 中新增:

sudo nano /etc/caddy/Caddyfile
kafka.example.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

重载:

sudo systemctl reload caddy

现在访问 https://kafka.example.com,就会看到 Kafka UI 的登录页,用刚才设置的用户名和密码登录:
登录成功

结束。


Edit page