大促直播开始后的第七分钟,告警群开始刷屏:单节点上跑着 OpenClaw 的 Docker 容器 CPU 打满,AI 调用超时率从 2% 冲到 30%,任务队列堆到几万条,最后被 OOM Killer 收走进程,整场直播的智能客服直接掉线。要把这套服务改造成 Docker + K8s 集群来撑住万级并发,副本数、探针和 HPA 只是表面功夫,底下还有一个必答题:模型调用的 Key 和出口怎么统一。这次的做法是先在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建一把 API Key,Base URL 填 https://taotoken.net/api,通过 Secret 注入 Deployment,再用 JMeter 分布式压测跑一遍,用 Pod 日志、监控面板和后台用量三处交叉验证通道到底通没通。
节奏上先复盘单机为什么塌,再给能直接粘贴的 Deployment 与环境变量,接着进 JMeter 的线程组设置,最后落到怎么看日志、看用量、怎么在滚动升级时确认模型调用没断。中间所有模型 ID 一律以模型广场当时的列表为准,不硬写某个特定串;Key 一律用 YOUR_API_KEY 占位,别把真 Key 贴在 YAML 里进 Git。
1. 直播大促把 OpenClaw 单节点压垮的现场回放
1.1 超时、堆积、宕机是三步走,不是一步到位
那天的曲线很有代表性。开场前 5 分钟 QPS 只有几百,一切正常;直播间挂上福袋、瞬时进人之后,OpenClaw 的请求量在 90 秒内翻了十倍。最先出问题的是响应时间,从 400ms 爬到 3s,前端开始出现"AI 正在思考"转圈不结束;紧接着是任务队列,Redis 里的待处理任务从几百涨到三万,消费速度完全跟不上生产速度。
第三个阶段才最致命。容器内存被堆在内存里的会话上下文撑满,Docker 的 --memory 限制触发 OOM,进程被杀,K8s 那边如果只有一台宿主机就只能看着服务反复重启。事后复盘,这三个症状其实是同一条因果链:模型调用变慢 → 工作线程被占住 → 请求堆积 → 内存膨胀 → 进程被杀。所以后面做集群化时,重点不是"多开几个进程",而是把这条链上的每一环拆开处理。
1.2 单节点的三个真瓶颈:线程池、内存、模型出口
第一个瓶颈是线程池。OpenClaw 的 worker 是同步调模型的,一个请求占一个线程,线程池打满之后新请求只能排队,排队的请求在前端看来就是超时。第二个瓶颈是内存,它不只是业务数据,还有每轮对话的上下文缓存,单机内存一旦到顶,GC 时间会指数级上升,响应时间跟着恶化。
第三个瓶颈最容易被忽略,就是模型出口。单机直连一家供应商的时候,出口的限流、抖动、偶发 5xx 都会原样传导到你的服务上;一旦被打满,重试又会把请求量翻倍,形成放大效应。把出口收敛到一个统一的 API 通道,配合 Key 级别的用量观测,才能在压测和故障时看清楚到底是自己扛不住,还是出口被打回来。这也是后面在 K8s 里做配置注入的直接原因。
1.3 上集群之前先定三件事:无状态、配置外置、出口统一
无状态化是第一件事。会话上下文、任务进度这些必须从容器本地内存挪到 Redis 或数据库,否则 Pod 一重建状态就丢,滚动升级等于主动制造故障。做法是把本地文件缓存和内存 Map 换成外部存储,容器里只留只读的镜像内容。
配置外置是第二件事。模型名、Base URL、并发数、超时时间全部进 ConfigMap 或环境变量,不要写死在代码里,否则每次换模型都要重新构建镜像。出口统一是第三件事,也是本篇最想讲透的一件:所有 Pod 的模型调用都指向同一个 Base URL,Key 从 Secret 注入,换 Key 不动镜像,扩副本不动配置。
2. 物料清单:镜像、存储、压测机,还有那把 YOUR_API_KEY
2.1 集群侧要准备的东西
先把清单列清楚,不然部署到一半发现缺东西最耗时间。镜像仓库一个,能推能拉;K8s 集群至少三个可调度节点,机器规格按 worker 的 CPU 请求量算,通常 4C8G 起步;Redis 或 PostgreSQL 一个实例用来放会话和任务队列,建议用云上的托管版,省得自己维护高可用。
压测侧需要几台和集群同网段的机器来跑 JMeter,单机线程数不是越多越好,2000 线程左右一台比较稳;再准备一个域名和 Ingress,让压测流量走真实入口而不是 Pod IP,否则你测不出 Ingress、Service 这一层的损耗。最后是监控,Prometheus + Grafana 或云监控都行,重点是能看到 QPS、P99、错误率和 Pod 重启次数。
2.2 打开 TaoToken 创建 API Key,并把 Base URL 记成不带 /v1 的形式
这件事本身不复杂,但有两个细节特别容易踩。第一,Key 要在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的落地页注册后进控制台创建,创建完立刻复制,页面刷新后再想看完整 Key 往往就看不到了。第二,填进容器的 Base URL 是 https://taotoken.net/api,末尾不带 /v1;很多 SDK 会自己拼 /v1/chat/completions,你再手写一个 /v1 就变成 /v1/v1/...,服务端只会回你 404 或者参数错误。
至于模型 ID,不要凭记忆写。模型广场里的列表会更新,某个日期后缀的型号今天有、下个月可能就下线了。正确的做法是部署前打开控制台看一眼当前可用的模型名,复制进 ConfigMap,然后在压测日志里确认它被真实调用过。Key、Base URL、模型 ID 这三样凑齐,集群侧的准备工作才算有底。
2.3 用 Secret 存 Key,别写进 Deployment YAML
Key 属于凭证,绝不能硬编码在 Deployment 里,一旦提交进代码仓库,回滚成本远高于多写一行命令。用 kubectl 直接建一个 Secret,把值从命令行塞进去,YAML 文件里只留引用:
kubectl create namespace openclaw kubectl -n openclaw create secret generic openclaw-model-secret \ --from-literal=api-key=YOUR_API_KEY之后所有需要 Key 的地方都通过 secretKeyRef 引用,包括 Deployment、Job、CronJob。如果团队用 Helm 或者 Kustomize,就把 Secret 放到单独的加密层里,别和应用清单混在一个目录。这样后面换 Key 只需要改 Secret 再滚动重启,镜像、代码、配置都不动。
3. openclaw-deployment.yaml:让每个 Pod 都从统一通道取模型
3.1 三副本起步,滚动更新策略选 maxUnavailable: 0
副本数是可用性和成本的平衡点。压测阶段建议从 3 副本起步,滚动更新时 maxSurge: 1、maxUnavailable: 0,这样升级过程中始终有 3 个 Pod 在提供服务,不会出现"新 Pod 还没起来、旧 Pod 已经停了"的空窗。生产环境再根据 HPA 的实测曲线决定最小副本数,别一上来就写 20。
探针也要配。readinessProbe 决定 Pod 什么时候接流量,配错了会出现"进程在跑但请求全 503"的假活状态。建议暴露一个 /healthz 接口,里面至少检查一次到 Redis 的连接和一次轻量的模型连通性探测——注意是轻量探测,不是真发一次完整对话,否则探针本身就把出口打满了。
3.2 一份可直接改的 Deployment 示例
下面这份模板里,Base URL 和模型 ID 走环境变量,Key 走 Secret,路径都是真实可用的写法:
apiVersion: apps/v1 kind: Deployment metadata: name: openclaw-worker namespace: openclaw spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: openclaw-worker template: metadata: labels: app: openclaw-worker spec: containers: - name: worker image: registry.example.com/openclaw/worker:1.4.0 ports: - containerPort: 8080 env: - name: OPENCLAW_MODEL_BASE_URL value: "https://taotoken.net/api" - name: OPENCLAW_MODEL_ID value: "YOUR_MODEL_ID" - name: OPENCLAW_MODEL_API_KEY valueFrom: secretKeyRef: name: openclaw-model-secret key: api-key - name: OPENCLAW_REQUEST_TIMEOUT_MS value: "30000" readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 5 resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "2" memory: "2Gi"OPENCLAW_MODEL_ID 先留占位,部署前换成控制台模型广场里实际存在的那个。资源限制别省,requests 决定调度,limits 决定它被 OOM 时会不会连累同节点上的其他 Pod。
3.3 kubectl apply 之后先看 rollout,再看日志
命令不多,但顺序要对:
kubectl apply -f openclaw-deployment.yaml kubectl -n openclaw rollout status deploy/openclaw-worker --timeout=180s kubectl -n openclaw get pods -o widerollout status 返回 successfully rolled out 只说明 Pod 起来了,不代表模型通道通了。接着看日志:
kubectl -n openclaw logs -f deploy/openclaw-worker --tail=200日志里应该出现模型调用的请求记录,包含目标地址、模型名、耗时和返回状态。如果看到大量 401 或 timeout,先别急着扩副本,回到 Key、Base URL、模型 ID 这三样上排查,扩容只会把错误放大。
4. JMeter 分布式压测:万级并发从线程组参数开始
4.1 五台压测机各 2000 线程,用 jmeter-server 拼出万级并发
单台机器跑一万线程基本不现实,线程数上去之后压测机自己先趴下,测出来的数字没意义。常规做法是分布式压测:一台 master 负责汇总,四到五台 slave 各跑 2000 线程,总并发凑到万级。启动顺序是先在各 slave 上跑 jmeter-server,再在 master 上通过 -R 指定远端机器。
参数化的目的是一次脚本多组数据复用。把线程数、爬坡时间、持续时间、目标域名都抽成属性,命令行覆盖即可:
jmeter -n -t openclaw-load.jmx \ -R 10.0.1.21,10.0.1.22,10.0.1.23,10.0.1.24,10.0.1.25 \ -Jthreads=2000 \ -Jrampup=120 \ -Jduration=600 \ -Jhost=openclaw.example.com \ -l result.jtl \ -e -o report/爬坡时间别设成 0,真实的直播流量是几秒钟冲上来的,但压测太陡会把连接建立本身变成瓶颈;120 秒爬坡更贴近实际,也更容易观察到 HPA 的扩容反应。
4.2 压测目标打在 Ingress 上,不要直接打 Pod IP
这一步和原文第五节的路子一致:压测的入口应该是域名或者 Ingress 地址,走完整的 DNS → Ingress → Service → Pod 链路。直接打 Pod IP 只测了容器本身,Service 的负载均衡、Ingress 的连接复用、K8s 的 kube-proxy 规则全都绕过去了,压出来的结论在生产里不成立。
同时把 JMeter 的 HTTP 连接超时和响应超时设得比 OpenClaw 的服务端超时略长一点,比如服务端 30s,压测端设 35s,否则服务端还没超时压测端先判失败,错误率会虚高,排查方向也会被带偏。
4.3 压测前先做一次滚动更新,让新 Key 生效
最容易翻车的地方在这里。很多人改了 Secret 就直接开压测,结果 Pod 里的进程用的还是启动时加载的旧环境变量——环境变量在容器启动那一刻就固定了,改 Secret 不会自动生效。正确做法是改完 Secret 之后触发一次滚动重启:
kubectl -n openclaw rollout restart deploy/openclaw-worker kubectl -n openclaw rollout status deploy/openclaw-worker --timeout=180s等所有 Pod 都是新版本、且 Ready 之后,再启动 JMeter。压测中途不要同时改配置,否则日志里新旧两种行为混在一起,你分不清哪条错误是新配置造成的。
5. 压测中三处交叉验证:日志、监控、用量
5.1 Pod 日志:该看到请求被发出去,不该看到 401 和 gap
日志是最近的一手证据。压测跑起来之后盯三样:一是模型调用的目标地址,是否都是统一通道的域名;二是返回码分布,正常应该是清一色的 200,偶尔的 429 可以接受但要记录比例;三是耗时分布,P99 应该稳定在一个区间里,如果随时间持续恶化,说明要么出口被限流,要么 Pod 已经接近资源上限。
如果出现 401,几乎可以肯定是 Key 的问题:Secret 没更新、Key 复制时带了空格、或者环境变量名和代码里读的不一致。如果出现形如 404 或者 invalid path 的错误,先检查 Base URL 后面是不是被人手动补了 /v1。日志里还有一种隐蔽情况:请求根本没发出去,日志只有任务入队记录没有出站记录,那要看线程池和队列配置,不是通道的问题。
5.2 监控面板盯四条曲线:QPS、P99、错误率、Pod 重启次数
Grafana 上不用铺满仪表盘,四条就够。QPS 反映压测是否按预期打进来;P99 反映用户体验;错误率反映通道和服务健康度;Pod 重启次数反映有没有隐藏的 OOM。这四条一起看,判断会快很多。
如果 QPS 上不去但压测端显示已发满,说明前端链路有阻塞;如果 QPS 上去了但 P99 同步飙升、错误率也涨,看 HPA 有没有扩容,副本数不动就说明指标阈值设高了或者资源不够调度。重启次数不为 0 时立刻去看 Pod 的 describe 和上一次容器的日志,别等到压测结束再回头查。
5.3 用量对账:这次压测到底消耗了多少 Token
压测跑完最重要的一件事是对账。回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的控制台,看这半小时里通道上的请求量和 Token 消耗,和 JMeter 汇总出来的请求数、平均输入输出长度对一下。数量级对得上,说明所有 Pod 的模型调用确实都从这个通道走了,没有漏网的老配置;对不上就去找那个还在用旧地址的 Pod。
想更快确认 Key 本身没问题,可以在本地用命令行先自测一次,注意接口地址同样不带 UTM、不带 /v1:
npm install -g @taotoken/taotoken taotoken cc -k YOUR_API_KEY -u https://taotoken.net/api -m YOUR_MODEL_ID本地能正常返回,再去怀疑集群里的 Secret 和环境变量,排查路径会短很多。
6. 报错对照表与滚动升级、故障转移的断流检查
6.1 401 与 Base URL 结尾多了 /v1
| 现象 | 常见根因 | 处理方式 |
|---|---|---|
| 401 Unauthorized | Secret 未更新、Key 带空格、环境变量名写错 | 重新 rollout restart,打印环境变量名核对 |
| 404 / invalid path | Base URL 结尾被写成带 /v1 的形式 | 改成 https://taotoken.net/api,末尾不加 /v1 |
| 请求超时无返回 | 出口慢或线程被占满 | 看 P99 和线程池等待队列长度 |
| 429 比例偏高 | 瞬时并发超过通道侧限制 | 降低爬坡速度,配合退避重试 |
这张表覆盖了压测期九成以上的报错。要注意的是,401 和 404 都是配置错误,扩副本、调内存对它们没有任何帮助,方向错了会白白浪费一轮压测。
6.2 超时、429 与副本数、HPA 的关系
超时不全是通道的锅。当 Pod 的 CPU 接近 limits,进程被限流,处理单个请求的时间会显著变长,表现出来也是超时。判断方法很简单:看 Pod 的 CPU 使用率是不是贴着 limits 走。是的话先调 requests 和 limits,再看 HPA 是否及时扩容。
429 通常来自出口侧的并发保护,处理方式是加指数退避重试,而不是无脑重试。一个实用技巧是把重试次数限制在 2 次以内,并给重试加随机抖动,否则所有 Pod 在同一时刻重试,会把被限流的瞬间变成雪崩。
6.3 CrashLoopBackOff 多半来自探针和资源限制
Pod 反复重启先看 describe 的 Events 和上一次容器日志。常见两种原因:readinessProbe 的 initialDelaySeconds 太小,进程还没初始化完就被判定失败;memory limits 给得太紧,会话缓存一涨就被 OOM。前者调大初始延迟,后者要么调大上限,要么把上下文挪到 Redis。
如果排查过程中要查数据库里的慢查询,让 AI 工具生成诊断 SQL 是没问题的,但执行必须由你在本地或者跳板机上用自己的客户端跑,再把执行结果贴回对话里分析。让模型直接连生产库执行语句,无论从权限还是风险上看都不合适,这条线要守住。
6.4 滚动升级和删 Pod 时,模型调用不断流的验证方法
高可用的真正验收场景不是压测峰值,而是升级和故障。压测进行中另开一个窗口,执行 rollout restart,观察 JMeter 的错误率曲线:如果配置正确,最多出现几条因为连接被切断的瞬时错误,整体曲线应该平滑。
再狠一点,随机删掉一个 Pod:
kubectl -n openclaw delete pod -l app=openclaw-worker --field-selector status.phase=Running新 Pod 起来接管期间,日志里不应该出现 401,也不应该出现长时间的空窗。原因是无状态化和统一通道这两件事已经做完了:状态在 Redis,Key 在 Secret,新 Pod 起来就能直接继续消耗 Token 干活,不需要任何人工干预。
7. 压测跑完后去控制台对一下这次消耗
配置跑通不等于可以收工,最后一步是对账和固化。压测结束后回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 看这次调用是否都记上了,请求量、Token 消耗和 JMeter 的汇总数字能不能对上;如果差得离谱,就回去翻 Pod 日志,多半有 Pod 还在用旧地址。
日常写代码或者调试压测脚本时,可以在 TaoToken 模型对话 里用同一把 Key 发一条测试消息,快速确认模型 ID 和 Base URL 没写错。长期跑集群建议看一眼 Coding Plan 的额度是否够压测期的消耗;需要给不同环境发不同的 Key,就在 控制台 API Keys 里分开创建。要把这套配置写进团队的部署文档,环境变量部分可以直接对照 Claude Code 接入文档 里的字段说明,改字段名的时候不至于漏项。
最后提醒一句:压测机上跑出来的万级并发数字,只说明你的集群在那一刻扛住了;真正的大促流量带着用户行为的随机性,建议把这次压测参数固化成定时任务,每两周跑一次,把 P99 和错误率的变化当成发布前的准入门槛。