我最早配中间件是老老实实在服务器上手动装的:下载安装包、解压、改配置文件、手工注册成服务,最长一次光是 RabbitMQ 的 Erlang 依赖就把我折腾到半夜。后来切到 Docker Compose,一台干净主机从零到把 Redis、MySQL、RabbitMQ、Kafka 这四个常用中间件全部编排起来,前后不到十分钟,而且卸载干净、迁移方便、换机器无痛。
这篇文章不聊虚的,就聊我在真实环境里使用 Docker Compose 安装常用中间件的完整方案:包括环境准备、一套能直接抄的 Compose 配置、启动后的健康检查与备份思路,以及我踩过的坑和完整排查链路。适合本地开发机、公司测试环境,也适合刚接触容器化部署的同学当速查手册。
1. 为什么中间件环境乱成一锅粥?我改用 Docker Compose 的完整理由
1.1 手动安装常见的三个“后遗症”
我最早管理服务器时,习惯去官网拿安装包、按照官方文档手动装。起初觉得这很可控:MySQL 装一次,Redis 装一次,RabbitMQ 再装一次。问题出在中间件数量上来之后,三种切身之痛会一起冒出来。
第一种是依赖冲突。RabbitMQ 要求特定版本的 Erlang,系统仓库里的版本太老,我只能编译安装 Erlang;编译又引入一堆库,后续升级 RabbitMQ 又得重来一遍。MySQL 需要 libaio、numactl 这些系统库,装的时候怕缺,装完又怕和别的软件冲突。到最后,我根本说不清这台机器上哪些库是中间件带进来的,哪些是系统本来就需要的。
第二种是环境“漂移”。同样的安装步骤,在这台机器上没问题,在另一台上就会因为内核版本、库版本、安全策略的差异失败。团队里只要超过两个人维护服务器,“我这跑得通、你那跑不通”就必然出现。更麻烦的是卸载:手动装的中间件散落在 /usr/local、/etc、/var/lib,删不干净,过段时间想重装,总会有旧配置残留。
第三种是升级和回滚没有退路。手动升级中间件之前,你很难做一个“快照”;升级到一半发现问题,想回滚旧版本,基本等于重新安装一遍。
这些问题本质上不是“某个中间件难装”,而是重复劳动一定会出错,而且两台机器永远有细微差异。
1.2 Compose 与 docker run、Kubernetes 的定位差异
很多人一开始会用 docker run 一条命令启动容器,我也这么干过。但中间件一多,每个中间件都要带着端口映射、数据卷、环境变量、健康检查这些参数,一条 docker run 能写到屏幕放不下,而且换台机器很容易漏配参数。
docker compose 解决的是“声明式管理”的问题:把一组容器的配置写进一个 YAML 文件,放进 Git,改配置走评审,启动、停止、查看日志都有统一命令。它和 docker run 的区别,就像“照着清单布置房间”和“凭记忆每次重新摆家具”的区别。
那为什么不直接用 Kubernetes?我的判断标准很简单:如果只有一到十台服务器,K8s 要维护控制面、网络插件、存储插件,本身的复杂度已经超过它省下来的运维成本。Compose 正好卡在“单机多容器编排”这个位置,一台 Docker 主机就能跑,不用搭集群。等业务量真到了需要多节点调度、自动扩缩容的地步,再往 K8s 迁也不迟。
顺便提一句“docker compose 是干什么的”这个高频问题:它就是 Docker 官方提供的多容器编排工具,用 YAML 描述“我要跑哪些服务、每个服务用什么镜像、映射什么端口、挂载哪些数据卷”,然后一条命令整体拉起。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先定四件事:安装插件、目录规范、端口策略和 .env
2.1 CentOS 上安装新版 Docker 与自带 Compose 插件
先说安装。现在新版 Docker 已经自带 Compose 插件,不需要再去 pip 安装一个独立的 docker-compose 二进制,这极大降低了兼容性问题。
如果是在 CentOS 上,我的安装顺序是这样:
bash复制# 1. 如果有旧版 Docker,先移除
sudo yum remove docker docker-common docker-selinux docker-engine
# 2. 安装 yum-utils 并添加 Docker 官方仓库
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# 3. 安装 Docker 及其组件
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 4. 启动并验证
sudo systemctl enable --now docker
docker --version
docker compose version
关键点在第 3 步:docker-compose-plugin 这个包提供了 docker compose 子命令。如果机器上还残留着旧版 python 写的 docker-compose,最好一并卸掉,否则两个命令混用很容易弄混版本。
我想提醒一句:尽量不要用 curl 到某个第三方脚本去安装 Docker。官方仓库的步骤虽然多两步,但来源可控,升级和卸载都干净。
2.2 目录和数据分离:一个中间件一套配置
我习惯把不同中间件拆开放到独立目录,而不是全部塞进同一个 compose 项目。推荐结构是这样:
code复制/opt/middleware/
├── redis/
│ ├── docker-compose.yml
│ └── .env
├── mysql/
│ ├── docker-compose.yml
│ ├── .env
│ └── init/
│ └── 01_init.sql
├── rabbitmq/
│ ├── docker-compose.yml
│ └── .env
└── kafka/
├── docker-compose.yml
└── .env
这样做的理由是:docker compose up -d 只影响当前目录下的项目,我想重启 Kafka 时不用把 MySQL 也一起牵连重启;备份时直接 tar 整个中间件目录,配置文件、初始化脚本和 compose 文件一次带走;别人接手服务器时,看目录结构就知道有哪些中间件,不用猜。
在 compose 文件内部,我坚持“配置用挂载、数据用命名卷”的原则。像 Redis 的配置、MySQL 的初始化脚本,我用 bind mount 挂进容器,因为改起来直观;而真正的业务数据,比如 /var/lib/mysql,我用命名卷,让 Docker 来管理宿主机上的实际位置。这样既方便改配置,又躲开了 bind mount 在权限和 SELinux 上的坑。
2.3 端口暴露的收敛原则
中间件一旦把端口暴露到局域网,就可能被别人的扫描工具命中。因此我有一条铁律:能只绑本机,就不绑 0.0.0.0。
| 中间件 | 端口 | 建议暴露方式 |
|---|---|---|
| Redis | 6379 | 127.0.0.1:6379:6379 |
| MySQL | 3306 | 127.0.0.1:3306:3306 |
| RabbitMQ AMQP | 5672 | 127.0.0.1:5672:5672 |
| RabbitMQ 管理面板 | 15672 | 127.0.0.1:15672:15672 |
| Kafka | 9092 | 127.0.0.1:9092:9092 |
直接写 "3306:3306" 意味着 Docker 会把端口绑定到所有网卡上,局域网内其他机器能直接访问。如果 MySQL 密码不够强,这基本等于引狼入室。只有业务确实需要从其他机器访问时,才放开绑定,并配合防火墙规则限制来源 IP。
还有一点容易被忽略:如果业务应用和中间件在同一个 compose 项目里,根本不需要ports 映射。容器之间通过 Docker 内部网络互访,直接用服务名加端口,比如 mysql:3306。我见过不少人在内部访问场景下还拼命映射端口,白白把服务暴露到宿主机网络上。
2.4 用 .env 集中管理密码和可替换变量
我不会把密码直接写进 docker-compose.yml,因为文件总会被提交到 Git、复制给同事。Compose 支持 ${VAR} 占位,会自动读取同目录下的 .env 文件。
一个典型的 .env 长这样:
code复制REDIS_PASSWORD=Mdq@2024
MYSQL_ROOT_PASSWORD=Mysql@2024
MYSQL_APP_USER=app
MYSQL_APP_PASSWORD=App@2024
RABBITMQ_USER=admin
RABBITMQ_PASSWORD=Mq@2024
然后 compose 文件里写 ${MYSQL_ROOT_PASSWORD} 引用。docker compose 在解析时会把值替换进去。这里有三个经验:
- 写完后一定要执行
docker compose config检查。这条命令会把替换后的完整配置打印出来,能直接确认密码有没有真正生效、端口有没有写错,是改配置后必跑的校验步骤。 .env文件加入.gitignore,另留一个.env.example作为模板提交到仓库。新同事复制example改成自己的密码就能用。- 密码里如果有
$、#这类特殊字符,最好用单引号包裹。我自己遇到过.env里密码带#导致密码被截断的惨案,排查半天才发现是注释符问题。
3. 一套可直接复用的全家桶 Compose:Redis、MySQL、RabbitMQ、Kafka 逐个拆解
这一节给你一套能直接跑到测试环境的完整配置。我按“版本固定、可回滚、能持久化、有健康检查”这四个标准来组织代码。下面每个中间件的 services 块,你可以单独复制出来用,也可以全部拼到同一个 docker-compose.yml 的 services 下面,并在文件底部声明同名卷,组成一个全家桶。
3.1 Redis:密码、AOF 和内存上限一起放进启动命令
yaml复制redis:
image: redis:7.2-alpine
container_name: redis
restart: unless-stopped
command:
- redis-server
- --requirepass ${REDIS_PASSWORD}
- --appendonly yes
- --maxmemory 256mb
- --maxmemory-policy allkeys-lru
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
我不单独挂载 redis.conf,而是把关键配置直接写进启动命令。好处是配置项肉眼可见,不需要去翻默认配置里到底改了什么。
三个参数值得逐一说明:
--requirepass 是访问密码。Redis 默认没有密码,装完不设密码等于裸奔,给容器绑上本机地址之后,密码仍然得设。如果你需要按用户区分权限,Redis 6 之后支持 ACL,生产环境可以再细化,但基础场景一个密码加网络隔离已经够用。
--appendonly yes 是开启 AOF 持久化。没开持久化的 Redis 重启后就是一个空缓存,如果里面已经存了业务数据,一次容器重建就会“失忆”。测试环境无所谓,生产环境必须开。
--maxmemory 256mb 加 --maxmemory-policy allkeys-lru 是内存上限和淘汰策略。中间件最怕无限增长,给个上限之后,即使业务方写入了异常数据,也不会把宿主机的物理内存吃光。这个值需要按实际业务调整,但设置上限这个动作本身必须有。
我选 redis:7.2-alpine 而不是普通版本,是因为 alpine 镜像体积小、攻击面小。如果你要在 Redis 里加载第三方模块,再考虑标准镜像,因为 alpine 的 musl 库可能和某些模块不兼容。
3.2 MySQL:字符集、时区、初始化脚本与数据卷
yaml复制mysql:
image: mysql:8.0
container_name: mysql
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: app_db
MYSQL_USER: ${MYSQL_APP_USER}
MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD}
TZ: Asia/Shanghai
ports:
- "127.0.0.1:3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
MySQL 8 默认的认证插件是 caching_sha2_password,一些老版本客户端和 JDBC 驱动连不上。我在 command 里加了 --default-authentication-plugin=mysql_native_password,兼容性更好。如果你用的全是新版本客户端,可以去掉这行。
utf8mb4 字符集和 utf8mb4_unicode_ci 排序规则是中文环境的标配。不设置的话,默认字符集可能不接受中文,或者排序结果不符合预期。另一个容易忽略的是时区:容器默认 UTC,Java 应用连上来如果不指定 serverTimezone,查时间就会差 8 小时。直接在 environment 里设置 TZ: Asia/Shanghai,一劳永逸。
关于初始化脚本,/docker-entrypoint-initdb.d 这个目录有个特性:只有在数据卷为空时,.sql 和 .sh 文件才会按文件名顺序执行。如果数据卷已经初始化过,再往 ./init 目录塞脚本不会生效。我当初不知道这一点,改完初始化脚本重启容器,发现表结构没变,一度以为脚本写错了。
MYSQL_ROOT_PASSWORD 只设置 root 密码,但创建业务数据库和用户是推荐做法:
MYSQL_DATABASE=app_db创建默认数据库。MYSQL_USER和MYSQL_PASSWORD创建一个业务账号,应用连接用这个账号,不要用 root。
业务账号加数据库隔离,避免应用拿到 root 权限后误删别的库。
3.3 RabbitMQ:管理面板、hostname 与健康检查
yaml复制rabbitmq:
image: rabbitmq:3.13-management
container_name: rabbitmq
restart: unless-stopped
hostname: rabbitmq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 15s
timeout: 10s
retries: 5
RabbitMQ 有两个端口:5672 是 AMQP 协议端口,给应用连;15672 是管理面板端口,浏览器访问它可以看到队列堆积、连接数、消息吞吐。镜像必须带 management 标签才有管理面板。没有面板排查问题会非常痛苦,建议即使不需要界面也别省。
hostname: rabbitmq 这行不是随便写的。RabbitMQ 以节点名作为数据目录的一部分,容器默认 hostname 是一串随机 ID,每次重建容器都会变,导致新容器认为自己是新节点,找不到旧数据。固定 hostname 配合命名卷,容器重建后数据才稳定。
RABBITMQ_DEFAULT_USER 和 RABBITMQ_DEFAULT_PASS 会创建默认虚拟主机 / 的管理员账号。后续要加更多用户,可以用 rabbitmqctl add_user,或者通过管理面板操作。
健康检查这块,我用的是 rabbitmq-diagnostics -q ping。这条命令直接探测 Erlang 运行时是否正常响应,比用 nc 去探测端口可靠得多。
3.4 Kafka:用 KRaft 模式绕开 ZooKeeper
yaml复制kafka:
image: apache/kafka:3.7.1
container_name: kafka
restart: unless-stopped
ports:
- "127.0.0.1:9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
volumes:
- kafka_data:/tmp/kraft-combined-logs
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
interval: 15s
timeout: 10s
retries: 5
老版本 Kafka 必须搭配 ZooKeeper 才能运行,等于一次要维护两个中间件。Kafka 3.3 之后引入 KRaft 模式,把元数据管理合并进 Broker,单节点可以直接用 broker,controller 双角色跑起来。
这段配置里最关键的是 KAFKA_ADVERTISED_LISTENERS。Kafka 会给客户端返回一个“回连地址”,客户端拿到这个地址后再去找 Broker。如果这里写的是容器内部的主机名,宿主机上的客户端解析不了就会连不上;写成 localhost:9092,宿主机上的客户端就能正常连接。如果业务应用跑在另一个容器里并通过 kafka:9092 访问,那就得把 advertised 改成 kafka:9092。这是 Kafka 新手最容易翻车的地方,后面我会专门讲我踩过的坑。
单节点环境必须把复制因子全部设为 1,否则 Kafka 会一直等待其他副本同步,日志里不断刷 ERROR。还有 KAFKA_AUTO_CREATE_TOPICS_ENABLE 我设成 true,测试环境方便;生产环境建议关掉,避免客户端误建大量无意义的 topic。
数据目录我挂到了 /tmp/kraft-combined-logs。这是官方镜像默认存放日志的位置,虽然带 tmp 字样,但确实是数据文件所在,必须挂到命名卷上,否则容器重建 topic 就全没了。
4. 服务起来只是开始:自检、日志、备份和资源限制
4.1 启动后首先做的三件事:ps、logs、config 校验
每次部署完,我会按固定顺序做三件事。
先执行 docker compose config,校验 compose 文件语法,并查看变量替换后的最终配置。这一步能发现 .env 里密码没写对、端口写重复、缩进错误等问题。我见过很多人跳过这步直接 up -d,结果服务起来后发现密码错误,又得重新删容器。
然后执行 docker compose up -d 启动服务。首次启动不建议加 --force-recreate,保持正常启动顺序。MySQL 首次初始化可能要几十秒,docker compose ps 会发现它暂时处于 starting 或 unhealthy 状态,这是正常的。
最后用 docker compose logs -f --tail=200 mysql 看日志。MySQL 的启动日志里出现 ready for connections 才算真正可用;如果一直 Restarting,日志里通常已经写明了原因。我有一次 MySQL 起不来,就是因为宿主机上 /var/lib/mysql 权限不对,日志里明晃晃写着 Permission denied,一眼定位。
4.2 healthcheck 的原理与“好用”的判断标准
healthcheck 是 compose 文件里容易被忽略但对自动化非常关键的一部分。它在容器内部按 interval 周期执行一条命令,命令返回成功则记为健康,连续失败超过 retries 次就会变成 unhealthy。
判断一个 healthcheck 好不好用,核心标准是:“端口活着”不等于“服务可用”。比如 MySQL 在初始化阶段端口可能已经监听了,但此时不能正常执行查询;Kafka 的端口通了,但 Broker 可能还在恢复元数据。所以尽量用中间件自带的管理命令,而不是简单的网络探测。
举个反例,有人用 ["CMD", "nc", "-z", "127.0.0.1", "3306"] 做 MySQL 健康检查。服务进程还在初始化时端口就已经监听,会误报健康;而真正开始提供服务时,反而可能因为连接数暂时限制而误报不健康。
我的做法是:
- Redis 用
redis-cli ping,返回 PONG 即健康。 - MySQL 用
mysqladmin ping,如果条件允许,可以在命令里带上真正的查询,比如SELECT 1。 - RabbitMQ 用
rabbitmq-diagnostics -q ping。 - Kafka 用
kafka-broker-api-versions.sh,它能证明 Broker API 真正响应。
这里有个细节:healthcheck 里要避免使用容器里不存在的命令。很多精简镜像没有 curl、没有 nc,你写了命令进去容器执行不了,健康检查永远失败。此外,MySQL这样的服务首次初始化慢,最好加一个 start_period 参数,在这个时间段内失败不计入重试次数,避免“启动即误报”。
4.3 数据备份思路:分别对待文件型与消息型中间件
中间件类型不同,备份策略完全不一样。我不会对消息和文件型中间件用同一种方案。
MySQL 我每天用 mysqldump 做逻辑备份:
bash复制mysqldump -h 127.0.0.1 -uroot -p --single-transaction --routines --events --triggers app_db > app_db_$(date +%F).sql
--single-transaction 对 InnoDB 是无锁备份,不影响线上读写;--routines --events --triggers 把存储过程、事件和触发器也一并导出。恢复时只需要一条导入命令。
Redis 我用 BGSAVE 加数据卷打包。执行 docker exec redis redis-cli -a "$REDIS_PASSWORD" BGSAVE 生成内存快照,然后备份数据卷里的 dump.rdb 和 appendonly.aof。如果 AOF 文件长期不 rewrite 会持续膨胀,可以定期执行 BGREWRITEAOF。
RabbitMQ 比较特殊。它由“定义”和“消息数据”两部分组成:定义包括用户、权限、交换机、队列、绑定关系;消息数据则是队列里存的实际内容。日常备份我优先做定义备份,用管理 API 导出成一个 JSON 文件:
bash复制curl -u admin:password -X GET http://127.0.0.1:15672/api/definitions -o rabbit_definitions.json
如果要求不丢消息,得停机后对数据卷做快照,但这会牺牲可用性,实际生产中很少对每条消息做完全备份。多数场景下,定义文件加上业务侧的重放能力,就足以应对“配置误删”这种事故。
Kafka 的日志数据在线备份并不可靠,topic 分片文件在写入时直接 tar,可能出现数据不一致。测试环境我会直接对卷打包;生产环境更推荐用 MirrorMaker 之类的工具做异步复制,让消费端把消息同步到另一个集群,实现真正的容灾。
| 中间件 | 备份方式 | 频率 | 恢复重点 |
|---|---|---|---|
| MySQL | mysqldump | 每日全量 | 导入 SQL |
| Redis | BGSAVE + 卷打包 | 每日 | 覆盖 dump.rdb/AOF |
| RabbitMQ | definitions.json + 卷快照 | 每日 | 导入定义 |
| Kafka | 镜像复制/卷快照 | 按业务 | 重放消息 |
4.4 日志轮转与内存上限,防住最常爆的两个问题
Docker 默认的 json-file 日志驱动不会自动清理。中间件如果一直打日志,几个月下来 /var/lib/docker 目录会被撑爆,连带着宿主机磁盘告警。我在每个服务上都加一段日志轮转配置:
yaml复制logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
这样每个容器日志最多保留 30MB,自动滚动,不会无限制增长。如果需要的日志量更大,应该接入集中日志系统,而不是让 Docker 本地日志无限膨胀。
内存上限同样重要。Compose 文件里可以这样配置:
yaml复制deploy:
resources:
limits:
memory: 1g
在新版 Docker Compose 的本地模式下,deploy.resources.limits 也会被识别;老版本环境不识别的话,可以直接用顶层配置 mem_limit: 1g。我的参考分配是:Redis 256MB、MySQL 1GB、RabbitMQ 512MB、Kafka 1GB 起步。具体值按业务体量调整,但一定要设置,否则 Java 系中间件(Kafka、Elasticsearch)经常因为堆外内存把宿主机吃满。
日常用 docker stats 看实际占用,长期超过水位再往上调,不要一开始就给到顶。
5. 我踩过的四个坑和完整排查链路
5.1 MySQL 起不来:从日志到目录权限再到 SELinux 的逐层定位
现象:docker compose up -d 之后 MySQL 一直 Restarting,docker compose logs mysql 里出现 Permission denied 或者 mysqld: Can't create/write to file '/var/lib/mysql/...。
排查链路:先看日志,报错关键词基本指向文件权限,不是配置错误。我以前习惯用 bind mount 把 ./data:/var/lib/mysql 挂进容器,在 CentOS 上开启 SELinux 后,容器进程访问宿主机目录会被拒绝。
我当时试了 chown -R 999:999 ./data,因为 MySQL 容器内进程以 uid 999 运行。这样做能解决一部分权限问题,但 SELinux 策略还拦着。最省心的方案是改用命名卷,把 ./data:/var/lib/mysql 换成 mysql_data:/var/lib/mysql,让 Docker 全权管理数据目录,既绕开 SELinux,也保证了数据不散落在宿主机文件系统里。
经验:遇到 Permission denied,不要下意识 chmod 777,先确认是谁在读写、它的 uid 是什么、宿主机有没有 SELinux,再决定怎么改。
5.2 Kafka 客户端连上就断:问题出在 advertised.listeners,不是端口没映射
现象:宿主机上写个 Java 客户端连 127.0.0.1:9092,日志显示第一次连接成功,在获取 Broker 元数据时立刻 Connection refused 或者 disconnected,反复重试都是这样。
排查链路:先用 ss -lntp | grep 9092 确认宿主机端口确有映射;再进容器执行 /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092,发现 Broker 本身正常。这时候才意识到是客户端拿到元数据后去连接“Broker 告诉它的地址”,这个地址在 advertised.listeners 里。结果我打开容器看到返回的 endpoint 是容器内部主机名,宿主机根本解析不了。
解决办法:宿主机上的客户端使用 PLAINTEXT://localhost:9092;如果应用跑在另一个 compose 项目里并通过 Docker 网络访问,就得把 advertised 改成 kafka:9092,或者再配置一个外部监听器专门给宿主机客户端使用。
这个坑的教训是:Kafka 这类对“回连地址”敏感的中间件,配置之前先想清楚客户端在哪里。端口映射只是第一步,advertised 地址才是决定客户端能否真正连上的关键。
5.3 docker compose down 不带 -v 为什么不丢数据,带 -v 为什么让人崩溃
现象:同事为了“清理环境”执行了 docker compose down -v,然后 MySQL、Redis 里的测试数据全没了,当场崩溃。
原因:down 默认只删容器和网络,命名卷会保留,数据不会丢;但 -v 会连 compose 文件里声明的卷一起删掉。也就是说:
docker compose down:停容器、删容器和网络,保留卷和数据。docker compose down --rmi all:连镜像一起删,数据卷仍保留。docker compose down -v:连数据卷一起删,破坏性操作。
经历过这次事故后,我在 README 和团队文档里都会写清楚:日常重启用 docker compose restart,彻底清环境用 docker compose down,只有在确定不要任何数据时才用 -v。另外,执行破坏性命令前先跑一下 docker volume ls 看看有哪些卷,确认没有重要数据再动手。
5.4 healthcheck 一直 unhealthy:命令路径与超时都可能是元凶
现象:服务明明能访问,docker compose ps 却一直显示 unhealthy,有些自动化脚本还把容器重启了。
排查链路:用 docker inspect <容器名> --format='{{json .State.Health}}' 查看历史检查记录,能看到具体的失败原因。
最常见的三类问题:
第一,命令在容器里不存在。比如用 curl 做健康检查,但 redis:alpine 镜像里没有 curl。解决办法是改用中间件自带工具,比如 redis-cli、mysqladmin、rabbitmq-diagnostics。
第二,执行超时。RabbitMQ 在初始化阶段比较慢,健康检查默认 30 秒超时可能不够。解决办法是调大 timeout,并加上 start_period,让初始化这段时间不参与失败计数。
第三,变量没有正确展开。["CMD", "mysqladmin", "ping", "-p", "$MYSQL_ROOT_PASSWORD"] 这种写法,$MYSQL_ROOT_PASSWORD 不会由 Shell 解析,传进去的是个字面量。解决办法是用 ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$MYSQL_ROOT_PASSWORD"]。
经验是:healthcheck 写好之后,手动进容器把那条命令原样执行一遍,如果手动能过,再放回 compose 观察两个周期;如果手动都跑不通,说明命令本身有问题。
如果你也打算把这套组合用到自己的环境里,我的建议是先保存所有配置到 Git,第一次启动前跑 docker compose config 确认密码替换正确,遇到问题先看日志而不是凭记忆改配置。中间件部署没有太多玄学,多数故障都出在版本、网络、权限和监听地址这四个变量上。把这四个变量盯住,Compose 这趟车基本就稳了。
