把 OpenClaw Agent 搬进 K8s:容器化部署全记录

本文是《AI Agent 生产化实战(OpenClaw 主线)》系列第 12 篇,也是系列收官。上一篇我们用 systemd 把 Agent 稳在了单机上,但单机方案有三个天花板:机器挂了就全挂、扩缩容靠手工、多环境配置靠人肉维护。本文记录一次真实的迁移过程------把跑在 systemd 上的 OpenClaw Agent 搬进 Kubernetes。面向已经有容器基础、要解决"多实例管理 + 弹性 + 环境一致性"的团队:讲清 Agent 容器化的五个特殊难点、镜像从 2.1GB 压到 420MB 的做法、探针与优雅停机在 K8s 里的正确写法、requests/limits 为什么不能照抄模板、以及五类高频故障的排障路径。读者将拿到八个可直接套用的 YAML、一张能力对照表、以及一份"迁移顺序"清单。

📌 版本声明 :本文基于 OpenClaw v2026.8、Kubernetes v1.30、Helm 3.15、containerd 1.7、Node.js v24.15 与 Redis 7.2 验证;Kubernetes 各版本的 API 版本与特性门控存在差异 (autoscaling/v2、PodDisruptionBudget 的行为在 1.25 前后有变化),落地前请用 kubectl api-resources 核对。文中镜像 tag、资源数值与副本数均为示例,不要照抄。涉及密钥的部分请配合外部密钥管理(Vault / KMS / External Secrets),不要只用裸 Secret。

