第1928篇:AI服务的优雅启动与关闭——避免流量损失的生命周期管理
第1928篇:AI服务的优雅启动与关闭——避免流量损失的生命周期管理
我第一次重视AI服务生命周期管理,是因为一次非常难看的故障。
当时我们在做一个例行的滚动发布,用的是Kubernetes,配置了rollingUpdate策略,旧Pod下线、新Pod上线。按道理这是很安全的。
但那次发布完,我们的大模型服务的成功率从99.5%掉到了94%,整整持续了8分钟。
排查日志,发现两类错误:
- 一批请求打到了新Pod,但新Pod的模型还没加载完,直接返回了503
- 另一批请求打到了旧Pod,但旧Pod已经在关闭过程中,直接返回了连接拒绝
这两个问题,说起来都很基础:新Pod没就绪就接流量,旧Pod有请求还没处理就被杀掉。但放到AI服务上,因为模型加载慢、推理耗时长,这两个问题都被放大了很多倍。
今天这篇,就好好讲讲AI服务的生命周期管理该怎么做。
AI服务生命周期的特殊性
普通Java微服务,从启动到就绪可能就几十秒。AI服务,光模型加载就可能要几分钟(特别是大参数量的模型),再加上JIT编译和CUDA预热,整个启动流程可能需要5-10分钟。
这意味着:
readinessProbe的探测等待时间要相应加长- 滚动发布时新Pod加入的时机要更保守
- 优雅关闭的等待时间要够长(因为AI推理请求本身耗时较长)
优雅启动:分阶段的就绪上报
启动就绪不是一个时刻,而是一个过程。我把AI服务的启动分为四个阶段,对外上报不同的就绪状态:
@Component
public class AIServiceLifecycleManager implements SmartLifecycle {
private volatile StartupPhase currentPhase = StartupPhase.INITIALIZING;
private final AtomicBoolean running = new AtomicBoolean(false);
@Autowired
private ModelLoader modelLoader;
@Autowired
private ModelWarmer modelWarmer;
@Autowired
private NacosServiceRegistry nacosRegistry;
@Autowired
private TrafficManager trafficManager;
@Override
public void start() {
running.set(true);
// 异步启动,不阻塞Spring容器初始化
CompletableFuture.runAsync(this::performStartupSequence)
.exceptionally(e -> {
log.error("启动失败", e);
currentPhase = StartupPhase.FAILED;
return null;
});
}
private void performStartupSequence() {
// 第一阶段:模型加载
currentPhase = StartupPhase.LOADING_MODEL;
log.info("阶段1: 开始加载模型...");
long loadStart = System.currentTimeMillis();
modelLoader.loadModel();
log.info("阶段1: 模型加载完成,耗时{}ms", System.currentTimeMillis() - loadStart);
// 第二阶段:连接检查(数据库、Redis、向量库等)
currentPhase = StartupPhase.CHECKING_DEPENDENCIES;
log.info("阶段2: 检查依赖连接...");
checkDependencies();
// 第三阶段:模型预热(几次推理让JIT稳定)
currentPhase = StartupPhase.WARMING_UP;
log.info("阶段3: 模型预热...");
WarmupResult warmupResult = modelWarmer.warmup();
log.info("阶段3: 预热完成,基准延迟: {}ms", warmupResult.getBaselineLatencyMs());
// 第四阶段:正式就绪,向注册中心上报
currentPhase = StartupPhase.READY;
log.info("阶段4: AI服务就绪,开始接收流量");
// 向Nacos注册并标记为ready
nacosRegistry.register();
trafficManager.enableTraffic();
}
// 对外暴露当前阶段,供健康检查端点使用
public StartupPhase getCurrentPhase() {
return currentPhase;
}
public boolean isReady() {
return currentPhase == StartupPhase.READY;
}
@Override
public void stop() {
// 在stop()里做优雅关闭
gracefulShutdown();
running.set(false);
}
@Override
public boolean isRunning() {
return running.get();
}
@Override
public int getPhase() {
return Integer.MAX_VALUE; // 最晚启动,最早关闭
}
@Override
public boolean isAutoStartup() {
return true;
}
}
enum StartupPhase {
INITIALIZING,
LOADING_MODEL,
CHECKING_DEPENDENCIES,
WARMING_UP,
READY,
FAILED,
SHUTTING_DOWN
}健康检查端点:精细的就绪状态上报
Kubernetes的readinessProbe要能区分"正在启动"和"启动失败":
@RestController
@RequestMapping("/actuator")
public class LifecycleHealthController {
@Autowired
private AIServiceLifecycleManager lifecycleManager;
@Autowired
private ModelLoader modelLoader;
@Autowired
private GpuMonitor gpuMonitor;
/**
* Liveness探针:进程是否存活
* Kubernetes会在这个接口返回失败时重启Pod
* 只检查最基本的JVM状态,不检查业务
*/
@GetMapping("/liveness")
public ResponseEntity<Map<String, Object>> liveness() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", "UP");
result.put("phase", lifecycleManager.getCurrentPhase());
result.put("timestamp", System.currentTimeMillis());
return ResponseEntity.ok(result);
}
/**
* Readiness探针:是否可以接收流量
* Kubernetes会在这个接口返回失败时把Pod从Service中摘除
*/
@GetMapping("/readiness")
public ResponseEntity<Map<String, Object>> readiness() {
Map<String, Object> result = new LinkedHashMap<>();
boolean ready = lifecycleManager.isReady();
result.put("ready", ready);
result.put("phase", lifecycleManager.getCurrentPhase());
if (!ready) {
// 不ready时,告诉调用方当前在哪个阶段,便于排查
result.put("reason", buildNotReadyReason());
} else {
// ready时,附加一些诊断信息
result.put("gpu.utilization",
String.format("%.1f%%", gpuMonitor.getGpuUtilization() * 100));
result.put("model.loaded", modelLoader.isModelLoaded());
}
return ready
? ResponseEntity.ok(result)
: ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(result);
}
/**
* Startup探针:专门用于检测启动是否完成
* 和readiness的区别:startup探针在整个启动期间一直检查
* readiness探针在启动完成后定期检查
*/
@GetMapping("/startup")
public ResponseEntity<Map<String, Object>> startup() {
StartupPhase phase = lifecycleManager.getCurrentPhase();
boolean started = phase == StartupPhase.READY;
Map<String, Object> result = Map.of(
"started", started,
"phase", phase,
"progress", estimateStartupProgress(phase)
);
return started
? ResponseEntity.ok(result)
: ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(result);
}
private String buildNotReadyReason() {
return switch (lifecycleManager.getCurrentPhase()) {
case LOADING_MODEL -> "模型加载中,请等待...";
case CHECKING_DEPENDENCIES -> "检查依赖连接中...";
case WARMING_UP -> "模型预热中,即将就绪...";
case FAILED -> "启动失败,请检查日志";
case SHUTTING_DOWN -> "服务正在关闭中";
default -> "初始化中...";
};
}
private int estimateStartupProgress(StartupPhase phase) {
return switch (phase) {
case INITIALIZING -> 5;
case LOADING_MODEL -> 40;
case CHECKING_DEPENDENCIES -> 70;
case WARMING_UP -> 90;
case READY -> 100;
default -> 0;
};
}
}Kubernetes配置:精准的探针配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-inference-service
spec:
template:
spec:
containers:
- name: ai-inference
image: ai-inference:latest
resources:
limits:
nvidia.com/gpu: 1
memory: "16Gi"
requests:
nvidia.com/gpu: 1
memory: "16Gi"
# Startup探针:在服务完全启动之前持续检查
# failureThreshold * periodSeconds = 最长等待时间
# 300 * 10s = 3000s = 50分钟,足够大型模型加载
startupProbe:
httpGet:
path: /actuator/startup
port: 8080
initialDelaySeconds: 30 # 容器启动30秒后开始检查
periodSeconds: 10 # 每10秒检查一次
failureThreshold: 300 # 失败300次后认为启动失败(约50分钟)
successThreshold: 1
# Readiness探针:服务启动后的就绪状态检查
readinessProbe:
httpGet:
path: /actuator/readiness
port: 8080
initialDelaySeconds: 0
periodSeconds: 5 # 每5秒检查一次
failureThreshold: 3 # 连续失败3次就摘除流量
successThreshold: 2 # 需要连续成功2次才重新接流量
# Liveness探针:进程存活检查(宽松设置,避免频繁重启)
livenessProbe:
httpGet:
path: /actuator/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
failureThreshold: 3
successThreshold: 1
# 优雅关闭:给30秒处理存量请求
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
# preStop sleep是为了等待Endpoints更新
# 在Pod开始关闭之前,给负载均衡器时间更新路由
# Pod级别的优雅关闭超时
terminationGracePeriodSeconds: 120 # 给120秒优雅关闭时间这里有一个经常被误解的点:terminationGracePeriodSeconds是Pod从收到SIGTERM到被强制kill的时间。它需要比preStop的sleep时间 + 应用优雅关闭的时间之和要长。
优雅关闭:不丢任何进行中的推理请求
@Component
@Slf4j
public class GracefulShutdownHandler {
@Autowired
private TrafficManager trafficManager;
@Autowired
private NacosServiceRegistry nacosRegistry;
@Autowired
private InFlightRequestTracker requestTracker;
@Autowired
private ModelUnloader modelUnloader;
private static final Duration MAX_GRACEFUL_WAIT = Duration.ofSeconds(90);
private static final Duration CHECK_INTERVAL = Duration.ofMillis(500);
/**
* 优雅关闭流程
* 由Spring的@PreDestroy或Lifecycle.stop()触发
*/
public void performGracefulShutdown() {
log.info("=== 开始优雅关闭 ===");
long shutdownStart = System.currentTimeMillis();
// Step 1: 停止接收新请求
// 先停止新流量进来,在等存量请求完成
log.info("Step 1: 停止接收新流量");
trafficManager.disableNewTraffic();
// 设置标志,所有新请求返回503
RequestAcceptanceFilter.setAccepting(false);
// Step 2: 从注册中心摘除(让上游停止路由到本实例)
log.info("Step 2: 从Nacos注销");
try {
nacosRegistry.deregister();
} catch (Exception e) {
log.warn("从Nacos注销失败,继续关闭: {}", e.getMessage());
}
// Step 3: 等待负载均衡器感知到实例已下线
// Nacos推送有延迟,上游可能还在把请求打过来
// 等15秒让上游感知到
log.info("Step 3: 等待负载均衡更新(15秒)");
sleep(15_000);
// Step 4: 等待存量推理请求完成
log.info("Step 4: 等待存量请求完成,当前活跃请求数: {}",
requestTracker.getActiveCount());
long waitStart = System.currentTimeMillis();
while (requestTracker.getActiveCount() > 0) {
long elapsed = System.currentTimeMillis() - waitStart;
if (elapsed > MAX_GRACEFUL_WAIT.toMillis()) {
log.warn("等待超时!仍有{}个请求未完成,强制关闭",
requestTracker.getActiveCount());
// 强制中断剩余请求(这里会有少量失败)
requestTracker.forceCompleteAll();
break;
}
log.info("等待请求完成中... 活跃请求: {}, 已等待: {}ms",
requestTracker.getActiveCount(), elapsed);
sleep(CHECK_INTERVAL.toMillis());
}
log.info("Step 4: 所有请求处理完毕");
// Step 5: 释放GPU资源
log.info("Step 5: 释放GPU资源");
try {
modelUnloader.unload();
} catch (Exception e) {
log.warn("GPU资源释放失败: {}", e.getMessage());
}
long totalShutdownTime = System.currentTimeMillis() - shutdownStart;
log.info("=== 优雅关闭完成,总耗时{}ms ===", totalShutdownTime);
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}请求追踪器:知道有多少请求正在处理
@Component
public class InFlightRequestTracker {
private final AtomicInteger activeCount = new AtomicInteger(0);
private volatile boolean shutdownMode = false;
public void trackRequest(Runnable task, Runnable onComplete) {
if (shutdownMode) {
throw new ServiceShuttingDownException("服务正在关闭,拒绝新请求");
}
activeCount.incrementAndGet();
try {
task.run();
} finally {
activeCount.decrementAndGet();
onComplete.run();
}
}
public int getActiveCount() {
return activeCount.get();
}
public void enterShutdownMode() {
shutdownMode = true;
}
public void forceCompleteAll() {
// 强制完成:这里可以向所有进行中的请求发送中断信号
// 具体实现取决于推理框架
log.warn("强制中断{}个未完成请求", activeCount.get());
activeCount.set(0);
}
}预热:不只是多跑几次推理
模型预热这件事,很多人的实现是"启动时跑几次推理就算了"。但我发现更有效的预热策略,是用与真实流量接近的数据来做预热,而不是随便找几条测试数据。
@Component
public class SmartModelWarmer {
@Autowired
private InferenceService inferenceService;
@Autowired
private TrafficPatternAnalyzer patternAnalyzer;
public WarmupResult warmup() {
log.info("开始智能预热...");
// 从历史流量中找出最有代表性的预热样本
// 覆盖不同输入长度、不同任务类型
List<WarmupSample> samples = patternAnalyzer.getRepresentativeSamples(30);
List<Long> latencies = new ArrayList<>();
for (int round = 0; round < 3; round++) {
log.info("预热第{}轮", round + 1);
for (WarmupSample sample : samples) {
long start = System.currentTimeMillis();
try {
inferenceService.infer(sample.getRequest());
latencies.add(System.currentTimeMillis() - start);
} catch (Exception e) {
log.warn("预热请求失败: {}", e.getMessage());
}
}
}
// 检查预热是否有效:最后一轮的延迟应该显著低于第一轮
if (latencies.size() >= 2) {
int quarterSize = latencies.size() / 4;
double firstQuarterAvg = latencies.subList(0, quarterSize).stream()
.mapToLong(Long::longValue).average().orElse(0);
double lastQuarterAvg = latencies.subList(latencies.size() - quarterSize,
latencies.size()).stream()
.mapToLong(Long::longValue).average().orElse(0);
log.info("预热效果: 初始延迟{:.0f}ms → 预热后{:.0f}ms,提升{:.1f}%",
firstQuarterAvg, lastQuarterAvg,
(1 - lastQuarterAvg / firstQuarterAvg) * 100);
}
// 取最后10次推理的平均延迟作为基准
double baselineLatency = latencies.subList(
Math.max(0, latencies.size() - 10), latencies.size()
).stream().mapToLong(Long::longValue).average().orElse(0);
return new WarmupResult(baselineLatency, latencies.size());
}
}交通模式分析器:找代表性样本
@Component
public class TrafficPatternAnalyzer {
@Autowired
private RequestLogRepository requestLogRepo;
/**
* 从历史请求日志中,挑选具有代表性的预热样本
* 要覆盖不同的输入长度分布
*/
public List<WarmupSample> getRepresentativeSamples(int count) {
// 从最近1000条请求中抽样
List<RequestLog> recentLogs = requestLogRepo.findRecent(1000);
// 按输入长度分桶
Map<String, List<RequestLog>> buckets = recentLogs.stream()
.collect(Collectors.groupingBy(log -> {
int len = log.getInputLength();
if (len < 100) return "short";
if (len < 500) return "medium";
if (len < 2000) return "long";
return "very_long";
}));
List<WarmupSample> samples = new ArrayList<>();
// 从每个桶中各取几条样本
int perBucket = count / buckets.size();
for (Map.Entry<String, List<RequestLog>> bucket : buckets.entrySet()) {
bucket.getValue().stream()
.limit(perBucket)
.map(log -> new WarmupSample(log.getRequest(), bucket.getKey()))
.forEach(samples::add);
}
return samples;
}
}滚动发布中的流量切换
滚动发布时,需要协调新旧Pod的流量切换,避免同时有太多新Pod在预热:
# 滚动发布策略
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 同时最多新增1个Pod
maxUnavailable: 0 # 不允许减少可用Pod数量(零宕机发布)maxSurge: 1配合AI服务的长启动时间,意味着每次只会有一个新Pod在启动。等这个新Pod通过了startupProbe(模型加载+预热完成),才会开始关闭一个旧Pod。这样整个发布过程中,始终有足够的健康Pod在服务。
金丝雀发布:新版本先接5%的流量
对于大版本更新,可以先让新版本接少量流量验证效果:
@Component
public class CanaryTrafficController {
@Autowired
private NacosNamingService nacosNaming;
/**
* 将新版本实例的权重设为5(旧版本100)
* 使新版本只接收约5%的流量
*/
public void setCanaryWeight(String serviceId, String instanceId, int weight) {
try {
Instance instance = nacosNaming.selectOneHealthyInstance(serviceId);
instance.setWeight(weight);
// 更新实例权重
nacosNaming.registerInstance(serviceId, instance);
log.info("已设置实例{}的流量权重为{}", instanceId, weight);
} catch (Exception e) {
log.error("设置金丝雀权重失败: {}", e.getMessage());
}
}
/**
* 监控金丝雀版本的错误率
* 如果错误率超过阈值,自动回滚
*/
@Scheduled(fixedDelay = 30_000)
public void monitorCanaryHealth() {
CanaryMetrics metrics = getCanaryMetrics();
if (metrics.getErrorRate() > 0.05) { // 错误率超过5%
log.error("金丝雀版本错误率{}%,触发自动回滚",
metrics.getErrorRate() * 100);
rollbackCanary();
}
}
}踩坑记录
几个我实际碰到过的坑:
坑一:terminationGracePeriodSeconds设太小。我们设了30秒,但AI推理请求可能需要20秒才能完成。在高并发下,Pod被kill时还有很多请求没完成,全部报错。改成120秒后彻底解决。
坑二:readinessProbe和startupProbe混用。有一次我们只用了readinessProbe,设了很大的failureThreshold(300次)来等待模型加载。结果模型加载失败时,300次 × 10秒 = 50分钟后Kubernetes才认定启动失败。如果同时配了startupProbe和readinessProbe,startupProbe成功后readinessProbe才会开始工作,两者职责分离更清晰。
坑三:preStop没有等待Endpoints更新。preStop执行时,Kubernetes开始关闭Pod,但Endpoints的更新是异步的,负载均衡器可能还没感知到这个Pod要下线,还在往它发请求。加了15秒的preStop sleep后,基本解决了这个问题。
坑四:预热用的测试数据太简单,线上长文本推理时还是慢。预热时用了很多"今天天气不错"这样的短文本,JIT只优化了短输入的执行路径,长文本推理时JIT重新开始编译优化,又有一波慢推理。改用覆盖各种长度的真实数据做预热后,这个问题消失了。
小结
AI服务的生命周期管理,关键是承认一个事实:AI服务启动慢、关闭有风险。
不能用对待普通服务的方式对待它,必须:
- 用startup探针替代过长的initialDelay,给足启动时间
- 预热要充分,覆盖真实的流量分布
- 优雅关闭要等待所有推理请求完成,不能暴力终止
- terminationGracePeriodSeconds要比推理最大耗时长得多
做好这些,AI服务的发布就不会再是"每次发布都心惊胆战"的体验了。
