
上周五给一个 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 模板比如换了镜像 tagDeployment 会创建一个新的 ReplicaSet然后让新 RS 的副本数逐步增加、旧 RS 逐步减少直到新 RS 达到期望副本数、旧 RS 缩到 0。这个逐步就是滚动更新的核心。关键点在于滚动更新的节奏不是随意的而是受到两个参数约束——maxUnavailable和maxSurge。它们决定了任何一个时刻比期望副本数多出来的 PodSurge增额和不能用的 PodUnavailable差额各允许是多少。打个比方期望 10 个副本maxSurge25%意味着滚动过程中最多可以有 12.5 个 Pod实际取整就是 13 个这里多出来的 2~3 个是新版本 PodmaxUnavailable25%意味着最少得保证 7.5 个 Pod 可用实际取整是 8 个。新旧交替就发生在这个整体容量不降、甚至略微超卖的区间里。1.3 滚动更新与蓝绿发布的本质差异很多 Java 团队听说过蓝绿发布先起一套完整的新版本环境绿验证通过后把流量一次性切过去蓝。这套方案的优点是切换瞬间完成、回滚极其简单切回蓝缺点也明显资源要双倍两套环境的数据兼容、配置一致性都是工程负担。滚动更新则不是环境级切换而是Pod 级交替。它不要求你准备一套全新环境新老 Pod 共享同一个 Service靠探针决定谁可以接流量。资源开销按参数控制可以做到不额外占用太多集群容量。代价是如果探针配置不到位K8s 默认认为新 Pod 一起来就是健康的流量可能过早打到一个还没初始化完的 JVM 上从而引发大量超时。这正是后面要反复强调健康检查的原因。2. RollingUpdate 核心参数拆解maxUnavailable 与 maxSurge 的真实作用2.1 这两个参数背后的计算逻辑strategy.rollingUpdate下有两个字段单位可以是整数或百分比。百分比的计算基准是replicas的向下取整maxUnavailable和向上取整maxSurge。举个例子加深印象假设replicas4maxUnavailable25%即允许 1 个不可用maxSurge25%即允许额外 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数字不是百分比相当于严格的老一退、新一进。这个组合最稳但也最慢。配置组合特点适用场景maxUnavailable0, maxSurge1严格保活发布慢集群峰值内存只多一个 Pod核心链路、不允许任何容量下降maxUnavailable25%, maxSurge25%速度与稳定平衡动态交替扩容缩容大多数常规 Java 服务maxUnavailable1, maxSurge0先停旧再起新会有短暂容量缺口集群资源很紧张、无法额外占用内存时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 --timeout180s超时后命令返回非 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 前先执行 preStopsleep 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-serviceregistry.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 --timeout180s输出会逐步变化实测中你会看到类似这样的信息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 apporder-service -w你会看到两个 RS 的 DESIRED/CURRENT 数值在交替变化直到旧 RS 的 DESIRED 变为 0。如果新 RS 的 Available 一直不增加而旧 RS 迟迟不缩那十有八九是新 Pod readiness 没通过。这时候别干等赶紧看 Pod 状态。4.4 发布出错后的快速回滚发布完了不一定是对的。如果发现新版本有严重的业务问题回滚操作要快kubectl rollout undo deployment/order-service -n prod如果想回到特定历史版本比如revision2kubectl rollout undo deployment/order-service -n prod --to-revision2回滚同样走一遍滚动更新流程新 Pod其实是旧镜像起来、新镜像 Pod 缩掉。注意回滚前最好观察一下旧版本的健康状态否则可能从新版本有问题变成回滚又把旧问题带回来了。5. 滚动更新常见故障与排查链路我踩过的坑5.1 新 Pod 一直 Pending 或 CrashLoopBackOff遇到滚动更新卡住第一反应是查 Podkubectl get pods -n prod -l apporder-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节奏本来就是慢吞吞的不是故障。PDBPodDisruptionBudget限制如果存在 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 状态、再看事件、最后对数时间戳排查思路理顺了问题基本都能定位。