经常有人跑来问我:Docker 到底怎么实现不停机更新?聊多了之后我发现,很多人卡住的地方根本不是命令,而是没想清楚“停”到底发生在哪一步。有人以为把docker stop换成docker restart就能不中断,有人把 Swarm 当摆设,自己写脚本去 pull、stop、start,结果更新一次 502 一次,最后只能挑凌晨两三点没人用的时候偷偷发版。
先给个结论:Docker 没有任何一条“一条命令搞定不停机更新”的魔法。它真正擅长的是把多副本、健康检查、负载均衡这些零件组合在一起,而你要做的,是把发布流程设计成“先起新、再切换、后删旧”。这篇文章不聊虚的,就讲三件事:为什么stop/start一定会断、单机和多机环境下分别怎么滚动更新、MySQL 和 Redis 这类有状态服务又该怎么处理。不管你是刚入行的运维,还是负责后端架构的开发者,这套思路都能直接落到生产环境里用。
1. 想聊不停机前,先理解 Docker 更新到底断在哪
1.1 容器重建不等于服务升级
很多人第一次接触 Docker,会把容器当成一台可以随时改配置的虚拟机。实际上容器的核心理念是“不可变基础设施”:应用代码、运行环境、依赖库全部打进镜像,发布新版本就等于构建新镜像、删掉旧容器、再从新镜像起一个新容器。
问题就出在“删掉旧容器”这一步。容器一旦被 stop,它持有的所有 TCP 连接会被操作系统直接关闭。如果你用的是最朴素的更新流程:
docker pull new-imagedocker stop old-containerdocker run new-image
第二步到第三步之间,服务端口没人监听,所有请求都会被拒绝。哪怕你把这个窗口压到 500 毫秒,对正在下单、正在上传文件的用户来说,就是一次实打实的报错。
另一个被低估的因素是容器启动时间。新容器从镜像仓库拉取、创建网络、挂载卷、执行 entrypoint、进程真正开始监听端口,往往需要几十秒。如果旧容器已经被停了,这个等待期就是完全的不可用期。所以一个最基本的认知是:只要“旧容器先死、新容器后生”,就不存在真正的不停机。要不停机,必须保证同一时刻至少有一个容器在对外提供服务。
1.2 至少想清楚三个层面的“不停机”
在实际做架构设计时,我会把“不停机”拆成三个层面,因为它们的实现难度完全不在一个数量级。
第一个是进程级。比如 Nginx 二进制文件更新,可以在不重启进程的情况下让旧 worker 处理完存量请求、新 worker 接管新连接,整个过程几乎没有感知。大部分开发语言也都有类似的热加载能力,但这类能力跟 Docker 没有关系,是应用自己实现的。
第二个是副本级。服务同时跑多个容器实例,更新时只替换其中一个,等到新副本健康了,再去替换下一个。整个过程流量始终存在,但某一个具体请求的响应者可能是新容器也可能是旧容器。这就是 Swarm、Kubernetes 里滚动更新的核心思路。
第三个是数据级。数据库如果要扩容、升级、迁移,单靠服务器重启副本解决不了,因为数据不会自己复制。这需要主从复制、online DDL、双写迁移等一整套体系。普通业务系统的“不停机更新”,绝大多数指的是副本级,数据库级是另外一门功课。
1.3 无状态和有状态是分水岭
为什么有的人用 Docker 滚动更新很丝滑,有的人一更新就事故?因为服务形态不一样。
无状态服务,比如 Web API、前端静态资源、消息消费者,每个副本都是完全等价的,杀掉任何一个都不影响整体,替换起来非常轻松。有状态服务,比如 MySQL、Redis、Elasticsearch,每个节点都有自己的数据,不能随便同时替换,否则数据就丢了。
还有一个隐形门槛是 Session。如果你的应用把用户登录状态存在本机内存里,那么就算你用了滚动更新,用户下一次请求被转发到新容器,也会发现自己掉线了。所以真正适合滚动更新的服务,Session 要么存 Redis 这类外部组件,要么使用无状态的 JWT。也就是说,不停机更新这件事,从应用架构层面就已经定了基调,Docker 只是把最后一步执行出来。
2. 单机多实例:用 Nginx 做蓝绿切换,把“断流窗口”降到零
2.1 为什么单机单容器做不到真正意义上的“零停机”
如果生产环境只有一台服务器,而且只跑了单个容器,那么严格意义上的零停机是不可能实现的。原因很简单:单容器模式下,新容器必须占用和旧容器相同的端口。端口是互斥的,新容器不可能在旧容器还监听 80 端口的情况下同时监听 80。于是只能先停旧的,再起新的,这个步骤天然带着断流窗口。
但这不意味着单机环境就没救了。一台服务器完全可以跑多个容器,让它们分别监听不同端口,然后通过宿主机的 Nginx 统一对外提供服务。Nginx 是流量入口,你更新应用时只需要修改 Nginx 的 upstream 指向,再执行一次nginx -s reload。reload 不会中断存量 TCP 连接,只会让新连接使用新配置,所以从外部用户视角看,服务一直没有断。这就是经典的蓝绿发布,只是很多人没意识到 Docker 单机环境也能这么玩。
2.2 蓝绿更新完整步骤
假设我的服务当前版本是 1.1.0,运行在 8081 端口,Nginx 把 80 端口的流量转发到它。现在要发布 1.2.0,我会创建第二个项目目录,让新版本跑在 8082 端口。
目录结构大概是这样的:
/workspace/app-blue/docker-compose.yml /workspace/app-green/docker-compose.ymlblue 的 compose 文件:
# /workspace/app-blue/docker-compose.yml services: app: image: registry.example.com/app:1.1.0 ports: - "127.0.0.1:8081:8080"green 的 compose 文件:
# /workspace/app-green/docker-compose.yml services: app: image: registry.example.com/app:1.2.0 ports: - "127.0.0.1:8082:8080"Nginx 配置先指向 blue:
upstream app_backend { server 127.0.0.1:8081; } server { listen 80; location / { proxy_pass http://app_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }发布时执行:
# 启动新版本容器 docker compose -p app-green up -d # 先探测新容器是否真的 ready curl -f http://127.0.0.1:8082/healthz # 修改 Nginx 指向 8082 # 把 server 127.0.0.1:8081; 改成 server 127.0.0.1:8082; # 校验配置并平滑重载 nginx -t && nginx -s reload # 观察一段时间,确认稳定后删掉旧版本 docker compose -p app-blue down这套流程最爽的地方在于,切换 Nginx 前后用户是没有感知的。nginx -s reload会启动新的 worker 进程处理后续新连接,旧的 worker 进程在完成手头的存量请求之后才退出,不会发生连接被野蛮掐断的情况。
2.3 蓝绿方案的核心约束
这套方案虽好用,但有三个前提必须满足,否则照样翻车。
第一,应用必须真的无状态。如果 Session 存在本机内存,用户切到新容器后登录态就丢了。正确的做法是把 Session 放到 Redis,或者用 JWT 这种自包含的凭证。
第二,数据库结构必须同时兼容两个版本的应用。因为切换瞬间,可能有部分用户连接还在旧容器上,而新连接已经打到新容器。如果新版本需要新增一张表,而旧版本不知道这张表,那没问题;但如果新版本删了一个旧版本还在用的字段,旧容器的请求就会报错。
第三,旧容器删掉之前要留一个观察期。我一般至少观察 10 到 20 分钟,确认监控曲线没有异常,再执行docker compose -p app-blue down。如果一切换完就立刻回收旧容器,发现新版本有问题的回滚余地就很小了。
这套方案适合中小项目、预算有限、只有一台生产服务器的场景。它能做到对外“零断流”,代价是需要手动操作 Nginx 配置,并且要预留两倍的应用资源。如果你已经上了 Docker Swarm,下一章的内容会更省心。
3. Swarm 滚动更新:官方滚动更新的正确玩法和参数调优
3.1 把副本交给 Docker 去管
单机蓝绿方案终究是手工活,服务器一多,靠人去改 Nginx 就忙不过来了。这时候应该上 Docker Swarm。Swarm 是 Docker 官方自带的容器编排工具,它把一组机器组成一个集群,你只需要声明“我要跑 3 个副本”,它就会负责调度、网络、服务发现和滚动更新。
Swarm 里的核心概念是 Service。一个 Service 定义了镜像、副本数、网络、端口、健康检查、更新策略。创建服务的命令大概长这样:
docker swarm init docker network create -d overlay app-net docker service create \ --name app \ --network app-net \ --replicas 3 \ --publish published=8080,target=8080 \ --update-order start-first \ --update-parallelism 1 \ --update-delay 10s \ --update-failure-action rollback \ --health-cmd "curl -f http://localhost:8080/healthz" \ --health-interval 5s \ --health-start-period 30s \ registry.example.com/app:1.2.0当你要发布新版本时,不需要手动去服务器上 stop 和 start,只需要执行:
docker service update --image registry.example.com/app:1.3.0 appSwarm 会按照你定义的更新策略,一个副本一个副本地替换。替换流程是:先在新节点启动新容器,等待健康检查通过,再把旧容器停掉。这样集群里始终有存活副本在服务,请求不会断。
3.2 更新参数不要照抄,要理解每个值的意义
很多教程会直接给你一段滚动更新配置,但如果你不理解参数含义,出了问题往往不知道从哪调。我把关键参数整理成了一张表:
| 参数 | 作用 | 我的推荐值 |
|---|---|---|
--update-parallelism | 同时更新几个副本,0 表示全部同时更新 | 1,一次一个,故障影响最小 |
--update-delay | 每更新完一组副本后等多少秒再更新下一组 | 10s 到 30s,给监控留观察窗口 |
--update-order | start-first先起新再停旧,stop-first先停旧再起新 | start-first,这是零停机的关键 |
--update-monitor | 更新一个副本后,持续监控多久才算成功 | 30s,太短发现不了问题 |
--update-failure-action | 更新失败时怎么办:pause、continue、rollback | rollback,自动回滚最省心 |
--stop-grace-period | 停止容器前等多久,给应用处理存量请求 | 30s,如果应用逻辑复杂可以更长 |
重点强调--update-order start-first。默认情况下,Swarm 老版本的更新顺序是 stop-first,也就是先把旧镜像的副本缩掉,再拉新镜像。这个过程中如果新副本启动较慢,服务容量会短暂下降,甚至出现 503。改成 start-first 之后,新副本先起来并完成健康检查,旧副本才退出,服务容量全程没有空洞。
docker service update \ --image registry.example.com/app:1.3.0 \ --update-parallelism 1 \ --update-delay 10s \ --update-order start-first \ --update-failure-action rollback \ app回滚同样是一条命令:
docker service rollback approllback默认回到上一次 service 定义,也就是旧镜像。这个动作在发布事故处理中非常救命,比手动改配置快得多。
3.3 关于虚拟 IP 和内部 DNS
Swarm 有一个容易被忽略的机制:它对每个 Service 分配一个虚拟 IP,服务名在集群内部可以通过 DNS 解析到这个 VIP,VIP 会把请求负载均衡到所有副本。
这意味着你不需要知道具体哪个副本跑在哪台机器上,也不需要维护一份后端地址清单。你只需要在代码里把数据库地址、Redis 地址这些外部依赖写成服务名,Swarm 的网络层会自动处理路由。在滚动更新替换副本的过程中,VIP 会动态调整后端的可用列表,新副本变健康后马上纳入负载均衡池,旧副本停止前先从池子里摘除。
不过有一点要提醒:Swarm 的 ingress 负载均衡模式下,如果某个服务所有副本都处于不健康状态,入口端口可能会直接返回 503。所以滚动更新的参数里,parallelism尽量不要设置成大于副本总数,否则可能出现“所有旧副本已经停掉、新副本还在拉镜像”的尴尬窗口。
4. MySQL、Redis 这类有状态服务的“不停机”是怎么实现的
4.1 容器层解决不了状态层的问题
很多人在学会 Swarm 滚动更新之后,会把同样的思路套到数据库上,比如直接对 MySQL 的 Service 执行docker service update --image mysql:8.0。结果往往是灾难性的:所有客户端连接中断,主库停机,数据文件版本不兼容,严重的话直接起不来。
原因在于,容器编排解决的是“无状态副本替换”的问题,而数据库是有状态的。它的核心不是“端口在不在监听”,而是“数据在不在、主备是否一致”。单实例数据库无论你用什么编排工具,都会出现停止旧容器、启动新容器之间的连接中断。要真正做到数据库层面的不停机,靠的不是 Docker 命令,而是数据层的高可用组件和复制机制。
4.2 MySQL 主从 + 切换升级
生产环境最常见的做法是 MySQL 主从集群加上高可用切换组件,比如 Orchestrator、MHA 或者 ProxySQL 配合 Keepalived。架构上至少有两个 MySQL 容器,一个主库、一个从库,应用通过虚拟 IP 或者数据库代理访问,代理负责把读写请求转发到当前主库。
升级一个大版本时,我遵循的顺序是:
- 先把从库的所有数据做一次全量备份,并记录主库当前的 binlog 文件和 position。
- 升级从库容器到目标大版本。比如从 MySQL 5.7 升到 8.0,我会先在从库上启动新版本容器,挂载原有数据目录,然后执行
mysql_upgrade或 8.0 的自动升级逻辑。从库升级期间,对外提供的读写能力不受影响,因为流量还在主库上。 - 等从库复制延迟归零,确认新版本从库能正常处理查询后,通过高可用组件执行主从切换,把流量切到新从库上。
- 最后再升级原主库,升级完成后让它作为新集群的从库重新建立复制关系。
这套流程里,真正影响“不停机”的不是 MySQL 容器本身,而是高可用组件能多快完成主从切换。Orchestrator 这类工具可以在几秒内完成探活和切换,比人工改配置快得多,也能在应用层造成更小的连接波动。
4.3 Redis 主从 + Sentinel / Cluster 的无感知切换
Redis 的情况比 MySQL 要简单一些,因为在 Redis 生态里,主从切换是官方支持得非常成熟的能力。如果你是单机 Redis,同样做不到不停机;但只要上了主从加 Sentinel,或者直接使用 Redis Cluster,更新主节点就可以做到业务无感。
我用 Sentinel 时的升级顺序是:
- 先升级所有 Sentinel 节点。Sentinel 本身不保存业务数据,它只是决定什么时候做主从切换,所以最先升级最安全。
- 再升级一个从库。操作方法是启动一个新版本的 Redis 容器,通过
replicaof把它挂到当前主库下面,等它同步完数据之后,用这个新容器替换旧从库容器。这一步也不会影响主库。 - 最后升级主库。先在 Sentinel 上执行
SENTINEL FAILOVER mymaster,强制把主库切换到一个已经升级好的从库上。原来的主库变成从库之后,你再把它升级到新版本,让它重新作为从库加入集群。
如果你用的是 Redis Cluster,更简单:在要升级的主节点上执行CLUSTER FAILOVER。这个命令会让该主节点的从库主动发起主从角色切换,主变从、从变主,整个切换过程对客户端的写入是连续的。切换完成后,你再从容地把原来的主容器升级替换。
4.4 一个常被忽略的顺序问题
应用和数据库一起升级时,很多人只盯着“哪边先更新”,却忽略了滚动更新期间新旧应用版本会同时存在这个事实。
假如应用新版本依赖数据库一张新字段,而数据库还是旧结构,新版本容器启动后一查询就报错。反过来,如果先把数据库结构改了,比如删掉一个旧应用还在用的字段,那么还在运行的旧版本应用立刻就会出现问题。
我的经验是:数据库变更必须做成“向前兼容”。能加列就不要删列,能加新表就不要改旧表的约束。先执行兼容性数据库变更,再更新应用,等应用新旧版本都已平稳后再清理废弃字段。如果必须在数据库升级过程中切换版本,那就要把数据库升级和应用更新当成同一个发布单元,设计整体灰度方案,而不是各自为政。
5. 给发布会留后路:健康检查、自动回滚和灰度节奏
5.1 健康检查的“口径”决定更新成败
滚动更新能不能安全执行,健康检查占了七成权重。但很多人的健康检查写得过于敷衍,只是返回一个固定的 200 字符串,完全没有检查依赖项是否可用。
更可靠的/healthz应该像一个“业务电源指示灯”,它必须检查当前实例是否真的能处理请求。我用 Python 写过一个比较典型的逻辑:
def healthz(): try: db_conn = db_pool.get_connection() db_conn.ping() db_conn.close() redis_client.ping() except Exception: return {"status": "unhealthy"}, 500 return {"status": "ok"}, 200这段逻辑虽然简单,但已经比很多生产环境的健康检查要强了。它不会在数据库连不上、Redis 超时的时候还假装自己很健康。Swarm 判断一个容器更新是否成功,依赖的就是这个探针的返回码。探针返回非 0,Swarm 就会认为新副本异常,触发你配置的失败策略。
探测频率也不宜太密。--health-interval 5s加上多个副本,每秒钟会发出不少探测请求,如果健康检查里面还要查数据库,反而会给系统增加没有意义的压力。我一般用interval 10s、timeout 5s、retries 3,再配合start_period 30s给启动过程留出预热时间。
5.2 failure_action 用 rollback,别只 pause
如果滚动更新过程中新副本起不来,Swarm 默认的行为是 pause,也就是暂停更新。暂停本身不算错,但它要求正好有个人盯着控制台,发现问题后还得手动执行docker service rollback。半夜发版遇到这种情况,等你打开电脑连上服务器,用户可能已经被 5xx 砸了很久了。
我更推荐直接把--update-failure-action rollback写进 Service 定义。这样一旦新副本启动失败或者健康检查不通过,Swarm 会自动停止更新并回到上一次可用的版本,不需要人工介入。
用 compose 文件形式的话,对应的配置是:
services: app: image: registry.example.com/app:1.2.0 deploy: replicas: 3 update_config: parallelism: 1 delay: 10s monitor: 30s failure_action: rollback order: start-first restart_policy: condition: on-failure healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 10s timeout: 5s retries: 3 start_period: 30s注意,自动回滚并不是万能的。如果新版本的 bug 是“进程能启动、端口能监听、健康检查能过”,但业务逻辑有严重错误,Swarm 的监控机制是发现不了的。这种问题只能靠日志监控、调用链追踪和足够的monitor观察时间来兜底。
5.3 没有服务网格也想灰度?用简化版金丝雀
Swarm 原生不支持按百分比切流量的灰度发布,这是它和 Kubernetes 生态差距比较大的地方。如果你没有能力上服务网格,又想在新版本上线后先观望一会,有一个土办法很好用:创建一个app-canary的 Service,用新镜像跑 1 个副本,挂在同一个外部负载均衡的备用 upstream 上,只分给它很小比例的流量,比如 5%。
docker service create \ --name app-canary \ --network app-net \ --replicas 1 \ --publish published=8081,target=8080 \ registry.example.com/app:1.3.0然后在宿主机 Nginx 里把 upstream 改成:
upstream app_backend { server 127.0.0.1:8080 weight=95; server 127.0.0.1:8081 weight=5; }观察一段时间,如果金丝雀实例的日志、错误率、接口耗时都正常,再对正式 Service 执行全量更新,最后把app-canary删掉。这个方法虽然不如 Istio 那样精细,但对绝大多数中小团队来说,已经足够在“无脑全量”和“复杂服务网格”之间取得一个很好的平衡。
6. 实操中那些能把“不停机”搞砸的细节坑
6.1 用 latest 或同名 tag 的更新隐患
镜像 tag 用latest是最容易埋雷的做法。第一次部署时nginx:latest拉下来的可能是 1.25,过了一个月你再拉一次latest,内容可能已经变成 1.27。Swarm 在对比 service 定义时,发现 image 名字没有变化,就认为不需要更新,但节点上的镜像可能已经从 1.25 悄悄变成了 1.27。这就是“同名 tag 漂移”问题。
更可控的做法是每次构建都打上版本号和 Git 短 SHA,比如app:1.3.0-ab12cd4。镜像一旦构建出来就不可变,更新时明确指定新 tag。回滚也简单,直接把 tag 指回上一个版本。发布现场最怕的就是“这个 tag 到底是不是我昨晚构建的那个镜像”,没人说得清。
6.2 镜像预热非常关键
Swarm 调度器在更新副本时,遇到某个节点上没有对应镜像,会现场从仓库拉取。如果仓库在公网、镜像比较大,或者网络不稳定,新容器几十秒甚至几分钟都起不来。假如这时旧副本已经被停止,服务就出现了空窗期。
所以我在发布前一定会先做镜像预热,把目标镜像提前推到集群所有节点上:
for node in node1 node2 node3; do ssh "$node" docker pull registry.example.com/app:1.3.0 done节点多的时候这个动作很机械,但能省掉更新过程中的大量等待。配合私有仓库的--with-registry-auth参数,可以把认证信息也带到 worker 节点,避免每个节点都要手动 docker login。
6.3 应用不处理 SIGTERM,Docker 帮你杀
Docker 停容器时,默认给进程发送 SIGTERM,然后等 10 秒。如果应用没有注册 SIGTERM 的处理逻辑,Docker 到时间会直接发 SIGKILL 强杀。此时如果进程还在处理某个请求,这个请求就会被中断。
解决办法有两步。第一步,在 compose 或 service 定义里调大停止宽限期:
services: app: stop_grace_period: 30s第二步,也是更本质的一步,应用要自己捕抓 SIGTERM。比如 Node.js 里就是:
const server = app.listen(8080); process.on("SIGTERM", () => { server.close(() => { process.exit(0); }); });收到 SIGTERM 后,先停止接收新连接,等待已进入的请求处理完毕,再退出进程。这样做和 Swarm 的start-first配合起来,才能真正做到一个请求都不丢。很多滚动更新表面上看是 DevOps 的锅,实际上背后是应用根本不支持优雅退出的问题。
6.4 WebSocket 和长连接并不会自动平滑
如果说上面这些坑还能靠参数配置绕过去,那么长连接就是滚动更新里最难啃的骨头。Swarm 或者 Kubernetes 在更新容器时,旧容器一旦被停止,所有挂在这个容器上的 WebSocket 连接都会断开。内部负载均衡虽然不感知连接迁移,它只知道“这个副本没了,后续新连接别过来”。
如果业务里 WebSocket 很关键,要么接受“服务端主动断开,客户端自动重连”的模式,把重连和断线续传做好;要么上更复杂的方案,比如网关层支持连接迁移、房间迁移。坦率地说,容器编排工具目前都没有把长连接的无损迁移做得特别好,所以“不停机”这个词对长连接场景需要打个问号。最稳妥的策略是发布前通过消息推送让客户端知道即将断开,客户端进入重连等待,服务端使用优雅退出让存量消息尽量发完。
6.5 挂载卷、权限和残留容器
容器更新过程中,最容易出现的一种诡异问题是:新容器启动时报目录权限错误。比如旧镜像的运行用户是 root,新镜像为了安全改成了 uid 10001,但挂载目录的所有者还是 root。新容器没有权限写日志目录,进程起不来。
排查思路其实很简单:先docker compose exec进容器里执行id,再看挂载目录的 owner 和权限。我习惯在 compose 里直接声明用户并保持和宿主机目录权限一致,或者在镜像构建时统一 uid。另一个容易忽略的是旧容器虽然已经从负载均衡摘掉,但进程因为长连接没退出,还在写共享目录。如果新容器也要写同一个文件,就会遇到文件锁或者日志轮转问题。切换后不要急着删旧容器,观察一段时间再清理,这不是胆小,是真的能避雷。
6.6 配置、密钥和数据库迁移要一起纳入发布
最后再提醒一个经常被忽略的发布组合拳:新版本应用往往不只是“代码变了”,环境变量、证书、数据库结构也变了。只更新镜像,不更新配置,新容器可能连启动都会失败。
在实际更新 Service 时,配置变更要一起带上:
docker service update \ --image registry.example.com/app:1.3.0 \ --env-add DATABASE_URL=mysql://user:pass@db:3306/app_v2 \ --secret-add db_cert_v2 \ app同一个 Service 的镜像和配置要当成一个整体版本去管理。理解这一点之后,就会发现“不停机更新”本质上不是某个 Docker 命令,而是发布系统的设计能力。你把无状态副本、健康检查、优雅停机、数据层兼容性这四件事做扎实了,Docker 只是帮你把最后那个替换动作执行得更干净而已。