微服务优雅停机:K8s PreStop钩子、Actuator Shutdown、流量排空
微服务优雅停机:K8s PreStop钩子、Actuator Shutdown、流量排空
适读人群:在K8s上运行微服务的后端工程师 | 阅读时长:约24分钟 | Spring Boot 3.2 / Kubernetes 1.28
开篇故事
有次我们做服务升级,滚动更新的时候监控上出现了一波错误率飙升,持续了大概30秒。翻日志发现,正在处理中的请求被强制中断了,有不少订单请求返回了连接断开的错误。
问题根源:K8s在发送SIGTERM信号后,Pod就立刻从Service Endpoint里摘除了,但JVM进程收到SIGTERM后需要时间来执行关闭钩子(包括等待in-flight请求处理完、从Nacos注销、关闭数据库连接等),这段时间内如果还有新请求进来,就会被强制中断。
我们当时没有配置PreStop钩子,也没有配置Spring Boot的优雅停机。直接SIGTERM,JVM立刻开始关闭,in-flight请求全部报错。
那次之后我把整套优雅停机方案搭起来了,再也没有因为发版导致的请求错误。今天把这套方案完整写出来。
一、核心问题分析
优雅停机需要解决的核心问题是:在服务停止时,如何确保所有in-flight请求能够正常处理完成,同时不再接受新的请求。
在K8s环境下,这个问题涉及三个层面:
K8s层面:Pod收到删除信号后,K8s需要从Service的Endpoints里摘除这个Pod,这需要时间(Endpoint Controller的同步延迟、负载均衡器的更新延迟),在这段时间内还可能有新请求进来。
Spring Boot层面:需要开启server.shutdown: graceful,让已有的HTTP请求能继续处理完,同时拒绝新的HTTP连接。还需要等待异步线程池里的任务完成。
Nacos层面:需要在停止前把自己从Nacos注销,让其他服务知道这个实例不可用了,不要再路由请求过来。
这三个层面如果没有协调好,就会出现停机期间的请求错误。
二、原理深度解析
2.1 K8s Pod停止的完整时序
2.2 优雅停机的关键时间窗口
三、完整代码实现
3.1 Spring Boot优雅停机配置
server:
# 开启优雅停机:收到关闭信号后,不接受新HTTP请求,等待in-flight请求处理完
shutdown: graceful
spring:
lifecycle:
# 等待in-flight请求的最大时间
# 超过这个时间,即使请求没处理完,也会强制关闭
timeout-per-shutdown-phase: 30s3.2 K8s Deployment配置(关键)
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 滚动更新时最大不可用Pod数,0表示始终保持全量
template:
spec:
# 终止宽限期:从SIGTERM到SIGKILL的最大时间,必须大于PreStop+处理时间
terminationGracePeriodSeconds: 60
containers:
- name: order-service
image: order-service:latest
ports:
- containerPort: 8080
lifecycle:
preStop:
exec:
# PreStop钩子:在发送SIGTERM之前执行
# 目的是等待kube-proxy/LB更新完毕,不再把新流量路由到本Pod
# sleep时间要大于EP更新延迟(通常5-10秒,保守取15秒)
command: ["/bin/sh", "-c", "sleep 15"]
# 就绪探针:Pod就绪后才接受流量
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
failureThreshold: 3
# 存活探针:Pod不健康时重启
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2000m"
memory: "2Gi"
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name3.3 Spring Boot Actuator健康检查配置
management:
endpoints:
web:
exposure:
include: health,info,readiness,liveness
endpoint:
health:
show-details: always
# 分别配置存活和就绪状态
probes:
enabled: true
health:
# 就绪状态依赖以下组件
readinessState:
enabled: true
livenessState:
enabled: true3.4 自定义关闭钩子(Nacos注销+连接池清理)
package com.laozhang.graceful;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.nacos.api.naming.NamingService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 优雅停机钩子:按顺序执行以下操作:
* 1. 从Nacos注销,让其他服务不再路由流量到本实例
* 2. 等待正在处理的请求完成(Spring Boot graceful shutdown负责)
* 3. 关闭数据库连接池等资源
*/
@Slf4j
@Component
public class GracefulShutdownHook implements DisposableBean {
private final NacosDiscoveryProperties nacosDiscoveryProperties;
@Value("${spring.application.name}")
private String applicationName;
@Value("${server.port:8080}")
private int serverPort;
public GracefulShutdownHook(NacosDiscoveryProperties nacosDiscoveryProperties) {
this.nacosDiscoveryProperties = nacosDiscoveryProperties;
}
@Override
public void destroy() throws Exception {
log.info("======= 开始执行优雅停机 =======");
// 步骤1:从Nacos注销实例,通知其他服务不要再路由到本实例
deregisterFromNacos();
// 步骤2:等待一段时间,确保其他服务的LoadBalancer缓存已更新
// 其他服务LoadBalancer默认缓存35秒,但注销后Push通知是实时的
// 等3秒让in-flight的Feign调用能接收到响应
log.info("等待3秒,确保Nacos客户端缓存更新完毕...");
Thread.sleep(3000);
log.info("======= 优雅停机准备完毕,等待Spring容器关闭 =======");
// 注意:Spring Boot的graceful shutdown会等待in-flight请求
// 本方法只是提前触发Nacos注销,其他清理由Spring容器的关闭钩子负责
}
private void deregisterFromNacos() {
try {
NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
String ip = nacosDiscoveryProperties.getIp();
if (ip == null || ip.isEmpty()) {
// 如果没有配置IP,使用本机IP
ip = java.net.InetAddress.getLocalHost().getHostAddress();
}
namingService.deregisterInstance(
applicationName,
nacosDiscoveryProperties.getGroup(),
ip,
serverPort
);
log.info("已从Nacos注销,serviceName={},ip={},port={}", applicationName, ip, serverPort);
} catch (Exception e) {
log.error("从Nacos注销失败,但不影响停机流程", e);
}
}
}3.5 线程池的优雅停机配置
package com.laozhang.graceful.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Slf4j
@Configuration
public class GracefulThreadPoolConfig {
@Bean("bizThreadPool")
public Executor bizThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("biz-thread-");
// 优雅停机关键配置:
// 关闭时等待已提交的任务完成(不接受新任务,但等现有任务跑完)
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待的最大时间,超时强制关闭
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}3.6 就绪状态管理(预热期间拒绝流量)
package com.laozhang.graceful;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 应用就绪状态管理
* 可以通过发送AvailabilityChangeEvent来控制Pod是否接受流量
* K8s readinessProbe探测/actuator/health/readiness
*/
@Slf4j
@Component
public class ReadinessManager {
private final ApplicationContext applicationContext;
public ReadinessManager(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
* 手动把服务标记为不就绪
* K8s会停止把流量路由到这个Pod(在停机前调用)
*/
public void markAsNotReady(String reason) {
log.info("标记服务为不就绪状态,原因:{}", reason);
AvailabilityChangeEvent.publish(
applicationContext,
ReadinessState.REFUSING_TRAFFIC
);
}
/**
* 手动把服务标记为就绪
* 预热完成后调用
*/
public void markAsReady() {
log.info("标记服务为就绪状态");
AvailabilityChangeEvent.publish(
applicationContext,
ReadinessState.ACCEPTING_TRAFFIC
);
}
/**
* 预热完成事件监听
*/
@EventListener
public void onWarmupComplete(WarmupCompleteEvent event) {
log.info("应用预热完成,开始接受流量");
markAsReady();
}
}四、生产配置与调优
4.1 完整的优雅停机时间规划
terminationGracePeriodSeconds = PreStop时间 + Spring关闭时间 + 缓冲
= 15s (PreStop sleep) + 30s (graceful shutdown) + 10s (缓冲)
= 55s → 设置为60s4.2 滚动更新策略优化
strategy:
type: RollingUpdate
rollingUpdate:
# 最多同时停掉多少个旧Pod
maxUnavailable: 0
# 最多同时新建多少个Pod(超出replicas数量的额外Pod)
maxSurge: 1设置maxUnavailable: 0确保滚动更新期间始终有足够的健康Pod可以处理请求,不会出现服务降级。
五、踩坑实录
坑一:PreStop钩子执行时间超过terminationGracePeriodSeconds,Pod被强制SIGKILL。
K8s的terminationGracePeriodSeconds是从Pod收到删除信号开始计时的,PreStop钩子的时间也算在内。如果PreStop钩子执行了30秒,Spring Boot优雅停机还要30秒,加起来60秒,如果terminationGracePeriodSeconds也设成60秒,就会超时被SIGKILL。
解决方案:terminationGracePeriodSeconds必须大于PreStop时间加Spring关闭时间的总和,留10秒以上的缓冲。
坑二:Nacos注销了但LoadBalancer缓存没更新,仍然有流量进来。
Nacos注销后,客户端的LoadBalancer本地缓存是有TTL的,在TTL内仍然会把请求发到已注销的实例上。Nacos 2.x有Push机制,注销后会主动推送给所有订阅了该服务的客户端,通常几秒内就能感知。但如果客户端Nacos连接不稳定,Push可能失败,这时候就要等到缓存TTL过期。
保险起见,在Nacos注销后等待一段时间(5-10秒),确保大部分客户端已经感知到,然后再开始Spring Boot关闭。
坑三:线程池没有配置waitForTasksToCompleteOnShutdown,异步任务被强制中断。
Spring Boot的server.shutdown: graceful只等待HTTP请求的处理线程,不等待自定义线程池里的任务。如果有异步任务(比如异步发送通知、写日志),这些任务在关闭时会被强制中断。
必须在自定义线程池配置里设置waitForTasksToCompleteOnShutdown=true和awaitTerminationSeconds。
坑四:在PreStop钩子里调用了Actuator Shutdown端点,结果Spring提前关闭。
有人在PreStop钩子里调用/actuator/shutdown来触发Spring关闭,但这个端点触发的是立即关闭,不等待HTTP请求。正确的做法是不要在PreStop钩子里显式触发关闭,K8s在PreStop执行完之后会自动发送SIGTERM,Spring Boot的ShutdownHook会响应SIGTERM并执行优雅关闭。
六、总结
K8s环境下微服务优雅停机需要三层协作:K8s层配置PreStop钩子和足够的terminationGracePeriodSeconds,Spring Boot层开启graceful shutdown,应用层主动Nacos注销并等待缓存更新。三层都做好了,滚动发版期间的错误率能降到接近0。
核心时间计算:terminationGracePeriodSeconds必须大于PreStop时间加Spring关闭时间之和,留缓冲。线程池必须配waitForTasksToCompleteOnShutdown,否则异步任务会被强制中断。
