04 · 排障与实战案例¶
本文按"现象 → 定位 → 修复"的顺序组织:
- §1-§4:四类常见排障路径
- §5:多 Pod fallback 放大 bug 详解
- §6:实战案例 —— 每 5 min 242 条心跳请求的完整排查复盘
排障流程总图¶
flowchart TD
Start{"现象是什么?"}
Start -->|"心跳请求量大、想降下来"| C1[03 cost-reduction]
Start -->|"改了 env 没生效"| S1[§1 env 没生效]
Start -->|"改了 YAML 没生效"| S2[§2 YAML 没读到]
Start -->|"shared 模式没起来"| S3[§3 redis_usage_cache 是 None]
Start -->|"K8s readiness 不就绪"| S4[§4 readiness 检查项]
Start -->|"想看真实案例"| S5[§6 实战案例]
S1 -->|确认 env 在 pod 但常量值还是默认| S1a[Pod 没真重启<br/>kubectl rollout restart]
S1 -->|env 不在 pod| S1b[deployment / configmap 没更新]
S2 -->|YAML 不是仓库版本| S2a[k8s configmap 挂了别的<br/>检查 mount path]
S2 -->|YAML 对的但字段名错| S2b[只能查启动 log 的 [HC-DEBUG-F] 类输出]
S3 -->|"_init_cache 失败"| S3a[Redis 连接 / cache_params 错误]
S3 -->|"litellm.cache.cache 不是 RedisCache"| S3b[cache 类型选错 / 降级]
1. env var 没生效¶
1.1 现象¶
设了 DEFAULT_SHARED_HEALTH_CHECK_TTL=3000 / DEFAULT_HEALTH_CHECK_INTERVAL=...,但心跳节奏没变。
1.2 第一性原因¶
litellm/constants.py 的常量是模块顶层 os.getenv,只在 Python 第一次 import 时执行一次:
进程已经启动后修改 env var 完全无效。
1.3 验证 checklist¶
POD=$(kubectl get pods -l app=litellm-proxy -o jsonpath='{.items[0].metadata.name}')
# A. env 是否真在 pod 进程的 environ 里
kubectl exec $POD -- env | grep DEFAULT_SHARED_HEALTH_CHECK_TTL
# 期望:DEFAULT_SHARED_HEALTH_CHECK_TTL=3000
# B. Python 常量真实值(最权威)
kubectl exec $POD -- python -c \
"from litellm.constants import DEFAULT_SHARED_HEALTH_CHECK_TTL; print(DEFAULT_SHARED_HEALTH_CHECK_TTL)"
# 期望:3000
# C. Pod 启动时间 vs 你改 env 的时间
kubectl get pods -l app=litellm-proxy \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.startTime}{"\n"}{end}'
1.4 修复¶
| A 输出 | B 输出 | 解读 | 修复 |
|---|---|---|---|
| 没看到 var | 300 | env 没注入到 deployment | 改 deployment env / configmap,apply |
| 看到 3000 | 300 | env 在 spec 里但 pod 用旧 spec 跑 | kubectl rollout restart deployment <name> |
| 看到 3000 | 3000 | env 完全 OK,问题不在这 | 进入 §3 / §5 排查 |
⚠️ K8s
kubectl set env/ 改 deployment env 不会自动 rolling restart Pod。必须显式执行:
2. YAML 没读到¶
2.1 现象¶
YAML 里写了 health_check_interval: 30,但启动日志或 /settings 显示 health_check_interval=300。
2.2 排查¶
# 看 Pod 实际挂的 YAML 文件
kubectl exec $POD -- cat /app/proxy_server_config.yaml | grep -E 'health_check|use_shared|background_health'
# 或者看 ConfigMap
kubectl get configmap <litellm-config-cm> -o yaml | grep -E 'health_check|use_shared'
2.3 常见原因¶
- 挂载路径不对:deployment 用的
--config指向某个路径,但 ConfigMap 没挂到该路径 - 多个 ConfigMap 冲突:另一个 mount 把同名文件覆盖了
- YAML 字段名拼错:
health_check_intervel(少 a)→ 静默回退默认 300,不报错
2.4 兜底验证¶
如果你愿意往代码里加临时 print(仅用于本地或非紧急场景),在 proxy_server.py:3180 后插:
print(
f"[HC-DEBUG-F] yaml general_settings keys: {list(general_settings.keys())} "
f"interval={health_check_interval} (raw={general_settings.get('health_check_interval')!r})",
flush=True,
)
启动后 grep [HC-DEBUG-F] 一目了然。验证完用 git checkout 撤回。
3. SHARED 模式没起来,走了 DIRECT¶
3.1 现象¶
YAML 里 use_shared_health_check: true,但心跳节奏不是 DEFAULT_SHARED_HEALTH_CHECK_TTL 控制的(拉大 TTL 没效果)。
3.2 第一性原因¶
proxy_server.py:2148 的 manager 创建条件是 AND:
if use_shared_health_check and redis_usage_cache is not None:
shared_health_manager = SharedHealthCheckManager(...)
redis_usage_cache 由 proxy_server.py:2519-2523 赋值:
if litellm.cache is not None and isinstance(
litellm.cache.cache, (RedisCache, RedisClusterCache)
):
redis_usage_cache = litellm.cache.cache
→ 即使 YAML 开了 use_shared_health_check,如果 litellm.cache 没起来 / 不是 RedisCache,就降级到 DIRECT。
3.3 验证¶
# A. Redis 里有没有 health_check_results key
kubectl exec $POD -- python -c "
import os, redis
r = redis.Redis(host=os.environ['REDIS_HOST'],
port=int(os.environ['REDIS_PORT']),
password=os.environ.get('REDIS_PASSWORD'))
print('PING:', r.ping())
print('keys:', r.keys('health_check*'))
"
# B. 启动日志里看 manager 是否被创建
kubectl logs $POD --since=2h | grep -E 'Initialized shared health check manager|_init_cache'
3.4 解读¶
| Redis ping | keys('health_check*') | 启动日志 | 结论 |
|---|---|---|---|
| OK | [b'health_check_results'] |
有 Initialized |
SHARED 工作正常,节奏受 TTL 控制 |
| OK | [] 持续为空 |
无 Initialized |
manager 没创建 → 走 DIRECT。检查 cache_params |
| OK | [] 但启动日志有 Initialized |
有 Initialized |
manager 创建了但写不进 Redis(权限/集群分片) |
| 失败 | — | — | Redis 不通,YAML 里 cache_params 配错或网络不通 |
3.5 DIRECT 路径下 5min 节奏的解释¶
如果 SHARED 没起来、走的 DIRECT,YAML 里 health_check_interval 要是没写或写错,就回退到 DEFAULT_HEALTH_CHECK_INTERVAL=300。这就是为什么"无 Redis + 多 Pod"也是每 5 分钟一批——不是 SharedManager 在工作,而是每个 Pod 独立按 300s 间隔跑 DIRECT。
4. K8s readiness 不就绪¶
4.1 现象¶
/health/readiness 一直返回 503 / readiness probe failed,Pod 不进 endpoint。
4.2 readiness 实际检查项¶
_health_endpoints.py:1218 health_readiness 检查:
- DB 连通(
prisma_clientping,SELECT 1) - Cache 连通(如果配了
cache: true) - 关键 callback 注册状态
- 不会触发任何模型 ping
4.3 排查¶
返回里会带每项的状态,根据失败项进一步查(DB / Redis / Callback)。
5. 多 Pod fallback 放大 bug 与修复¶
5.1 历史现象¶
修复前 requests/burst ≈ Pod 数 × deployment 数,远超"理论上 1 个 Pod 跑就够"。本项目 prod 实测每 5 分钟 ~242 条 ≈ 9 Pod × 28 deployment。
5.2 上游根因(已在本 fork 修复)¶
上游 PR #15380 引入 SharedHealthCheckManager 时,follower 抢锁失败的回退路径如下:
else:
# Lock not acquired
await asyncio.sleep(2) # ← 只等 2 秒
cached_results = await self.get_cached_health_check_results()
if cached_results is not None:
return ...
# Still no cache, fall back to local health check
return await perform_health_check(model_list=model_list, details=details)
cache 过期那一刻:
- N 个 Pod 同时进入 perform_shared_health_check
- 1 个 Pod 抢到锁、开始跑(28 模型,实测 12-30 秒)
- 另外 N-1 个 Pod 等 2 秒,cache 还没出现,全部 fallback 自打
- 总请求数 = N × deployment 数
时间常数错配:等待 leader 的窗口(2 秒)远小于 leader 实际耗时(12-30 秒),导致协调机制完全失效。
5.3 本 fork 的修复¶
把 follower 的 sleep 2 + 自打 改成 polling loop + 超时跳过这一 tick,从此 follower 永远不调用 perform_health_check。
修复代码 shared_health_check_manager.py:218-285 perform_shared_health_check 的 follower 分支:
# Follower path: poll cache up to lock_ttl seconds. NEVER fall back to local pinging.
deadline = time.time() + self.lock_ttl
while time.time() < deadline:
await asyncio.sleep(self.FOLLOWER_POLL_INTERVAL_SECONDS) # 2s 轮询节拍
cached_results = await self.get_cached_health_check_results()
if cached_results is not None:
return (
cached_results.get("healthy_endpoints", []),
cached_results.get("unhealthy_endpoints", []),
)
# Leader 在 lock_ttl 内没完成 → 放弃这一 tick,下次 health_check_interval 秒后再争锁
verbose_proxy_logger.warning(
"Pod %s gave up waiting for leader after %ds; skipping this tick",
self.pod_id, self.lock_ttl,
)
return [], []
5.3.1 关键设计选择¶
| 选择 | 我们的取值 | 理由 |
|---|---|---|
| 轮询节拍 | 2 秒 | 复用原代码常量;9 Pod × 60s 上限 × 30 次 GET = ~270 次 Redis GET / burst,无压力 |
| 总等待上限 | lock_ttl(默认 60,推荐 prod 180) |
Redis 锁过期就意味着原 leader 已不可信,不再继续等 |
| 超时返回值 | ([], []) |
项目 /health 端点未被消费(Prometheus 只读 /metrics),空返回不影响业务 |
| follower 是否兜底自打 | 否 | 自打就是 bug 本身,永远不能保留 |
5.3.2 量化收益¶
下表以 9 Pod × 28 deployment、health_check_interval=300s 为例:
| 项 | 修复前 | 修复后(同等 TTL) | 修复 + TTL=1800 |
|---|---|---|---|
| 单次 burst 请求数 | ~242 | 28 | 28 |
| 频率 | 每 5 分钟 | 每 5 分钟 | 每 30 分钟 |
| 每天总量 | ~69,120 | ~8,064(÷8.6) | ~1,344(÷51) |
5.3.3 配套配置建议¶
# k8s deployment env
DEFAULT_SHARED_HEALTH_CHECK_TTL=1800 # 30 min 真实 ping 一次
DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL=180 # 3 min,大于单次 health check 总耗时
lock_ttl 必须大于一次完整 health check 的最大耗时,否则会出现:
- T=0:leader 抢锁开跑
- T=60:lock 过期(因为 lock_ttl 太短)
- T=70:某 follower 检测到锁不在,抢到新锁,自己开始跑
- T=80:原 leader 终于跑完,写入 cache
- T=120:新 leader(其实是 follower)又跑完,覆盖 cache
虽然不会爆炸放大,但会出现两个 leader 同时跑的边角情况。lock_ttl=180 留足 buffer。
5.3.4 边界行为¶
- 首次启动空 cache:N 个 Pod 同时进入 → 1 个抢到锁开跑 → N-1 个 polling → leader 12s 后写完 cache → follower 接下来 2s 内的 polling 都命中 → 退出 polling
- leader 在 health check 中崩溃:lock 在 lock_ttl 后过期 → 所有 follower polling 超时返回
[], []→ 下个 tick(30s 后)某 Pod 重新抢锁,系统自愈 - /health 端点在 follower polling 期间被打:返回 follower 进程内的
health_check_resultsdict,在第一次 cache hit 之前是空的,之后是上次的旧值。本项目不消费 /health,无影响 - lock_ttl 小于实际 health check 耗时:见 §5.3.3,会出现双 leader,但不会无限放大
5.4 与上游的差异¶
截至 2026-05,上游 main 分支的
shared_health_check_manager.py仍是"sleep 2 + fallback 自打"的原始实现。本 fork 的修复是项目内自维护补丁,未提交上游 PR。如未来同步上游时该文件冲突,保留本 fork 的 follower polling 逻辑。
如果将来想往上游 PR,需要补:
- pytest 单测(模拟多 follower 同时进入、leader 慢/leader 崩溃等场景)
- changelog 条目
- docs/proxy/shared_health_check.md 文档对应更新
实战案例:每 5 min 242 条心跳请求的根因排查¶
来自本项目 prod 2026-05 的真实排障,完整记录推断、证伪、定位的过程。
6.1 起点:现象与初步推断¶
现象:prod 前端 logs 每隔 5 分钟出现一批 242 条心跳请求。
初步推断(错的):
28 个模型 × 9 条日志/模型 ≈ 252,可能是 callback(s3_v2/prometheus/rzzx_token_deduct)放大日志条数。
用户澄清:242 是真实请求数,不是日志条数。推断作废。
6.2 假说 1:deployment 数 ≈ 242¶
curl -sH "Authorization: Bearer $LITELLM_MASTER_KEY" \
https://prod-proxy/v1/model/info \
| python -c "import json,sys; print(len(json.load(sys.stdin)['data']))"
# 输出:28
→ 实际 deployment 是 28,不是 242。假说证伪。
6.3 假说 2:多 Pod fallback 放大¶
242 / 28 ≈ 8.6 —— 怀疑 8-9 个 Pod 各自跑一遍 health check。
代码定位 shared_health_check_manager.py:259-275 发现 fallback bug(详见 §5.2)。
6.4 假说 3:env var 没生效¶
导师在 prod 设置 DEFAULT_SHARED_HEALTH_CHECK_TTL=3000 并重启所有 Pod,但 5min 节奏没变。
怀疑 env 没真进 Python 进程。
6.5 本地复现 + 加 6 段 print 验证¶
由于无法直接操作 prod K8s,转向本地 bash scripts/start-local.sh 排查。在 6 个关键位置加 print(统一前缀 [HC-DEBUG-X]):
| 标签 | 文件位置 | 验证什么 |
|---|---|---|
| A | litellm/constants.py |
env 是否进 Python 常量 |
| B | proxy_server.py:_run_background_health_check |
manager 是否创建 |
| C | 同上 loop 体内 | 每次 tick 走哪条路径 |
| D | shared_health_check_manager.py |
manager 内部分支决策 |
| E | proxy_server.py:_init_cache |
redis_usage_cache 是否被赋值 |
| F | proxy_server.py:3180 附近 |
YAML 的 health_check_interval 是否读到 |
6.6 本地启动日志(关键片段)¶
[HC-DEBUG-A] DEFAULT_SHARED_HEALTH_CHECK_TTL=1000 env_TTL='1000'
[HC-DEBUG-E] redis_usage_cache SET to RedisCache
[HC-DEBUG-F] health_check_interval=30 (yaml_raw=30, default=300)
[HC-DEBUG-B] manager created: health_check_ttl=1000 lock_ttl=60
→ 本地 env / Cache / YAML / Manager 全部正确。
6.7 本地 tick 节奏¶
T+0 LOCK ACQUIRED — running real health check (will ping 12 models)
T+12 cache written, ttl=1000
T+30 CACHE HIT (age=30.0s)
T+60 CACHE HIT (age=60.0s)
T+90 CACHE HIT (age=90.0s)
...
T+180 CACHE HIT (age=180.0s)
→ 180 秒了仍然全部 cache hit,没有重新打模型。
如果 TTL 实际是 300(默认),第 5 分钟那次 tick 必然变 CACHE MISS → 再打。本地没有这个现象。
6.8 平行验证:Redis TTL 直接监控¶
.venv312/bin/python -c "
import redis, time
r = redis.Redis(host='127.0.0.1', port=16379, decode_responses=True)
while True:
keys = r.keys('health_check*')
out = ' | '.join(f'{k}:TTL={r.ttl(k)}' for k in keys) or '<no keys>'
print(f'[{time.strftime(\"%H:%M:%S\")}] {out}', flush=True)
time.sleep(5)
"
输出:
[11:25:55] health_check_results:TTL=685
[11:26:00] health_check_results:TTL=680
[11:26:05] health_check_results:TTL=675
[11:26:10] health_check_results:TTL=670
...
TTL 从 685 平滑递减,每 5 秒减 5 —— 没有跳回 300。实锤本地 TTL=1000 完全生效。
6.9 结论¶
| 维度 | 本地 | prod |
|---|---|---|
| env 进进程 | ✅ HC-DEBUG-A 显示 1000 | ❓(导师重启过 Pod 但未直接验证) |
redis_usage_cache |
✅ 是 RedisCache | ❓ |
| YAML interval | ✅ 30 | ❓ |
| Manager 创建 + 用新 TTL | ✅ TTL=1000 | ❓ |
| Redis 真实 TTL | ✅ 1000 平滑递减 | ❓ |
| 5-min burst | ❌ 没有 | 有 |
本地代码逻辑完全正确,TTL env 完全生效。prod 的 5-min 现象不是代码 bug,是 prod 环境差异。
6.10 对 prod 的下一步建议¶
按概率排序的 prod 验证动作:
POD=$(kubectl get pods -l app=litellm-proxy -o jsonpath='{.items[0].metadata.name}')
# 1. env 真在 Pod 里?
kubectl exec $POD -- env | grep DEFAULT_SHARED_HEALTH_CHECK_TTL
# 2. Python 常量真实值?
kubectl exec $POD -- python -c \
"from litellm.constants import DEFAULT_SHARED_HEALTH_CHECK_TTL; print(DEFAULT_SHARED_HEALTH_CHECK_TTL)"
# 3. Redis 通吗?manager 在写吗?
kubectl exec $POD -- python -c "
import os, redis
r = redis.Redis(host=os.environ['REDIS_HOST'],
port=int(os.environ['REDIS_PORT']),
password=os.environ.get('REDIS_PASSWORD'))
print('PING:', r.ping())
print('keys:', r.keys('health_check*'))
print('TTL:', r.ttl('health_check_results'))
"
# 4. ConfigMap 是否真的挂了正确 YAML
kubectl exec $POD -- cat /path/to/proxy_server_config.yaml | grep -E 'health_check|use_shared'
90% 概率会在第 1-3 步定位(env 没进进程 / Redis 不通 / ConfigMap 挂错)。
6.11 总结:本案例的方法论¶
- 现象数字必须先核实定义:"242 条" 是日志还是请求?是单 Pod 还是多 Pod 总和?
- 本地复现 + print 是定位环境问题的最快路径,比读代码强得多
- Redis 直接监控 TTL 是验证 cache 行为最权威的方法
- 不要轻信"我重启了",要用
python -c "from ... import ...; print(...)"验证常量真实值 - 代码逻辑正确不等于配置生效,要把"代码是否对"和"环境是否对"分开排查
7. 模块常量"导入即冻结"陷阱(参考章)¶
LiteLLM 大量常量是模块顶层 os.getenv 调用,进程启动后无法运行时修改。常见踩坑:
| 常量 | 文件 |
|---|---|
DEFAULT_HEALTH_CHECK_INTERVAL |
litellm/constants.py |
DEFAULT_SHARED_HEALTH_CHECK_TTL |
litellm/constants.py |
DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL |
litellm/constants.py |
HEALTH_CHECK_TIMEOUT_SECONDS |
litellm/constants.py |
DEFAULT_HEALTH_CHECK_PROMPT |
litellm/constants.py |
修改任何一个都需要 rolling restart Pod。