上个月我把一台跑了两年的单机 MySQL 8 容器迁到了 Docker Swarm 集群上,本来以为就是个“换个编排工具”的事,结果从网络模式、数据卷约束到健康检查配置,踩了一路的坑。这篇文章就把整套“Docker Swarm 部署 MySQL 8”的思路、配置和排障过程完整整理出来:为什么用 Swarm 而不是硬扛单机、overlay 网络下 MySQL 的连接行为有什么变化、数据落在哪里才安全、滚动更新怎么不丢数据,以及我实际部署过程中遇到的那些坑和解决办法。
如果你正在纠结“要不要把 MySQL 搬上 Swarm”,或者已经搬上去了但服务总是奇奇怪怪地连不上,这篇文章基本可以帮你避掉大部分问题。
1. 为什么要把 MySQL 搬进 Swarm,而不是继续单机跑
1.1 单机容器的三个真实痛点
很多人一开始用 Docker 跑 MySQL,就是一句docker run -d -p 3306:3306 -v mysql-data:/var/lib/mysql mysql:8.0的事。单机跑个开发库、内部系统确实没问题,但跑着跑着问题就来了。
第一个痛点是“进程死了没人拉”。Docker 的 restart policy 虽然能保证容器挂了之后自动重启,但那是基于“宿主机还活着”的前提。宿主机重启、磁盘满了、内核 panic,Docker daemon 自己都起不来,容器自然也就无从恢复。
第二个痛点是“写挂一个容器累死一个人”。我遇到过一次磁盘 IO 异常导致 mysqld 频繁 crash,每次都要 ssh 上机器手动清理日志、重启容器,半夜被叫醒不说,整个业务中断的窗口全凭运气。
第三个痛点是“环境不可复制”。配置、初始化脚本、参数散落在不同的机器上,换一台机器部署等于重新来一遍,而且没人能保证生产环境和测试环境用的是同一套 MySQL 配置。
1.2 Swarm 到底能解决什么,不能解决什么
把所有容器都交给 Swarm 编排之后,解决的核心问题有两个:一个是“健康检查 + 自动重启”,节点挂了 or 容器 hang 了,Swarm 会重新调度任务;另一个是“声明式部署”,一份 compose 文件扔上去,服务、网络、卷、重启策略全部按定义执行,环境差异被干掉一大半。
但必须说清楚:Swarm 不是高可用数据库方案。它管的是“容器编排”,不是“数据复制”。Swarm 不会替你解决 MySQL 主从同步、脑裂、双写冲突这些问题。如果你跑的是单副本 MySQL,节点挂了之后 Swarm 会想办法在另一个节点把容器拉起来,但数据在不在那边,取决于你用的是哪种存储方案。这一点我在后面第 2 章和第 5 章的排障里会反复强调,也是整个部署里最容易被低估的地方。
所以先明确结论:Swarm 部署 MySQL 8 的定位是“把单机容器变成集群内可调度、可自愈、可声明式管理的服务”,而非“让 MySQL 变成分布式数据库”。
2. 整体设计:网络、存储、节点怎么安排
2.1 节点规划与服务约束
部署前先想清楚:集群里有几个节点,MySQL 允许跑在哪些节点上,哪些节点是空闲的。
常见的集群拓扑是 3 个 manager 节点 + 若干 worker 节点。MySQL 是有状态服务,对 IO 和网络都比较敏感,我不建议让 MySQL 任务在集群里到处乱飘,而是通过节点标签把 MySQL 固定到指定节点上。比如选出两台 IO 性能较好、CPU 核数相对充足的服务器,打上标签mysql=true,然后在服务的 placement 约束里写明“只允许调度到带 mysql=true 标签的节点”。
命令行打标签很简单:
docker node update --label-add mysql=true node-01 docker node update --label-add mysql=true node-02这里有个细节:标签是打在节点上的,不区分 manager 或 worker。生产环境如果想让 MySQL 跑在专用 worker 上,顺手在 manager 节点上加一条约束node.role != manager会更稳妥,避免数据服务跟 Swarm 控制面抢资源。
2.2 overlay 网络与 VIP、DNSRR 模式的选择
Swarm 集群里,服务之间默认通过 overlay 网络通信,同一个 overlay 网络内可以用服务名直接解析到对应的容器。MySQL 8 放在 overlay 网络后面时,有一个非常关键的点:Swarm 服务默认的 endpoint mode 是 VIP,即 Swarm 会给服务分配一个虚拟 IP,访问这个 VIP 时 Swarm 会把连接转发到某个健康的任务容器上。
MySQL 是有状态服务,单副本场景下 VIP 没什么问题,但如果你图省事把 replicas 调成 2,VIP 模式下两个 MySQL 容器会同时提供服务,业务端根本感知不到自己连的是哪一台。一旦两个实例的写入互相冲突,数据一致性直接崩掉。MySQL 这类服务在 Swarm 里最正确的姿态就是单副本 + 自动重启,数据库层的高可用交给 MySQL 主从复制去解决,而不是靠 Swarm 的负载均衡。
外部流量访问 MySQL 时,端口模式建议用host。把 MySQL 的 published port 模式设为 host,意味着业务机器直接通过宿主机 IP + 端口连进来,少一层 IPVS 转发,延迟和连接稳定性都更好。
2.3 存储选型与数据安全边界
Swarm 部署 MySQL 时,“数据卷放在哪”是决定成败的问题。Docker 自带的 local volume 只能用在单个节点上,容器被调度到哪个节点,数据卷就创建在哪个节点。如果节点挂了,Swarm 把 MySQL 任务调度到另一台机器上,而另一台机器上没有这份数据,新容器启动时看到的是数据目录为空,MySQL 会直接初始化成一个全新的空实例,业务侧看到的现象就是“数据全丢了”。
这就是我在开头说的“Swarm 不代表数据高可用”的典型场景。我见过不止一个人在生产环境踩这个坑:节点宕机,Swarm 确实把服务拉起来了,但库是空的,比服务不可用还难排查。
可行的方案有这么几种:
- 固定节点 + local volume:把 MySQL 约束在固定节点上,数据卷用 local driver 放在本机,简单可靠,缺点是节点硬件故障时数据恢复依赖磁盘本身。
- 共享存储:使用 NFS 这类网络文件系统作为卷后端,容器调度到任何节点都能挂载同一份数据。实际效果与网络质量和文件锁兼容性强相关,MySQL 跑在 NFS 上性能折损明显,不建议对写入压力大的库使用,除非存储方案本身专门针对数据库场景做过调优。
- 高可用架构:MySQL 做主从复制,每个节点上的实例各自带一份本地数据卷,Swarm 只负责维持主库和从库的容器运行状态。数据安全性与可用性由 MySQL 复制机制保证,这是我最推荐的做法。
后面所有配置我都按“placement 约束 + local volume + 单副本”来写,这也是 Swarm 部署 MySQL 最经典、最不容易出问题的组合。
3. 部署配置逐行拆解
3.1 服务定义与基础参数
下面这份docker-compose.yml是我最终在用的部署配置,基于 Docker Compose v3.8 格式,用docker stack deploy下发到 Swarm。
version: '3.8' services: mysql: image: mysql:8.0 hostname: mysql8 command: - mysqld - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-authentication-plugin=caching_sha2_password - --max_connections=500 - --innodb-buffer-pool-size=2G - --lower_case_table_names=1 environment: MYSQL_ROOT_PASSWORD: "在这里填一个足够长的随机密码" MYSQL_DATABASE: appdb MYSQL_USER: appuser MYSQL_PASSWORD: "应用账号的独立密码" TZ: Asia/Shanghai volumes: - mysql-data:/var/lib/mysql networks: - mysql-overlay ports: - target: 3306 published: 3306 protocol: tcp mode: host deploy: replicas: 1 placement: constraints: - node.labels.mysql == true resources: limits: cpus: '2' memory: 4G reservations: cpus: '0.5' memory: 1G restart_policy: condition: on-failure delay: 15s max_attempts: 3 update_config: order: stop-first failure_action: rollback monitor: 30s parallelism: 1 delay: 30s networks: mysql-overlay: driver: overlay attachable: true driver_opts: encrypted: "true" volumes: mysql-data: driver: local逐个说下几个我认为最重要的点。
command里显式以mysqld开头,这是为了避免覆盖官方镜像的默认启动入口后导致 MySQL 无法正常启动。字符集和排序规则统一用utf8mb4和utf8mb4_unicode_ci,现在业务系统基本都要支持 emoji 或生僻字,再用老旧的utf8就等着后面改库吧,代价极大。
lower_case_table_names=1表示表名不区分大小写,这是个偏业务侧兼容性的设置。如果你的应用是从 Windows 环境迁过来的,表名大小写风格可能不一致,务必先在测试库确认这个参数是否符合业务要求,因为 MySQL 8 里这个参数在初始化后就不能再改了。
--innodb-buffer-pool-size=2G是我根据业务数据量约 60G、内存总量 8G 的机器配的。这个值建议设为可用内存的 50% 到 70%,不要盲目往上加到 6G、7G,因为 MySQL 还有一堆内部结构、连接线程、排序缓冲要占内存,容器内存 limit 设 4G 时 buffer pool 就最好不要超过 2G,否则会频繁触发内存回收甚至直接被 OOM kill。
3.2 健康检查与更新策略
MySQL 官方镜像本身不自带健康检查,Swarm 判断“容器活着”和“MySQL 真正可用”完全靠你自己定义。没有健康检查的服务,容器起来之后 mysqld 还没完成启动时,Swarm 已经认为这个任务是健康的,流量进来直接撞上 “Can't connect to MySQL Server”,业务报错一堆。
我把健康检查直接写进了服务定义里:
healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] interval: 20s timeout: 5s retries: 5 start_period: 60s这里有一个很隐蔽的坑:compose 文件中要拿到环境变量MYSQL_ROOT_PASSWORD,直接写-p$MYSQL_ROOT_PASSWORD会被 shell 抢先展开成空值,必须写$$MYSQL_ROOT_PASSWORD,Compose 才会把它当作“运行时环境变量引用”传给容器内的命令。
start_period: 60s是给 MySQL 留出初始化缓冲时间。第一次启动时如果数据目录为空,MySQL 要做初始化、建库、设密码等一堆事,可能耗时一两分钟,这段时间健康检查失败是正常的,Swarm 不会重启容器。没有这个参数的话,启动慢一点就被判定不健康、反复重启,然后就陷入“起不来-被重启-更起不来”的死循环。
更新策略order: stop-first值得单独解释一下。很多人在 Swarm 里跑无状态服务喜欢用start-first,先起新再停旧,零停机。但 MySQL 是单副本有状态服务,数据卷只有一份,用 start-first 的话新容器和旧容器会在同一节点上同时启动,两个 mysqld 进程同时挂载同一个数据目录,不炸才是怪事。所以这里必须 stop-first,先停旧容器,再在同一节点上起新容器。
failure_action: rollback是说更新过程中如果出现启动失败或者健康检查过不了,Swarm 会自动回滚到旧版本。这个能力在数据库升级场景里是底线保障,强烈建议保留。
3.3 配置文件与初始化脚本注入
如果你有额外的 MySQL 参数想配置,比如slow_query_log、max_allowed_packet、binlog_expire_logs_seconds这些,最忌讳的做法是把宿主机上的某个路径用 bind mount 直接挂进服务里,然后在 stack 文件里写:
volumes: - /home/admin/mysql-conf:/etc/mysql/conf.d:ro这种写发在单机 Docker 上没问题,到了 Swarm 就成了隐患。因为 bind mount 的路径必须在容器实际调度到的那个节点上存在,一旦 MySQL 因为节点故障被调度到另一台没有这个路径的机器上,容器直接就起不来。而且滚动更新时 Swarm 也可能在新旧节点之间迁移任务,配置文件缺失会造成更新失败。
正确的做法是利用 Docker Config 对象把配置文件注入到容器里。先把mysql.cnf准备好:
[mysqld] skip-name-resolve slow_query_log=1 slow_query_log_file=/var/lib/mysql/slow.log long_query_time=2 max_allowed_packet=64M max_connections=500 expire_logs_days=7然后在 compose 文件中引入:
configs: - source: mysql_cnf target: /etc/mysql/conf.d/custom.cnf mode: 0440 configs: mysql_cnf: file: ./mysql.cnfDocker Config 会把文件内容下发到集群中所有节点,不管容器调度到哪都能挂载到同样的配置。需要修改配置时,改完文件后要重新执行 stack deploy,并且因为 config 对象本身是不可变的,通常需要改个名字或者先删除旧 config 再创建,不然 Swarm 只会对“名称和内容都变了”的 config 进行更新。
初始化脚本会用得少一些,但确实存在。首次初始化时需要创建表结构、导入基础数据,你可以利用官方镜像的/docker-entrypoint-initdb.d目录机制,把.sql文件作为 config 挂进去,容器首次初始化数据目录时会自动执行目录里的脚本。注意只有数据目录为空、首次启动时才会执行,数据卷已经存在的情况下重复挂载不会再次执行。
4. 完整部署流程
4.1 初始化集群与节点标签
假设你已经有 3 台机器,分别叫 node-01、node-02、node-03。第一台作为 manager 节点,执行初始化命令:
docker swarm init --advertise-addr 192.168.10.11这里--advertise-addr填 manager 节点在集群网络里的 IP,一定要选对。如果机器有多个网卡,特别是有内网和管理网两套网络时,这个参数不写或者写错会导致 worker 加入集群后节点状态一直 Down。
然后拿到 manager 节点输出的 worker join 命令,在另外两台节点上执行。加入完成后,给 MySQL 指定的两个节点打标签:
docker node update --label-add mysql=true node-01 docker node update --label-add mysql=true node-02用docker node ls看节点状态,全部 Ready 再继续。
4.2 下发服务
在 manager 节点上,准备好上面那份 docker-compose.yml 和 mysql.cnf,然后执行:
docker stack deploy -c docker-compose.yml mysql这里的mysql是 stack 名字,可以换成你的项目名。Swarm 会按定义自动创建 overlay 网络mysql_mysql-overlay、数据卷mysql_mysql-data,以及服务mysql_mysql,不用手动一个个建。
下发后立刻验证一下任务是否正常:
docker service ls docker service ps mysql_mysql如果你看到任务一直在Pending或不停Starting,多半是 placement 约束的节点标签没打上,或者资源预留不足导致无法调度。这个问题我在第 5 章再展开。
4.3 验证服务与外部连接
先确认 MySQL 在哪个节点上运行,再去看日志是否初始化完成:
docker service ps mysql_mysql --no-trunc docker logs <容器ID>看到 “ready for connections” 字样,说明 mysqld 已经正常起来。
从其他机器连接时,直接用 MySQL 所在节点的宿主机 IP 加 3306 端口:
mysql -h 192.168.10.11 -P 3306 -u appuser -p在集群内部的另一个容器里连接时,直接用服务名:
docker exec -it <其他容器> mysql -h mysql_mysql -P 3306 -u appuser -p这里要特别注意:在 overlay 网络里,服务名解析到的是 Swarm VIP 或该服务的 DNSRR 记录,不能使用localhost或容器的 ID 去连,必须走服务名或 VIP。
4.4 故障演练与滚动更新
部署完不验证故障转移,等于白部署。我自己一定会做两个演练。
第一个是“节点不可用演练”。把跑 MySQL 的节点node-01置为 drain,模拟计划内维护:
docker node update --availability drain node-01因为 MySQL 被约束到 node-01 和 node-02,而 node-01 已经不可调度,Swarm 会把任务重新调度到 node-02 上启动。如果数据卷是 local 且只存在于 node-01,这时候你会看到新容器在 node-02 上启动,但数据目录是全新的空库。对第一次演练的人来说,这是个非常震撼的“效果演示”:Swarm 的故障转移真的会触发,但你的数据并不会跟着转移。
所以生产环境做这个演练之前,要么保证两个节点用共享存储,要么接受“单副本 + local volume 只能恢复服务、不能保证数据完整”的现实。如果你要在 node-01 恢复后重新把 MySQL 的 local volume 数据迁移回 node-01 或者让新容器重新挂载旧数据,需要手动拷贝数据目录,这是后面第 5 章“数据卷漂移”事故的来源。
第二个是“滚动升级演练”。假设要升级到 mysql:8.0.36 这样一个 patch 版本:
docker service update --image mysql:8.0.36 mysql_mysql由于更新策略是 stop-first,Swarm 会先停掉旧容器,再在同一节点上重新拉镜像起新容器。观察更新过程:
docker service ps mysql_mysql更新后数据卷还挂在原节点上,数据不会丢。但注意,如果旧容器更新时因为数据卷被占用或者端口冲突导致新任务起不来,Swarm 会根据 rollback 策略回到旧镜像。所以更新前记得先确认版本兼容性,MySQL 跨大版本升级(比如 8.0 到另一个大版本)绝对不能只靠 update 命令就完事。
5. 常见问题速查与排障思路
5.1 服务一直 Pending,调度不上去
现象是docker service ps里任务状态停在 Pending,docker service ls显示 0/1 副本就绪。排在第一个的原因是 placement 约束没满足,先回忆节点标签打了没有:
docker node inspect node-01 --format '{{ .Spec.Labels }}'如果约束写得是node.labels.mysql == true但标签没有,或者标签值对不上,把标签补上即可。另一个常见原因是资源预留。deploy 中 reservations 写的内存和 CPU 比节点可用资源还大,Swarm 会判断“集群内没有满足条件的节点”,任务自然起不来。查节点剩余资源用:
docker node inspect node-01 --format '{{ .Description.Resources }}'如果资源充足,再看看是不是网络创建失败。overlay 网络依赖的 VXLAN 端口或集群通信端口被防火墙挡了,任务也会卡住。这时候要看 manager 上 Swarm 的日志或者检查节点之间 7946/TCP、7946/UDP、4789/UDP 这三个端口是否互通。
5.2 MySQL 客户端报“Can't connect”或连接被重置
这个坑我差点没排查出来。现象是容器状态正常、健康检查通过,但宿主机上用 mysql 客户端连 3306 就是连不上,报错要么是Can't connect to MySQL server,要么是连接刚建立就被重置。
如果端口模式是 VIP,而业务机器尝试连接的是 Swarm 的 ingress 网络入口,IPVS 转发可能会把来自同一个客户端的多个连接分到不同的后端起点的容器,但 MySQL 单副本只有一个容器,问题不大。真正常见的原因是 MySQL 8 的 host 权限限制。默认 root 账号只允许从 localhost 登录,应用用宿主机 IP 连进来时,会先经过 TCP 握手,然后 MySQL 校验用户来源,如果创建用户时没指定对来源网段,直接报Host 'xxx' is not allowed to connect to this MySQL server。
这种报错不是 Swarm 的问题,是数据库账号授权问题。给应用账号授权时明确来源范围,比如:
CREATE USER 'appuser'@'10.%' IDENTIFIED BY 'xxxx'; GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'10.%'; FLUSH PRIVILEGES;授权之后把skip-name-resolve加上,减少反向 DNS 解析带来的额外延迟,否则每次连接都要去解析客户端主机名,连接速度肉眼可见地变慢。
还有一种情况:为了业务方便,直接把宿主机上的/var/lib/mysqlbind mount 到容器里,而宿主机上已经有一个 mysqld 在运行,端口被宿主机的原生进程占用了。这时候容器 port publish 会失败,日志里能看到Bind for 0.0.0.0:3306 failed: port is already allocated,属于最常见的端口冲突问题。
5.3 数据卷漂移,新容器起来之后数据全没了
Swarm 的故障转移会触发一个非常误导人的现象:MySQL 容器在另一台节点成功启动了,应用连接也正常了,但登录进去发现库表全是空的。
原因我在第 2 章已经讲过:服务定义了 placement 约束到 node-01 和 node-02,但 volume 是 local driver,实际数据只有 node-01 上有。node-01 宕机后 Swarm 把任务调度到 node-02,node-02 上没有数据卷,MySQL 看到空数据目录就执行了全新初始化,新库就这么出现了。
这种场景下,如果谁能第一时间发现业务库变成空库,还能抢救一下:把原节点恢复,手动把mysql-data卷里的数据拷贝到新节点,再用新卷替换。实际生产里,我见过团队花了大半天时间才意识到“服务没坏,数据目录不对”,损失已经完全造成。
所以要再三强调一个原则:Swarm 管服务调度,不管数据同步。要么用共享存储、要么上 MySQL 主从复制,二选一,不能裸奔。
5.4 健康检查误判让容器被反复重启
这个问题的表象是 MySQL 一直在“Running-Failed-Running”循环,健康检查日志显示mysqladmin连接被拒绝。
第一次启动时数据目录要初始化,可能耗个几十秒到几分钟,start_period设短了会在这期间误判失败。严重情况下 Swarm 把容器杀掉重启,导致每次启动都要重新初始化数据目录——如果数据目录频繁初始化,老数据被新初始化过程覆盖,也是数据丢失的一种。
另一种情况是 healthcheck 命令本身写错了。上面我强调过$$MYSQL_ROOT_PASSWORD的转义问题,如果写成$MYSQL_ROOT_PASSWORD,Compose 在渲染时会把变量展开为空或者宿主机的同名变量,导致密码错误、连接失败。
建议把健康检查命令直接写成一个固定的可执行脚本,避免环境变量展开的麻烦,比如在初始化脚本里创建一个专用健康检查账号:
CREATE USER 'healthcheck'@'127.0.0.1' IDENTIFIED BY 'some-password'; GRANT SELECT ON *.* TO 'healthcheck'@'127.0.0.1';healthcheck 命令改成:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "healthcheck", "-p一些固定密码"]固定密码直接写进 compose 文件里,虽然不算最佳安全实践,但健康检查专用账号只读权限、无业务风险。如果对安全要求高,也可以把密码放到 Docker Secret 里再引用。
5.5 更新或扩容时端口冲突
MySQL 服务用mode: host发布 3306 端口后,两个副本无法运行在同一节点,因为端口冲突。如果你不小心执行了docker service scale mysql_mysql=2,第二个任务大概率会在调度时因为端口占用而失败。
这也是一种隐性保护。对数据库服务来说,副本数应该严格保持 1,多副本的唯一例外是搭建主从架构,让从库以另一个服务名部署、映射另一个端口。不要把两个写库放进同一个 Swarm 服务里,否则你会在数据一致性上付出远高于省下那点运维成本的代价。
6. 一点个人取舍
最后聊几句我从这套部署里得到的最直接的经验。
如果你问“MySQL 8 到底适不适合跑在 Swarm 上”,我的答案是:适不适合取决于你把它当什么。把它当成一个“跑在集群里的单机 MySQL”,Swarm 提供的声明式部署、健康检查、自动重启、滚动更新能力都能实打实地减少运维负担,很适合中小团队在裸金属或者虚拟机上自建数据库服务;但如果你指望 Swarm 解决数据库高可用问题,那就想错了。MySQL 的高可用靠的是主从复制、半同步复制或者集群方案,不是编排器。
我这套配置里特意保留了很多“保守”的设置:单副本、stop-first、资源预留、节点约束。这些选择牺牲了一定的弹性,但换来的是数据安全边界清晰、故障可预期、排障成本低。数据库服务本来就不需要弹性伸缩,它需要的是稳定、可预测、坏了能恢复,出事时你能在一个小时内定位到问题是出在容器层还是数据库层。
再补充一个数据库备份的小建议:不管 Swarm 调度得多完美,定时备份不能省。容器环境最容易出现的问题就是“一切都正常,数据没了”。我现在的做法是每天凌晨在业务低峰期对 MySQL 做一次全量逻辑备份到对象存储,保留 7 天。如果你用的是 xtrabackup 这类物理备份工具,记得配合 binlog 才能做到时间点恢复,光有全量备份在“昨晚误删了一张表”这种事故里是完全不够用的。
这套方案我实际跑了几个月,线上没有因为 Swarm 本身出过事故。如果你正在计划把 MySQL 迁进 Swarm,建议先把节点标签、数据卷方案、健康检查这三件事想透再动手,网络上那些“一条命令部署成功”的教程不会告诉你的事,基本都在上面这些章节里了。