上周五给一个 Spring Boot 服务发新版本,团队里一位同事说:直接改镜像、把旧 Pod 全删了不就完了吗?我说行,然后生产环境在监控图上出现了一道刺眼的红色缺口,持续了大概 40 秒。那一瞬间我意识到,很多 Java 团队对 K8s 的滚动更新(RollingUpdate)理解还停留在"能自动更新"的层面,根本没吃透它的参数语义、健康检查联动和排空机制。
Java 部署最核心的诉求其实就一句话:新版本上线,旧版本平稳退出,期间服务不能断。K8s 的 Deployment 原生就提供 RollingUpdate 策略,但要用好它,你得先搞懂它背后的调度逻辑,再配合足够可靠的探针,否则滚动更新给你带来的可能不是平滑发布,而是"看起来在滚、实际上在抖"。这篇文章我就把 RollingUpdate 从参数到实战、从健康检查到排错链路完整拆一遍,适合正在用 Java(尤其是 Spring Boot)跑 K8s、想把发布过程做得更稳的工程师参考。
1. 滚动更新到底解决了什么问题:先看没有它时的发布方式
1.1 宕机式发布:最省事但也最容易翻车的方案
不引入 K8s 之前,Java 服务的传统部署就是"停服-替换-启动"。把旧的 jar 包从服务器上移走,上传新包,重启进程,这个过程中服务端口是关闭的,外部请求全部连接失败。哪怕你只在深夜操作,对调用方来说仍然是不可接受的中断,更别提如果新包启动失败,你还得赶紧把旧包换回来,又是一轮中断。
上到 K8s 之后,不少团队的第一直觉是把 Deployment 的副本数先缩到 0,再改镜像、再扩容。这种"先删后建"本质上还是宕机式发布,只是把"删服务器上的进程"换成了"删 Pod"。新 Pod 的调度、镜像拉取、Spring 容器初始化、JVM 热身,这些步骤动辄几十秒,期间服务对外是不存在的。
1.2 ReplicaSet 的新老交替机制:滚动更新的底层原理
Deployment 本身不直接管理 Pod,它管理的是 ReplicaSet。当你修改了 Pod 模板(比如换了镜像 tag),Deployment 会创建一个新的 ReplicaSet,然后让新 RS 的副本数逐步增加、旧 RS 逐步减少,直到新 RS 达到期望副本数、旧 RS 缩到 0。这个"逐步"就是滚动更新的核心。
关键点在于:滚动更新的节奏不是随意的,而是受到两个参数约束——maxUnavailable和maxSurge。它们决定了任何一个时刻,比期望副本数多出来的 Pod(Surge,增额)和不能用的 Pod(Unavailable,差额)各允许是多少。打个比方,期望 10 个副本,maxSurge=25%意味着滚动过程中最多可以有 12.5 个 Pod(实际取整就是 13 个),这里多出来的 2~3 个是新版本 Pod;maxUnavailable=25%意味着最少得保证 7.5 个 Pod 可用(实际取整是 8 个)。新旧交替就发生在这个"整体容量不降、甚至略微超卖"的区间里。
1.3 滚动更新与蓝绿发布的本质差异
很多 Java 团队听说过蓝绿发布:先起一套完整的新版本环境(绿),验证通过后把流量一次性切过去(蓝)。这套方案的优点是切换瞬间完成、回滚极其简单(切回蓝),缺点也明显:资源要双倍,两套环境的数据兼容、配置一致性都是工程负担。
滚动更新则不是"环境级切换",而是"Pod 级交替"。它不要求你准备一套全新环境,新老 Pod 共享同一个 Service,靠探针决定谁可以接流量。资源开销按参数控制,可以做到不额外占用太多集群容量。代价是:如果探针配置不到位,K8s 默认认为新 Pod 一起来就是健康的,流量可能过早打到一个还没初始化完的 JVM 上,从而引发大量超时。这正是后面要反复强调健康检查的原因。
2. RollingUpdate 核心参数拆解:maxUnavailable 与 maxSurge 的真实作用
2.1 这两个参数背后的计算逻辑
strategy.rollingUpdate下有两个字段,单位可以是整数或百分比。百分比的计算基准是replicas的向下取整(maxUnavailable)和向上取整(maxSurge)。举个例子加深印象:
假设replicas=4,maxUnavailable=25%(即允许 1 个不可用),maxSurge=25%(即允许额外 1 个新 Pod)。滚动过程大致是:
- 新 RS 扩容到 1(此时总 Pod 数 5,可用 4,满足约束)。
- 新 Pod 就绪后,旧 RS 缩容到 3(此时总 Pod 数 4,可用 4,不满足任何不可用条件)。
- 新 RS 扩容到 2,旧 RS 缩容到 2,交替推进。
- 直到新 RS 扩到 4、旧 RS 归零。
这个交替节奏会自动根据 Pod 就绪状态暂停等待。如果新 Pod 一直不就绪,滚动更新会卡住,并不会无限创建下去。反过来说,如果你把maxUnavailable设成 0,就意味着必须保证所有旧 Pod 都处于可用状态时才能停掉它们,代价是必须有额外的maxSurge名额来先起新 Pod。
2.2 参数对 Java 应用资源的实际影响
Java 应用和普通静态站点不同,启动时往往需要分配较大的内存(-Xmx),这直接影响了 Pod 的调度。如果maxSurge设置过大,会在瞬间多出几个 JVM 实例同时申请内存,碰到节点资源不足时,新 Pod 会一直 Pending,整个滚动更新死等。反过来,如果maxUnavailable设置过大,可能一下子杀掉多个就绪的旧 Pod,而新 Pod 还在冷启动,可用实例数量骤降,流量洪峰直接压到剩余实例上。
我自己的经验是:对于 Java 这种启动重、内存大的应用,建议先保守地把maxSurge控制在 25% 以内,maxUnavailable不要超过 25%,必要的时候在流量低谷期更新。如果追求极致平滑、且集群有余量,可以设成maxUnavailable: 0、maxSurge: 1(数字不是百分比),相当于严格的老一退、新一进。这个组合最稳,但也最慢。
| 配置组合 | 特点 | 适用场景 |
|---|---|---|
| maxUnavailable=0, maxSurge=1 | 严格保活,发布慢,集群峰值内存只多一个 Pod | 核心链路、不允许任何容量下降 |
| maxUnavailable=25%, maxSurge=25% | 速度与稳定平衡,动态交替扩容缩容 | 大多数常规 Java 服务 |
| maxUnavailable=1, maxSurge=0 | 先停旧再起新,会有短暂容量缺口 | 集群资源很紧张、无法额外占用内存时 |
2.3 用 kubectl rollout 掌控更新全过程
滚动更新不是发出去就撒手不管。kubectl rollout子命令是发布过程中最重要的控制工具。实测下来这几条命令最常用:
kubectl rollout status deployment/order-service:阻塞查看滚动更新的进度,直到完成或超时。它会显示新旧 RS 的交替情况,比如Waiting for deployment spec update to be observed...、Waiting for rollout to finish: 2 out of 4 new replicas have been updated。kubectl rollout pause deployment/order-service:暂停更新。注意,pause 只是暂停"继续创建更多新 Pod",已经创建的新 Pod 若没就绪,仍会等待就绪,不会回滚。kubectl rollout resume deployment/order-service:恢复被暂停的滚动更新。kubectl rollout undo deployment/order-service:回滚到上一个版本。--to-revision可以指定回到更早的 revision。
需要提醒的是,rollout status默认会一直阻塞,生产上建议配合--timeout,比如kubectl rollout status deployment/order-service --timeout=180s,超时后命令返回非 0,方便在 CI/CD 里根据退出码判失败。
3. Java 应用部署前必须配好的健康检查:探针是滚动更新的刹车片
3.1 readinessProbe 才是决定"能不能接流量"的关键
很多 Java 工程师会混淆两个探针:readinessProbe(就绪探针)和livenessProbe(存活探针)。在滚动更新语境下,readinessProbe 的地位远高于 livenessProbe。原因在于:
- readiness 失败时,Pod 不会被重启,但会被从 Service 的 Endpoints 中摘除,也就是说流量不会打过来。对滚动更新来说,新 Pod 只有 readiness 通过,才会被 RS 认为是"可用副本",滚动才会继续往下走。
- liveness 失败时,Pod 会被 Kubelet 杀掉重启。它解决的是"进程活着但实际已死锁"的问题,而不是发布流量的问题。
如果把 liveness 配得比 readiness 更敏感,比如 liveness 探针的initialDelaySeconds太短,JVM 还在启动,探针就会连续失败,Kubelet 反复重启容器,形成 CrashLoopBackOff。这会导致滚动更新永远不完成。
3.2 Spring Boot Actuator 探针的最短配置
Spring Boot 2.3+ 内置了 readiness/liveness 探针端点(/actuator/health/readiness和/actuator/health/liveness),配合spring-boot-starter-actuator即可。关键是要在 application.yml 里开启:
management: endpoints: web: exposure: include: health,info,readiness,liveness endpoint: health: probes: enabled: true health: readinessstate: enabled: true livenessstate: enabled: true对应到 K8s Deployment 的探针配置可以这样写:
readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 15 timeoutSeconds: 2 failureThreshold: 3这里的initialDelaySeconds非常关键。Spring Boot 应用冷启动可能要 20~40 秒,容器一启动就立刻打 liveness 显然不合适。我见过太多因为initialDelaySeconds给得太小而导致的滚动更新失败案例。
3.3 JVM 启动慢的正确应对:startupProbe
如果应用启动时间不稳定,比如有时 30 秒、有时 90 秒(依赖了外部中间件、加载了超大 XML 配置),固定initialDelaySeconds不太好卡。K8s 1.16+ 引入了startupProbe,它会先于 liveness 运行,在 startup 通过之前,liveness 不会生效。
startupProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 30这个配置允许应用最长 5 + 30 * 5 = 155 秒才完成启动,这段时间内 liveness 不参与判定,容器不会被过早杀掉。startup 通过后,startupProbe 自动停止,liveness 接管。对 Java 应用来说,这是标准的组合姿势。
3.4 优雅下线差一步,滚动更新就会翻车
探针解决了"新 Pod 什么时候能接流量",但滚动更新还有一个容易忽略的方向:旧 Pod 什么时候安全退出。默认情况下,K8s 给 Pod 发 SIGTERM,然后等terminationGracePeriodSeconds(默认 30 秒)内完成清理,超时则 SIGKILL。
对 Java 应用来说,Spring Boot 默认对外关闭后还在处理存量请求,但如果你没有开启优雅停机,或没有在关闭时从注册中心/Service 摘除自己,就会出现"新请求已经不会被转发到旧 Pod(因为它的 readiness 还没变为失败),但该 Pod 收到 SIGTERM 后立刻关闭了端口"的竞态,导致少量请求 502。
建议做两件事:
- 在 Spring Boot 中开启优雅停机:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 20s- 给容器加
preStop钩子,给 JVM 一点缓冲时间:
terminationGracePeriodSeconds: 60 lifecycle: preStop: exec: command: - /bin/sh - -c - "sleep 15"这里的逻辑是:K8s 在发送 SIGTERM 前先执行 preStop,sleep 15 秒期间,Pod 状态仍然不是立刻终止,等 sleep 结束后再发 SIGTERM,此时 Spring Boot 的优雅停机开始运作。整体 60 秒的宽限期足够完成大部分存量请求。这个"preStop + 优雅停机 + 足够大的 terminationGracePeriodSeconds"三件套,是我排查过数次偶发 502 之后的固定解法。
4. 实操:一个 Spring Boot 应用从零完成滚动更新
4.1 一份可直接参考的 Deployment YAML
假设我有一个 Java 服务名为order-service,镜像仓库在registry.example.com/order-service,版本 tag 是v1.2.0。一份可用的 Deployment 配置如下:
apiVersion: apps/v1 kind: Deployment metadata: name: order-service namespace: prod spec: replicas: 4 selector: matchLabels: app: order-service strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 25% maxSurge: 25% template: metadata: labels: app: order-service spec: terminationGracePeriodSeconds: 60 containers: - name: order-service image: registry.example.com/order-service:v1.2.0 ports: - containerPort: 8080 resources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi cpu: "1" startupProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 30 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 10 periodSeconds: 15 failureThreshold: 3 lifecycle: preStop: exec: command: - /bin/sh - -c - "sleep 15"顺带一提,Java 应用的resources不能省。如果只写limits不写requests,调度器无法准确判断节点是否有足够内存容纳 Surge 出来的临时 Pod,容易导致新 Pod Pending。
4.2 构建镜像并触发一次更新
代码改动后,流程通常是:
# 构建并推送新镜像(这里以不同 tag 区分版本) docker build -t registry.example.com/order-service:v1.3.0 . docker push registry.example.com/order-service:v1.3.0 # 更新 Deployment 的镜像 kubectl set image deployment/order-service order-service=registry.example.com/order-service:v1.3.0 -n prod看到deployment.apps/order-service image updated后,滚动更新就已经触发了。此时 Deployment 会自动创建新的 ReplicaSet,并把 controller-revision-hash 写入 Pod 标签中。
4.3 观察滚动更新的完整过程
在另一个终端执行:
kubectl rollout status deployment/order-service -n prod --timeout=180s输出会逐步变化,实测中你会看到类似这样的信息:
Waiting for deployment "order-service" rollout to finish: 1 out of 4 new replicas have been updated... Waiting for deployment "order-service" rollout to finish: 1 out of 4 new replicas have been updated... Waiting for deployment "order-service" rollout to finish: 2 out of 4 new replicas have been updated... Waiting for deployment "order-service" rollout to finish: 2 out of 4 new replicas have been updated... Waiting for deployment "order-service" rollout to finish: 3 out of 4 new replicas have been updated... Waiting for deployment "order-service" rollout to finish: 3 out of 4 new replicas have been updated... deployment "order-service" successfully rolled out如果想看得更细,可以观察 RS 的扩张收缩:
kubectl get rs -n prod -l app=order-service -w你会看到两个 RS 的 DESIRED/CURRENT 数值在交替变化,直到旧 RS 的 DESIRED 变为 0。如果新 RS 的 Available 一直不增加,而旧 RS 迟迟不缩,那十有八九是新 Pod readiness 没通过。这时候别干等,赶紧看 Pod 状态。
4.4 发布出错后的快速回滚
发布完了不一定是对的。如果发现新版本有严重的业务问题,回滚操作要快:
kubectl rollout undo deployment/order-service -n prod如果想回到特定历史版本(比如revision=2):
kubectl rollout undo deployment/order-service -n prod --to-revision=2回滚同样走一遍滚动更新流程,新 Pod(其实是旧镜像)起来、新镜像 Pod 缩掉。注意,回滚前最好观察一下旧版本的健康状态,否则可能从"新版本有问题"变成"回滚又把旧问题带回来了"。
5. 滚动更新常见故障与排查链路:我踩过的坑
5.1 新 Pod 一直 Pending 或 CrashLoopBackOff
遇到滚动更新卡住,第一反应是查 Pod:
kubectl get pods -n prod -l app=order-service kubectl describe pod <新Pod名称> -n prod常见根因分几类:
- 资源不足:describe 里出现
0/3 nodes are available: 1 Insufficient memory, 2 Insufficient cpu。这就是maxSurge峰值带来的临时资源需求超出节点余量。解法是调小 maxSurge,或者给节点预留资源缓冲。 - 镜像拉取失败:出现
ImagePullBackOff、ErrImagePull。常见于把私有仓库的镜像 tag 写错,或者集群节点上没有 registry 的访问凭证。先看事件里的错误信息,再检查 imagePullSecrets。 - 容器启动即退出:
CrashLoopBackOff。这时kubectl logs <Pod名称> -n prod看 Java 进程自身的报错,多半是启动参数问题、端口被占用或者数据库连不上。 - 探针配置不当:java 进程其实起来了,但 liveness 一击就失败,容器反复被杀。core 场景下把 startupProbe 加上就能解决。
5.2 新 Pod 就绪后旧 Pod 迟迟不退出
滚动更新的一部分是新旧交替,如果新 Pod 已经 Available 了,旧 Pod 还一直缩不下去,通常有这三个原因:
maxUnavailable: 0且maxSurge较小:这种配置下每扩容一个新 Pod,可能要等它就绪后才能缩一个旧 Pod,节奏本来就是慢吞吞的,不是故障。- PDB(PodDisruptionBudget)限制:如果存在 PDB,而且
minAvailable设得高,手动驱赶旧 Pod 会被 PDB 挡住,滚动更新里旧 RS 缩容也可能受其影响。 - 旧 Pod 卡在 Terminating:这通常不是"不退出",而是退了一半卡住了。
kubectl describe会显示 Pod 处于 Terminating,长时间不消失。大概率是容器的preStop里 sleep 设置过长,加上terminationGracePeriodSeconds很大,整个排空时间变长。合理,但要有耐心;若一直不消失,检查是否有进程持有文件句柄不释放、或者某种 Finalizer。
5.3 一次"滚动更新后偶发 502"的完整排查案例
有一次发布 Spring Boot 服务,滚动更新完成后监控显示 502 零星出现。现象很奇怪:不是持续报错,而是发布结束后的一分钟内有几条请求失败,之后恢复正常。
排查链路如下:
- 确认时间点:从网关日志里把报错请求的时间戳提取出来,精确到秒,和 Pod 创建/删除事件做对照,发现正好落在某个 Pod 的状态变为
Terminating的时间点附近。 - 看 Pod 事件:
kubectl describe pod <旧Pod>显示 Termination 时间。此时容器已收到 SIGTERM,但 readiness 仍然是 Ready。因为 Spring Boot 默认的 shutdown 阶段并不会主动把 readiness 变成 false。 - 定位竞态:请求被 Service 转发到仍处于 Ready 的旧 Pod,但它已经关闭了 HTTP 端口(或正在关闭中),连接被拒,网关报 502。这正是"旧 Pod 还在 Endpoints 里,但实际已没有服务能力"的竞态窗口。
- 修复方案:启用 Spring Boot 优雅停机(
server.shutdown: graceful),并利用它自带的"优雅停机阶段先标记不健康"的机制。加上 preStop sleep 15 秒,给负载均衡器/Endpoints 同步留出缓冲。重新发布后,同样的时间点不再出现 502。
这里要特别说明,readiness 状态的切换和 Endpoints 摘除在 K8s 里不是完全实时同步的,总会有一个短暂的延迟窗口。preStop sleep的本质不是真的需要 15 秒,而是给这个同步一个缓冲期,让流量不再往这个 Pod 上发。凡是 Java 服务,我都建议这个 sleep 时间不小于 10 秒,除非你对网络插件和 Endpoints 传播时间非常确定。
5.4 探针配置不当导致滚动更新"假成功"
还有一类坑是滚动更新显示成功了,但服务其实并不健康。原因是 readiness 探针打了/actuator/health,而这个端点在 Spring Boot 默认情况下包含磁盘、DB 连接池等检查项。如果数据库依赖短暂抖动,readiness 会变红,Pod 被摘除,本来这是好事;但如果你配置的 readiness 路径是/actuator/health而不是专门的 readiness 端点,那么整个应用的健康状态都会影响 Pod 的摘除,有时会因为某个外部依赖抖动导致所有副本被摘除,全部不可用,发布倒是"成功"了,服务却全灭了。
所以,readiness 探针应当使用/actuator/health/readiness,只反映就绪状态;liveness 才使用/actuator/health/liveness。不要图省事把两个探针指向同一个通用健康端点。
6. 什么时候不该用 RollingUpdate:进阶发布选型
6.1 一次性任务与数据库结构变更别躺着用
滚动更新适用于无状态服务。如果你的"发布"是一段一次性数据迁移脚本、或者需要改数据库表结构,那就不能依赖 Deployment 的滚动更新。这类任务应该用 K8s Job 跑,或者单独先执行 migration(比如 Flyway、Liquibase),等数据库 schema 兼容新老两个版本时,再滚动发布应用。
Java 服务常见的风险是:新版本代码假设数据库已经是新 schema,但滚动更新过程中新旧 Pod 同时运行,旧 Pod 可能还在用旧代码往新表里写不符合约束的数据。所以务必要保证"数据库变更向前兼容"优先于"代码发布"。顺序应该是:先迁移数据库,再滚动更新应用代码。
6.2 有状态服务和 StatefulSet 的更新方式
如果 Java 服务本身是有状态的,比如使用本地磁盘存储(EBS 挂载、emptyDir 都不算),Deployment 这种"谁先起谁后挂"的随机滚动完全不适合。此时应该考虑 StatefulSet 的OnDelete或RollingUpdate方式,后者会按 Pod 序号从大到小逐个更新,且每个 Pod 要保持顺序启动。Java 生态里真正强状态的场景(比如自研的注册中心、某些需持久化本地数据的中间件)才需要用到。
对大多数 Java 业务应用来说,把状态外置到 Redis、数据库之后,应用本身已经是无状态,Deployment 滚动更新完全够用。
6.3 金丝雀发布与加权流量:滚动更新的进阶形态
滚动更新的粒度是"Pod 数量",它不控制流量权重。如果你想先发 5% 的流量给新版本观察一段时间,再继续滚,那原生的 RollingUpdate 做不到——它在满足参数约束后会自动把新版本滚满。
进阶做法有几类:
- 把 Deployment 的 replicas 临时改成 1,更新后先观察,再逐步调大副本数。这还是滚动更新的变种,只是人为把节奏拉长。
- 使用 Argo Rollouts 这类工具,支持按流量权重(Service Mesh / Ingress 层配合)做金丝雀、逐步放量、自动回滚。它的底层仍然复用滚动更新的思路,但控制面更精细。
- 用 Service Mesh(如 Istio)做按 header 或比例的流量切分,适合复杂灰度场景,但引入的运维复杂度也高。
我的建议是:如果团队刚起步,先把原生 RollingUpdate 和探针玩明白,90% 的 Java 服务场景都能覆盖。只有当你真的需要按百分比放量、需要自动化的灰度决策时,再上 Argo Rollouts 这类工具。工具不是越多越好,发布稳定性的基础永远是"健康检查可靠、参数语义清楚、回滚路径顺手"这三件事。
根据我自己维护 Java 服务的经验,最后再分享一个细节:很多人会反复调maxSurge和maxUnavailable,但真正让滚动更新稳定的其实是探针和优雅停机。参数只是决定了发布的形状,健康检查才决定了一个新 Pod 能不能"安全地"加入流量、一个旧 Pod 能不能"体面地"退出。你先把 startupProbe 加对、把 Spring Boot 的server.shutdown: graceful打开、给容器加上 preStop sleep,再去调整那些百分比,会发现发布过程中的幺蛾子少一大半。以后遇到滚动更新卡住或偶发报错,也记得先看 Pod 状态、再看事件、最后对数时间戳,排查思路理顺了,问题基本都能定位。