跳转至

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 时执行一次

DEFAULT_SHARED_HEALTH_CHECK_TTL = int(
    os.getenv("DEFAULT_SHARED_HEALTH_CHECK_TTL", 300)
)

进程已经启动后修改 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。必须显式执行:

kubectl rollout restart deployment <litellm-deployment>
kubectl rollout status  deployment <litellm-deployment>


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_cacheproxy_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 检查:

  1. DB 连通(prisma_client ping,SELECT 1
  2. Cache 连通(如果配了 cache: true
  3. 关键 callback 注册状态
  4. 不会触发任何模型 ping

4.3 排查

# 直接打看具体哪一项失败
curl http://your-proxy/health/readiness
# 注意:不需要带 Authorization

返回里会带每项的状态,根据失败项进一步查(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_results dict,在第一次 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 总结:本案例的方法论

  1. 现象数字必须先核实定义:"242 条" 是日志还是请求?是单 Pod 还是多 Pod 总和?
  2. 本地复现 + print 是定位环境问题的最快路径,比读代码强得多
  3. Redis 直接监控 TTL 是验证 cache 行为最权威的方法
  4. 不要轻信"我重启了",要用 python -c "from ... import ...; print(...)" 验证常量真实值
  5. 代码逻辑正确不等于配置生效,要把"代码是否对"和"环境是否对"分开排查

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


下一步