
上个月我负责的订单服务发布用的还是老套路脚本停掉所有旧实例然后重新拉起一批新容器。那天新版本因为数据库连接池配置错误启动直接失败而旧实例早就被停干净了线上整整瘫痪了十几分钟群里全是喊回滚的却连上一个镜像包都翻不出来。那是我最后一次用“全杀全建”的方式做应用部署。此后我把所有无状态服务都迁到了 Kubernetes 的 Deployment 滚动更新配合 Helm 做包管理再没出过这种“要么全有、要么全无”的事故。这篇东西就围绕应用部署与滚动更新来写先讲滚动更新解决的是部署哲学上的什么问题再拆解 maxSurge 和 maxUnavailable 这两个节奏控制参数然后给出 Helm 部署 Java 应用的完整实践最后是我自己踩过的坑和排查链条。适合正在从手工脚本发布往 K8s 迁移、或者已经在用 Deployment 但总感觉“哪里没调明白”的团队参考。1. 从“全杀全建”到“逐个替换”滚动更新到底解决了什么1.1 部署方式的演进逻辑早期部署应用最常见的方式就两种手动 SSH 到服务器拉代码重启服务或者写一套 shell 脚本做“停旧起新”。这两种方式本质上是同一种操作哲学——先把旧的干掉再把新的拉起来。中间隔着一段空白期服务不可用。业务量小的时候无所谓凌晨两三点发个版影响可控但流量起来之后任何一次部署都是一次赌博赌的是“新版本一定能正常启动”。滚动更新的核心思路完全不同它不再把“部署”当作一个瞬间完成的切换而是一个渐进替换的过程。旧实例还在继续承接流量新实例一个一个地起来等新的确认健康了才把一个旧的拿走。整个过程中服务的总量始终保持在一个目标副本数附近任何时候都有实例在响应请求。这正是它与蓝绿部署、金丝雀发布之间的定位差异。蓝绿部署是准备两套完整环境流量一次性从蓝色切到绿色回滚也只要切回去成本是资源翻倍金丝雀发布是先放少量新版本流量试水确认没问题再逐步放量适合对流量质量极度敏感的场景。滚动更新则是在不额外准备整套环境的前提下用“逐步替换”换来了部署过程的可控性。它不是最优雅的方案却是资源开销和操作复杂度上最务实的一个。1.2 为什么 Deployment 成了事实标准在 Kubernetes 里滚动更新的能力由 Deployment 控制器承载而 Deployment 往下管理 ReplicaSetReplicaSet 再往下管 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 常见参数组合的取舍实际配置中我会根据服务的流量敏感度和集群资源余量来选组合。下面这组对比是排障时最常用的参照。组合maxUnavailablemaxSurge特点适用场景保守型01先扩容一个新 Pod确认 Ready 后再缩一个旧 Pod可用副本数始终不降核心交易链路对瞬时容量极其敏感速度型10先缩一个旧 Pod腾出配额再起新 Pod资源占用最小集群资源紧张能容忍短暂降容均衡型25%25%默认配置一增一减同时进行速度和开销相对均衡大多数内部服务激进型50%50%一次性放大量新旧替换更新时间最短资源非常充裕或更新窗口很短以replicas4, maxSurge1, maxUnavailable0的保守型组合为例更新流程可以拆成这么几步控制器先创建 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:MaxRAMPercentage75.0 -XX:InitialRAMPercentage50.0 EXPOSE 8080 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar app.jar]这里MaxRAMPercentage的作用是让 JVM 自动感知容器 cgroup 的内存限制而不是取宿主机内存。如果不用这个参数而手动设-Xmx很容易出现“容器 limits 设了 2GJVM 却按宿主机 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.tag1.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 undoHelm 回滚更彻底——它连配置一起回退而不是只切换镜像版本。不过要提醒一句helm rollback 本质上是执行一次新的发布它同样会触发滚动更新流程回滚过程也是渐进式的。别以为回滚是瞬间完成的事它同样要等待新 Pod Ready。所以在发布前检查镜像、在发布中盯监控远比把希望寄托在“发布失败再回滚”上靠谱。4. 滚动更新踩坑实录从持续重启到流量受损4.1 探针配置不合理导致的滚动更新风暴第一个坑我印象最深发生在一次 Java 服务的常规发版中。发版前我们给新版本加了更严格的启动校验应用启动时间从原来的 30 秒拉长到了 90 秒。但 Deployment 里的 readinessProbe 还是沿用旧参数initialDelaySeconds20, periodSeconds10, failureThreshold3。发布一启动新 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.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase20s这两件事的分工是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 慢慢就绪也别为了省几十秒让整个集群陪着抖一下。