Java 应用上 K8s 之后,很多人第一次做滚动更新都会有一种错觉:K8s 默认就是滚动更新的,我什么都不用配,直接kubectl set image就行了吧。但这个“默认 RollingUpdate”对 Java 应用来说,往往就是事故的开始——有一半的概率你会看到 504、连接被重置、旧节点被杀的时候请求还埋在连接池里。这篇文章就聊聊 Java 应用在 K8s 里用 RollingUpdate 策略做发布这件事,把原理、参数、YAML 和踩坑一起讲清楚,给正在做容器化改造的 Java 开发、K8s 运维和 SRE 同学一个可以直接照着抄的底稿。
1. 滚动更新机制拆解:RollingUpdate 到底在滚什么
1.1 先搞明白默认行为:为什么“能用”不等于“好用”
K8s 里 Deployment 默认的更新策略确实是 RollingUpdate,但这套默认值是按“通用 Web 无状态服务”设计的,从来没考虑过 JVM 的启动速度问题。默认参数是maxSurge: 25%、maxUnavailable: 25%,含义是:更新过程中,最多可以有 25% 的 Pod 先被删掉,同时最多再多启动 25% 的 Pod。听起来很合理对吧?但对 Java 应用来说,这个组合很容易让可用容量瞬间掉一截。
举个实际场景。一个 Java 服务有 4 个副本,集群高峰期每个 Pod 的 CPU 使用率已经到 90%,此时我触发一次滚动更新。按照默认 25% 的maxUnavailable,K8s 会先缩掉 1 个旧 Pod,这时集群里只剩 3 个 Pod 在扛流量。新 Pod 因为 JVM 启动、Spring 容器初始化、连接池预热,可能要 40 秒甚至更久才就绪。这 40 秒里,4 倍的流量压力压到 3 个 Pod 上,接口 P99 延迟直接翻倍,运气不好就是雪崩。
所以我的第一个建议是:默认 RollingUpdate 策略只适合“启动快、无状态、随时可杀”的进程,Java 应用必须把参数抠到“先扩容、后缩容”的方向,不能稀里糊涂用默认值。K8s 的滚动更新不是魔法,它只保证“最终替换完成”,不保证“替换过程中容量平稳”,容量平稳这件事得靠参数和探针自己设计。
1.2 maxSurge 和 maxUnavailable:两个参数一台天平
滚动更新的核心就两个参数,理解了它们,部署策略就理解了一半。
maxSurge:允许超出期望副本数(replicas)的 Pod 数量,可以是绝对数值也可以是百分比。它决定新 Pod 最多能提前启动几个。maxUnavailable:允许低于期望副本数的 Pod 数量,同样可以是数值或百分比。它决定旧的 Pod 最多能先停几个。
这两个值加起来设计,实际是在控制“更新期间有多少个可用的 Pod”。一个简单的约束关系是:
- 可用 Pod 数下限:不低于
replicas - maxUnavailable - 总 Pod 数上限:不超过
replicas + maxSurge
我通常用 4 副本的 Java 服务举例,几个典型组合如下:
| 组合 | 表现 | 适用场景 |
|---|---|---|
maxSurge: 1, maxUnavailable: 0 | 先起 1 个新 Pod,等它 Ready 后再停 1 个旧 Pod,如此循环 | Java 服务推荐,容量始终 100% |
maxSurge: 0, maxUnavailable: 1 | 先停 1 个旧的,再起 1 个新的,滚动过程中始终少 1 个实例 | 资源紧张、必须控制峰值用量的场景 |
maxSurge: 25%, maxUnavailable: 25% | 默认值,两边同时动,节奏快但容量有缺口 | 启动快的非 Java 服务 |
maxSurge: 100%, maxUnavailable: 0 | 先把所有新 Pod 全部启动,Ready 后再一次性停掉旧的 | 接近蓝绿发布,资源得够用 |
这里有一个容易踩的细节:maxUnavailable: 0也不等于绝对安全。如果你的集群资源不足,新 Pod 一直 Pending,滚动更新会直接卡住,旧的也不停,意思是发布卡死了。从好的方面看,这至少没把线上流量搞挂;但如果你没有设置progressDeadlineSeconds,这个“卡死”状态会一直挂到天荒地老,不会被标记为失败。我一直建议线上 Deployment 设置progressDeadlineSeconds: 300,超过 5 分钟还没完成就自动把 rollout 标记为 Failed,给告警一个抓手。
1.3 一次滚动更新的完整执行流程
搞清楚机制之后,我用 3 副本、maxSurge: 1、maxUnavailable: 0为例,拆解一次完整更新:
- 你修改了 Pod 模板(比如换了镜像 tag),创建一个新版本的 ReplicaSet。
- 新 ReplicaSet 先扩展出 1 个新 Pod(因为
maxSurge: 1),此时集群里总共有 4 个 Pod,其中 3 个旧、1 个新。 - 新的 Pod 进入 Pending → Running,然后等待 readinessProbe 返回成功。这个期间旧 Pod 一个都不会动,流量全在旧实例上。
- 新 Pod Ready 后,旧 ReplicaSet 缩掉 1 个 Pod(因为要维持总数不超过
replicas + maxSurge),总数又回到 3。 - 新 ReplicaSet 再扩到 2 个新 Pod,等 Ready 后旧 ReplicaSet 再缩到 1 个旧 Pod。
- 重复上述节奏,直到新 ReplicaSet 拥有全部 3 个 Ready Pod,旧 ReplicaSet 缩到 0,滚动更新完成。
整个过程中,K8s 会同时存在新旧两个 ReplicaSet,并且新的不断扩,旧的不断缩。明白了这个流程,再看后面 Java 应用的具体配置就会很清晰:每一个“新 Pod 等待 Ready”的步骤,就是 Java 应用启动的缓冲时间;每一个“旧 Pod 被缩掉”的步骤,就是优雅停机要保护的关键时刻。
2. Java 应用滚动更新的关键前提:让 JVM 学会“快启动、慢退出”
2.1 为什么新 Pod 起来了却没流量:就绪探针才是第一道闸门
很多人会问:新 Pod 状态显示 Running,为什么访问 8080 还是 Connection refused?因为在 K8s 里,Running 只代表进程被拉起来了,不代表业务可用。JVM 进程起来之后,还要做类加载、字节码校验、Spring Boot 上下文初始化、数据库连接池创建、注册中心注册,这一套下来通常在 20 秒到几分钟不等。如果你的服务还做了本地缓存预热、消息队列监听初始化,时间会更长。
这时候如果 Service 直接把流量转发到新 Pod,用户请求就会打到“还没准备好的进程”上,出现大量的 500、502。而就绪探针(readinessProbe)存在的意义,就是告诉 K8s:这个 Pod 到底能不能接流。只有 readinessProbe 连续通过,Pod 才会被加进 Service 的 Endpoints 列表;一旦探针失败,又会被摘出去。
Java 服务的 readinessProbe 我推荐直接挂到 Actuator 的专用端点,而不要用/actuator/health。Spring Boot 2.3+ 的健康端点支持分组,把/actuator/health/readiness和/actuator/health/liveness分别暴露出来,readiness 里可以包含数据库、Redis 等外部依赖的健康检查,liveness 只反映进程自身是否存活。这样 K8s 的探针语义才和 Spring Boot 的健康语义对齐:数据库短时间不可用,readiness 失败,Pod 被摘流量但不会被杀掉重启;如果连 JVM 都僵死,liveness 失败,K8s 才会重启容器。
关于initialDelaySeconds,我有过一个实际教训。某个服务首次启动平均要 45 秒,我把initialDelaySeconds设置成了 30,结果第一次探针在启动过程中就失败了,failureThreshold又比较严格,Pod 直接变成 CrashLoopBackOff。实际上initialDelaySeconds应该略大于“正常情况下最慢的启动时间”,给慢启动留足余量。如果服务启动时间不确定,宁可设置 60 秒甚至 90 秒,探针本身不是越快越好,它是要表达“什么条件下算就绪”,不是用来找出启动慢的 Pod。
2.2 优雅停机:不是 kill 完事,是“把手里的活干完”
滚动更新里最容易被忽略的就是旧 Pod 被删除的那一刻。默认情况下,K8s 删除 Pod 的流程是这样的:
- Pod 状态变为 Terminating。
- K8s 从所有关联的 Service Endpoints 中摘除这个 Pod 的 IP。
- K8s 向容器主进程发送 TERM 信号。
- 进程有
terminationGracePeriodSeconds的时间做清理,默认是 30 秒。 - 超时后 K8s 发送 KILL 信号,直接把进程杀掉。
问题在于,Java 进程默认收到 TERM 信号后,做的事情是直接退出,不会等正在处理的请求结束。如果你的服务同时有数据库事务、文件处理、消息业务正在执行,这一刀下去就是事务中断、数据不一致。所以 Java 应用的优雅停机必须双管齐下:
第一,Spring Boot 侧开启优雅停机。Spring Boot 2.3 开始支持server.shutdown=graceful,在application.yml里配置:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s这样应用收到 TERM 信号后,会停止接收新请求,然后等待当前请求处理完,最多等待timeout-per-shutdown-phase指定的时长。
第二,K8s 侧配置 preStop hook。为什么有了优雅停机还要 preStop?因为摘流量这个动作需要时间。K8s 从 Endpoints 里摘除 Pod 的 IP,到网关、Ingress、客户端连接池真正感知到,往往有几秒的延迟。如果 TERM 信号来得太快,新请求可能还在往这个 Pod 上打。常见的做法是给 preStop 加一个 sleep,留出流量摘除窗口:
lifecycle: preStop: exec: command: ["sh", "-c", "sleep 15"]这个 15 秒不是拍脑袋定的,它至少要覆盖“依赖链路感知 IP 摘除”的时间。我见过很多 Nginx 上游配置了keepalive,连接池里的连接不会立刻断掉,如果 5 秒就发 TERM,连接池里的请求可能还在往旧 Pod 上发。15 到 20 秒是经过验证比较稳的窗口,宁可多等一会儿也别把请求浪费在路上。
2.3 Java 进程的资源画像:别让内存和 CPU 在更新时掉链子
滚动更新期间,新旧 Pod 会短暂并存,资源总需求量会比平时高出一截。Java 应用在这里有两个典型的资源陷阱。
第一个是 JVM 堆内存和容器 limit 的关系。很多老项目直接在启动参数里写-Xmx2g,但容器限了 1GiB,结果 Pod 一启动就被 OOMKilled。反过来,容器给 4GiB,JVM 只敢用 2GB 堆,浪费了一大半。JVM 在容器里有个特性:如果不显式设置堆大小,老版本的 JVM 会直接拿宿主机内存来算默认堆,导致容器外的进程把内存打爆。JDK 8u191+ 支持-XX:MaxRAMPercentage,我常用的配置是:
env: - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=75.0 -XX:MinRAMPercentage=75.0"这样 JVM 堆占容器内存 limit 的 75% 左右,剩下的留给 Metaspace、线程栈、堆外缓冲。如果容器 limit 是 2GiB,堆大约 1.5GiB;如果调成 4GiB,堆自动翻倍,不再需要同步改 JVM 参数。
第二个是 CPU limit 导致启动变慢。如果给 Pod 设了limits.cpu: 1,而宿主机负载又高,JVM 的编译线程、GC 线程都会受到 CPU 限流影响,启动时间可能比平时慢出两倍。滚动更新时新 Pod 启动慢,整体 rollout 时间就长。建议 CPU requests 和 limits 不要差得太小,requests 保证调度和基础资源,limits 防失控,但也别限得太狠。
资源问题的另一个侧面是集群容量。滚动更新的maxSurge: 1意味着更新期间会多出一个临时 Pod。如果你的集群资源使用率已经到 95%,新 Pod 大概率调度不上去,更新卡在 Pending。这个不是配置问题,是容量规划问题。线上发布前用kubectl describe node看一眼可分配资源,或者用 Descheduler 把集群整理一下,心里就有数了。
3. 生产可用配置:一份经过实战打磨的 Deployment YAML
3.1 先定参数:不同副本数下的策略选择和计算
配置 RollingUpdate 之前,先按副本数想清楚参数怎么定。我自己的经验是,不管是 3 副本还是 20 副本,线上 Java 服务我几乎都用“先增后减”的策略:maxSurge取一个合适的值,maxUnavailable设为 0。区别只是maxSurge取多少:
- 3 副本:
maxSurge: 1,更新过程中最多 4 个 Pod,资源要求不高,节奏灵活。 - 5 副本:可以用
maxSurge: 1,也可以maxSurge: 2。如果业务流量波动小,1 个足够;如果希望发布更快,2 个更合适,代价是更新期间峰值多 2 个 Pod。 - 10 副本以上:
maxSurge: 1会让发布很慢,10 个副本要滚 10 轮,每轮等 1 分钟就 10 分钟了。这种情况可以用百分比,比如maxSurge: 20%,K8s 会把 20% 向上取整,也就是 2 个。
maxUnavailable为什么坚持 0?因为在 Java 服务的场景里,“少一个实例”通常比“多一个实例”更危险。资源不够导致maxSurge加不进来,只是更新卡住,业务还能跑;但如果maxUnavailable: 1,K8s 会先杀旧 Pod,在高并发下这就是真实故障。所以我的选择一直是“宁可卡发布,也不丢容量”。
progressDeadlineSeconds是容易被忽略但很重要的字段。它表示如果 Rollout 在指定时间内没有完成,Deployment 会被标记为失败,后续的新版本会被判定为不可用。这个值不要设得太小,Java 应用启动慢,加上滚动节奏,5 分钟起步,我一般用 600。它的价值在于:发布卡死时,你能在界面和告警里快速看见“失败”状态,而不是三天后才发现 publish 没完成。
3.2 完整 YAML:从镜像到探针再到滚动更新全量配置
下面这份 YAML 是我在线上 Java 服务里实际用过的配置模板。服务是无状态 Spring Boot 应用,8080 暴露 HTTP,Actuator 健康检查依赖spring-boot-starter-actuator:
apiVersion: apps/v1 kind: Deployment metadata: name: java-order-service namespace: prod labels: app: java-order-service spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 progressDeadlineSeconds: 600 selector: matchLabels: app: java-order-service template: metadata: labels: app: java-order-service spec: terminationGracePeriodSeconds: 90 containers: - name: app image: registry.example.com/java-order-service:1.2.3 imagePullPolicy: IfNotPresent ports: - containerPort: 8080 protocol: TCP env: - name: SPRING_PROFILES_ACTIVE value: prod - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=75.0" resources: requests: cpu: 500m memory: 512Mi limits: cpu: "2" memory: 1Gi readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 60 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 90 periodSeconds: 15 timeoutSeconds: 2 failureThreshold: 3 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 15"]这份配置里我故意把terminationGracePeriodSeconds设成了 90 秒,因为 preStop sleep 15 秒 + Spring Boot 优雅停机最多等 30 秒 + 数据库事务收尾要时间,30 秒默认值经常不够用。90 秒是比较平衡的数值:既不会因等待太久拖慢发布,也不会因为时间不够强杀进程。
3.3 关键字段逐行解释:别漏掉任何一个坑
这份 YAML 看着不长,但每个字段背后都有讲究。
imagePullPolicy: IfNotPresent是很多团队容易踩的坑。如果你发布的镜像 tag 是latest,而镜像仓库里又更新了同样的 tag,IfNotPresent可能导致节点上缓存着旧的latest,新 Pod 拉下来的是旧镜像。线上发布我强烈建议用不可变 tag,比如1.2.3、git-abc123,配合IfNotPresent就足够安全。如果必须用latest,那就老老实实把imagePullPolicy设为Always。
resources里的limits.memory: 1Gi配合上面 Java_OPTS 的MaxRAMPercentage=75.0,JVM 堆上限就是 768 MiB 左右,剩下的给 Metaspace 和线程栈。这里要提防一个现象:Java 容器里除了堆,还有大量非堆内存。如果服务用了大量堆外缓存、JNI 或者 DirectByteBuffer,75% 的堆占比会不合适。我自己习惯先用-Xmx实验性地算出稳定内存画像,再换算成容器 limit。比如本机测试堆 512M、非堆 300M,那容器 limit 至少 1Gi,堆占 50% 左右比较稳。
readinessProbe的failureThreshold: 3意味着连续 3 次失败才把 Pod 摘流量。这对 Java 服务很重要。有些业务的下游依赖偶尔抖一下,一次探针失败就摘流量,容易造成 Pod 频繁上下线;3 次失败已经能过滤掉一小段瞬时抖动。timeoutSeconds设 2 秒,探针请求太慢可能是 JVM 卡顿或 GC 停顿的信号,2 秒超时能更快暴露问题。
livenessProbe的路径我强调一下,千万不要把/actuator/health/readiness当 liveness 用。Spring Boot 默认的/actuator/health会把 Redis、数据库等下游状态都带进来,如果数据库短时不可用而进程本身没问题,liveness 失败会导致 K8s 重启整个容器,反而放大故障。正确做法是在 Spring Boot 里单独配置这两个分组:
management: endpoint: health: probes: enabled: true group: readiness: include: db, redis liveness: include: pingliveness只包含ping,意思就是“进程还活着就行”,而readiness包含真实依赖的健康状态。这样 K8s 的探针语义才符合 Java 应用的运行模型。
3.4 发布时的操作流程与验证命令
配置写好后,发布流程也应该标准化。我自己的操作习惯是这样的:
- 修改镜像 tag,用
kubectl apply -f deployment.yaml提交更新。注意不要用kubectl edit或者kubectl set image临时改,线上配置要保证 Git 里的 YAML 和集群里的实际状态一致。 - 观察发布进度:
kubectl rollout status deployment/java-order-service -n prod。这条命令会一直输出更新进度,直到 rollout 完成或者超时失败。 - 在另一个终端看 Pod 变化:
kubectl get pods -n prod -l app=java-order-service -w。重点观察新 Pod 从 Pending 到 Running 再到 Ready 的过程,以及旧的 Pod 一个一个被 Terminate。 - 新 Pod Ready 后,手动打一下业务接口,确认返回正常,再等下一批。
- 全部完成后保留发布历史:
kubectl rollout history deployment/java-order-service -n prod,记录这次版本号。
如果发布到一半发现了问题,最稳妥的做法不是把 YAML 改回去再 apply,而是直接回滚到上一个稳定版本:
kubectl rollout undo deployment/java-order-service -n prod也可以回滚到指定历史版本:kubectl rollout undo deployment/java-order-service -n prod --to-revision=3。K8s 会重新触发一次滚动更新,用旧版本的配置替换掉有问题的版本。
回滚操作我建议演练过一次再上生产。不少人以为 rollout undo 是“瞬间恢复”,其实它本质上也是一次滚动更新,同样要走探针和优雅停机流程,所以回滚时间不会比发布快多少。这是很多人初次上手时认知上的偏差。
4. 常见问题与排查技巧实录
4.1 滚动更新卡住不动:Pod 一直在 Pending 怎么查
最典型的卡 Pending 场景:运行kubectl rollout status之后,进度停在Waiting for deployment spec update to be observed...或者半个多小时不结束,新 Pod 的 STATUS 一直是 Pending。这时候先别瞎猜,直接看事件的真相:
kubectl describe pod <new-pod-name> -n prod重点看 Events 区域。按照我的经验,常见原因就几种:
- 资源不足:节点没有足够的 CPU/内存满足新 Pod 的 requests。Events 里会显示
0/6 nodes are available: 2 Insufficient cpu, 2 Insufficient memory。解决思路是看看节点水位,或者临时调小 requests(注意这会影响调度质量,别把它当长期手段)。 - 镜像拉取失败:Events 里有
Failed to pull image或ImagePullBackOff。常见原因是镜像 tag 不存在、仓库凭证过期、私有仓库没配 imagePullSecret。 - PVC 挂载失败:如果你的 Java 服务挂载了 Volume,可能在 attach 阶段卡住,Events 里会报
FailedMount。
排查节奏上,describe永远比get pods信息多,我很少靠猜去解决问题。
4.2 更新完成了,但流量还是打到旧 Pod 上
滚动更新明明全部成功,新 Pod 也都是 Ready,但调用方的流量仍然时不时连到旧 Pod 上。这个问题的根源通常不在 K8s 本身,而在上游链路。
第一种常见原因是Service Endpoints 更新延迟。K8s 的 kube-proxy 在节点上维护 iptables/IPVS 规则,规则同步有一定的延迟。Pod 刚被摘掉时,旧 IP 可能还在 iptables 规则里,仍然会被转发到不存在的 Pod 上,结果这个 IP 的请求 Connection refused。
第二种常见原因是客户端或网关的连接池缓存了旧 IP。如果你的 Java 服务通过 Feign/HTTP Client 连接下游,而下游 Pod 更新后 IP 变了,连接池里残留的旧连接不会立刻失效。很多 HTTP 客户端默认开启 keep-alive,连接在超时之前不会重建。结果就是“更新完成,但一部分请求还被发往已销毁的 Pod”。
针对这个场景,建议在三个层面做防护:
- 让所有 Java 客户端启用连接复用前的 ICMP 探测或者 idle connection 清理,比如 Apache HttpClient 配置
setValidateAfterInactivity(5000)。 - 网关层(Nginx/Ingress)把
keepalive_timeout调小一些,别一直维持过期的 upstream 连接。 - 在 preStop 里 sleep 更久,给上游链路充分的时间清空连接池。
4.3 新 Pod 反复重启:readiness 失败 vs. liveness 误杀
新 Pod 一直处于 CrashLoopBackOff 或者反复重启,第一件要做的事是区分是 liveness 探针失败,还是真的应用崩溃。
先看一段日志:
kubectl logs <pod-name> -n prod --previous如果--previous里能看到 Spring Boot 正常启动日志但随后进程退出,大概率是 liveness 探针误杀。最常见的原因是 liveness 路径带了完整 health 检查,下游 Redis 抖动导致探针失败,K8s 重启容器。
如果 Pod 状态是 Running 但一直 NotReady,这个是 readiness 失败。可能的原因我没少见:
- 数据库连接迟迟建立不了,readiness 里的
dbhealth 一直是 DOWN,K8s 认为 Pod 不可用,不摘掉它也不加流量。 - 探针路径写错,比如用了
GET /actuator/health,但服务没有暴露 actuator 端点,返回 404。 - 探针 timeout 太短,健康检查超时被当成失败。
区分清楚之后的动作也不一样:readiness 失败不能靠加failureThreshold掩盖,要看下游依赖为什么连不上;liveness 误杀则要把探针路径改成只反映进程自身的 liveness,或者减小检查频率。
4.4 旧 Pod 迟迟不退出,Terminating 挂了很久
滚动更新时,旧的 Pod 状态变成 Terminating,但一直不消失,这通常意味着优雅停机没有在terminationGracePeriodSeconds内完成。你可能会看到kubectl get pods里一个 Pod 卡在 Terminating 状态过了好几分钟。
两个排查方向:
第一,看 Pod 里的进程在干嘛。进入容器看线程:
kubectl exec <pod-name> -n prod -- jstack <java-pid>如果主线程在等待数据库事务提交、消息队列 ack、或者第三方接口响应,说明这是正常的“活还没干完”,只是 90 秒还不够。遇到这种情况要反思的是业务里有没有不可控的长任务,比如消息消费处理超过 60 秒,那 preStop 和 graceful 的时间都要加长。
第二,查是不是有 Pod 的 finalizer 卡住。如果 Pod 挂在 Terminating 且事件里没有明确的信号,检查kubectl get pod <pod-name> -o yaml里的metadata.finalizers。有第三方控制器(比如 Service Mesh 的 sidecar 注入器)在 finalizer 里执行清理,清理逻辑卡住,Pod 就一直 Terminating。
很多团队的初始反应是“直接 kubectl delete pod --force”,但这会让 Pod 里的 Java 进程直接收到 KILL 信号,正在处理的事务全部中断。强制删除是最后手段,不是常规手段。在服务做了优雅停机配置之前,别用 force。
4.5 排查工具与命令速查表
| 场景 | 命令 | 说明 |
|---|---|---|
| 查看 Rollout 进度 | kubectl rollout status deployment/<name> -n <ns> | 阻塞式输出直到完成或超时 |
| 查看 Rollout 历史 | kubectl rollout history deployment/<name> -n <ns> | 列出历史 revision |
| 回滚到上一版本 | kubectl rollout undo deployment/<name> -n <ns> | 触发一次新滚动更新 |
| 查看 Pod 事件 | kubectl describe pod <pod> -n <ns> | 排查 Pending、镜像、调度问题 |
| 查看上次容器日志 | kubectl logs <pod> -n <ns> --previous | 排查崩溃前现场 |
| 实时观察 Pod 状态 | kubectl get pods -w -l app=<app> -n <ns> | 发布过程中的动态监控 |
| 查看 Service Endpoints | kubectl get endpoints <svc> -n <ns> | 确认 Pod IP 是否被摘除 |
| 强制删除卡死 Pod | kubectl delete pod <pod> -n <ns> --grace-period=0 --force | 最后手段,慎用 |
5. 从滚动更新往前走一步:金丝雀发布与更稳的发布流程
5.1 为什么生产环境我更推荐“先灰度再滚动”
RollingUpdate 解决的是“替换”的问题,但替换过程中出了问题,要么等全部滚完,要么立即回滚。在核心的 Java 服务上,我更倾向于把范围再收窄一层:先通过金丝雀发布把 5% 或 10% 的流量切到新版本,观察一段时间(看日志、看指标、看错误率),没问题再走完整的滚动更新。
K8s 社区有几个成熟方案:Argo Rollouts、Flagger、OpenKruise。它们本质上是把 Deployment 替换成一个支持灰度策略的控制器,流量权重可以精细控制。Argo Rollouts 最大的好处是能和 Ingress 或 Service Mesh 配合,把新旧版本的流量按比例拆分,并且支持自动检测指标异常后回滚。金丝雀阶段的时长可以配置,一般我是 30 分钟到 1 小时,观察新版本 Pod 的 GC 指标、接口延迟和日志里的异常信息。
我为什么要强调“先灰度再滚动”?因为 RollingUpdate 本身没有“按比例切流量”的概念。它只是逐个替换 Pod,在替换过程中 Service 会把流量平均打到新旧版本上,这个“平均分布”是你无法精确控制的。你在怀疑新版本有隐患时,它可能已经把 50% 以上的流量交到新版本手里了。而金丝雀发布不一样,你能精确知道现在只有 5% 的流量到达新版本,风险面可控得多。如果从我的实际经验出发,线上所有核心 Java 服务至少都要有金丝雀这一步,RollingUpdate 更适合作为灰度验证通过后的“全量发布通道”。
5.2 不引入额外组件也能做的变通方案
引入 Argo Rollouts 这样的组件需要一定学习和改造成本,有些团队暂时做不到。这种情况下,有一个不引入额外组件的变通做法:用 Deployment 本身的滚动更新配合手工控制批次。
具体操作是这样的:把 YAML 里的maxSurge: 1, maxUnavailable: 0和progressDeadlineSeconds保持不变,但发布镜像时不是一次性把所有 Pod 切到新版本,而是分两到三步操作。第一步只更新一个 Pod 的镜像,观察它的日志和指标,如果没问题,再手动把副本数里的其余 Pod 也切换到新版本。实现上可以用两个 Deployment 并存,也可以临时改replicas和maxSurge来控制节奏。
这个方法不够自动化,但在早期团队里非常实用:你不需要学习新控制器,也能做到“小范围验证再全量”。等发布操作形成了固定节奏,再考虑上 Argo Rollouts 也不迟。我个人在刚开始带运维团队时,用的就是这种“手工金丝雀”的方式,跑顺一个月后再上的 Argo Rollouts,过渡非常平滑。
滚动更新这件事本身不难,难的是把你手上这个 Java 应用的个性琢磨透——启动要多长时间、下线时要处理什么业务、依赖的下游能不能容忍短时的连接重连。我目前最顺手的一套组合就是:maxSurge: 1、maxUnavailable: 0、readiness 和 liveness 分开配、preStop sleep 15 秒、terminationGracePeriodSeconds 90 秒,这套模板撑过了绝大多数高并发发布场景。不同的服务还会有自己的特殊情况,但先把这五个基础项做对,90% 的滚动更新问题就已经提前被挡在门外了。接下来不管你是继续深挖金丝雀发布,还是研究 Service Mesh 的流量管理,前面这些基本功都会是你最扎实的底座。