文章目录

    • [1️⃣ 为什么要把 Agent 搬进 K8s:先看清天花板在哪](#1️⃣ 为什么要把 Agent 搬进 K8s:先看清天花板在哪)
      • [1.1 单机方案走到头了](#1.1 单机方案走到头了)
      • [1.2 Agent 容器化的五个特殊难点](#1.2 Agent 容器化的五个特殊难点)
      • [1.3 三种部署形态的对比与选型](#1.3 三种部署形态的对比与选型)
      • [1.4 迁移的收益与代价:一次坦诚的算账](#1.4 迁移的收益与代价:一次坦诚的算账)
    • [2️⃣ 核心概念介绍](#2️⃣ 核心概念介绍)
      • [2.1 镜像分层:为什么 Agent 镜像容易胖到 2GB](#2.1 镜像分层:为什么 Agent 镜像容易胖到 2GB)
      • [2.2 配置与密钥:三层分离](#2.2 配置与密钥:三层分离)
      • [2.3 有状态部分:会话外置与卷的取舍](#2.3 有状态部分:会话外置与卷的取舍)
      • [2.4 探针在 K8s 里的落地](#2.4 探针在 K8s 里的落地)
      • [2.5 requests 与 limits:Agent 最难配的一对参数](#2.5 requests 与 limits:Agent 最难配的一对参数)
      • [2.6 HPA:为什么 Agent 的扩缩容不能只看 CPU](#2.6 HPA:为什么 Agent 的扩缩容不能只看 CPU)
      • [2.7 Service 与 Ingress:粘性的正确落点](#2.7 Service 与 Ingress:粘性的正确落点)
    • [3️⃣ 环境准备](#3️⃣ 环境准备)
      • [3.1 版本与依赖要求](#3.1 版本与依赖要求)
      • [3.2 集群前置条件清单](#3.2 集群前置条件清单)
      • [3.3 三条前置决策](#3.3 三条前置决策)
    • [4️⃣ 核心功能详解:八步把 Agent 搬上 K8s](#4️⃣ 核心功能详解:八步把 Agent 搬上 K8s)
      • [4.1 第一步:写 Dockerfile(多阶段 + 非 root)](#4.1 第一步:写 Dockerfile(多阶段 + 非 root))
      • [4.2 第二步:ConfigMap 与 Secret](#4.2 第二步:ConfigMap 与 Secret)
      • [4.3 第三步:Deployment(探针、preStop、资源)](#4.3 第三步:Deployment(探针、preStop、资源))
      • [4.4 第四步:Service 与 Ingress(含会话粘性)](#4.4 第四步:Service 与 Ingress(含会话粘性))
      • [4.5 第五步:存储](#4.5 第五步:存储)
      • [4.6 第六步:HPA 与 PDB](#4.6 第六步:HPA 与 PDB)
      • [4.7 第七步:Helm 化与多环境](#4.7 第七步:Helm 化与多环境)
      • [4.8 第八步:K8s 上的灰度(双 Deployment)](#4.8 第八步:K8s 上的灰度(双 Deployment))
    • [5️⃣ 进阶与优化](#5️⃣ 进阶与优化)
      • [5.1 启动慢的三种解法](#5.1 启动慢的三种解法)
      • [5.2 镜像分发的坑](#5.2 镜像分发的坑)
      • [5.3 日志与可观测性](#5.3 日志与可观测性)
      • [5.4 成本:requests 超配的隐性浪费](#5.4 成本:requests 超配的隐性浪费)
      • [5.5 五类高频故障与排障路径](#5.5 五类高频故障与排障路径)
      • [5.6 迁移顺序:不能一次切](#5.6 迁移顺序:不能一次切)
      • [5.7 本地开发与生产的差异](#5.7 本地开发与生产的差异)
    • [6️⃣ 适用边界与风险提示](#6️⃣ 适用边界与风险提示)
      • [6.1 ⚠️ 适用场景](#6.1 ⚠️ 适用场景)
      • [6.2 ⚠️ 不适用场景](#6.2 ⚠️ 不适用场景)
      • [6.3 ⚠️ 版本与兼容性注意](#6.3 ⚠️ 版本与兼容性注意)
      • [6.4 ⚠️ 生产建议](#6.4 ⚠️ 生产建议)
      • [6.5 六个高频问题](#6.5 六个高频问题)
    • [7️⃣ 总结](#7️⃣ 总结)
      • [7.1 回到开头的问题](#7.1 回到开头的问题)
      • [7.2 本篇的核心方案回顾](#7.2 本篇的核心方案回顾)
      • [7.3 一页纸速查表](#7.3 一页纸速查表)
    • 参考资料

1️⃣ 为什么要把 Agent 搬进 K8s:先看清天花板在哪

1.1 单机方案走到头了

上一篇我们用 systemd 把 Agent 做得很稳:进程能自愈、有看门狗、优雅停机、会话级灰度。那套方案有一个无法通过配置解决的问题:机器本身是单点。

一台机器挂了,Restart=always 再怎么配也没用------因为守护它的 systemd 也一起挂了。 这时候唯一的办法是人跑进机房(或者连上带外管理)把机器弄起来。

单机方案还有两个隐性的天花板:

天花板一:扩容只能靠"整机复制"。 假设你的 Agent 平时 2 核够用,月末对账那三天要 8 核。单机方案下你要么常年备着 8 核(浪费 70% 的成本在 28 天里 ),要么手动改配置、重启服务(有停机,还得有人值班)。而弹性伸缩恰恰是 K8s 最擅长的。

天花板二:环境一致性靠人。 开发机、测试机、生产机的差异------Node 版本、系统依赖库(libreoffice 用于文档转换、chromium 用于网页抓取之类)、字体、时区------这些差异只能靠文档和纪律维持 。而"文档和纪律"在团队扩大到 3 个人以上时必然失效。容器镜像的本质价值不是"轻量",而是"把环境变成可版本化的制品"------你的镜像有 tag、有摘要(digest)、可回滚、可复现。

1.2 Agent 容器化的五个特殊难点

如果你做过后端微服务的容器化,会觉得"这不就是把 jar 换成 node 吗"。Agent 有五处不一样,其中三处会让"照抄模板"的做法直接失败。

难点 传统 Java 微服务 OpenClaw Agent 直接后果
① 有状态 无状态,随便换实例 会话上下文、三级记忆、工作目录 必须做会话外置 + 粘性路由
② 镜像大 JDK + jar ≈ 300MB Node + 技能依赖 + 工具链(可能含无头浏览器) 拉取慢、节点磁盘紧、扩缩容迟钝
③ 启动慢 10~20 秒,可预热 技能加载 + 索引构建 + 连接池,30~60 秒 默认探针会判死,滚动更新极慢
④ 内存峰值高 曲线平滑 基线小、长上下文时突增(第 11 篇 5.2 节) 按基线配 limit 必然 OOMKilled
⑤ 密钥多 一个数据库密码 多个模型 API Key + 渠道密钥 + 数据库 Secret 管理不当会明文散落

其中第 ②④ 两条最容易被低估。

镜像大这件事,很多人以为"反正内网快,无所谓"。 但当你要弹性扩容时问题就来了:HPA 触发扩容,新 Pod 要拉一个 2.1GB 的镜像------如果节点上没有缓存,拉取可能要 1~3 分钟 。而月末高峰的持续时间可能只有 2 小时。结果就是"扩容永远慢半拍,等 Pod 起来高峰已经过去了"。 这也是为什么本文会把镜像瘦身写成一整节(2.1 + 5.2)。

内存峰值高这件事,直接决定 OOMKilled 的发生率。 我实测过一个 Agent:空闲时 RSS 约 220MB,正常处理时 400~600MB,但处理一个 15MB 的 Excel 附件时瞬间涨到 1.8GB (解析 + 结构化 + 序列化模型输入,中间有好几份副本)。如果你按"正常值 × 1.5 = 900MB"配了 limit,那么这个 Pod 在遇到大附件时会 100% 被 OOMKilled 。而 K8s 的 OOMKilled 是直接 SIGKILL,不给优雅停机机会------正在处理的会话直接被砍断。

1.3 三种部署形态的对比与选型

先把选择摆出来。不是所有场景都该上 K8s,这句话我在上一篇 1.4 节说过,这里再落地成一张表。

形态 部署方式 弹性 环境一致性 运维复杂度 适用
裸机 + systemd 手工/脚本部署 ❌ 无 ⚠️ 靠纪律 低 单机、内部工具、日活几十
单机 + Docker Compose 一份 compose 文件 ❌ 无(但好扩机器) ✅ 好 低 2~3 台、需要环境一致但不需要弹性
K8s 声明式 YAML / Helm ✅ 好 ✅ 好 高 多实例、需要弹性、多环境、有 SRE 能力

选型判据我给三条,满足两条以上才建议上 K8s:

第一,你的实例数需要超过 3 个,或者需要弹性伸缩。 少于 3 个实例时,K8s 带来的调度能力用不上,但它的复杂度你已经全额承担了。

第二,你有需要"完全一致"的多套环境。 开发/测试/预发/生产四套环境靠人维护,出错是必然的。这是 K8s 最容易被低估的价值------它让"环境"成为一个可版本化的制品。

第三,你有(或愿意养)一个懂容器网络与存储的人。 这一条是硬门槛。K8s 的故障有相当比例发生在网络(CNI)和存储(CSI)层,这类问题没有概念基础的人排不动。如果团队里没人懂,上 K8s 的结局通常是"一个 CrashLoopBackOff 卡了一整天"。

若三条都不满足,我真诚建议留在 systemd 或 Compose。 我见过的最不划算的一次投入,是给一个日活 40 的内部 Agent 上了 K8s,运维成本从每月 0.5 人天涨到 3 人天,而可用性没有任何提升------因为它从来没有并发压力,单点故障也只影响 40 个人,而他们的容忍度是"第二天早上修好就行"。

1.4 迁移的收益与代价:一次坦诚的算账

迁完这一趟,我手上的数字是这样的。我把代价也列出来,因为只看收益的迁移计划十有八九会中途放弃。

项目 迁移前(systemd 单机) 迁移后(K8s 3 副本) 变化
服务可用性 机器挂 = 全挂 单节点挂 = 掉 1/3 容量 显著改善
发布停机 优雅停机 8~20 秒 滚动更新 0 秒中断 改善
扩容耗时 手工,30 分钟+ HPA 自动,含镜像拉取约 2 分钟 改善(但有 2 分钟下限)
月度资源成本 ¥860(固定 8 核) ¥740(弹性 2~8 核) 降 14%(不是降一半,见 5.4)
排障耗时 看 journald,平均 20 分钟 要看 Pod/事件/CNI,平均 45 分钟 变差
首次搭建投入 2 天 12 天(含调试、灰度、文档) 显著变差
每次改配置 改 env 重启 改 ConfigMap + rollout restart 略变差(多了两步)

这张表里有两行必须正视:首次投入 12 天、排障耗时翻倍。

首次投入 12 天里,真正写 YAML 的时间不超过 2 天 ,剩下 10 天花在了:镜像瘦身与构建流水线(3 天)、探针与优雅停机的调参(2 天)、存储与权限(1 天)、网络与 Ingress(2 天)、灰度与回滚演练(2 天)。如果谁给你说"K8s 迁移三天搞定",他要么已经在别的项目踩过这些坑、要么还没踩。

排障耗时翻倍是最容易被忽略的代价。 单机时代 journalctl -u openclaw -f 就能看全部;K8s 时代你要先判断"是应用问题还是调度问题",再看 kubectl describe pod 的事件、再进容器看日志、再查 CNI 连通性。这个代价是永久的,不会随熟练度消失------只是从 45 分钟降到 25 分钟。

所以我的结论是:K8s 不是"更高级",而是"更贵但更强"。 这笔账只在你有跨机可用性需求、或实例数上到 3 个以上时才划算。

(图 1:Agent 部署形态的演进路径与决策点------从 systemd 到 Compose 到 K8s,每一步都有明确的触发条件;跳过中间形态直接上 K8s 是常见但代价更高的选择。)
#mermaid-svg-6tiMZ6uGGR8Coiav{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiMZ6uGGR8Coiav .error-icon{fill:#552222;}#mermaid-svg-6tiMZ6uGGR8Coiav .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6tiMZ6uGGR8Coiav .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6tiMZ6uGGR8Coiav .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6tiMZ6uGGR8Coiav .marker.cross{stroke:#333333;}#mermaid-svg-6tiMZ6uGGR8Coiav svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6tiMZ6uGGR8Coiav p{margin:0;}#mermaid-svg-6tiMZ6uGGR8Coiav .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster-label text{fill:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster-label span{color:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster-label span p{background-color:transparent;}#mermaid-svg-6tiMZ6uGGR8Coiav .label text,#mermaid-svg-6tiMZ6uGGR8Coiav span{fill:#333;color:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav .node rect,#mermaid-svg-6tiMZ6uGGR8Coiav .node circle,#mermaid-svg-6tiMZ6uGGR8Coiav .node ellipse,#mermaid-svg-6tiMZ6uGGR8Coiav .node polygon,#mermaid-svg-6tiMZ6uGGR8Coiav .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6tiMZ6uGGR8Coiav .rough-node .label text,#mermaid-svg-6tiMZ6uGGR8Coiav .node .label text,#mermaid-svg-6tiMZ6uGGR8Coiav .image-shape .label,#mermaid-svg-6tiMZ6uGGR8Coiav .icon-shape .label{text-anchor:middle;}#mermaid-svg-6tiMZ6uGGR8Coiav .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6tiMZ6uGGR8Coiav .rough-node .label,#mermaid-svg-6tiMZ6uGGR8Coiav .node .label,#mermaid-svg-6tiMZ6uGGR8Coiav .image-shape .label,#mermaid-svg-6tiMZ6uGGR8Coiav .icon-shape .label{text-align:center;}#mermaid-svg-6tiMZ6uGGR8Coiav .node.clickable{cursor:pointer;}#mermaid-svg-6tiMZ6uGGR8Coiav .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6tiMZ6uGGR8Coiav .arrowheadPath{fill:#333333;}#mermaid-svg-6tiMZ6uGGR8Coiav .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6tiMZ6uGGR8Coiav .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6tiMZ6uGGR8Coiav .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiMZ6uGGR8Coiav .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6tiMZ6uGGR8Coiav .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiMZ6uGGR8Coiav .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster text{fill:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav .cluster span{color:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6tiMZ6uGGR8Coiav .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6tiMZ6uGGR8Coiav rect.text{fill:none;stroke-width:0;}#mermaid-svg-6tiMZ6uGGR8Coiav .icon-shape,#mermaid-svg-6tiMZ6uGGR8Coiav .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiMZ6uGGR8Coiav .icon-shape p,#mermaid-svg-6tiMZ6uGGR8Coiav .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6tiMZ6uGGR8Coiav .icon-shape .label rect,#mermaid-svg-6tiMZ6uGGR8Coiav .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiMZ6uGGR8Coiav .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6tiMZ6uGGR8Coiav .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6tiMZ6uGGR8Coiav :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不需要
需要
不需要
需要
否
是
无
有
起点:单个 Agent 进程
有对外 SLA?
systemd 单机

第 11 篇方案
需要环境一致的

多套环境?
systemd + 会话外置

  • 多实例 + Nginx
    实例数 > 3

或需弹性伸缩?
Docker Compose

2~3 台机器
团队有容器网络

与存储能力?
先补能力

或用托管 K8s
K8s 容器化

本文主题
第 1 步 Dockerfile

→ 第 8 步灰度


2️⃣ 核心概念介绍

2.1 镜像分层:为什么 Agent 镜像容易胖到 2GB

先把镜像"胖"的原因找准。它几乎从不是 Node 运行时本身的问题 ------node:24-slim 只有约 80MB。胖的来源是三块:

第一块:构建期的依赖没清理。 单阶段构建里,npm install 装完了、编译完了,源码、devDependencies、构建缓存全都留在同一个层里 。这块通常占 400MB~1.2GB(如果依赖里有 puppeteer、sharp、canvas 这类带原生二进制或需下载 Chromium 的包,数字会更大)。

第二块:技能与工具链。 Agent 的价值在于"能办事",所以往往需要外部工具:文档转换要 libreoffice(单个就 600MB+ )、网页抓取要无头浏览器(约 300MB )、图像处理要 imagemagick。这是 Agent 特有的胖源,也是瘦身时最需要权衡的部分------你不能简单删掉它,删了功能就没了。

第三块:基础镜像选错。 node:24(Debian 完整版)约 390MB,node:24-slim 约 80MB,node:24-alpine 约 50MB。从完整版换到 slim,几乎零成本省下 300MB。

瘦身的顺序应该是"先换基础镜像 → 再多阶段构建 → 最后再考虑裁剪工具链",因为前两步零风险,第三步要付功能代价。4.1 节给完整做法。

这里要提醒一个反直觉的点:alpine 不是无脑选择。

node:alpine 用的是 musl libc 而不是 glibc。部分预编译的原生模块在 musl 上跑不起来或性能差 (尤其是一些用 node-gyp 编译、或者带 CUDA/科学计算库的包)。判断标准很简单:先构建、跑一次完整的功能自测,通过了再用;通不过就退回 slim。 我自己的项目里,纯 Node 依赖的部分用 alpine 没问题,但只要牵扯到无头浏览器和文档转换,一律用 slim------因为那两个工具链在 glibc 上最省心。

2.2 配置与密钥:三层分离

把配置写进镜像是最常见也最贵的一个错误 ,因为它的代价是"改一个超时时间要重新构建镜像、推仓库、滚一轮 Pod",本来 10 秒的事变成 10 分钟。

所以配置必须做三层分离:

层 内容 存放方式 变更频率 变更后的动作
镜像层 代码、Node 运行时、系统依赖、字体 镜像 低(跟随版本) 重新构建 + 滚动更新
配置层 模型名、超时、日志级别、功能开关、渠道参数 ConfigMap 中 改 ConfigMap + rollout restart
密钥层 模型 API Key、渠道 Secret、DB 密码 Secret(或外部密钥管理) 低但敏感 同上,且必须审计

三层里最容易做错的是 Secret。 六个必须知道的事实:

  1. K8s 的 Secret 默认只是 base64 编码,不是加密 。任何有 get secret 权限的人都能读出明文。
  2. 所以要么开启 etcd 静态加密(EncryptionConfiguration) ,要么用 External Secrets / Vault 注入。
  3. envFrom: secretRef 很方便,但它会把所有密钥塞进环境变量 ------而环境变量会被 kubectl exec ... env、进程崩溃时的 dump、以及部分 APM 上报意外泄露。
  4. 更安全的做法是挂载成文件 (volume mount),应用从文件读取,用完可以不留痕。
  5. Secret 更新后,以 env 方式注入的 Pod 不会自动拿到新值 (必须重启 Pod);以 volume 方式挂载的会在一段时间后自动更新(kubelet 同步周期内)。这个差异经常导致"我改了密钥怎么没生效"。
  6. Secret 的 stringData 写入后会被转成 data,但 Git 里的 YAML 会留下明文 ------密钥绝不能进 Git,哪怕是私有仓库。

2.3 有状态部分:会话外置与卷的取舍

上一篇 4.4 节已经说过"会话状态必须外置"。在 K8s 里这件事从"好习惯"升级为"硬约束" ,原因是:Pod 是可被随时替换的临时实体。

Pod 可能在以下任一情况下消失,且都是没有预告的 :节点资源不足被驱逐(Evicted)、节点缩容、节点故障、滚动更新、HPA 缩容、以及最讨厌的------Pod 因为探针失败被 K8s 主动重建。

所以第一原则是:Agent 进程内不能有"丢了会导致业务受损"的数据。

然后是卷的选择。K8s 里给 Agent 用的卷主要有三种,选错会导致"重启后记忆全丢"或"多副本互相覆盖":

卷类型 生命周期 多副本共享 Agent 里适合放什么 风险
emptyDir 随 Pod 生死 ❌ 各自独立 临时文件、附件缓存、日志缓冲 Pod 重建即清空
PVC(RWO) 独立于 Pod ❌ 一次只能被一个节点挂载 三级记忆(第 8 篇) 副本数 > 1 时互相抢挂载
共享存储(RWX / 对象存储) 独立于 Pod ✅ 多副本共享的知识库、记忆归档 配置复杂、延迟高于本地盘

这里有个我认为最值得讲的坑:把 MEMORY 目录放在 RWO 的 PVC 上,然后扩到 3 副本。

RWO(ReadWriteOnce)的语义是"一次只能被一个节点以读写方式挂载 "。如果你把 Deployment 的 replicas 从 1 改成 3,新 Pod 调度到不同节点时会因为卷挂载冲突而卡在 ContainerCreating ,事件里能看到 Multi-Attach error 或 volume is already exclusively attached。更糟的情况是调度到了同一节点 ------那多个 Pod 会共享同一份文件,三个 Agent 同时往同一个 MEMORY.md 里写,内容互相覆盖。

所以我的处理原则是分三类:

  1. 必须跨副本共享的状态 → 放 Redis / 数据库,不放文件。会话上下文、槽位、记忆索引都属于这类。
  2. 每副本独立的临时数据 → emptyDir(附件缓存、临时转换文件)。
  3. 需要持久化但可以"单副本写入"的数据 (如原始日志归档、审计流水) → PVC,但前提是副本数为 1,或者由专门的写入者负责 。一旦副本数 > 1,就必须换成共享存储或对象存储。

这条原则一句话总结:文件系统在 K8s 里是"有副本亲和性的",而你的 Agent 副本是无差别的------这两者天然冲突,冲突的解法是把状态推到外部服务,而不是给文件系统加共享。

2.4 探针在 K8s 里的落地

上一篇 2.3 节讲了三级探针的设计(live / ready / deep)。到了 K8s,同样三个端点,但要用 YAML 声明,而且多了几个必踩的坑。

探针 K8s 字段 检查端点 关键参数 失败后果
startup startupProbe /health/live failureThreshold: 30、periodSeconds: 2 超过总时长 → 重启容器
liveness livenessProbe /health/live periodSeconds: 10、failureThreshold: 3 重启容器
readiness readinessProbe /health/ready periodSeconds: 5、failureThreshold: 3 摘出 Endpoints,不重启

四个必知的坑,每一个我都真实踩过:

坑一:没有 startupProbe 时,livenessProbe 会在启动期就判死。 Agent 启动要 30~60 秒,如果 liveness 的 initialDelaySeconds 设成 10 秒,容器会在启动完成前就被重启,然后进入无限重启 。这不是探针写错了,而是缺少"启动期豁免"机制 。startupProbe 的作用正是"在它成功之前,禁用 liveness 和 readiness"。

坑二:failureThreshold × periodSeconds 才是真实容忍时间,很多人只看 periodSeconds。 比如 readinessProbe 设 periodSeconds: 5, failureThreshold: 3,意味着要连续 15 秒不健康才会被摘流量 。这个值在发布时很关键:容忍时间越长,切流量越准,但故障实例被摘除越慢。我的取值是 readiness 用 3 次(15 秒)、liveness 用 3 次(30 秒)。

坑三:不要把 deep 探针做成 livenessProbe。 这是上一篇 2.3 节的原则在 K8s 里的复述,但因为 K8s 的 YAML 太容易"顺手把三个探针都指向同一个端点",这个错误的发生率极高 。deep 探针要真调模型,一旦模型抖动,所有 Pod 会同时被重启------这就是上一篇说的"自己把自己 DDoS 了",而且规模乘以副本数。

坑四:readinessProbe 对优雅停机至关重要(下一篇不再有,这里说透)。 K8s 在删除 Pod 时,会并行做两件事:把 Pod 从 Endpoints 摘除、给容器发 SIGTERM 。这两件事没有先后保证 。所以如果应用收到 SIGTERM 就立刻停止接受连接,在 Endpoints 刷新完成前到达的请求会失败 。这就是上一篇 2.4 节"排空窗口"在 K8s 里的对应物,落地手段是 preStop 钩子加一个 sleep(4.3 节给配置)。

2.5 requests 与 limits:Agent 最难配的一对参数

这对参数配错的后果分两种:OOMKilled(limit 太小)和资源浪费(request 太大)。 而 Agent 的内存曲线特殊,让"照抄模板"必然出错。

先把概念说清(这两个词被混用得最厉害):

  • requests 是调度依据 :K8s 用它决定这个 Pod 能放到哪个节点,也是 HPA 计算利用率的分母;
  • limits 是硬约束 :CPU 超了被限流(throttle),内存超了直接被 OOMKilled(SIGKILL,不给优雅停机)。

CPU 和内存的配法完全不同,这点必须先建立:

资源 建议配法 原因
CPU requests 给基线(如 500m),limits 可以不给或给 2 倍 Agent 的 CPU 消耗突发性强(JSON 解析、附件处理),给死 limit 会导致 throttling,延迟毛刺
内存 requests 给正常值,limits 必须是峰值的 1.3 倍以上 超了就 SIGKILL;宁可多给内存,也不要 OOMKilled

为什么 CPU 的 limit 可以不给? 因为 CPU 是可压缩资源------超了只是变慢,而节点上其他 Pod 会通过权重公平竞争。但如果你给了死 limit,就制造了一类人为的延迟毛刺 :Agent 处理一个附件时瞬间需要 2 核,而 limit 是 1 核,它会被内核 throttle 住,导致单次请求从 4 秒变成 12 秒 。而如果你的超时是 10 秒,这就变成了一次"莫名其妙的超时失败"。

内存则相反,必须给 limit,而且要给足。 因为内存是不可压缩的:节点内存不足时,内核会 OOM Killer 挑一个进程杀掉,挑谁是不确定的。给 limit 至少能保证"被杀的是我自己,而不是把邻居一起拖下水"。

那么 Agent 的 limit 具体给多少? 我的做法是按峰值而不是平均值来配,而峰值要靠实测:

bash 复制代码
# 在容器内观察真实的内存峰值,而不是猜
# 重点看第 4 列:处理大附件时的高峰
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"

# 更精确的做法:记录 Node 的堆 + 外部内存(Buffer 等)峰值
# 外部内存是大附件的主要消耗处,最容易被漏掉(第 11 篇 5.2 节)
node -e "
  setInterval(() => {
    const m = process.memoryUsage();
    console.log(JSON.stringify({
      rssMB: Math.round(m.rss/1048576),
      heapMB: Math.round(m.heapUsed/1048576),
      extMB: Math.round(m.external/1048576)
    }));
  }, 5000);
"

我的实测数据是这样的 (一个带文档解析能力的客服 Agent):空闲 rss 220MB、正常处理峰值 620MB、处理 20MB Excel 附件时峰值 1.9GB。

如果按"正常峰值 × 1.5"配 limit(930MB),大附件场景 100% OOMKilled。 我的实际配置是:

场景 requests.memory limits.memory 说明
纯对话(无附件) 512Mi 1Gi limit 是正常峰值的 1.6 倍
有附件处理 1Gi 2.5Gi 按最坏情况配,并额外留 30%
带无头浏览器 1.5Gi 3Gi 浏览器本身吃内存

同时必须做一个应用侧改动:给附件大小设上限。 因为"按最坏情况配 limit"这件事有边界------如果用户上传 200MB 的文件,你怎么配都会 OOM 。所以我的规则是:单附件上限 20MB(在网关层拦掉)、解析结果超 5000 行则降级为"只处理前 5000 行并告知用户" 。这是"配 limit"和"约束输入"必须同时做,只做前者是在赌用户不会传大文件。

(图 2:Agent 在 K8s 里的完整部署架构------Deployment 管理多个 Pod,Pod 通过 Service 暴露,Ingress 接入外部流量;会话状态经 Redis 外置;ConfigMap/Secret 注入配置与密钥;HPA 依据自定义指标扩缩容。)
#mermaid-svg-M9udhqy0HJiF0w3M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M9udhqy0HJiF0w3M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M9udhqy0HJiF0w3M .error-icon{fill:#552222;}#mermaid-svg-M9udhqy0HJiF0w3M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M9udhqy0HJiF0w3M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M9udhqy0HJiF0w3M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M9udhqy0HJiF0w3M .marker.cross{stroke:#333333;}#mermaid-svg-M9udhqy0HJiF0w3M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M9udhqy0HJiF0w3M p{margin:0;}#mermaid-svg-M9udhqy0HJiF0w3M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M9udhqy0HJiF0w3M .cluster-label text{fill:#333;}#mermaid-svg-M9udhqy0HJiF0w3M .cluster-label span{color:#333;}#mermaid-svg-M9udhqy0HJiF0w3M .cluster-label span p{background-color:transparent;}#mermaid-svg-M9udhqy0HJiF0w3M .label text,#mermaid-svg-M9udhqy0HJiF0w3M span{fill:#333;color:#333;}#mermaid-svg-M9udhqy0HJiF0w3M .node rect,#mermaid-svg-M9udhqy0HJiF0w3M .node circle,#mermaid-svg-M9udhqy0HJiF0w3M .node ellipse,#mermaid-svg-M9udhqy0HJiF0w3M .node polygon,#mermaid-svg-M9udhqy0HJiF0w3M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M9udhqy0HJiF0w3M .rough-node .label text,#mermaid-svg-M9udhqy0HJiF0w3M .node .label text,#mermaid-svg-M9udhqy0HJiF0w3M .image-shape .label,#mermaid-svg-M9udhqy0HJiF0w3M .icon-shape .label{text-anchor:middle;}#mermaid-svg-M9udhqy0HJiF0w3M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M9udhqy0HJiF0w3M .rough-node .label,#mermaid-svg-M9udhqy0HJiF0w3M .node .label,#mermaid-svg-M9udhqy0HJiF0w3M .image-shape .label,#mermaid-svg-M9udhqy0HJiF0w3M .icon-shape .label{text-align:center;}#mermaid-svg-M9udhqy0HJiF0w3M .node.clickable{cursor:pointer;}#mermaid-svg-M9udhqy0HJiF0w3M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M9udhqy0HJiF0w3M .arrowheadPath{fill:#333333;}#mermaid-svg-M9udhqy0HJiF0w3M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M9udhqy0HJiF0w3M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M9udhqy0HJiF0w3M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9udhqy0HJiF0w3M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M9udhqy0HJiF0w3M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9udhqy0HJiF0w3M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M9udhqy0HJiF0w3M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M9udhqy0HJiF0w3M .cluster text{fill:#333;}#mermaid-svg-M9udhqy0HJiF0w3M .cluster span{color:#333;}#mermaid-svg-M9udhqy0HJiF0w3M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M9udhqy0HJiF0w3M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M9udhqy0HJiF0w3M rect.text{fill:none;stroke-width:0;}#mermaid-svg-M9udhqy0HJiF0w3M .icon-shape,#mermaid-svg-M9udhqy0HJiF0w3M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9udhqy0HJiF0w3M .icon-shape p,#mermaid-svg-M9udhqy0HJiF0w3M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M9udhqy0HJiF0w3M .icon-shape .label rect,#mermaid-svg-M9udhqy0HJiF0w3M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9udhqy0HJiF0w3M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M9udhqy0HJiF0w3M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M9udhqy0HJiF0w3M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Kubernetes 集群
集群外部
数据层
命名空间 agent-prod
Deployment openclaw-agent (3 副本)
用户 / 企业微信 / 钉钉
模型 API
Ingress

agent.example.com
Service openclaw-agent

sessionAffinity: ClientIP
Pod A

startup/ready/live 探针
Pod B
Pod C

灰度 v2
ConfigMap

模型名/超时/开关
Secret

API Key/渠道密钥
HPA

自定义指标驱动
PDB

minAvailable: 2
Redis

会话 + 记忆短期
PostgreSQL

工单 + 审计

2.6 HPA:为什么 Agent 的扩缩容不能只看 CPU

HPA(Horizontal Pod Autoscaler)默认支持 CPU 与内存指标。但 Agent 的负载特征让这两个指标都不好用。

问题在于:Agent 的时间主要花在"等"上,不是花在"算"上。

一次请求 8 秒的耗时构成大致是:模型推理等待 5.5 秒(纯等待,CPU 空闲 )、工具调用等待 1.5 秒(也是等待 )、本地计算 1 秒(JSON 解析、提示词组装)。也就是说 CPU 利用率只有 12%。

后果是:10 个并发请求时 CPU 可能才 40%,而每个用户的等待时间已经因为排队变长了。 如果你把 HPA 的阈值设成 CPU > 70%,那么在你扩容之前,用户体验已经恶化很久了------因为瓶颈根本不是 CPU,而是"每实例能同时处理的会话数"。

所以 Agent 的扩缩容指标应该用"并发数"类指标,我推荐三个,按优先级:

指标 含义 阈值建议 为什么合适
agent_inflight_sessions(首选) 每实例正在处理的会话数 平均 > 15 扩容 直接对应瓶颈,与用户等待强相关
agent_queue_length 队列中待处理的会话数 > 20 扩容 与 inflight 互补,能提前预警
http_requests_in_flight 通用并发请求数 > 25 无需自定义指标即可用(部分场景已足够)

实现上有个必须知道的取舍 :这三个指标里,http_requests_in_flight 可以用 prometheus-adapter 或 keda 从已有的 Prometheus 指标直接转成 HPA 可用指标(成本低 );而自定义的 agent_inflight_sessions 需要你在应用里暴露它、并在 adapter 里配一条规则(成本略高,但准确得多 )。我建议先上 http_requests_in_flight(半天就能通),等有明确证据说明它不够准,再补自定义指标。

然后是两个容易被忽略的 HPA 保护性配置:

保护一:stabilizationWindowSeconds。 Agent 的启动要 30~60 秒,如果负载波形有抖动,HPA 会疯了似地反复扩容缩容 。设置缩容稳定窗口(比如 300 秒)能让它"看 5 分钟的平均值再决定缩容"。没有这个配置的 HPA 在大附件场景下会反复震荡。

保护二:scaleDown 的 policies。 默认缩容策略可能一次缩掉很多副本,导致在途会话被中断。应该限制"每 2 分钟最多缩 1 个 Pod",给优雅停机留出时间。
#mermaid-svg-SdaTGpWKi5a7SUQQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SdaTGpWKi5a7SUQQ .error-icon{fill:#552222;}#mermaid-svg-SdaTGpWKi5a7SUQQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SdaTGpWKi5a7SUQQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .marker.cross{stroke:#333333;}#mermaid-svg-SdaTGpWKi5a7SUQQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SdaTGpWKi5a7SUQQ p{margin:0;}#mermaid-svg-SdaTGpWKi5a7SUQQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster-label text{fill:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster-label span{color:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster-label span p{background-color:transparent;}#mermaid-svg-SdaTGpWKi5a7SUQQ .label text,#mermaid-svg-SdaTGpWKi5a7SUQQ span{fill:#333;color:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .node rect,#mermaid-svg-SdaTGpWKi5a7SUQQ .node circle,#mermaid-svg-SdaTGpWKi5a7SUQQ .node ellipse,#mermaid-svg-SdaTGpWKi5a7SUQQ .node polygon,#mermaid-svg-SdaTGpWKi5a7SUQQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .rough-node .label text,#mermaid-svg-SdaTGpWKi5a7SUQQ .node .label text,#mermaid-svg-SdaTGpWKi5a7SUQQ .image-shape .label,#mermaid-svg-SdaTGpWKi5a7SUQQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-SdaTGpWKi5a7SUQQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .rough-node .label,#mermaid-svg-SdaTGpWKi5a7SUQQ .node .label,#mermaid-svg-SdaTGpWKi5a7SUQQ .image-shape .label,#mermaid-svg-SdaTGpWKi5a7SUQQ .icon-shape .label{text-align:center;}#mermaid-svg-SdaTGpWKi5a7SUQQ .node.clickable{cursor:pointer;}#mermaid-svg-SdaTGpWKi5a7SUQQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .arrowheadPath{fill:#333333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SdaTGpWKi5a7SUQQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SdaTGpWKi5a7SUQQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SdaTGpWKi5a7SUQQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster text{fill:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ .cluster span{color:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SdaTGpWKi5a7SUQQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SdaTGpWKi5a7SUQQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-SdaTGpWKi5a7SUQQ .icon-shape,#mermaid-svg-SdaTGpWKi5a7SUQQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SdaTGpWKi5a7SUQQ .icon-shape p,#mermaid-svg-SdaTGpWKi5a7SUQQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SdaTGpWKi5a7SUQQ .icon-shape .label rect,#mermaid-svg-SdaTGpWKi5a7SUQQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SdaTGpWKi5a7SUQQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SdaTGpWKi5a7SUQQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SdaTGpWKi5a7SUQQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
是
否
采集指标

http_requests_in_flight / CPU
每 15 秒计算

期望副本数
期望 > 当前?
scaleUp 行为

稳定窗口 0 秒

允许一次翻倍
立即调整 replicas
scaleDown 行为

稳定窗口 300 秒

每 2 分钟最多缩 1 个
过去 5 分钟

所有建议都指向缩容?
保持当前副本数

过滤短时波动
达到 min/max 边界?
锁定在边界值

(图 3:HPA 扩缩容的决策链与两个保护窗口------扩容可以激进(快速响应),缩容必须保守(避免抖动和中断在途会话)。这是与传统 Web 服务相反的配置取向。)

2.7 Service 与 Ingress:粘性的正确落点

上一篇用 Nginx 的 hash $arg_session_id consistent 做会话粘性。在 K8s 里,Service 层只提供 sessionAffinity: ClientIP,粒度是客户端 IP------这对 Agent 来说不够。

为什么不够?因为同一个企业微信用户,可能在办公室用 WiFi、在通勤路上用 4G ,IP 变了就会被路由到不同 Pod。如果会话状态在 Redis 里,这不会出错,但会产生额外的跨 Pod 读 Redis 开销(每次请求都要从 Redis 加载上下文)。

我的做法是"两层粘性":

层 手段 粒度 作用
Ingress 层 Nginx Ingress 的 upstream-hash-by: $http_x_session_id 会话级(准确) 让同一个会话尽量落到同一 Pod
Service 层 sessionAffinity: ClientIP IP 级(兜底) Ingress 直连 Pod 时的备份

关键是 Ingress 那一层 :Nginx Ingress Controller 支持通过注解 nginx.ingress.kubernetes.io/upstream-hash-by 指定哈希键,用 $http_x_session_id 就能实现会话级粘性 ------而 session_id 由应用在请求头里透传(上一篇 4.4 节的 proxy_set_header X-Session-Id)。

这里必须强调一遍上一篇的结论,因为它在 K8s 里被违反的概率更高:粘性是性能优化,不是正确性保证。 正确性来自"会话状态在 Redis 里"------粘性只影响延迟,不影响功能 。任何依赖粘性来保证数据正确的设计,在 Pod 被驱逐时都会出错。这是我在 K8s 上最重要的一个判断。

(图 4:Pod 从创建到就绪的完整生命周期与三类探针的作用区间------startupProbe 覆盖启动期(此时 liveness 被禁用),readinessProbe 决定何时接入流量,livenessProbe 负责运行期自愈。)
#mermaid-svg-UlbRXe9Fpu0ZtLzo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .error-icon{fill:#552222;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .marker.cross{stroke:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo p{margin:0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-UlbRXe9Fpu0ZtLzo g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-UlbRXe9Fpu0ZtLzo g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UlbRXe9Fpu0ZtLzo .edgeLabel .label text{fill:#333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .label div .edgeLabel{color:#333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo #statediagram-barbEnd{fill:#333333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .cluster-label,#mermaid-svg-UlbRXe9Fpu0ZtLzo .nodeLabel{color:#131300;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .note-edge{stroke-dasharray:5;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-note text{fill:black;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram-note .nodeLabel{color:black;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagram .edgeLabel{color:red;}#mermaid-svg-UlbRXe9Fpu0ZtLzo #dependencyStart,#mermaid-svg-UlbRXe9Fpu0ZtLzo #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-UlbRXe9Fpu0ZtLzo .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UlbRXe9Fpu0ZtLzo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调度中
节点已选,拉镜像/挂卷
readinessProbe 通过,接入 Endpoints
livenessProbe 通过
readinessProbe 失败
依赖恢复
容器启动
startupProbe 通过
连续失败超阈值
滚动更新 / 缩容 / 驱逐
Pending
ContainerCreating
Starting
initContainer 完成预热门
startupProbe 每 2 秒探测
未就绪(最多 60 秒)
探测通过
InitPhase
AppBoot
StartupProbing
ReadyPhase
Ready
Serving
Unready
Terminating
preStop 睡眠 10 秒(等 Endpoints 刷新)
发送 SIGTERM
应用优雅停机
exit 0
PreStopSleep
SIGTERM
GracefulShutdown


3️⃣ 环境准备

3.1 版本与依赖要求

组件 本文验证版本 最低要求 备注
Kubernetes v1.30 v1.25+ 低于 1.25 的 autoscaling/v2beta2 已废弃
Helm 3.15 3.10+ 用于模板化多环境
容器运行时 containerd 1.7 1.6+ Docker 作为运行时自 1.24 起已移除
OpenClaw v2026.8 v2026.5+ 探针端点与优雅停机依赖此版本
Node.js(镜像内) v24.15-slim v22.11+ alpine 需先验证原生模块
Redis 7.2 6.2+ 会话状态与分布式锁
Ingress Controller NGINX Ingress 1.11 1.8+ upstream-hash-by 注解依赖它
metrics-server 0.7 0.6+ HPA 读 CPU/内存指标的前提
prometheus-adapter / KEDA 4.11 / 2.14 ------ 用自定义指标驱动 HPA 时必需

3.2 集群前置条件清单

这些是"没有就跑不起来"的前置项,建议先逐条核对,不要等部署失败才回头查:

  1. 一个 StorageClass (PVC 依赖它)。用 kubectl get storageclass 确认有默认 SC。
  2. Ingress Controller (对外暴露依赖它)。没有的话可以先只用 NodePort 验证。
  3. 一个可达的镜像仓库 (私有 Harbor / 云厂商 ACR/TCR / 或 Docker Hub)。国内环境这一条最容易成为第一道坎,见 5.2。
  4. metrics-server (HPA 依赖它)。kubectl top pods 能返回即正常。
  5. 资源配额策略:决定是否为 Agent 单独划命名空间并设置 ResourceQuota。
  6. 日志采集 (可选但强烈建议):stdout 落盘到集群日志系统(Loki / ES)------否则 Pod 被删后日志就没了。

3.3 三条前置决策

决策一:命名空间怎么划分?

三种做法:按环境分(agent-dev/agent-test/agent-prod)、按应用分(所有环境同命名空间用不同 Deployment 名)、按环境 + 应用分(推荐)。

我推荐第三种 :agent-prod、agent-staging 各自独立命名空间。理由不是洁癖,而是权限与配额的隔离 :命名空间是 K8s 里 RBAC 与 ResourceQuota 的作用域单位。没有命名空间隔离,"给测试环境的人开点权限"就等于把生产也开了。

决策二:镜像仓库与 tag 策略。

tag 策略只有三条规则,但违反任何一条都会在生产里出事:

  1. 绝不用 latest。它会让你完全无法回答"线上跑的是哪个版本"。
  2. 用不可变 tag (如 v2026.8.3-20260920 或 Git commit SHA ),并在部署时记录 digest (@sha256:...)。digest 才是唯一真正不可变的标识------tag 是可能被覆盖的。
  3. 每个构建产物保留至少 30 天(上一篇 6.4 节建议 9 的同源要求:回滚的前提是"旧版本还在")。

决策三:手写 YAML 还是 Helm?

方式 优点 缺点 适用
手写 YAML 直观、无学习成本 多环境要靠 sed/kustomize 变体,易漂移 单环境、YAML < 5 个
Kustomize K8s 原生、overlay 清晰 复杂逻辑(条件、循环)表达力弱 2~4 套环境、差异较小
Helm 模板 + values 分离、生态成熟 模板语法难调、--dry-run 也有坑 多环境、差异大、需要复用

我的建议是 Helm,但有个前提条件:环境差异必须尽量收进 values。 如果两个环境的 Deployment 结构完全不同(不是值不同,而是字段不同),那 Helm 模板会变成一堆 {``{ if .Values.isProd }},可读性比手写还差 。这种情况下正确做法是把"结构差异"也变成 values 里的开关,而不是模板里的 if。


4️⃣ 核心功能详解:八步把 Agent 搬上 K8s

先看整体路径。
#mermaid-svg-DoiUkVCEWJC62To6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DoiUkVCEWJC62To6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DoiUkVCEWJC62To6 .error-icon{fill:#552222;}#mermaid-svg-DoiUkVCEWJC62To6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DoiUkVCEWJC62To6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DoiUkVCEWJC62To6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DoiUkVCEWJC62To6 .marker.cross{stroke:#333333;}#mermaid-svg-DoiUkVCEWJC62To6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DoiUkVCEWJC62To6 p{margin:0;}#mermaid-svg-DoiUkVCEWJC62To6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DoiUkVCEWJC62To6 .cluster-label text{fill:#333;}#mermaid-svg-DoiUkVCEWJC62To6 .cluster-label span{color:#333;}#mermaid-svg-DoiUkVCEWJC62To6 .cluster-label span p{background-color:transparent;}#mermaid-svg-DoiUkVCEWJC62To6 .label text,#mermaid-svg-DoiUkVCEWJC62To6 span{fill:#333;color:#333;}#mermaid-svg-DoiUkVCEWJC62To6 .node rect,#mermaid-svg-DoiUkVCEWJC62To6 .node circle,#mermaid-svg-DoiUkVCEWJC62To6 .node ellipse,#mermaid-svg-DoiUkVCEWJC62To6 .node polygon,#mermaid-svg-DoiUkVCEWJC62To6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DoiUkVCEWJC62To6 .rough-node .label text,#mermaid-svg-DoiUkVCEWJC62To6 .node .label text,#mermaid-svg-DoiUkVCEWJC62To6 .image-shape .label,#mermaid-svg-DoiUkVCEWJC62To6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-DoiUkVCEWJC62To6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DoiUkVCEWJC62To6 .rough-node .label,#mermaid-svg-DoiUkVCEWJC62To6 .node .label,#mermaid-svg-DoiUkVCEWJC62To6 .image-shape .label,#mermaid-svg-DoiUkVCEWJC62To6 .icon-shape .label{text-align:center;}#mermaid-svg-DoiUkVCEWJC62To6 .node.clickable{cursor:pointer;}#mermaid-svg-DoiUkVCEWJC62To6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DoiUkVCEWJC62To6 .arrowheadPath{fill:#333333;}#mermaid-svg-DoiUkVCEWJC62To6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DoiUkVCEWJC62To6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DoiUkVCEWJC62To6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DoiUkVCEWJC62To6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DoiUkVCEWJC62To6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DoiUkVCEWJC62To6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DoiUkVCEWJC62To6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DoiUkVCEWJC62To6 .cluster text{fill:#333;}#mermaid-svg-DoiUkVCEWJC62To6 .cluster span{color:#333;}#mermaid-svg-DoiUkVCEWJC62To6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DoiUkVCEWJC62To6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DoiUkVCEWJC62To6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-DoiUkVCEWJC62To6 .icon-shape,#mermaid-svg-DoiUkVCEWJC62To6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DoiUkVCEWJC62To6 .icon-shape p,#mermaid-svg-DoiUkVCEWJC62To6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DoiUkVCEWJC62To6 .icon-shape .label rect,#mermaid-svg-DoiUkVCEWJC62To6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DoiUkVCEWJC62To6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DoiUkVCEWJC62To6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DoiUkVCEWJC62To6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
1 写 Dockerfile

多阶段构建
2 ConfigMap

  • Secret
    3 Deployment

探针/preStop/资源
4 Service + Ingress

会话粘性
5 存储

PVC / emptyDir
6 HPA + PDB

扩缩容保护
7 Helm 化

多环境模板
8 灰度发布

双 Deployment
验证通过?
纳入 CI/CD

(图 5:Agent 上 K8s 的八个步骤------注意第 3 步到第 8 步是有回环的:灰度验证不通过要回到 Deployment 调整(很可能是资源或探针参数),而不是继续往下走。)

4.1 第一步:写 Dockerfile(多阶段 + 非 root)

这是全篇投入产出比最高的一步:认真写一次,镜像从 2.1GB 降到 420MB,之后每次扩缩容都受益。

dockerfile 复制代码
# ---------- 阶段一:构建(需要全套工具链)----------
FROM node:24.15-slim AS builder

WORKDIR /build

# 为什么先拷 package 文件再 npm ci?
# 因为 Docker 分层缓存:只要依赖没变,这一层就命中缓存,
# 改业务代码时不会重装依赖(构建从 6 分钟降到 40 秒)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts

# 拷源码并构建(TypeScript 编译等)
COPY tsconfig.json ./
COPY src ./src
RUN npm ci --include=dev && npm run build && npm prune --omit=dev
# 注意最后 prune:把 devDependencies 从 node_modules 里彻底清掉
# 不清的话构建产物里会多出 300MB+ 的编译工具链

# ---------- 阶段二:运行(只要运行时 + 最小系统依赖)----------
FROM node:24.15-slim

# Agent 需要的系统工具:文档转换 + 字体(否则中文 PDF 全是方块)
# 单独一行 apt-get,装完立刻清缓存,控制层体积
RUN apt-get update && apt-get install -y --no-install-recommends \
      libreoffice-writer-nogui \
      fonts-noto-cjk \
      poppler-utils \
      ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && fc-cache -f
# 注意 fonts-noto-cjk 约 200MB,但中文场景不可省 ------ 删了会出乱码

# 为什么创建非 root 用户?
# 第 7 篇的权限边界原则:即使提示词注入成功,也执行不了危险操作
# 另外 k8s 的 PodSecurityStandard 的 restricted 级别要求非 root
RUN groupadd -g 10001 agent \
    && useradd -u 10001 -g 10001 -m -s /usr/sbin/nologin agent

WORKDIR /app
COPY --from=builder --chown=10001:10001 /build/dist ./dist
COPY --from=builder --chown=10001:10001 /build/node_modules ./node_modules
COPY --chown=10001:10001 package.json ./

# 目录规划对齐第 11 篇 3.2 节的权限设计
RUN mkdir -p /var/lib/openclaw/workspace /var/lib/openclaw/memory \
    && chown -R 10001:10001 /var/lib/openclaw

USER 10001:10001

# 为什么用 exec 形式(数组)而不是 shell 形式?
# shell 形式会多一层 /bin/sh 进程,导致 Node 收不到 SIGTERM
# 这是容器里"优雅停机失效"的头号原因
ENTRYPOINT ["node", "/app/dist/index.js"]

这段 Dockerfile 里有四个关键点,每个都对应一个真实踩过的坑。

坑一(性能):COPY package.json 与 npm ci 的顺序。 如果把源码和依赖一起 COPY 再 install,每次改一行代码都要重装一遍依赖 (6 分钟)。分开之后,依赖层被缓存,改代码后的构建只要 40 秒。

坑二(体积):忘了 npm prune --omit=dev。 构建阶段装了 devDependencies(TypeScript、测试框架、linter),如果不清理就拷到运行阶段,会白白多出 300MB+ 。多阶段构建能隔离"构建工具",但如果你在构建阶段最后把 node_modules 整个拷过去,等于没隔离。

坑三(功能):字体不能省。 fonts-noto-cjk 占用约 200MB,看起来是块肥肉。但一旦删掉,任何中文 PDF / Word 的转换结果都是方块。 这是典型的"瘦身瘦出功能故障"------我建议所有"看起来可以删"的系统依赖,都要先在测试环境跑一遍完整功能自测再删。

坑四(最隐蔽):ENTRYPOINT 必须用 exec 数组形式。 用 shell 形式(ENTRYPOINT node /app/dist/index.js)时,容器里的 PID 1 是 /bin/sh,Node 是它的子进程 。K8s 发 SIGTERM 时发给 PID 1(也就是 sh),而 sh 默认不会把信号转发给子进程 ------结果是 Node 永远收不到 SIGTERM,terminationGracePeriodSeconds 到期后被 SIGKILL ,上一篇辛苦实现的优雅停机在容器里完全失效。

这个坑的可怕之处在于"没有任何报错" :服务能起来、能工作、日志正常,只有在滚动更新时你才会发现"在途会话全被砍断了"。排障方向很容易跑到应用代码上去("我的 SIGTERM handler 是不是写错了"),而真正的问题在 Dockerfile 的一行。

4.2 第二步:ConfigMap 与 Secret

yaml 复制代码
apiVersion: v1
kind: ConfigMap
metadata:
  name: openclaw-agent-config
  namespace: agent-prod
data:
  # ---- 模型与路由(第 4 篇)----
  PRIMARY_MODEL: "qwen-max"
  FALLBACK_MODEL: "qwen-plus"
  MODEL_TIMEOUT_MS: "20000"

  # ---- 探针与限流 ----
  PROBE_DEEP_INTERVAL_MS: "300000"
  SESSION_TTL_SECONDS: "259200"       # 3 天,对齐第 11 篇 4.4 节

  # ---- 灰度开关(第 11 篇 4.5 节)----
  CANARY_PERCENT: "5"

  # ---- 日志 ----
  LOG_LEVEL: "info"
  LOG_FORMAT: "json"                   # 容器里必须用 json,便于日志系统解析
---
apiVersion: v1
kind: Secret
metadata:
  name: openclaw-agent-secret
  namespace: agent-prod
type: Opaque
stringData:
  # ⚠️ 仅用于演示结构。生产请勿把明文写进 YAML / Git
  # 正确做法:External Secrets / Vault Injector / Sealed Secrets
  MODEL_API_KEY: "<REPLACE_ME>"
  WECOM_CORP_SECRET: "<REPLACE_ME>"
  REDIS_PASSWORD: "<REPLACE_ME>"

关于 Secret 的三条操作纪律:

第一,stringData 只是为了写起来方便 ,apply 后 K8s 会把它转成 base64 存进 data。但你的 YAML 源文件里是明文,所以这个文件绝不能进 Git。

第二,优先级顺序是"外部密钥管理 > Sealed Secrets > 裸 Secret" 。规模小的时候用裸 Secret 可以接受(配合 etcd 加密),但必须在 kubectl get secret -o yaml 能出明文这件事上保持清醒------审计的时候这一条经常被判为高危。

第三,改 Secret 后记得 rollout restart。 因为以 env 注入的密钥不会自动更新(2.2 节第 5 点)。这是一个非常常见的"改了怎么没生效"。

4.3 第三步:Deployment(探针、preStop、资源)

这是整篇最核心的一份 YAML。

yaml 复制代码
apiVersion: apps/v1
kind: Deployment
metadata:
  name: openclaw-agent
  namespace: agent-prod
  labels:
    app: openclaw-agent
spec:
  replicas: 3
  revisionHistoryLimit: 5          # 保留最近 5 个 ReplicaSet,回滚才有得回
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                  # 先多起 1 个(保证容量不掉)
      maxUnavailable: 0            # 关键:绝不允许"先杀后起"
      # maxUnavailable=0 + maxSurge=1 是最稳的组合:
      # 任何时刻可用副本数都不少于目标副本数
  selector:
    matchLabels:
      app: openclaw-agent
  template:
    metadata:
      labels:
        app: openclaw-agent
        version: v1                # 灰度靠这个标签分流(第 8 步)
    spec:
      # ---- 优雅停机的总预算 ----
      terminationGracePeriodSeconds: 60
      # 必须 > 应用优雅停机总耗时(上一篇的排空 8s + 在途 25s + 刷盘 13s ≈ 46s)
      # 给 60 秒留 30% 余量。给太小 → SIGKILL 砍断;给太大 → 发布变慢

      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault

      initContainers:
        # 预热:在正式容器启动前先把"慢操作"做完
        # 为什么独立成 initContainer?因为它失败可以单独重试,且不占主容器启动预算
        - name: warmup
          image: registry.example.com/openclaw/agent:v2026.8.3-20260920
          command: ["node", "/app/dist/warmup.js"]
          resources:
            requests: { cpu: "200m", memory: "256Mi" }
            limits:   { memory: "512Mi" }

      containers:
        - name: agent
          image: registry.example.com/openclaw/agent:v2026.8.3-20260920
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080

          # ---- 配置注入:ConfigMap 用 envFrom(非敏感),Secret 用 volume(敏感)----
          envFrom:
            - configMapRef:
                name: openclaw-agent-config
          env:
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            # 把 Pod 名注入,日志里带上它就能知道"这条日志来自哪个副本"
            # 没有这个,3 副本的日志混在一起,排障时你分不清是谁出的问题
          volumeMounts:
            - name: secrets
              mountPath: /etc/openclaw
              readOnly: true              # 密钥以文件形式挂载,不走环境变量(2.2 节)
            - name: workspace
              mountPath: /var/lib/openclaw/workspace
            - name: tmp
              mountPath: /tmp             # emptyDir,避免写容器层导致磁盘膨胀

          # ---- 资源:CPU 不设 limit,内存必须给足(2.5 节)----
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              memory: "2.5Gi"             # 按"处理 20MB 附件的最坏峰值 1.9GB × 1.3"
              # cpu 刻意不设 limit:避免处理附件时被 throttle 造成延迟毛刺

          # ---- 三级探针:顺序与职责不能混(2.4 节)----
          startupProbe:
            httpGet: { path: /health/live, port: http }
            periodSeconds: 2
            failureThreshold: 30
            # 最长容忍 60 秒启动。Agent 启动 30~60 秒,留足余量
            # 它成功之前,liveness 与 readiness 都不会执行

          livenessProbe:
            httpGet: { path: /health/live, port: http }
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 3
            # 容忍 30 秒。只检查进程与内部状态,绝不检查外部依赖

          readinessProbe:
            httpGet: { path: /health/ready, port: http }
            periodSeconds: 5
            failureThreshold: 3
            timeoutSeconds: 3
            successThreshold: 1
            # 容忍 15 秒。失败只摘流量,不重启

          # ---- 优雅停机:preStop 是 K8s 里的"排空窗口"----
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]
                # 这 10 秒在做什么?等 Endpoints 刷新。
                # K8s 删 Pod 时"摘 Endpoints"与"发 SIGTERM"是并行的、无先后保证,
                # 所以必须用 preStop 拖住,让上游先停止转发(上一篇 2.4 节同源原则)

      volumes:
        - name: secrets
          secret:
            secretName: openclaw-agent-secret
            defaultMode: 0400            # 只读,防误改
        - name: workspace
          emptyDir:
            sizeLimit: 2Gi
            # 为什么工作目录用 emptyDir 而不是 PVC?
            # 因为它只放临时文件(转换中间产物、附件暂存);
            # 需要持久化的记忆在 Redis 与数据库里(2.3 节的分层原则)
            # sizeLimit 防止某个 Pod 把节点磁盘写满
        - name: tmp
          emptyDir:
            sizeLimit: 1Gi

这份 YAML 里有六个必须讲清楚的决策。

决策一:maxUnavailable: 0 + maxSurge: 1。

这是"可用性优先"的滚动策略:任何时刻可用副本数都不低于目标值 。代价是发布时短暂多占 1 个副本的资源 ,且发布更慢(因为必须等新 Pod 就绪才能杀旧的,而 Agent 就绪要 30~60 秒,3 副本滚完约 4 分钟)。

如果换成 maxUnavailable: 1,发布快一倍,但容量会短暂掉到 2/3。 对于有 SLA 的服务,这 1/3 的容量缺口可能就是某个高峰的雪崩起点。我的判断是:Agent 服务的发布频率不高(通常一周几次),用 4 分钟换"发布期零容量损失"非常划算。

决策二:terminationGracePeriodSeconds: 60。

这个值的计算依据是"应用侧优雅停机的总耗时",必须比它大。 上一篇的优雅停机是:排空窗口 8 秒 + 在途处理最长 25 秒 + 刷盘 8+5 秒 ≈ 46 秒。给 60 秒留了约 30% 余量。

这两个数字必须成对配置,而且要放在一起看:

  • 如果 terminationGracePeriodSeconds 小于 应用优雅停机耗时 → 应用还没刷完盘就被 SIGKILL,数据丢失;
  • 如果远大于 (比如 300 秒)→ 每次发布时旧 Pod 都要拖 300 秒才消失,滚动更新奇慢,且发布期间资源占用翻倍。

我的规则是:terminationGracePeriodSeconds = 应用侧最大优雅停机耗时 × 1.3,然后把应用侧的最大耗时写进代码注释里。

决策三:preStop 里那个 sleep 10。

这是 K8s 里最反直觉、也最容易被质疑的一句配置。 很多人第一反应是"为什么要 sleep,这不是浪费时间吗"。

原因在 2.4 节坑四:K8s 删除 Pod 时,"从 Endpoints 摘除"和"给容器发 SIGTERM"是并行进行的,没有任何顺序保证。 所以当你的应用收到 SIGTERM 并开始拒绝新连接时,kube-proxy / Ingress 可能还没更新完转发规则 ------这段时间到达的请求会被转发到一个已经"关门"的 Pod 上,直接连接失败。

sleep 10 就是给上游留的反应时间 (10 秒通常足够 kube-proxy 完成 iptables/ipvs 规则同步与 Ingress Controller 更新后端列表)。代价是每次发布每个 Pod 多 10 秒 ,3 副本滚完多 30 秒。用 30 秒换"发布期零失败请求",是显著划算的。

决策四:CPU 不给 limit。

2.5 节已经论证:给死 CPU limit 会制造延迟毛刺 (处理附件时被 throttle,单次请求从 4 秒变 12 秒,撞上超时就是一次"莫名失败")。内存必须给 limit(不可压缩资源),CPU 可以不给(可压缩资源)------这是两类资源本质差异的体现,不是随意的偏好。

顺带说一个风险 :不给 CPU limit 意味着这个 Pod 理论上可以吃掉节点上所有空闲 CPU。如果节点上混部了其他敏感服务,那么应该给一个"宽松但有上限"的值(比如 2 核),而不是完全不给。 我通常会在"业务专属节点池"里不给,在"混部节点池"里给 2 倍余量。

决策五:工作目录用 emptyDir + sizeLimit。

sizeLimit 这个字段值得单独说。 默认的 emptyDir 会一直涨到把节点磁盘写满------而节点磁盘写满会引发一连串故障 (kubelet 无法写日志、镜像无法拉取、所有 Pod 一起异常)。给每个 emptyDir 设 sizeLimit 是集群卫生的基本要求,尤其对 Agent 这种会处理大文件的负载。

决策六:revisionHistoryLimit: 5。

默认值是 10。保留 5 个 ReplicaSet 意味着 kubectl rollout undo 可以回退 5 个版本,足够覆盖"一次错误发布 + 快速迭代几轮"的场景 ,同时不至于让集群里堆积太多无用对象。但请记住:rollout undo 只能回滚"Deployment 的 spec",不能回滚数据库结构或 Redis 里的状态结构(上一篇 4.7 节讲过这个边界)。

4.4 第四步:Service 与 Ingress(含会话粘性)

yaml 复制代码
apiVersion: v1
kind: Service
metadata:
  name: openclaw-agent
  namespace: agent-prod
spec:
  type: ClusterIP
  selector:
    app: openclaw-agent
  ports:
    - name: http
      port: 80
      targetPort: http             # 用端口名而不是数字,端口改了不用同步改这里
  sessionAffinity: ClientIP        # IP 级粘性作为兜底
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800        # 3 小时,与 SESSION_TTL 对齐
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: openclaw-agent
  namespace: agent-prod
  annotations:
    # 会话级粘性:按请求头 X-Session-Id 做一致性哈希(2.7 节)
    # 这比 Service 的 ClientIP 粘性准确得多 ------ 用户换网络也不会换 Pod
    nginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_session_id"

    # ---- 超时:Agent 单次最长 30 秒,给 60 秒余量 ----
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"

    # ---- 禁止在响应超时时重试 ----
    nginx.ingress.kubernetes.io/proxy-next-upstream: "error"
    # 只保留 error:连接失败才重试。
    # 绝不能加 timeout ------ 超时很可能意味着后端正在执行工具调用,
    # 重试会导致业务重复执行(第 10 篇 / 第 11 篇的同源原则)

    # ---- 附件上传上限:与应用侧限制对齐(2.5 节)----
    nginx.ingress.kubernetes.io/proxy-body-size: "20m"

    # ---- 单 IP 限流(防刷,与第 6 篇的成本控制呼应)----
    nginx.ingress.kubernetes.io/limit-rps: "10"
spec:
  ingressClassName: nginx
  rules:
    - host: agent.example.com
      http:
        paths:
          - path: /api/agent
            pathType: Prefix
            backend:
              service:
                name: openclaw-agent
                port:
                  number: 80
  tls:
    - hosts: [agent.example.com]
      secretName: agent-tls

这份 Ingress 里,proxy-next-upstream: "error" 是最值得强调的一行。

它的默认值包含 timeout ------也就是说,如果不显式改成 error,Nginx Ingress 在默认配置下会自动在超时后重试下一个后端 。而 Agent 的超时很可能意味着"后端正在执行工具调用",重试就是重复执行业务(改地址执行两遍、工单开两张)。

为什么 K8s 场景下这个风险比 Nginx 裸机场景更高? 因为副本数更多 ------3 个 Pod 意味着 Nginx 有 2 个"下一个后端"可以重试。副本越多,重复执行的概率越高。 这也是一个"扩容带来新风险"的典型例子。

4.5 第五步:存储

按 2.3 节的分层原则,Agent 在 K8s 里大多数情况下不需要 PVC------会话与记忆在 Redis,工作目录用 emptyDir。

但有一类数据确实需要 PVC:审计与归档。 这类数据的特点是"必须持久化、可以只由单副本写、不需要高速随机读"。

yaml 复制代码
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: openclaw-audit-archive
  namespace: agent-prod
spec:
  accessModes: ["ReadWriteOnce"]     # RWO:一次只被一个节点挂载(2.3 节的关键限制)
  storageClassName: standard-rwo
  resources:
    requests:
      storage: 20Gi

这里必须再强调一次 2.3 节那个坑的应对方式:如果用 RWO 的 PVC,这个 Deployment 的副本数就必须是 1(针对挂这个卷的工作负载)。

正确做法是把"归档"拆成独立的单副本 Deployment (比如一个 agent-archiver,replicas: 1),它从 Redis/消息队列里读审计事件、写入 PVC。而主 Agent 服务保持无状态、可随意扩缩容。 这是"有状态部分隔离"的标准做法:

工作负载 副本数 卷 职责
openclaw-agent 3(可扩缩) emptyDir 处理会话,完全无状态
agent-archiver 1(固定) PVC (RWO) 消费审计流,落盘归档
agent-memory-sync 1(或 Leader 选举) PVC (RWO) 三级记忆的定期快照

这张表体现的是一个通用原则:不要把"需要单副本写"和"需要多副本横向扩"的职责放进同一个 Deployment。 否则你永远会卡在"要扩副本但 PVC 挂不上"或者"不扩副本但接不住流量"的两难里。

4.6 第六步:HPA 与 PDB

按 2.6 节的判断,HPA 用并发类指标而不是 CPU。

yaml 复制代码
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: openclaw-agent
  namespace: agent-prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: openclaw-agent
  minReplicas: 3
  maxReplicas: 10
  metrics:
    # ---- 主指标:并发请求数(2.6 节首选)----
    - type: Pods
      pods:
        metric:
          name: http_requests_in_flight
        target:
          type: AverageValue
          averageValue: "20"        # 每 Pod 平均 20 并发时扩容
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0     # 扩容不等待:响应要快
      policies:
        - type: Percent
          value: 100                    # 允许一次翻倍
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300   # 缩容等 5 分钟:防止抖动震荡
      policies:
        - type: Pods
          value: 1                      # 每 2 分钟最多缩 1 个
          periodSeconds: 120
          # 为什么缩容这么保守?
          # 1) Agent 启动要 30~60 秒,缩错了再扩回来代价很大
          # 2) 缩容会中断在途会话(虽有优雅停机,但用户会多等)

扩容激进、缩容保守------这个取向和传统 Web 服务是相反的。 传统 Web 服务通常两者对称(因为实例启动快、无状态、请求短)。Agent 的三个特征(启动慢、有状态、请求长)共同要求"宁可多留一会儿,也不要频繁缩容"。

这里必须指出一个 HPA 的常见误解:stabilizationWindowSeconds 不是"延迟执行",而是"看窗口内的历史建议值并取最保守的那个"。 设成 300 秒意味着"只有当过去 5 分钟内所有建议都指向缩容时才真的缩"------它能有效过滤掉短时波动导致的震荡。 没有这个配置时,一个波浪形的负载会让 HPA 每 15 秒变一次副本数,Pod 一直在创建和销毁,而每次创建都要拉镜像、建连接池。

然后是 PDB(PodDisruptionBudget)。

yaml 复制代码
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: openclaw-agent
  namespace: agent-prod
spec:
  minAvailable: 2                     # 任何"自愿中断"操作时至少保留 2 个可用
  selector:
    matchLabels:
      app: openclaw-agent

PDB 的作用是保护"自愿中断"场景 :节点排水(kubectl drain,用于升级节点、缩容节点池)。没有 PDB 时,drain 会把节点上的所有 Pod 一次性驱逐 ------如果你只有 3 个副本且恰好有 2 个在同一节点上,那一刻服务只剩 1 个副本,容量掉 2/3。

必须理解 PDB 的边界:它只保护"自愿中断",不保护"非自愿中断"。 节点突然宕机、OOM Killer、探针失败重启------这些 PDB 一概管不了 。所以 PDB 不是"高可用方案",它只是"运维操作时的安全阀"。 真正的高可用来自"多节点分布 + 多副本",PDB 只保证你的运维操作不会把它破坏掉。

顺带一条配套要求:一定要配反亲和性,让副本分散到不同节点。

yaml 复制代码
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector:
                  matchLabels:
                    app: openclaw-agent

这里我刻意用了 preferred(软约束)而不是 required(硬约束)。 硬约束的问题是:如果节点数少于副本数,Pod 会永远处于 Pending 状态 ------比如 3 副本但只有 2 个节点,第 3 个 Pod 永远起不来。软约束下它会退化到"尽量分散",至少服务能跑。 这是一个"可用性优先于隔离性"的取舍;如果你的节点数足够(≥ 副本数),可以改为硬约束获得严格保证。

4.7 第七步:Helm 化与多环境

YAML 手写到这一步,你会发现 agent-dev 和 agent-prod 的差异只有副本数、资源、域名、模型名 这几处。把它们抽成 values,其余全部模板化。

复制代码
charts/openclaw-agent/
├── Chart.yaml
├── values.yaml                 # 默认值(开发环境)
├── values-staging.yaml         # 预发覆盖
├── values-prod.yaml            # 生产覆盖
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    ├── hpa.yaml
    └── pdb.yaml
yaml 复制代码
# values-prod.yaml
replicaCount: 3
image:
  repository: registry.example.com/openclaw/agent
  tag: "v2026.8.3-20260920"      # 绝不用 latest(3.3 节决策二)
  pullPolicy: IfNotPresent

resources:
  requests: { cpu: "500m", memory: "1Gi" }
  limits:   { memory: "2.5Gi" }  # CPU 不设 limit(4.3 节决策四)

config:
  primaryModel: "qwen-max"
  logLevel: "info"
  canaryPercent: 5

ingress:
  host: "agent.example.com"
  sessionHashBy: "$http_x_session_id"

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
bash 复制代码
# 多环境的用法:只差一个 -f
helm upgrade --install openclaw-agent ./charts/openclaw-agent \
  -n agent-prod -f values-prod.yaml \
  --set image.tag=v2026.8.3-20260921 \
  --atomic --timeout 6m

--atomic 这个参数值得解释:它让 Helm 在升级失败时自动回滚到上一个版本。

为什么我强制在生产用 --atomic? 因为没它的时候,一次失败的升级会留下一个半更新的状态 (部分资源已改、部分没改),而这个状态没人知道它到底处于哪个版本 。有 --atomic 至少能保证"要么全成功,要么回到升级前"。

配套的 --timeout 6m 也很重要 :默认 5 分钟。因为 Agent 的就绪要 30~60 秒,3 副本滚动更新(maxUnavailable=0)需要约 4 分钟 ,加上 PDB 与 preStop 的 10 秒,默认超时就偏紧 。超时的后果是 Helm 判定失败并触发回滚,而回滚本身又要 4 分钟------一次虚惊变成了 8 分钟的折腾。

4.8 第八步:K8s 上的灰度(双 Deployment)

上一篇的灰度是"会话级路由 + 人工放量"。在 K8s 里,最简洁的落地方式是"双 Deployment + 共享 Service" :新旧版本各是一个 Deployment,靠 version 标签被同一个 Service 选中,副本数的比例就是流量比例。

yaml 复制代码
# 新版本:先起 1 个副本(3 个旧 + 1 个新 ≈ 25% 流量)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: openclaw-agent-v2
  namespace: agent-prod
spec:
  replicas: 1
  selector:
    matchLabels:
      app: openclaw-agent          # 关键:与旧 Deployment 相同的 app 标签
      version: v2
  template:
    metadata:
      labels:
        app: openclaw-agent        # 被同一个 Service 选中
        version: v2
    spec:
      containers:
        - name: agent
          image: registry.example.com/openclaw/agent:v2026.8.4-20260921
          # 其余字段与 v1 一致(用 Helm 模板复用)

这种方式有三个优点,也有一个必须处理的缺点。

优点 :改副本数就是改流量比例(kubectl scale deployment openclaw-agent-v2 --replicas=1)、回滚就是 scale 到 0、完全不需要服务网格 、两个版本可独立观测(Prometheus 按 version 标签分组即可对比指标)。

缺点(必须处理):会话粘性会失效。 因为现在是两个不同的 Deployment ,Pod 名、Pod IP 都不同,而 Ingress 的 upstream-hash-by 是对所有匹配的 Pod 做哈希------同一个 sessionId 在 Pod 集合变化时会重新分配到不同 Pod(一致性哈希能缓解但不能消除)。

处理方式就是上一篇 2.5 节的方案:会话级灰度的"固定版本"必须落在 Redis 的 pinnedVersion 上,而不是依赖路由。 具体做法是:在应用层读 pinnedVersion 决定用哪套提示词与记忆结构 ,然后 Ingress 只需要保证"尽量同 Pod"(性能优化,不是正确性保证)。

这就回到了 2.7 节那个结论 :粘性是性能优化,不是正确性保证。 在 K8s 这种"Pod 随时被替换"的环境里,任何把正确性寄托在路由稳定上的设计都会失败。

(图 6:K8s 上灰度的双 Deployment 流量演进与放量节奏------副本数比例决定流量比例;回滚就是把新 Deployment 缩到 0,比传统"切流量"更快也更干净。)
#mermaid-svg-3Azsg6LmRyu58fJG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Azsg6LmRyu58fJG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Azsg6LmRyu58fJG .error-icon{fill:#552222;}#mermaid-svg-3Azsg6LmRyu58fJG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Azsg6LmRyu58fJG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Azsg6LmRyu58fJG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Azsg6LmRyu58fJG .marker.cross{stroke:#333333;}#mermaid-svg-3Azsg6LmRyu58fJG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Azsg6LmRyu58fJG p{margin:0;}#mermaid-svg-3Azsg6LmRyu58fJG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3Azsg6LmRyu58fJG .cluster-label text{fill:#333;}#mermaid-svg-3Azsg6LmRyu58fJG .cluster-label span{color:#333;}#mermaid-svg-3Azsg6LmRyu58fJG .cluster-label span p{background-color:transparent;}#mermaid-svg-3Azsg6LmRyu58fJG .label text,#mermaid-svg-3Azsg6LmRyu58fJG span{fill:#333;color:#333;}#mermaid-svg-3Azsg6LmRyu58fJG .node rect,#mermaid-svg-3Azsg6LmRyu58fJG .node circle,#mermaid-svg-3Azsg6LmRyu58fJG .node ellipse,#mermaid-svg-3Azsg6LmRyu58fJG .node polygon,#mermaid-svg-3Azsg6LmRyu58fJG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3Azsg6LmRyu58fJG .rough-node .label text,#mermaid-svg-3Azsg6LmRyu58fJG .node .label text,#mermaid-svg-3Azsg6LmRyu58fJG .image-shape .label,#mermaid-svg-3Azsg6LmRyu58fJG .icon-shape .label{text-anchor:middle;}#mermaid-svg-3Azsg6LmRyu58fJG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3Azsg6LmRyu58fJG .rough-node .label,#mermaid-svg-3Azsg6LmRyu58fJG .node .label,#mermaid-svg-3Azsg6LmRyu58fJG .image-shape .label,#mermaid-svg-3Azsg6LmRyu58fJG .icon-shape .label{text-align:center;}#mermaid-svg-3Azsg6LmRyu58fJG .node.clickable{cursor:pointer;}#mermaid-svg-3Azsg6LmRyu58fJG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3Azsg6LmRyu58fJG .arrowheadPath{fill:#333333;}#mermaid-svg-3Azsg6LmRyu58fJG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3Azsg6LmRyu58fJG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3Azsg6LmRyu58fJG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Azsg6LmRyu58fJG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3Azsg6LmRyu58fJG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Azsg6LmRyu58fJG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3Azsg6LmRyu58fJG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3Azsg6LmRyu58fJG .cluster text{fill:#333;}#mermaid-svg-3Azsg6LmRyu58fJG .cluster span{color:#333;}#mermaid-svg-3Azsg6LmRyu58fJG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3Azsg6LmRyu58fJG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3Azsg6LmRyu58fJG rect.text{fill:none;stroke-width:0;}#mermaid-svg-3Azsg6LmRyu58fJG .icon-shape,#mermaid-svg-3Azsg6LmRyu58fJG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Azsg6LmRyu58fJG .icon-shape p,#mermaid-svg-3Azsg6LmRyu58fJG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3Azsg6LmRyu58fJG .icon-shape .label rect,#mermaid-svg-3Azsg6LmRyu58fJG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Azsg6LmRyu58fJG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3Azsg6LmRyu58fJG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3Azsg6LmRyu58fJG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 回滚路径
阶段 3:全量切新
v1: 0 副本

v2: 3 副本

流量 0% / 100%
阶段 2:扩大验证
v1: 2 副本

v2: 2 副本

流量 50% / 50%
阶段 1:内部试用
v1: 3 副本

v2: 1 副本

流量 ≈75% / 25%
阶段 0:全量 v1
v1: 3 副本

v2: 0 副本

流量 100% / 0%
v2 scale 到 0

约 30 秒完成


5️⃣ 进阶与优化

5.1 启动慢的三种解法

Agent 启动 30~60 秒,会带来三个连锁问题:滚动更新慢、扩缩容迟钝、探针容易误判。三种解法按投入排序:

解法一(必做,零成本):用 startupProbe 覆盖启动期。 4.3 节已配。它的作用是"在启动完成前禁用 liveness 和 readiness",避免容器在启动期被误判重启。 这是"零成本解决"------不减少启动时间,但消除误判。

解法二(推荐,低成本):initContainer 预热。 把"技能清单解析、字体缓存、依赖连通性检查"这类可以提前做的事 放进 initContainer。4.3 节的 warmup 就是这个用途。

为什么独立成 initContainer 而不是放在主容器启动逻辑里? 三个理由:① 失败可独立重试 (主容器启动失败会触发整个 Pod 重建);② 不占用主容器的启动预算 (startupProbe 只计主容器时间);③ 资源可以单独配(预热可能需要 512MB,主容器基线只需 256MB)。

解法三(进阶,高成本):延迟加载 + 就绪探测通过后再后台加载。 让应用先快速起来并通过 readiness(只加载核心能力),把"非核心技能"的加载放到后台异步进行。

这个方案有明确的风险,必须权衡 :后台还在加载时,用户如果问到一个尚未加载完成的技能,会得到"我暂时无法处理"的回答。 这是我上一篇说的"功能性降级"。它适合的场景是"核心 20% 技能覆盖 80% 请求"------如果你测出来核心技能覆盖率只有 50%,那这个方案会把一半请求变成降级回答,得不偿失。

我的建议组合:解法一(必做)+ 解法二(推荐);解法三只在实测"核心技能覆盖率 > 80%"时才用。

5.2 镜像分发的坑

国内环境的镜像拉取是上 K8s 的第一道坎,值得单独一节。 三个高频问题:

问题一:拉基础镜像超时。 docker.io、gcr.io 在国内经常不可达。解法有三 :用云厂商的镜像加速器、用国内基础镜像(如各厂商提供的 node 镜像)、或者把基础镜像先同步到自己的私有仓库(最稳,推荐生产用)。

问题二:ImagePullBackOff 但不报具体原因。 常见原因依次是:镜像不存在(tag 写错)、私有仓库认证失败、节点上无 imagePullSecrets、网络不通。排查命令按顺序:

bash 复制代码
# 1. 看事件(最直接,90% 的情况这一条就能定位)
kubectl describe pod -n agent-prod <pod-name> | grep -A 10 "Events:"

# 2. 确认 Secret 已创建且被正确引用
kubectl get secret -n agent-prod
kubectl get deployment openclaw-agent -n agent-prod \
  -o jsonpath='{.spec.template.spec.imagePullSecrets}'

# 3. 手动在节点上验证(最可靠,绕开 K8s 排查网络)
# 在目标节点上执行:
#   crictl pull registry.example.com/openclaw/agent:v2026.8.3-20260920

问题三:同一镜像在多个节点重复拉取,浪费带宽与时间。 解决方案是部署一个节点级镜像缓存(registry mirror / dragonfly 之类) ,或者让同一工作负载尽量调度到同一批节点(用 nodeSelector 打标签) 。弹性扩容场景下这个优化收益最明显------HPA 扩容时新 Pod 能在几十秒内起来而不是几分钟。

顺带一个和镜像相关的容量提醒:节点磁盘要给镜像留够空间。 kubelet 的镜像回收阈值(默认 85%)到达后会开始删未使用的镜像,而它删除的恰好可能是你下次扩容要用的那个版本 。如果你的 Agent 镜像有 400MB,节点上保留 5 个版本就是 2GB------这在 40GB 系统盘上是很可观的比例(本机 C 盘就是 118GB 装 104GB 的惨状,感受很直接)。

5.3 日志与可观测性

容器里有一条铁律:日志必须写 stdout,不能写文件。 原因是:Pod 被删后容器文件系统一起消失 ,写在文件里的日志你事后拿不到;而 stdout 会被容器运行时接管,进而在节点上落盘并被日志系统采集。

但 Agent 在 K8s 里有两个特有的日志问题:

问题一:多副本的日志混在一起,无法区分。 传统做法是日志里带 traceId 串联(第 3 篇),但在 K8s 里还必须带 Pod 名 ,否则你看到 3 份并行日志,不知道哪条来自出问题的那个副本。

解法就是 4.3 节那段 env: POD_NAME ------把 Pod 名注入环境变量,日志里输出它:

javascript 复制代码
// src/logger.js ------ 每条日志都带 pod 与 version 标签
export const logger = pino({
  base: {
    pod: process.env.POD_NAME || "local",           // 来自 fieldRef 注入
    version: process.env.APP_VERSION || "dev",
    // 这两个字段让"按 Pod 聚合"与"按版本对比指标"成为可能(第 11 篇 4.6 节)
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});

问题二:日志采集系统里的 pod 标签默认带随机后缀。 Pod 名形如 openclaw-agent-7d9f8b6c4d-x2k9p------每滚动更新一次,名字就全变。这让"按 Pod 看日志"在跨发布时毫无意义。

正确做法是采集时做标签归一 :把 pod 名按前缀截取成"逻辑名"(openclaw-agent),把随机后缀放到单独字段 。这样你既能看"某个具体 Pod"(临时排障),也能看"整个 Deployment"(长期趋势)。这个归一化一旦没做,日志系统里的时间序列会无限膨胀(每次发布新增 3 个标签组合)。

日志量控制上给一个真实数字参考: 我这边 Agent 的日志量在 info 级别下约 每次会话 15~40 行 (含模型调用的输入输出摘要)。按日均 2000 次会话算,每天约 5 万行、80MB 左右 ;如果开启 debug 并把完整提示词打出来,会涨到 1.5GB/天 。所以"生产环境不要长期开 debug 日志"这条纪律在 Agent 上尤其重要------它不仅占存储,还可能把提示词里的业务信息落到日志系统里(合规问题)。

5.4 成本:requests 超配的隐性浪费

这一节我要给出一个可能让人不舒服的数字:K8s 集群的平均资源利用率通常在 15%~30%。

也就是说,你按 requests 付了钱,但只用了其中的 1/5。原因不是技术问题,而是"配 requests 的心理偏误" :怕 OOM 就多给内存,怕被限流就多给 CPU,反正"宁可浪费也不能出事"。

我手上这个 Agent 的真实数字:

项 数值 说明
requests.memory 1Gi × 3 副本 = 3Gi 实际常态使用约 1.8Gi
内存利用率 60% 内存尚且可以接受
requests.cpu 500m × 3 = 1.5 核 实际常态使用约 0.25 核
CPU 利用率 17% 这是浪费的主要来源
月度成本(云上按 requests 计费) ¥740 按实际使用应为约 ¥320

所以优化方向很明确:把 CPU 的 requests 降下来(内存不要动)。

为什么内存不要动? 因为内存 requests 决定了调度的安全边界,而内存超出是 OOMKilled(硬故障),降它的风险收益比很差 。CPU 超出只是变慢(软影响),降它的风险小得多。

但降 CPU requests 有一个必须知道的前提 :requests 同时也是 HPA 计算利用率的分母 。如果你把 requests.cpu 从 500m 降到 250m,同样的实际用量算出的利用率会翻倍 ------如果你的 HPA 是基于 CPU 的,它会立刻开始疯狂扩容。 所以降 requests 之前必须先把 HPA 换成非 CPU 指标(2.6 节),或者同步调整 HPA 阈值。

这个连锁关系是我见过最常见的"改一个参数引发一场故障"的案例。 它提醒我们一个通用原则:在 K8s 里,资源参数不是孤立的------requests 同时是"调度依据"和"HPA 的分母",改它必须同时检查这两个用途。

5.5 五类高频故障与排障路径

这五类故障覆盖了我在 Agent 容器化期间遇到的绝大部分问题。 我把它们整理成决策树,因为 K8s 排障最大的困难不是"不会修",而是"不知道先看哪里"。
#mermaid-svg-7PE5NZ5ZDTr9L9M7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .error-icon{fill:#552222;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .marker.cross{stroke:#333333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 p{margin:0;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster-label text{fill:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster-label span{color:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster-label span p{background-color:transparent;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .label text,#mermaid-svg-7PE5NZ5ZDTr9L9M7 span{fill:#333;color:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node rect,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node circle,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node ellipse,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node polygon,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .rough-node .label text,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node .label text,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .image-shape .label,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .rough-node .label,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node .label,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .image-shape .label,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .icon-shape .label{text-align:center;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node.clickable{cursor:pointer;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .arrowheadPath{fill:#333333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster text{fill:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .cluster span{color:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .icon-shape,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .icon-shape p,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .icon-shape .label rect,#mermaid-svg-7PE5NZ5ZDTr9L9M7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7PE5NZ5ZDTr9L9M7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CrashLoopBackOff
是
否
OOMKilled
是
否
Pending
资源不足
卷挂载冲突
亲和性冲突
ImagePullBackOff
能
不能
Running 但探针失败
是
否
Pod 有问题
kubectl get pods

状态是什么?
RESTARTS 在涨

应用启动失败
kubectl logs --previous

看上一次崩溃的日志
是配置/密钥问题?
检查 ConfigMap 挂载

与 Secret 是否存在
检查启动自检哪一步失败

第 11 篇 5.1 节的 last_fail.json
内存超 limits
查峰值来源:

附件大小? 上下文长度?
是正常业务峰值?
提高 limits + 在网关层

限制附件大小
查内存泄漏

第 11 篇 5.2 节三类泄漏
调度失败
kubectl describe pod

看 Events 里的原因
原因类型?
节点 CPU/内存不够

或 requests 配太大
RWO 卷被其他节点占用

副本数必须为 1
硬反亲和节点数不足

改 preferred
镜像拉取失败
crictl pull 手动验证
手动能拉到?
检查 imagePullSecrets

与 ServiceAccount 绑定
网络/认证/tag 问题

见 5.2 节
应用不健康
curl 容器内探针端点

看返回内容
是深探针失败?
外部依赖问题

不要重启,先查依赖
检查 readiness 依赖项

Redis / 元数据接口 / 工作目录

(图 7:K8s 上五类高频故障的排障决策树------先用 kubectl get pods 的状态定位大类,再按分支查,避免"上来就进容器翻日志"。)

其中我要特别讲两个最容易走错方向的:

第一,CrashLoopBackOff 时不要先看当前日志,要看上一次的。 因为当前日志往往是"正在启动中"的日志,而崩溃原因在上一次运行的尾部 。命令是 kubectl logs <pod> --previous。这个参数我强烈建议记住------不知道它的人会在"日志里看不出问题"上耗掉很久。

第二,探针失败不要直接重启或改探针,先分清"哪一类探针失败"。 如果是 readinessProbe 失败,那服务没死,只是被摘了流量 ,去查依赖(Redis 通不通、模型元数据接口通不通、工作目录可不可写)。很多人的第一反应是"把探针的 failureThreshold 调大点" ------这等于把烟雾报警器拆了,故障依然存在,只是你看不见了。

5.6 迁移顺序:不能一次切

最后讲方法论:从 systemd 单机迁到 K8s,绝不能一次切完。 我用的顺序是五步,每步都可独立回退:

阶段 做什么 流量 回退方式 验证标准
① 只建镜像 写 Dockerfile、本地跑通全部功能自测 0% 无影响 镜像内功能与裸机一致
② 集群内跑通 部署 1 副本,先用 kubectl port-forward 验证 0% 无影响 探针全绿、能完成一次完整会话
③ 影子流量 用 Ingress 分流 5% 真实流量到 K8s 5% Ingress 权重改回 0 错误率、延迟与裸机持平
④ 逐步切换 5% → 25% → 50% → 100%(按会话固定版本) 递增 权重回退 业务指标(第 11 篇 4.6 节)无退化
⑤ 下线裸机 观察一周后停掉 systemd 服务 100% 保留配置与镜像 一周内无回退需求

其中第 ③ 步"影子流量"是最有创意也最有价值的一步 ,值得展开:让 5% 的真实流量同时打到裸机(返回给用户)和 K8s(结果丢弃),然后对比两边的输出。

为什么这一步值得做? 因为容器化改变的东西里,有一部分是"静态测试测不出来的" :字体渲染、时区、文件编码、系统库版本。影子流量能让这些差异在"不影响任何用户"的前提下暴露出来。 我就是在这一步发现了"容器里没有中文字体导致 PDF 转换全是方块"------如果直接切流量,这个 bug 会在生产上出现,而它的表现是"用户收到一份乱码文档",修复要重建镜像再滚一轮。

第 ⑤ 步"保留配置与镜像"是给回退留的后路。 很多团队在切换完成后立刻清理裸机环境,结果发现 K8s 上有个偶发问题时,已经没有可以退回去的地方了 。保留的成本几乎为零(一台空闲服务器而已),而它的价值在真正需要时是无价的。

`

5.7 本地开发与生产的差异

迁移到 K8s 之后,团队会遇到一个新问题:本地开发环境和生产不一致了。 典型表现是"本地跑得好,上 K8s 就出问题"。四个最常见的差异源:

差异一:CPU/内存限制。 本地开发机是 8 核 16GB 且无限制,容器里可能只有 500m 和 2.5GB 。所以本地永远测不出 OOM 和 throttling 问题。 解法是在本地也用 Docker 跑,并且用和生产相同的 limits 参数启动 (docker run --memory=2.5g --cpus=0.5)。

差异二:文件系统。 本地是 NTFS / APFS,容器里是 overlayfs,生产是 ext4/xfs 。差异体现在:大小写敏感性 (Windows 不敏感、Linux 敏感,require("./Utils") 在本地能过、在容器里报错)、文件权限 (10001 用户在本地可能不存在)、inode 与文件数限制。

差异三:网络。 本地直连,容器里要过 CNI,生产还要过 Ingress、Service、NetworkPolicy 。这意味着"DNS 解析、连接超时、MTU"这些问题只在集群里出现。 一个典型现象是"容器内请求模型 API 偶发超时",本地完全复现不了------常见原因是 MTU 不匹配(CNI 的 MTU 小于物理网卡)。

差异四:密钥注入方式。 本地从 .env 读,生产从挂载的文件读。这两条代码路径必须在开发期都测过 ------否则上线后会出现"密钥明明挂了但读不到"(路径写死成 .env 了)。

我的做法是给团队配一份 docker-compose.yml ,用与生产一致的镜像、一致的资源限制、一致的密钥注入方式(挂载目录而非 .env)。它把上面四个差异一次性消掉三个 (CPU/内存、文件系统、密钥注入),只剩网络差异无法在本地复现。这份 compose 文件的维护成本很低,但它省下的"本地过了线上炸了"的时间非常多。


6️⃣ 适用边界与风险提示

6.1 ⚠️ 适用场景

场景一:实例数需要 3 个以上,或需要弹性伸缩。 这是 K8s 的核心价值区间。月末高峰、工作日高峰这类有明确波形的负载,弹性带来的成本节约能覆盖运维复杂度(我这边实测降 14%,但如果是尖峰极明显的业务,降幅能到 40%)。

场景二:需要多套完全一致的环境。 开发/测试/预发/生产四套环境,用 Helm + values 维护的漂移率远低于靠文档与纪律。

场景三:已有 K8s 基础设施和运维能力。 如果公司已有集群和其他服务在上面跑,新增一个 Agent 的边际成本很低 ------你只需要写 YAML,不需要搭整个平台。这种情况下上面的"12 天投入"可能只有 3 天。

场景四:需要严格的资源隔离与配额管控。 在多团队共享环境下,命名空间 + ResourceQuota + NetworkPolicy 能提供裸机给不了的隔离------这对"Agent 要访问敏感数据"的场景尤其重要(第 7 篇的权限边界在集群层也有落点)。

场景五:发布流程需要标准化与可审计。 K8s 的声明式特性让"谁在什么时候把什么版本推上了生产"天然有记录 (Deployment 的 revision、Helm 的 release history)。这在应对合规审计时省事很多。

6.2 ⚠️ 不适用场景

场景一:实例数 ≤ 2,且没有弹性需求。 这时候 K8s 的调度能力完全用不上,但集群本身的运维成本(升级、证书、CNI、存储)你全额承担了。

场景二:团队没有容器网络与存储的排障能力。 这是硬门槛。如果你现在遇到 pod 无法访问外网 会束手无策,那么上 K8s 只会让你从"服务挂了"升级为"服务挂了且不知道为什么"。

场景三:对延迟极度敏感的本地推理场景。 如果 Agent 要调用本地 GPU 做推理,K8s 的 GPU 调度与直通配置会带来额外的复杂度和性能损耗 (虽然 NVIDIA 有 device plugin,但配置与排障成本高)。这类场景裸机 + systemd 往往更稳更快。

场景四:需要访问宿主机硬件或特殊设备。 比如要读本机 USB 设备、要操作宿主机的特定端口、要挂载宿主机目录。这些在容器里都能做(hostPath、privileged),但每一个都会削弱隔离性 ------为了这点便利而引入的安全风险通常不划算。

场景五:日活极低且无 SLA 的内部工具。 前面算过这笔账:日活 40 的内部 Agent 上 K8s,运维成本从 0.5 人天/月涨到 3 人天/月,可用性零提升。 这类场景留在 systemd 或 Compose 是更理性的选择。

6.3 ⚠️ 版本与兼容性注意

Kubernetes API 版本 :autoscaling/v2beta2 在 1.26 被移除,必须用 autoscaling/v2 ;policy/v1beta1 的 PDB 在 1.25 被移除。部署前用 kubectl api-resources | grep autoscaling 核对。

容器运行时 :自 1.24 起 dockershim 被移除,docker 不能作为运行时 。如果你的集群还是 1.23 或更早,迁移计划里必须先安排运行时切换(containerd 或 CRI-O)。这一步是破坏性的(节点要重建),必须单独排期。

Nginx Ingress 注解 :upstream-hash-by 的取值依赖 Controller 版本的模板变量支持。1.8 以下对 $http_x_session_id 这类变量的支持不完整 ,建议 1.11+。如果你的集群用的是别的 Ingress Controller(Traefik、APISIX),粘性配置方式完全不同------需要对照各自文档。

Node 版本与 alpine :如果镜像是 node:24-alpine,某些原生模块(sharp、canvas、部分数据库驱动)需要额外的构建步骤 。验证方式很简单:构建后在容器里跑一次完整功能自测,不通过就换 slim。(2.1 节)

Helm --atomic 与 --wait :--atomic 隐含了 --wait,它会等待所有资源就绪 。如果集群里还有 Job 或 CronJob 且不需要 wait,需要额外参数。这一点在把 Agent 与其他资源放同一个 release 时会踩到。

6.4 ⚠️ 生产建议

  1. tag 永不使用 latest,部署时记录 digest。 否则你无法回答"线上跑的是哪个版本"。
  2. terminationGracePeriodSeconds 必须大于应用优雅停机总耗时,建议 ×1.3。
  3. preStop 的 sleep 不能省。 K8s 摘 Endpoints 与发 SIGTERM 无先后保证,这 10 秒是给上游的反应时间。
  4. ENTRYPOINT 必须用 exec 数组形式,否则容器里 PID 1 是 sh,SIGTERM 永远到不了 Node。
  5. CPU 不设 limit(或给 2 倍),内存必须给足。 一个软影响、一个硬故障,不能同等对待。
  6. startupProbe 必配,failureThreshold × periodSeconds 要大于真实启动时间。
  7. 所有 emptyDir 都设 sizeLimit。 防单个 Pod 写满节点磁盘引发连锁故障。
  8. Secret 尽量挂载成文件而不是注入环境变量。 环境变量会通过多种途径意外泄露。
  9. 配 podAntiAffinity(软约束)+ PDB。 前者防单节点故障,后者防运维操作打穿容量。
  10. HPA 缩容必须保守(稳定窗口 + 限速),扩容可以激进。 与"启动快、无状态"的传统服务相反的取向。
  11. readinessProbe 与 livenessProbe 绝不用同一个深层端点。 依赖检查放 readiness。
  12. 迁移必须分五步走,且切换完成后保留裸机配置至少一周。

6.5 六个高频问题

Q1:3 个副本就够了吗?要不要凑个奇数(比如 3、5)?

副本数的选择不涉及"多数派"问题,因为 Agent 服务是无状态的 ------奇数/偶数的讲究是给需要 Leader 选举的有状态服务(Etcd、ZooKeeper)用的。Agent 的副本数应该按容量算 :单实例稳态能支撑 N 个并发会话,高峰期需要 M 个并发,那副本数 = ceil(M / N) + 1(加 1 是给"单副本故障"留的余量 )。我这边单实例稳态 15 并发、峰值 40 并发,所以 ceil(40/15) + 1 = 4,考虑到成本我配的是 3 ~ 10 的弹性区间(低峰 3、高峰自动到 6 左右)。

Q2:Agent 处理长任务(几分钟)会不会被 K8s 判定为不健康?

只要任务期间应用还在响应探针就不会。 关键在于:探针端点和业务处理必须在同一个事件循环上异步执行 ------如果你的长任务是同步阻塞的(比如一个 3 分钟的同步循环),事件循环被占住,探针响应不了,livenessProbe 就会判死并重启容器(这正好是上一篇 5.4 节"事件循环卡死"演练在 K8s 里的版本)。

解法是两层的 :① 应用侧改成长任务异步化(分片处理 + await) ,这是根治;② livenessProbe 的 timeoutSeconds 给宽一点(3 秒) ,给一些抖动余量。注意不要用"调大 failureThreshold"来掩盖它------那等于让"卡死"这个故障更晚被发现。

Q3:滚动更新时用户会不会感知到?

配置正确时不会,配置错误时会。 三个必要条件:maxUnavailable: 0(容量不掉)、startupProbe 覆盖启动期(新 Pod 就绪才切流量)、preStop sleep(旧 Pod 等 Endpoints 刷新)。

我用一个指标来判断"有没有感知":发布期间的用户可见错误率。 正确配置下它应该是 0% ------如果发布时有任何一波错误率上升,说明三个条件里有一个没生效。这个指标应该在每次发布后都看一眼,它是发布质量最直接的证据。

Q4:K8s 上的灰度比第 11 篇的 Nginx 方案好在哪?

好在"回滚更干净"。 第 11 篇的 Nginx 方案回滚时要改 Ingress/上游配置并把所有实例切回 v1;K8s 上回滚就是把新 Deployment scale 到 0 ------一条命令,约 30 秒完成,且旧版本的 Pod 从来没被销毁过 (不需要重新拉镜像、重建连接池)。这是"声明式"相对"命令式"的真实优势。

但不好在"流量比例控制不够精细" :双 Deployment 的流量比例只能是"副本数之比"(3:1 就是 25%),做不到"精确 5%" 。如果要 1% 级别的精细控制,就需要服务网格(Istio 的 VirtualService 可以按权重精确分流)。 我的判断:20% 级别的粗粒度灰度用双 Deployment 足够,1% 级别才值得引入服务网格。

Q5:集群升级会不会影响 Agent?

会有影响,但可控。 影响来自两个必然动作:① 节点排水(drain)会驱逐 Pod ,这由 PDB + 反亲和性保护;② API 版本兼容 ,这需要在升级前用 kubectl convert 或 pluto 之类工具扫一遍废弃 API。

Agent 特有的风险点是"本地 GPU 或大内存节点" :如果你的 Agent 调度在特定配置的节点上,节点池升级时的替换顺序会直接影响容量 。建议在升级窗口前把 minReplicas 临时调高 1 (多留一份余量),升级完成后再调回来------这是成本极低但对可用性很有帮助的一个小动作。

Q6:这套东西能复用到别的 Agent 上吗?

能,而且这是投入 12 天最值的部分。 Helm chart 里真正 Agent 专属的只有:ConfigMap 的字段、镜像内的构建方式、以及探针端点的路径。其余(Deployment、Service、Ingress、HPA、PDB、rollout 流程)全部可以复用。

我的做法是把 chart 抽成一个内部 chart 仓库,新 Agent 接入只需要写一个 values-xxx.yaml + 一个 Dockerfile。 第二个 Agent 的上 K8s 成本从 12 天降到 1 天 。这也是为什么我不建议在第一个 Agent 上"凑合"------凑合出来的 chart 没法复用,你会为每个新 Agent 重踩一遍坑。


7️⃣ 总结

7.1 回到开头的问题

开头说单机方案有三个天花板:机器是单点、扩容靠整机复制、环境一致靠纪律。走完这八步,这三个问题分别对应到了:

  • 单点 → 多副本 + podAntiAffinity + PDB + 多节点(但注意 PDB 只管自愿中断);
  • 扩容 → HPA + 弹性副本区间(但受"镜像拉取 2 分钟"这个下限约束);
  • 环境一致 → 镜像 + Helm values(但前提是本地也用同一镜像,见 5.7 节)。

同时必须承认代价 :首次投入 12 天、日常排障从 20 分钟变成 45 分钟、每次改配置多了两步。这些代价不会消失,只是在"需要跨机可用性与弹性"的时候才值得付。

如果让我用一句话总结这一趟迁移,我会说:K8s 没有让 Agent 变得更好,它让 Agent 变得更"可管理"。 "更好"是靠前 11 篇做的------提示词、记忆、成本、权限、渠道。K8s 只在"多人协作、多环境、需要弹性"这三点上带来了真实的增量。

7.2 本篇的核心方案回顾

决策点 我的选择 核心理由
镜像 多阶段 + node:slim + prune 2.1GB → 420MB,扩缩容从"拉不动"到 2 分钟
基础镜像 slim 而非 alpine 文档转换/无头浏览器在 glibc 上最省心,先验证再用 alpine
ENTRYPOINT exec 数组形式 shell 形式会让 PID 1 是 sh,SIGTERM 到不了 Node
配置 ConfigMap(非敏感)+ Secret 挂载(敏感) 三层分离;Secret 挂文件而非环境变量,防泄露
状态 会话/记忆进 Redis,工作目录用 emptyDir 文件系统有"副本亲和性",与无差别副本天然冲突
PVC 只给单副本的归档负载用 RWO 卷与多副本互斥;把职责拆成不同 Deployment
探针 startup / live / ready 三级 startup 覆盖启动期;依赖检查只放 readiness
滚动策略 maxUnavailable: 0 + maxSurge: 1 发布期零容量损失,代价是多 1 副本与更慢的发布
优雅停机 terminationGracePeriodSeconds: 60 + preStop sleep 10 60 秒 = 应用耗时 ×1.3;sleep 是给上游刷 Endpoints 的时间
资源 CPU 不给 limit,内存给峰值的 1.3 倍 一个软影响(throttle)一个硬故障(OOMKilled)
HPA 并发指标驱动 + 扩容激进/缩容保守 Agent 的瓶颈不是 CPU(利用率仅 12%),是每实例并发数
粘性 Ingress upstream-hash-by + Service ClientIP 会话级准确,IP 级兜底
灰度 双 Deployment + 副本比例 回滚 = scale 到 0,约 30 秒,比切流量更干净
迁移 五步走,含"影子流量"阶段 字体/时区/编码差异只能靠真实流量暴露

7.3 一页纸速查表

四个必用命令 :kubectl logs <pod> --previous(看崩溃前日志)、kubectl describe pod(看事件)、kubectl rollout undo(回滚 spec)、crictl pull(在节点上验证镜像拉取)。

三个必配字段 :terminationGracePeriodSeconds(大于优雅停机总耗时 ×1.3)、preStop sleep(等 Endpoints 刷新)、emptyDir.sizeLimit(防写满节点磁盘)。

三个必须不做的 :不用 latest tag、不在 liveness 里查外部依赖、不让 Ingres 在 timeout 时重试(proxy-next-upstream: "error")。

三条资源纪律 :CPU 不设死 limit、内存按峰值 ×1.3、改 requests.cpu 前先检查 HPA 的分母。

一条判断标准 :"发布期间的用户可见错误率是否为 0%"------这是唯一能验证"探针 + 优雅停机 + 滚动策略"三者都配对了吗的指标。其他所有 K8s 的正确性检查都是间接的,只有这一条是直接证据。


参考资料

相关推荐
遇码1 小时前
不用转场引擎做幻灯片转场:相机滑动、黑场淡入与真全屏的完整做法
人工智能·数码相机·ai·rust·大模型·agent
catino2 小时前
SDD:规范驱动开发(Spec-Driven Development)
驱动开发·ai
flytalei2 小时前
从 Docker Compose 到 K3s:逐浪笔记首次完整部署与故障排查实战
笔记·docker·容器
全栈练习生2 小时前
大模型评测可信
python·ai
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-17
人工智能·ai
炽橙超真云3 小时前
「人工智能+制造」五类场景如何确定优先级?四个判断维度与三类场景落地实践
人工智能·ai·工业智能·制造业转型·工业ai
菩提小狗3 小时前
每日极客日报 · 2026年10月17日
ai·开源·极客日报·it热点·技术资讯
kaixin_啊啊3 小时前
【零基础学AI】第 12 章课后练习与答案
人工智能·ai