配置多级覆盖把「唯一权威来源」压掉:上游合同 600 rps,网关却按 130 rps 发请求
日期:2026-09-27 | 分支:
2026-09-27-0800| 场景目录:scenario/2026-09-27-0800/关键词:配置优先级 / 多级配置覆盖 / 配置合并顺序 / 唯一权威来源 / single source of truth / deployment contract / 部署契约 / 「后写的赢」vs「权威的赢」/ last-writer-wins / 静默兜底 silent fallback / 契约缺失 / 速率限制 ratelimit / capacity planning 容量规划 / Python 配置装载 config loader / config precedence / 网关 forwarding rps 节拍 / 重启无效扩容无效 / 无报错静默失效 / docker compose 复现
一、问题背景
网关(Python)负责给一个新上游打分(压测)。集群下发的部署契约 conf/gateway.env
写得很清楚,而且契约页明确声明这是唯一权威来源:
# conf/gateway.env —— 集群下发、只读、不许改
GATEWAY_UPSTREAM_URL=upstream:80
GATEWAY_UPSTREAM_MAX_RPS=600
契约页口径:
GATEWAY_UPSTREAM_MAX_RPS是上游速率的唯一权威来源;当其它配置层与它不一致时,以它为准,并在日志里记录被忽略的值。
实际现象(上游侧投诉):
- 上游报「网关发过来的量只有合同的一半不到」;
- 网关自己没有任何报错:进程活着、日志正常、退出码 0、上报文件是合法 JSON;
- 重启无效、扩容两个副本也无效 → 不是资源问题;
- 真实节拍
conf-report.json:upstream_max_rps = 130.0、interval_ms = 7.692(=1000/130), 而契约要求1000/600 = 1.667 ms→ 节拍慢了 4.6 倍。
一句话:上游被打成了 130 rps,而不是合同写的 600 rps。
二、复现步骤
环境:Docker + docker compose(本机 docker 29.6.2 / compose v5.3.1)。
cd scenario/2026-09-27-0800
docker compose up -d --build
docker compose exec gateway bash
# 1) 复现问题:打印生效配置 + 真实节拍 + 与契约比对
/srv/ops/repro.sh # -> RESULT: BAD(退出码 1)
# 2) 问题态自检基线
/srv/ops/verify.sh # -> PASS=2 FAIL=9,RESULT: BAD
# 3) 外部验收探针(只读契约视角)
python3 /srv/client/check_ratelimit.py # -> RESULT: BAD(退出码 1)
# 4) 改 app/ 下的代码后必须重建镜像(COPY 进镜像,restart 不生效)
exit
docker compose up -d --build
docker compose exec gateway /srv/ops/repro.sh # -> RESULT: OK
docker compose exec gateway /srv/ops/verify.sh # -> PASS=11 FAIL=0 / RESULT: OK
docker compose exec gateway python3 /srv/client/check_ratelimit.py # -> RESULT: OK
docker compose down
问题态实际输出(已实测):
== 1) 当前生效的速率配置(看 config_sources 的胜出者)==
upstream_max_rps = 130.0
胜出来源 = conf/config.override.json:upstream.max_rps
== 4) 与部署契约相比 ==
契约 GATEWAY_UPSTREAM_MAX_RPS = 600 rps
实际生效 upstream_max_rps = 130.0
节拍间隔 interval_ms = 7.692 ms (契约应为 1.667 ms)
RESULT: BAD —— 上游【合同值】600 rps 被压到 130.0 rps,节拍慢了 4.6 倍
verify.sh 问题态:PASS=2 FAIL=9(只有 C1「契约没被改动」、C2「非冲突项仍能覆盖」是绿的)。
D 组真实压测:1 秒内只发出 130 次。
配置层各写了什么(现场线索)
| 层 | 文件 | upstream_max_rps | 实际身份 |
|---|---|---|---|
| 1 | 内置默认值 | 100 | 兜底 |
| 2 | conf/gateway.env | 600 | 集群下发的部署契约(唯一权威来源) |
| 3 | conf/ratelimit.conf | 1500 | 2026-03-11 压测演练残留,没人清 |
| 4 | conf/config.override.json | 130 | 本次压测的临时覆盖层 |
| 5 | 环境变量 GATEWAY_UPSTREAM_MAX_RPS | 未设 | —— |
notes/plan.md(演练记录)写着:「把上游速率压到 130 rps,跑 30 分钟后放回……
演练结束没有专门回滚」——临时值就是这么坐上王位的。
notes/oncall-handover.md 则记着「重启无效、扩容无效,怀疑中间层但没敢动」。
三、根本原因
3.1 代码的合并语义是「后写的赢」,不是「权威的赢」
app/config.py 的 load_config() 是一条顺序赋值流水线:
cfg["upstream_max_rps"] = float(contract[CONTRACT_KEY]) # 契约 600 ← 先写
...
cfg["upstream_max_rps"] = float(mid["upstream_max_rps"]) # 中间层 1500 ← 覆盖
...
cfg["upstream_max_rps"] = float(up["max_rps"]) # 覆盖层 130 ← 再覆盖,赢了
每一层都是无条件直接赋值,前面的值被静默丢弃。契约排在第 2 层出场, 后面两层排着队把它踩死 —— 权威来源全程没有受到任何保护。
文档写的是「权威的赢」,代码实现的是「后写的赢」。两者在只有一层配置时完全无法区分:
- 本地开发只有
DEFAULTS+ 契约,顺序执行恰好等于正确答案; - 只有多级配置同时在场、且后写的值不同时,语义差异才暴露。
这就是这个 bug 能活半年的原因 —— 它需要「至少两层配置 + 值不同」才显形。
3.2 契约缺失时静默兜底(第二个独立缺陷)
_read_env_file() 在文件不存在时返回 {},if CONTRACT_KEY in contract 不成立就跳过,
cfg 里于是保留着上一层或 DEFAULTS 的值继续跑。
实测把契约删掉:不是报错,而是 SILENT:130.0 ——
权威来源消失了,进程却安安静静拿着别人写的临时值继续对外服务。
契约既然是「唯一权威」,它不在就应该大声失败,而不是挑一个历史遗留值顶上。
3.3 为什么全程零报错
app/limits.py 忠实地做了 1000/130 = 7.692 ms,app/forwarder.py 也如实上报。
下游全部正确,错误的只有输入。 所以:
- 重启无效(配置还是那三层);
- 扩容无效(不是资源问题,是口径问题);
- 进程活着、日志正常、退出码 0、JSON 合法 —— 故障对监控完全不可见。
四、解决办法
只改 app/config.py,把语义从「后写的赢」改成「权威的赢」。
conf/ 三个文件一个字符都不能改(用 ops/conf.baseline.md5 核对)。
关键改动:
- 先读权威层:契约里的
GATEWAY_UPSTREAM_MAX_RPS最先读入,作为唯一权威值。 - 其余层遇到该键 → 记录并忽略:新增
_reject_if_conflicting(),当key == AUTHORITATIVE_KEY且值不同时不覆盖,而是塞进upstream_max_rps_ignored并向 stderr 打[config][WARN]日志(满足契约「记录被忽略的值」)。 - 权威键缺失 → 抛
ContractError:load_config()直接抛异常、非 0 退出,绝不静默兜底。 - 非法值也报错:
_as_float()对 list/dict/非数值抛错(生效速率必须是单个数值)。 - 非冲突键不动:
retry_budget、retention_days、upstream_url仍按原顺序正常覆盖 —— 别修过头。 - 环境变量层纳入同一套规则:
env:GATEWAY_UPSTREAM_MAX_RPS也不得压掉权威值。
class ContractError(RuntimeError):
"""部署契约缺失或不可用时抛出 —— 宁可起不来,也不要静默跑错口径。"""
AUTHORITATIVE_KEY = "upstream_max_rps"
def load_config() -> dict:
...
# ── 1) 权威层:部署契约 ──
contract = _read_env_file(CONF / "gateway.env")
if CONTRACT_KEY not in contract:
raise ContractError(
f"部署契约 {contract_path} 缺失 {CONTRACT_KEY}:"
f"{AUTHORITATIVE_KEY} 的唯一权威来源不可用,拒绝静默兜底启动")
contract_origin = f"conf/gateway.env:{CONTRACT_KEY}"
authoritative_value = _as_float(contract[CONTRACT_KEY], contract_origin)
cfg[AUTHORITATIVE_KEY] = authoritative_value
sources[AUTHORITATIVE_KEY] = contract_origin
def _reject_if_conflicting(key, raw, origin) -> bool:
"""对权威键做「记录并忽略」;返回 True 表示 caller 可继续处理该键。"""
if key != AUTHORITATIVE_KEY:
return True # 非冲突键:照常覆盖
value = _as_float(raw, origin)
if value != authoritative_value:
ignored.setdefault(AUTHORITATIVE_KEY, []).append(
{"source": origin, "value": value})
_warn(f"{origin} 试图把 {AUTHORITATIVE_KEY} 覆盖为 {value},"
f"但契约 {contract_origin} 的值 {authoritative_value} 是唯一权威来源,已忽略该值")
return False
改完必须重建镜像:
docker compose up -d --build # restart 不会生效!
五、验证
5.1 repro.sh → RESULT: OK(退出码 0)
== 1) 当前生效的速率配置(看 config_sources 的胜出者)==
[config][WARN] conf/ratelimit.conf:upstream_max_rps 试图把 upstream_max_rps 覆盖为 1500.0,
但契约 conf/gateway.env:GATEWAY_UPSTREAM_MAX_RPS 的值 600.0 是唯一权威来源,已忽略该值
upstream_max_rps = 600.0
胜出来源 = conf/gateway.env:GATEWAY_UPSTREAM_MAX_RPS
...
upstream_max_rps = 600.0
interval_ms = 1.6666666666666667
effective_rps = 600.0
peak_sustained_rps = 600.0
RESULT: OK —— 生效速率与契约一致
5.2 verify.sh → PASS=11 FAIL=0 / RESULT: OK
── A 组:现象与现场 ──
[PASS] A1 生效的 upstream_max_rps 应等于契约值 600(600.0)
[PASS] A2 胜出来源应是契约文件 conf/gateway.env(conf/gateway.env:GATEWAY_UPSTREAM_MAX_RPS)
[PASS] A3 节拍间隔应是 1.667 ms(1000/600)(1.667)
── B 组:判据 ──
[PASS] B1 计划间隔应为 1.667 ms(1.667)
[PASS] B2 真实调用间隔应落在 1.5~2.0 ms(实测 1.981 ms)(yes)
[PASS] B3 生效速率必须是单个数值而不是数值集合(600.0)
── C 组:契约不可变 + 等价类 ──
[PASS] C1 conf/ 部署契约必须保持原样(a53d9da5c5cb6dfadc1c54a475050bd6)
[PASS] C2 非冲突项仍应被覆盖层正常覆盖(retry_budget=20)(20)
[PASS] C3 契约值改为 321 后生效值应跟着变(证明真的在读契约)(321.0)
[PASS] C4 契约缺失时应报错而不是静默兜底(RAISED)
── D 组:真实压测 ──
[PASS] D1 1 秒内应发到 500~700 次(约 600 rps),实测 581 次(yes)
RESULT: OK PASS=11 FAIL=0
581 次/秒 ≈ 600 rps(修复前 130 次)—— 真实节拍回到合同口径。
5.3 外部探针 check_ratelimit.py → RESULT: OK(退出码 0)
契约要求 upstream_max_rps = 600
网关实际生效 = 600.0
节拍间隔 = 1.667 ms (契约应为 1.667 ms)
配置胜出来源 = conf/gateway.env:GATEWAY_UPSTREAM_MAX_RPS
RESULT: OK
5.4 额外边界验证
E1 环境变量注入压不动权威值(约束明确不许靠 env 绕过):
[config][WARN] env:GATEWAY_UPSTREAM_MAX_RPS 试图把 upstream_max_rps 覆盖为 130.0,…已忽略该值
600.0 conf/gateway.env:GATEWAY_UPSTREAM_MAX_RPS
E2 契约缺失 → 非 0 退出(大声失败):
config.ContractError: 部署契约 /srv/conf/gateway.env 缺失 GATEWAY_UPSTREAM_MAX_RPS:
upstream_max_rps 的唯一权威来源不可用,拒绝静默兜底启动
python exit=1
E3 conf/ 不可变基线:三个文件 md5 与 ops/conf.baseline.md5 完全一致,
git status --short 只有 M app/config.py。
六、经验与避坑
核心认知
- 「配置写对了」≠「配置生效了」。 永远看胜出来源(
config_sources), 别只看文件里写了什么 —— 三个文件里同一个键各写一个值,只有一个是真在用的。 - 优先级链的语义必须与文档口径逐字对齐。 「后写的赢」(last-writer-wins)和 「权威的赢」(authoritative-wins)在只有一层配置时完全无法区分 —— 所以这类 bug 能潜伏很久,只在多级配置同时在场时才显形。 把「哪一层赢」写成显式声明,别依赖文件读取顺序的副作用。
- 对「唯一权威来源」这类字段,缺失必须大声失败,不许静默兜底。
if key in contract:这种写法让缺失悄悄降级成上一层的值 —— 这正是130/1500这些历史遗留值能活半年的机制。缺权威来源就拒绝启动。 - 区分「冲突键」和「非冲突键」。 修复不能一刀切删掉中间层/覆盖层:
retry_budget=20被覆盖层覆盖是正常且需要的功能。 正确做法是按 key 区分:权威键走「权威的赢」,其它键维持「后写的赢」。 - 「重启无效 + 扩容无效 + 零报错」是配置口径问题的典型指纹。 资源问题会随副本数和重启改变;配置问题不会。而且当下游全部正确、只有输入错时, 故障对监控完全不可见。
踩坑清单
| # | 坑 | 说明 |
|---|---|---|
| 1 | restart 不生效 | app/ 是 COPY 进镜像的,改完必须 docker compose up -d --build。第一次改完 restart 现象完全没变。 |
| 2 | verify.sh 的 C3/C4 会临时改/移 conf/gateway.env | 脚本用 cp+sed+mv 做现场改动。若中途 Ctrl-C 或超时打断,会留下改过的契约文件,后续判定全部失真 → 跑完务必核对 md5 基线。 |
| 3 | conf-report.json 是上一轮残留 | repro.sh 会先 rm -f,但手工看 interval_ms 前要确认报告是刚生成的,否则读到旧值。 |
| 4 | 别一刀切删配置层 | 删掉中间层/覆盖层的读取,C2(retry_budget=20)立刻变红。 |
| 5 | 别静默兜底 | 契约缺失要抛异常,不要挑一个「看起来合理」的值顶上。 |
| 6 | [config][WARN] 打到 stderr | verify.sh 输出里会混入告警行(不影响取 stdout 的判定)。这是契约要求的「记录被忽略的值」,有意保留。 |
同类影响面(值得自查的地方)
- 任何「多级配置合并」的加载器:Spring
@PropertySource顺序、 K8s ConfigMap + env + 命令行 flag、docker compose的env_filevsenvironment、 npm/pip 的.npmrc/pip.conf、Log4j/Logback 的 include 顺序; - 任何「有部署契约/基线配置」的系统:分片数、副本数、超时、配额、限流阈值;
- 自查一句话:「把权威来源的值改掉,生效值会跟着变吗?把它删掉,进程会怎样?」 —— 两个问题的答案应当分别是「会变」和「报错」。
最小修复清单(可直接照搬)
- 找出「唯一权威来源」是哪一个键、哪一层;
- 让权威层先读,并记录其值;
- 其它层遇到该键 → 值相同就跳过,值不同就忽略 + 记录到日志/上报字段;
- 权威键缺失 → 抛异常 / 非 0 退出;
- 非冲突键维持原有覆盖行为(别修过头);
- 用「改值 → 跟着变」「删值 → 报错」两个等价类验证判据真的有牙。
