降SpringAI阿里第18掌-神龙摆尾-登云K8s
降SpringAI阿里第18掌-神龙摆尾-登云K8s
本地 Compose 跑得欢,一上 K8s 长连接全断、会话全丢。
[读者] 已经把数字人 Agent 服务跑在单机 Docker 的 Java 后端
[痛点] 长连接一滚动就断、状态绑死 Pod、回滚没有抓手
[现在读] 第17掌的指标已经就位,该把服务真正推上生产集群
[读完] 拿到一套可滚动、可回滚、可恢复的 K8s 上线闭环
[旧方案] docker compose up,单机四容器,状态全在容器本地
|
v
[新需求] 多副本、滚动发布、故障自愈、随时可回滚
|
v
[冲突] 长连接有粘性、状态留在本地盘、启动慢于探针耐心
|
v
[后果] 升级即断流,Pod 重启即丢会话,出事只能整机重来
我是老李,上一掌把指标和面板立起来之后,收到的问题反而更尖锐了:面板很漂亮,但服务只在单机上。三个数字人项目、几十个 Web 运行页、十几个 LiveKit 房间,全压在一台 8C16G 的机器上,一重启就是全员掉线。
这套系统的基线能力不算复杂:注册登录、数字人项目 CRUD、标题主题色背景图、开场白结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互。复杂的是它的连接形态——Web 运行页走 SSE 流式回包,实时交互走 WebSocket 与 LiveKit 的 WebRTC 信令,Agent 之间还有 A2A 调用。这些连接天生不喜欢被切断。
第 15 掌写的评测、第 17 掌接的指标,本来就是为了这一刻:上线质量不是靠“感觉稳”,而是靠测试与指标说话。本章沿用 alibaba/spring-ai-alibaba v1.1.2.2、JDK 21 LTS,分支 chapter/18-k8s-production,参考官方主仓库、DataAgent 与 deepresearch 两个官方应用的部署形态,K8s 侧以官方文档为准。
那,多副本到底难在哪?
是把 replicas 从 1 改成 3 就行,还是必须先把状态搬出 Pod?
如果先加副本、后搬状态,会付出什么代价?
01、故事
上线前最后一次内部评审,运维同学问了一个很朴素的问题:发一次版要多久。我算了一下:docker compose down、docker compose up -d --build,镜像构建 4 分钟,服务重启 40 秒。这 40 秒里,所有正在跟数字人对话的用户都会看到连接中断,页面上弹出一句“网络异常,请重试”。
任务是把这套数字人 Agent 服务推上 Kubernetes,并且做到发版不掉线、Pod 挂了能自愈、发错了能回滚。听起来只是“换一个编排工具”,实际做起来完全不是。
当前方案的问题在第 3 掌的底座里就埋下了:为了让 V1 跑得快,Session 存在内存里,Memory 存在内存里,Graph Checkpoint 写在容器本地文件系统,连 Nacos 都是单容器跑在同一台机器。这套结构在单机上是优点——“开箱即用,零外部依赖”;在多副本环境里立刻变成致命伤:请求打到 A 副本,会话在 B 副本,用户看到的是“AI 失忆”。
第一个坑出现在本地 kind 集群里。我把 replicas 设成 3,滚动升级,结果升级过程中 Web 运行页的 SSE 流全部断开,客户端疯狂重连,重连后又打到了新 Pod,上下文对不上,用户直接看到“请重新开始”。那一刻我意识到:这不是一个部署问题,这是一个状态归属问题。
第二个坑紧接着来。为了让探针尽快通过,我把 readinessProbe 的 initialDelaySeconds 设得很小,结果 Pod 刚起来、还没连上 Nacos 和 Redis,就被打进流量,接口 500。livenessProbe 又因为一次模型 API 超时被拖挂,Pod 被反复重启,整个 Deployment 陷入震荡。
第三个坑最贵:模型 API 触发限流后,Pod 内线程池被慢调用占满,readiness 开始失败,K8s 认为实例不健康并开始摘流量,剩余 Pod 承担全部压力,然后一起挂。这就是典型的多副本耦合坍塌。
单机跑得顺
多副现原形
连接有脾气
状态会认人
02、问题
旧方案的失效不是局部的,是结构性的。docker compose 的模型默认“一个服务等于一个容器实例”,它没有就绪探针、没有滚动策略、没有优雅退出窗口、没有声明式回滚。一旦把它套到需要持续在线、需要多副本、需要长连接的 Agent 服务上,四个能力全部缺失。
业务影响很直接:发版必须挑凌晨、必须公告停机、必须留人值守;扩容只能加机器不能加副本,因为加副本会让会话错乱;单点故障无法自愈,容器挂了只能人工上去重启。
技术表现可以归纳成四类可观测症状:升级期间 SSE 连接成功率下降;Pod 重启后同一会话的上下文丢失;服务发现里出现已经终止的实例地址;模型 API 限流时整个 Deployment 一起不可用。
所以本篇要交付的完成标准必须是可验证的,而不是“看起来上了 K8s”。我给自己定了五条:
第一条,镜像可复现:多阶段构建、非 root 运行、镜像内容不随构建机变化。
第二条,Pod 可被正确判定:startup、readiness、liveness 三个探针语义正确,冷启动不被误杀,不健康不被放行。
第三条,状态可外置:Checkpoint、Memory、Session 全部落在 Pod 之外的存储上,Pod 无状态可替换。
第四条,滚动可无损:maxUnavailable 为 0 的前提下完成一次版本切换,在途请求不被硬切断。
第五条,回滚可执行:一条命令回到上一个镜像,且不需要回滚数据库结构。
旧法已见底
新标要可验
不先定边界
升级必翻车
03、原理
K8s 里最容易被误解的一点是:Pod 不是“进程的容器”,而是“一次调度与生命周期的单位”。它随时可以被创建、被驱逐、被替换,这是设计意图,不是异常。任何把重要状态写进 Pod 本地文件系统或 JVM 堆内存的做法,都是在跟这个设计意图对抗。
反直觉判断:在多副本系统里,真正需要被保护的不是 Pod 的存活,而是“请求与状态的一致性”。你把 Pod 做得越不容易死,往往越掩盖状态耦合的真相;你允许 Pod 随便死,反而逼你把状态放对位置。
第二个必须理解的机制是探针的语义分层。startupProbe 负责“允许你慢慢启动”,它在通过之前会抑制 livenessProbe,专门解决 JVM 冷启动慢的问题。readinessProbe 负责“是否可以接流量”,失败时只是从 Service 端点中摘除,不重启进程。livenessProbe 负责“是否需要重启”,它必须只反映进程死锁、不可恢复的状态,绝不能包含任何外部依赖的健康——把模型 API 超时写进 liveness,等于把一个外部故障放大成全集群震荡。
第三个机制是终止流程的时序。K8s 删除 Pod 时并发做两件事:从 Service 端点摘除,和向容器发送 SIGTERM。这两件事没有先后保证。如果进程收到 SIGTERM 立刻退出,而端点摘除还在传播,那么在这段窗口里,Ingress 仍会把新请求送到已经退出的进程上。这就是必须配置优雅退出窗口与 preStop 的原因。
第四个机制是外置状态的一致性边界。Graph Checkpoint 是“可重放的执行快照”,必须持久化;Memory 与 Session 是“用户可感知的对话连续性”,必须共享;而模型客户端的连接池、模板缓存、本地配置,属于可重建的派生物,允许留在 Pod 内。把这两类东西区分清楚,Pod 就天然无状态。
进程是过客
数据才是家
探针非装饰
退出即协议
04、架构
[输入] 浏览器 SSE/WS + LiveKit 音频信令 + A2A 调用
|
v
[模块] Ingress → Service → Agent Pod(Spring Boot 应用)
|
v
[数据/状态] Redis(Session/Memory)+ MySQL(Checkpoint)+ Vector DB + Nacos + LiveKit
|
v
[处理] ChatClient / Tool / MCP / Graph 执行,事件经 SSE 回推
|
v
[输出] 流式回答 + 数字人音视频 + 指标与日志
这个链路的边界很清楚:K8s 只负责 Agent 这一层无状态计算。数据库、缓存、向量库、注册中心、实时音视频全部外置——官方示例应用中 DataAgent 与 deepresearch 这类项目也是按“应用容器 + 外部依赖”的形态组织的,具体清单与依赖项需按各自仓库当前文档核验。在本地 kind 环境里,MySQL/Redis/Nacos 可以用 StatefulSet 或独立容器模拟;到云上则建议直接换成托管实例,不要自己维护有状态中间件。
收益是明确的:Agent 层可以任意扩缩、任意重启、任意替换,Pod 变成一次性资源。代价同样明确:外部依赖从 0 个变成 4~5 个,每个都是一个潜在的故障点和延迟来源;本地开发必须能连上这些依赖,否则启动就失败;网络分区时,一个连不上 Nacos 的 Pod 到底是“不该接流量”还是“该重启”,需要在探针上做严格区分。
适用条件也需要写清楚。这套架构适用于“有状态但状态可外置”的服务。如果某个状态天然只能存在本地(例如浏览器直连的本地音频缓冲、GPU 显存内的模型权重、本地磁盘上的大文件),就必须用会话亲和(sessionAffinity)或专门的连接网关来处理,而不是假装它无状态。
入口收流量
中间跑无状
外置存关键
出口回事件
05、实战一次
本节给出最小但完整的一套 V1。以下配置与命令未在当前环境实测,以下为预期结果;示例输出仅用于说明形态,不代表真实运行数据。
环境与版本:JDK 21 LTS、Spring Boot 3.x、alibaba/spring-ai-alibaba v1.1.2.2、kind 或 minikube、kubectl。Spring AI Alibaba 的依赖坐标与版本以 v1.1.2.2 的 BOM 为准,需按当前官方文档核验。
第一步,多阶段、非 root 镜像。构建阶段用完整 JDK,运行阶段只带 JRE;显式创建非 root 用户并切换;JVM 用容器感知参数,避免默认看到宿主机内存而按整机大小设堆。
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -q -DskipTests package
FROM eclipse-temurin:21-jre
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app app
WORKDIR /app
COPY --from=builder --chown=10001:10001 /build/target/*.jar app.jar
USER 10001
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseG1GC", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "/app/app.jar"]
第二步,应用侧开启优雅退出与探针端点。
server:
port: 8080
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
第三步,最小 K8s 组件清单。Deployment 用 maxUnavailable 0 保证任何时刻都有足量可用副本;preStop 留出摘流量与在途请求收尾的时间。
apiVersion: apps/v1
kind: Deployment
metadata:
name: saa-agent
namespace: digital-human
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: saa-agent
template:
metadata:
labels:
app: saa-agent
spec:
terminationGracePeriodSeconds: 60
containers:
- name: agent
image: registry.example.com/digital-human/saa-agent:0.18.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: saa-agent-config
- secretRef:
name: saa-agent-secret
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "2Gi"
startupProbe:
httpGet:
path: /actuator/health/readiness
port: http
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
第四步,Service、ConfigMap 与 Secret。配置项走 ConfigMap,密钥走 Secret,绝不打进镜像。
apiVersion: v1
kind: Service
metadata:
name: saa-agent
namespace: digital-human
spec:
selector:
app: saa-agent
ports:
- name: http
port: 80
targetPort: http
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: saa-agent-config
namespace: digital-human
data:
SPRING_PROFILES_ACTIVE: "prod"
SPRING_DATA_REDIS_HOST: "redis.internal"
SPRING_DATASOURCE_URL: "jdbc:mysql://mysql.internal:3306/digital_human"
NACOS_SERVER_ADDR: "nacos.internal:8848"
LIVEKIT_URL: "wss://livekit.internal"
---
apiVersion: v1
kind: Secret
metadata:
name: saa-agent-secret
namespace: digital-human
type: Opaque
stringData:
SPRING_AI_DASHSCOPE_API_KEY: "<your-api-key>"
SPRING_DATASOURCE_PASSWORD: "<your-db-password>"
LIVEKIT_API_SECRET: "<your-livekit-secret>"
第五步,Ingress。长连接必须在网关层放开超时并关掉缓冲,否则 SSE 会被缓冲成“一次性返回”。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: saa-agent
namespace: digital-human
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
spec:
ingressClassName: nginx
rules:
- host: agent.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: saa-agent
port:
number: 80
启动与验证,用 kind 先跑通:
kind create cluster --name saa-ch18
kubectl create namespace digital-human
kubectl -n digital-human apply -f k8s/
kubectl -n digital-human rollout status deploy/saa-agent --timeout=300s
kubectl -n digital-human get pods -o wide
示例输出:
NAME READY STATUS RESTARTS AGE
aa-agent-6b9c7f8d4-2xk9p 1/1 Running 0 92s
saa-agent-6b9c7f8d4-7p4qt 1/1 Running 0 92s
saa-agent-6b9c7f8d4-lm8vd 1/1 Running 0 92s
到这里 V1 能跑通,但留下的问题很清楚:复制的会话还在内存里,探针阈值是拍脑袋定的,网关超时是照抄的。这些必须在下一章被验证,而不是被假设。
镜像分两段
非root落地
清单先最小
跑通再谈稳
06、排查
第一条链路:滚动升级期间 SSE 大量中断。
现象:执行 set image 触发滚动升级,Web 运行页的流在 3~5 秒内集中断开,客户端重连率飙升。
怀疑:旧 Pod 被硬杀,在途请求没有收尾时间。
检查:对比 Describe Pod 里的 terminationGracePeriodSeconds、preStop 配置,以及应用日志中收到 SIGTERM 到进程退出的时间差。
证据:日志显示进程在收到终止信号后不到 1 秒即结束,且没有出现优雅关闭的收尾日志;说明 server.shutdown 未生效或根本没配。
根因:终止时序缺少“摘流量 → 停止接新请求 → 等在途请求完成 → 退出”的完整链路,SIGTERM 直接触发了进程结束。
修复:开启 graceful shutdown 并设置合理的 shutdown phase 超时,配合 preStop 延迟,使端点摘除与进程退出之间形成缓冲。
错误尝试: 直接把 terminationGracePeriodSeconds 从 30 提到 300。为什么错:这个参数只是“允许你慢慢退出的上限”,它不会让应用真的慢下来。应用仍会在收到信号后立刻结束,只是把超时上限撑大,等于给一个不优雅的进程留了更多无意义的等待时间,问题一点没解决。
第二条链路:Pod 重启后用户上下文丢失。
现象:任意一个 Pod 重启后,部分用户的数字人“忘了刚才说过什么”,开场白重新播放。
怀疑:会话状态存在 JVM 内存或 Pod 本地磁盘。
检查:查看 Graph Checkpoint 与 Memory 的写入目标,确认是否落到了容器本地路径或内存实现。
证据:本地文件系统里出现了 checkpoint 目录,且 Pod 重建后该目录为空;同一用户连续两次请求落到不同 Pod 时,返回的上下文完全不同。
根因:状态与计算生命周期绑死。Pod 是一次性资源,写在本地的状态随 Pod 一起消失;多副本下每个 Pod 又是各自的内存孤岛。
修复:把 Checkpoint 落到外部数据库,把 Memory 与 Session 落到 Redis 这类共享存储,并保证序列化格式跨实例一致。
第三条链路:模型 API 限流引发整体不可用。
现象:上游模型接口返回 429 后,Pod 的 readiness 陆续失败,剩余实例 CPU 打满,整个服务在几分钟内全部不可用。
怀疑:readiness 判据里混入了外部依赖健康,且缺少限流与超时保护。
检查:查看 readiness 端点返回的组件列表,确认是否包含模型连通性检查;查看调用链路的超时配置与并发上限。
证据:readiness 输出中模型健康项为 DOWN,触发摘流量;同时应用线程池活跃线程数逼近上限,说明慢调用已经堆积。
根因:把外部依赖的健康写进了实例就绪判据,使一次外部波动被放大为实例批量摘除;同时缺少超时、重试退避与并发闸门,导致局部拥塞升级为全局雪崩。
修复:readiness 只反映进程自身能否服务请求,外部依赖降级为独立的指标与告警;对模型调用加超时、有限重试与并发上限,触发限流时快速失败而不是排队。
现象先钉住
怀疑要能证
错误也留痕
根因才认账
07、优化
V2 的每一条修改都必须能追溯到第 06 章的证据。
根因一:终止时序缺失。修改:开启优雅退出、设置 shutdown phase 超时、preStop 预留缓冲、maxUnavailable 设为 0。原因:让“端点摘除”和“进程退出”这两件并发的事在时间上错开。新行为:滚动升级时旧 Pod 先停止接收新请求,再等待在途请求自然结束。验证:升级期间持续发起 SSE 请求,观察是否出现连接被硬切断,并在应用日志中确认出现了优雅关闭的收尾记录。
根因二:状态本地化。修改:Checkpoint 写外部数据库,Memory 与 Session 写共享 Redis,Pod 内不保留任何需要跨实例可见的状态。原因:让 Pod 的消失不再等于用户数据的消失。新行为:任一 Pod 重启或替换后,用户会话可以继续。验证:手动删除一个 Pod,等待重建,再发起同一会话请求,检查上下文是否连续。
根因三:探针语义混淆。修改:startup 只负责冷启动宽限,readiness 只看本进程能否服务,liveness 只看进程是否死锁;外部依赖不进入任何探针。原因:避免一次外部波动触发批量摘流量与重启。新行为:模型接口限流时,实例保持 Ready,只是请求以受控方式失败。验证:人为让模型端点返回错误,观察 Pod 的 RESTARTS 是否保持为 0,以及 readiness 是否仍为通过。
根因四:长连接网关配置缺失。修改:Ingress 层放开读写超时、关闭响应缓冲、使用支持流式的 HTTP 版本。原因:SSE 依赖“边生成边下发”,任何缓冲都会把流式体验退化成一次性响应。新行为:客户端能持续收到增量事件。验证:用 curl 的 -N 参数请求流式接口,观察事件是否按时间间隔陆续到达,而不是最后一次性吐出。
根因五:缺少破坏性预算保护。修改:按需引入 PDB 与 HPA。原因:PDB 保证节点维护、驱逐时不会同时干掉太多副本;HPA 保证流量上涨时能扩,但扩容必须以“新 Pod 能通过 readiness”为前提。新行为:节点排水时仍有可用副本,流量上涨时副本数随之上升。验证:用 kubectl drain 模拟节点维护,观察是否被 PDB 阻止过度驱逐。
以上验证均为方法论与观察口径,未在当前环境实测;具体阈值需结合第 15 掌的评测集与第 17 掌的指标面板共同标定,不能用拍脑袋的数字代替。
根因定改法
改法要能验
新行为可测
旧问题归零
08、演进
[同一输入]
|
+--[V1] 单副本 + 状态在本地 / 代价:升级必断、重启必丢
|
+--[V2] 多副本 + 状态外置 + 优雅退出 / 代价:依赖变多、成本上升
|
[Trade-off]
得到可滚动、可回滚、可恢复;失去单机的简单与零依赖;适用于状态可外置的 Agent 服务
正确性上,V1 在单请求场景下完全正确,但在并发与会话连续性上不成立;V2 把正确性的边界从“单进程”扩展到“多实例视图一致”。
稳定性上,V1 的稳定性等于单机的稳定性,任何一次容器重启都是可见故障;V2 引入了外部依赖,稳定性上限更高,但下限取决于依赖治理能力——依赖不稳,V2 反而比 V1 更容易出问题。
复杂度上,V1 的复杂度接近 0,一条 compose 文件搞定;V2 增加了镜像构建、清单管理、探针调优、状态序列化兼容、网关长连接配置,运维认知成本显著上升。
成本上,V1 是一台机器;V2 是多副本计算加外部托管服务,固定成本上升,但换来了按需扩缩的能力。
适用范围上,V1 适合内部试用、演示、单用户体验;V2 适合需要对外提供、需要发版不停机、需要自愈的生产环境。
遗留问题上,V2 依然没有解决跨实例的长连接粘性问题:SSE 与 WebSocket 一旦建立就绑定在某个 Pod 上,Pod 被驱逐时连接必然重建。这一层需要靠客户端重连策略与服务端会话恢复能力来兜底,而不是靠 K8s 的机制本身。
同输入分叉
各有各的价
得稳必失简
边界写清楚
09、洞见
9.1 有状态的是数据,不是进程
上 K8s 之前最该做的一件事,是把系统里所有状态列一张表,然后逐个回答:这个状态丢了会怎样?如果答案是“用户会察觉”,它就必须外置。反直觉判断:把服务改成无状态,靠的不是消灭状态,而是把状态搬到更远、更持久、更共享的地方。
9.2 反直觉判断:探针是生命周期协议,不是监控配置
大多数团队把探针当成“顺便配一下”的东西,实际上它是应用与编排系统之间的契约。readiness 说“我现在能不能接活”,liveness 说“我还有没有必要活着”。把外部依赖塞进 liveness,等于把别人的故障变成自己的重启风暴;把业务线程池塞进 readiness,等于用自己的拥塞去惩罚自己。
9.3 反直觉判断:滚动升级的成败在退出,不在启动
所有人都在优化启动速度,但真正决定发版是否掉线的,是旧副本怎么退场。一个能在 1 秒内启动、却只能硬退出的服务,发版体验会远差于一个启动 30 秒、但能优雅退出的服务。启动慢可以靠 startupProbe 解决,退出粗暴没有任何探针能救。
9.4 回滚能力是发版前准备的,不是故障时获得的
可回滚的前提有三个:镜像不可变且带版本号、状态与计算分离、数据库变更向前兼容。这三条只要缺一条,“回滚”就变成“回滚代码再手工修数据”。所以回滚预案的验收,必须在上线之前完成,而不是在出事那一刻才第一次执行。
9.5 上线质量要用前几掌的测试与指标来签字
第 15 掌的评测集回答“功能对不对”,第 17 掌的指标回答“运行稳不稳”。K8s 上线不应该引入一套新的评判标准,而应该把已有的测试与指标作为门禁:滚动升级期间的关键指标不劣化,才允许继续扩大流量。
洞见非口号
出自证据链
反直觉常对
顺直觉常错
10、系统落地
原来有什么:一套能在单机 Compose 中运行的数字人 Agent 服务,包含注册登录、项目 CRUD、Web 运行页、LiveKit 交互,以及第 4 到第 11 掌建立的模型调用、Memory、Tool、MCP、RAG、Graph 能力,但所有状态绑在单机与单进程上。
本篇新增了什么:一个多阶段非 root 镜像的构建方式;一套最小 K8s 组件清单(Deployment、Service、ConfigMap、Secret、Ingress,按需 HPA 与 PDB);一套正确的探针语义;一套外置状态方案;一套滚动升级与回滚的操作路径;以及一份长连接与限流的验证口径。
现在能做什么:可以多副本对外服务,可以在不中断在途对话的情况下发版,可以在 Pod 被杀后自动重建并让会话延续,可以在发现问题后一条命令回到上一个稳定版本。
还缺什么:缺少镜像构建与部署的自动化流水线,目前仍需人工触发;缺少灰度能力,无法按用户维度逐步放量;缺少跨实例的长连接粘性与会话恢复方案;缺少容量基线与压测结论,HPA 的阈值仍需要真实数据来标定。
下一步如何演进:把镜像构建、清单渲染、部署与回滚接入 CI/CD,形成提交即验证的闭环;引入灰度或蓝绿发布,以第 15 掌的评测结果作为放量门禁;把 HPA 指标从 CPU 扩展到队列深度、活跃连接数等更贴近 Agent 负载的信号;最后把 A2A 服务发现与 Nacos 的实例注册注销与 Pod 生命周期彻底对齐。
原来能对话
现在能发版
还缺自动滚
下一步接CI
11、小结
Q1 → 因为连接粘在旧 Pod 上,且终止流程没给在途请求留收尾时间
Q2 → Checkpoint、Memory、Session 必须外置;模型客户端与配置可留在 Pod
Q3 → 镜像不可变、状态外置、变更向前兼容,三者同时成立才叫可回滚
状态 → 镜像构建→本地K8s→Secret→Probe→滚动升级→故障回滚闭环跑通
本篇最终可交付的能力是:一套可复现的容器镜像构建方式,一套语义正确的探针配置,一套把状态搬出 Pod 的改造方案,以及一条能在 kind/minikube 上完整走通的升级与回滚路径。它没有引入新的业务功能,但把前面十七掌积累的所有能力,从“能跑”推进到了“能上线”。
三问皆有答
一境已闭环
能力可交付
边界要写明
12、作业
12.1 理解题:为什么 livenessProbe 里不能包含外部依赖健康检查?
参考答案:liveness 的语义是“是否必须重启进程”。外部依赖的健康属于外部世界的状态,进程重启并不能修复它。把模型 API 或数据库的健康写进 liveness,会让一次外部抖动触发所有副本同时重启,把局部故障放大为全局震荡。外部依赖的状态应通过 readiness 之外的独立指标与告警来观察。
12.2 实战题:把示例 Deployment 的 replicas 从 1 改到 3,制造一次“用户会话丢失”的现象,然后完成外置改造。
参考答案:先把 Memory 与 Session 实现保持在内存态,用同一会话连续请求两次,观察是否命中不同副本导致上下文缺失;再把 Checkpoint 写入外部数据库、Memory 与 Session 写入共享 Redis,重复同一实验,确认上下文连续。关键检查点是序列化格式在实例间一致、键设计包含会话 ID 与项目 ID。
12.3 排障题:滚动升级期间 SSE 连接被切断,把 terminationGracePeriodSeconds 调到 300 无效,为什么?
参考答案:该参数只是优雅退出的时间上限,并不会让应用真正等待。根因在于应用侧未开启优雅退出、preStop 没有留出缓冲、maxUnavailable 允许同时下线过多副本。正确做法是开启 server.shutdown=graceful 并设置 shutdown phase 超时,配合 preStop 延迟与 maxUnavailable: 0,再验证升级期间的连接连续性。
12.4 架构判断题:MySQL、Redis、Vector DB、Nacos、LiveKit 是否应该和 Agent 一起部署在集群内?
参考答案:取决于环境与团队能力,但方向是分开。生产环境优先选择托管服务,把有状态组件的运维责任外移;本地 kind/minikube 环境可以用简化部署模拟,但不能把本地的简化形态当作生产方案。判断标准是:这个组件挂了,Agent 能否靠重启自愈?如果不能,它就不应该和 Agent 共享同一个生命周期边界。
四题分四路
理解到判断
动手再排障
判断定取舍
13、思考
这一掌真正的冲突,从来不是“Docker 换成了 Kubernetes”,而是“状态该由谁负责”。Compose 用一台机器掩盖了状态归属的问题,K8s 用多副本把这个问题暴露到了明面上。凡是把重要状态放在 Pod 里的服务,都注定会在第一次滚动升级时付出代价。
由此得到一个可复用的判断:上编排系统之前,先把系统里的状态列清、归类、外置,再谈副本数与自动扩缩。探针不是监控配置,而是生命周期协议;优雅退出不是可选项,而是发版的入场券;回滚能力不是事故现场临时攒出来的,而是上线前就已经验证过的能力。
神龙摆尾,摆的不是尾,是整个身体的重心。把重心从 Pod 内部移到外部存储上,服务才真正具备了在云上自由游动的前提。
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号