上个月我负责的订单服务发布,用的还是老套路:脚本停掉所有旧实例,然后重新拉起一批新容器。那天新版本因为数据库连接池配置错误,启动直接失败,而旧实例早就被停干净了,线上整整瘫痪了十几分钟,群里全是喊回滚的,却连上一个镜像包都翻不出来。那是我最后一次用“全杀全建”的方式做应用部署。此后我把所有无状态服务都迁到了 Kubernetes 的 Deployment 滚动更新,配合 Helm 做包管理,再没出过这种“要么全有、要么全无”的事故。
这篇东西就围绕应用部署与滚动更新来写:先讲滚动更新解决的是部署哲学上的什么问题,再拆解 maxSurge 和 maxUnavailable 这两个节奏控制参数,然后给出 Helm 部署 Java 应用的完整实践,最后是我自己踩过的坑和排查链条。适合正在从手工脚本发布往 K8s 迁移、或者已经在用 Deployment 但总感觉“哪里没调明白”的团队参考。
1. 从“全杀全建”到“逐个替换”:滚动更新到底解决了什么
1.1 部署方式的演进逻辑
早期部署应用,最常见的方式就两种:手动 SSH 到服务器拉代码重启服务,或者写一套 shell 脚本做“停旧起新”。这两种方式本质上是同一种操作哲学——先把旧的干掉,再把新的拉起来。中间隔着一段空白期,服务不可用。业务量小的时候无所谓,凌晨两三点发个版,影响可控;但流量起来之后,任何一次部署都是一次赌博,赌的是“新版本一定能正常启动”。
滚动更新的核心思路完全不同:它不再把“部署”当作一个瞬间完成的切换,而是一个渐进替换的过程。旧实例还在继续承接流量,新实例一个一个地起来,等新的确认健康了,才把一个旧的拿走。整个过程中服务的总量始终保持在一个目标副本数附近,任何时候都有实例在响应请求。
这正是它与蓝绿部署、金丝雀发布之间的定位差异。蓝绿部署是准备两套完整环境,流量一次性从蓝色切到绿色,回滚也只要切回去,成本是资源翻倍;金丝雀发布是先放少量新版本流量试水,确认没问题再逐步放量,适合对流量质量极度敏感的场景。滚动更新则是在不额外准备整套环境的前提下,用“逐步替换”换来了部署过程的可控性。它不是最优雅的方案,却是资源开销和操作复杂度上最务实的一个。
1.2 为什么 Deployment 成了事实标准
在 Kubernetes 里,滚动更新的能力由 Deployment 控制器承载,而 Deployment 往下管理 ReplicaSet,ReplicaSet 再往下管 Pod。这个层级关系不是随便设计的。
Deployment 的核心逻辑是声明式协调:你只需要告诉它“我期望运行 4 个副本,镜像版本是 v1.2.3”,它就不停地去比较当前状态和期望状态,有偏差就纠正。体现在滚动更新上,就是当你在 yaml 里改了镜像 tag 并 apply 之后,Deployment 不会直接删掉旧 Pod,而是创建一个新的 ReplicaSet,把期望副本数放到新 RS 上,同时逐步缩掉旧 RS。新旧两套 ReplicaSet 在更新期间是共存的,Deployment 通过计算两者副本数的比例来控制替换节奏。
这套机制的另一个红利是回滚成本极低。因为每一次更新都会留下对应的 ReplicaSet 记录,Deployment 的 rollout history 里存着之前的版本状态,一条kubectl rollout undo命令就能让控制器把流量节奏再走一遍,从新 RS 缩回旧 RS。你不需要重新构建镜像,也不需要手工恢复配置。
相比之下,StatefulSet 也有滚动更新能力,但它是为有状态服务设计的,Pod 的名字、存储、网络标识都带固定序号,更新时按序逐个替换,通常还要配合 headless Service 使用。Operator 则更进一步,把业务自身的运维逻辑也写进控制器里。你的服务如果是无状态的,Deployment 就是最合适的载体,没有必要引入更重的抽象。
1.3 什么时候不该用滚动更新
滚动更新不是银弹,至少有两类场景我会刻意避开。
第一类是数据库 schema 不兼容的版本发布。比如新版本代码要读写一张新表,而旧版本代码还在按旧结构操作。滚动更新的过程中新旧实例会短暂共存,旧实例一旦碰到新结构的数据就可能出异常。这时候应该先做数据迁移或双写兼容,让新旧代码都能正常工作,再滚动替换实例。
第二类是首启注定失败的发布。滚动更新只能在“新实例能健康起来”的前提下发挥作用,如果镜像本身有问题,新 Pod 起一个失败一个,Deployment 会按照策略反复尝试,但永远不会成功。相比手动部署那种瞬间炸掉,滚动更新只是把失败过程拉长了,它给你的是“发现问题后可以不中断服务地回滚”的机会,而不是拯救错误镜像的能力。判断镜像能不能用,还是得靠本地启动验证和 CI 里的健康检查。
2. maxSurge 与 maxUnavailable:滚动更新的节奏控制
2.1 两个参数在说什么
滚动更新的节奏不是固定的,Deployment 给了你两个旋钮:maxUnavailable和maxSurge。
maxUnavailable表示更新过程中允许有多少个 Pod 处于不可用状态,相对于期望副本数的百分比或绝对值。默认值是 25%,含义是更新过程中可以有四分之一副本不可用。maxSurge表示允许超出期望副本数的最多 Pod 数量,默认也是 25%,含义是更新过程中最多可以多跑四分之一副本。
这两个参数合起来,实际上框定了滚动更新期间 Pod 总数的上下边界:总量不能超过replicas + maxSurge,可用副本数不能低于replicas - maxUnavailable。控制器就是在这个区间里来回腾挪,既保证服务不降级到危险水位,又给新实例腾出替换空间。
需要特别注意,这两个值不能同时为 0,因为那意味着“既不允许旧 Pod 不可用,又不允许新 Pod 超出总量”,控制器就彻底没有腾挪空间了,根本没法执行更新。
2.2 常见参数组合的取舍
实际配置中,我会根据服务的流量敏感度和集群资源余量来选组合。下面这组对比是排障时最常用的参照。
| 组合 | maxUnavailable | maxSurge | 特点 | 适用场景 |
|---|---|---|---|---|
| 保守型 | 0 | 1 | 先扩容一个新 Pod,确认 Ready 后再缩一个旧 Pod,可用副本数始终不降 | 核心交易链路,对瞬时容量极其敏感 |
| 速度型 | 1 | 0 | 先缩一个旧 Pod,腾出配额再起新 Pod,资源占用最小 | 集群资源紧张,能容忍短暂降容 |
| 均衡型 | 25% | 25% | 默认配置,一增一减同时进行,速度和开销相对均衡 | 大多数内部服务 |
| 激进型 | 50% | 50% | 一次性放大量新旧替换,更新时间最短 | 资源非常充裕,或更新窗口很短 |
以replicas=4, maxSurge=1, maxUnavailable=0的保守型组合为例,更新流程可以拆成这么几步:控制器先创建 1 个新 Pod,此时集群里总共有 5 个 Pod;新 Pod 通过就绪探针检查后,控制器再终止 1 个旧 Pod,总副本数回到 4;接着再创建 1 个新 Pod、Ready 后终止 1 个旧 Pod。如此一增一减,直到所有旧 Pod 都替换成新版本。
如果是 25% 的均衡型,4 个副本算下来 maxSurge 和 maxUnavailable 都是 1,流程上就可能出现“起 1 个新 Pod 的同时停 1 个旧 Pod”交错进行的局面,总 Pod 数在 3 到 5 之间浮动。理解了这种腾挪逻辑,你就明白为什么滚动更新在某些场景下看起来“有点慢”了:控制器每一轮都要等新 Pod 变成 Ready,这个等待时间是被就绪探针的周期和阈值拉着的。
2.3 参数配置对发布窗口的实际影响
我遇到过很多团队把滚动更新当作“一键发布”,点完就盯日志,结果发现发布比预期慢得多。原因往往是忽略了就绪探针周期对整体节奏的放大效应。
假设探针每 10 秒探测一次,initialDelaySeconds设了 30 秒,一个新实例从创建到 Ready 大概需要 40 秒以上。如果副本数是 20,均衡型参数下大约要经历 10 到 20 轮替换,每轮都卡在探针等待上,整个发布跑完就是十几分钟。这还不算镜像拉取时间、Java 的类加载和初始化时间。
所以参数设计不能只看 maxSurge 和 maxUnavailable,要和探针配置一起做预算。如果业务对发布速度有硬性要求,要么提高 maxSurge 让更多新实例并行起来,要么优化探针的periodSeconds和failureThreshold。我个人倾向于保留保守的 maxUnavailable,而把 maxSurge 适度调大,用资源换时间,而不是用可用性换时间。
3. Helm 编排下的 Java 应用部署实践
3.1 Chart 的基本结构与镜像构建要点
Helm 的价值不是“帮你写 K8s yaml”,而是“把你日常改动最频繁的部分抽成参数”。我的 Java 服务目录结构通常是这样的:
order-service/ ├── Chart.yaml ├── values.yaml ├── values-dev.yaml ├── values-prod.yaml └── templates/ ├── deployment.yaml ├── service.yaml └── _helpers.tplChart.yaml里声明应用名和版本,templates/deployment.yaml是 Deployment 的模板文件,里面把镜像地址、副本数、资源限制、探针参数全部写成占位符,由values.yaml提供默认值。_helpers.tpl放一些公共变量,比如根据 Chart 名和 Release 名生成统一的资源标签。
镜像层面,Java 应用和 Go、Node 应用的构建策略完全不同。Go 可以轻松打成 scratch 镜像,Java 至少需要一个带 glibc 的运行时环境。我在生产环境用的是 Eclipse Temurin 的 JRE 镜像,并且显式加上容器内存感知的 JVM 参数:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/order-service.jar app.jar ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0" EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]这里MaxRAMPercentage的作用是让 JVM 自动感知容器 cgroup 的内存限制,而不是取宿主机内存。如果不用这个参数而手动设-Xmx,很容易出现“容器 limits 设了 2G,JVM 却按宿主机 64G 内存去计算堆大小”的问题,轻则浪费资源,重则还没到高峰期就被 OOM Killer 干掉。
3.2 values.yaml 的多环境配置策略
Java 应用部署里最让人头疼的是环境差异:开发环境连接测试数据库,生产环境连接主库,日志级别、熔断阈值、注册中心地址全不一样。Helm 对这个问题给出的答案是:一份模板,多份 values。
我习惯在values.yaml里放所有环境通用的配置,比如镜像仓库地址、Java 启动参数、公共标签;values-dev.yaml和values-prod.yaml分别覆盖环境相关的差异项。发布时用一个命令就能完成环境切换:
helm upgrade --install order-service ./order-service \ -n order-system \ -f values-prod.yaml \ --set image.tag=1.2.3--set参数覆盖单点配置,适合在发版时指定镜像 tag 这种高频变动项。多环境之间天然形成配置隔离,不用每套环境维护一套完整的 yaml 文件。
这里有一个容易被忽略的细节:helm upgrade --install这个命令组合是有意义的。首次执行时它执行 install,后续执行时它执行 upgrade,你不需要在 CI 脚本里写判断逻辑去区分“这是不是第一次发布”。这个命令是我目前最常用的发布入口。
3.3 Java 应用特有的探针配置
Kubernetes 的滚动更新判断“新 Pod 是否健康可用”,靠的是探针。Java 应用在这一点上特别容易出问题:进程启动和“业务可用”之间存在一个明显的延迟区间。Spring Boot 启动过程中要加载类、初始化数据库连接池、创建线程池、注册到注册中心,哪怕进程已经起来了,端口已经监听了,业务上也没法立刻处理请求。
如果只配 readinessProbe 而不配 startupProbe,在极端情况下的表现是:探针从 Pod 创建就开始计时,Java 启动慢,在initialDelaySeconds之后的第一次探测就失败,反复几次后容器被重启,然后循环往复,滚动更新卡死。这就是所谓的“启动死循环”。
正确的做法是三层配合。startupProbe 判定“进程是否完成了启动过程”,readinessProbe 判定“当前是否具备接收流量能力”,livenessProbe 判定“进程是否还活着”。对应到 Spring Boot 应用上:
startupProbe: httpGet: path: /actuator/health port: 8080 periodSeconds: 5 failureThreshold: 30 readinessProbe: httpGet: path: /actuator/health port: 8080 periodSeconds: 10 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 20 failureThreshold: 3启动探针给了 Java 应用最长 150 秒的启动缓冲,期间不触发就绪探针,避免启动慢被误杀;启动完成后,就绪探针以 10 秒周期持续校验业务健康状态。注意 Actuator 的 health 端点是聚合了数据库、Redis、消息队列等组件的整体状态,只要数据库连接池初始化失败,这个接口就会返回非 200,滚动更新也就不会把流量切给一个“进程活着但业务废了”的实例。
3.4 发布记录与回滚管理
Helm 的好处在于它把每一次helm upgrade的结果都记录成了 release 历史。跑完helm history order-service -n order-system,你能看到每一次发布的版本号、更新时间、执行状态,甚至可以比较不同发布之间的 values 变更。
发布失败需要回退时的路径同样清楚:
helm rollback order-service 3 -n order-system这条命令会把 release 回退到历史版本 3 对应的模板和 values 组合。相比直接在 Deployment 上执行kubectl rollout undo,Helm 回滚更彻底——它连配置一起回退,而不是只切换镜像版本。
不过要提醒一句:helm rollback 本质上是执行一次新的发布,它同样会触发滚动更新流程,回滚过程也是渐进式的。别以为回滚是瞬间完成的事,它同样要等待新 Pod Ready。所以在发布前检查镜像、在发布中盯监控,远比把希望寄托在“发布失败再回滚”上靠谱。
4. 滚动更新踩坑实录:从持续重启到流量受损
4.1 探针配置不合理导致的滚动更新风暴
第一个坑我印象最深,发生在一次 Java 服务的常规发版中。发版前我们给新版本加了更严格的启动校验,应用启动时间从原来的 30 秒拉长到了 90 秒。但 Deployment 里的 readinessProbe 还是沿用旧参数:initialDelaySeconds=20, periodSeconds=10, failureThreshold=3。
发布一启动,新 Pod 就开始被反复重启。因为 20 秒后探针第一次探测时应用还没就绪,返回失败;30 秒后第二次继续失败;40 秒后第三次失败,Kubelet 判定探针失败次数达到阈值,直接重启容器。滚动更新就在“创建新 Pod、探针失败、容器重启、再次失败”的循环里原地打转。
完整的排查链路是这样的:先kubectl get pods看到新 Pod 处于 CrashLoopBackOff;再kubectl describe pod <pod-name>确认事件里写着探针失败;接着kubectl logs <pod-name> --previous看上一轮容器日志,确认应用确实启动到了最后阶段;最后对照应用日志里的启动完成时间,发现和探针的失败时间点完全吻合。
解决方式就是前面说的加入 startupProbe,给慢启动应用一个合理的缓冲期,同时把 readinessProbe 的failureThreshold从 3 调整为更适合业务敏感度的值。这个问题的根子在于:探针参数必须跟着应用启动时间走,而不是套用一份固定模板。应用启动时间变了,探针参数就得重新评估。
4.2 优雅停机与 JVM 退出的协作问题
第二个坑比第一个隐蔽得多:滚动更新本身一路绿灯,新 Pod 全部 Ready,旧 Pod 也被逐个删掉了,但监控显示发布期间有一批请求的耗时突然飙升到几十秒,甚至出现少量 5xx。
排查到最后,问题出在旧 Pod 被终止的那个瞬间。滚动更新删除旧 Pod 时,Kubelet 向容器主进程发送 SIGTERM 信号,然后等待进程自己退出。Java 应用收到 SIGTERM 后,如果没有任何优雅停机配置,默认行为是立刻退出。此刻还在被负载均衡转发到该 Pod 上的请求就会直接断开——Spring Boot 的线程池里正在处理的请求全部被硬生生掐断。
解决方案是配置 Spring Boot 2.3 以上的优雅停机,加上 Kubernetes 侧的 preStop 钩子:
lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]同时在你自己的 Java 服务配置里加上:
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=20s这两件事的分工是:preStop 钩子在 Kubelet 发送 SIGTERM 前先让 Pod 进入 Terminating 状态并停机 10 秒,此时负载均衡已经将该 Pod 从可用端点列表里摘除,不再转发新请求;Spring Boot 的 graceful shutdown 则负责在收到 SIGTERM 后等待线程池中正在执行的请求完成,最多等 20 秒。两者配合,旧实例是“把手上活干完再退出”,而不是“说走就走”。
这个坑同样容易出现在 Service Mesh 或 Cloud Native 负载均衡的场景里,只要端点摘除和连接断开之间存在时间差,优雅停机就是必须的。
4.3 长连接与会话粘性问题
第三个坑来自一个 WebSocket 服务。它的滚动更新一切正常,但发布期间在线用户集体掉线,前端 WebSocket 全部断开重连。原因很直白:滚动更新替换旧 Pod 后,长连接原先挂在旧 Pod 上,Pod 被删除时连接自然被切断。
如果业务对长连接断开的容忍度很低,单纯调参解决不了。可行的方案是给服务设计一个“滚动更新前的摘流”机制:在 preStop 里加更长的等待时间,让网关先把该 Pod 的连接全部耗尽再真正下线,这个方案能缓解但不能完全避免掉线,长连接总有生命周期上限。
更治本的思路是让服务无状态化:WebSocket 连接只做消息通道,会话数据和状态全部外置到 Redis,用户重连后可以从 Redis 恢复上下文。这样发布期间用户虽然会经历一次短暂的断线重连,但体验无损,业务状态不会丢失。这也是我在负责的项目里最终采用的方案——与其在滚动更新参数上精益求精,不如把服务的状态边界彻底推干净。
4.4 回滚的两种方式与各自的注意点
发布过程如果出现异常,需要快速回滚。Kubernetes 原生和 Helm 各有一套回滚方式,生产环境里我两种都用过,适用场景有区别。
Deployment 原生层面:
kubectl rollout undo deployment/order-service -n order-system kubectl rollout status deployment/order-service -n order-system这种方式适合只回退镜像版本、不想动其他配置的场景。它会触发一次反向的滚动更新,新 RS 缩到 0,旧 RS 重新扩容。
Helm 层面的回滚则是把整个 release 状态恢复:
helm rollback order-service 3 -n order-system这里有个常见误解:rollback 到某个版本后,如果你再执行helm upgrade --set image.tag=新版本,Helm 会比较当前 release 与目标 chart 版本之间的差异,而不是直接基于 rollback 后的版本做增量。所以回滚后如果想再次发布新版本,最好先确认当前 release 的配置状态是你期望的,再执行升级。
无论用哪种回滚,验证标准都一样:跑探针、看日志、盯错误率和延迟。回滚不是执行一条命令就结束了,它是发布流程的后半段,需要和发布一样被严肃对待。
提示:滚动更新的核心参数、探针策略、优雅停机配置,建议全部写进 Helm 的 values 文件,而不是散落在各个服务的 yaml 里。版本化之后,每次发布做了什么调整、回滚回的是哪一套配置,全都有据可查。
实话说,滚动更新不等于零风险,它只是把发布事故从“瞬间全挂”变成了“逐步暴露”。我现在的做法是每次发布前先按当前流量的峰值规模算一遍 maxSurge 和 maxUnavailable,发布过程中盯的是 QPS、错误率和 P99 延迟,而不是一直刷 Pod 状态。踩过那几次坑之后,我对应用部署的态度就一句话:慢就是快。宁可让每条新 Pod 慢慢就绪,也别为了省几十秒让整个集群陪着抖一下。