Rust in Everything
基于 pingora-load-balancing 0.9.0

attempt to calculate the remainder with a divisor of zero

主线位置:请求主线的 upstream_peer,用 LoadBalancer::select 挑后端的那一行;一致性哈希的情况更早,在启动时 update() 建选择器那一步。选择算法见选择算法,LoadBalancer 和 update 见 LoadBalancer。

flowchart TB
  conf["权重来自配置"] --> upd["LoadBalancer update"]
  upd --> algo{"算法"}
  algo -->|Ketama| bucket["Bucket::new 断言"]
  bucket -->|"有一个 0"| p1["启动时 panic"]
  algo -->|Weighted| table["加权表"]
  table -->|"全是 0"| empty["空表"]
  empty --> sel["每次 select 取模"]
  sel --> p2["请求任务 panic"]

实验 crate exp-pingora/pitb-lab,bin p26_weight,脚本 tools/p26.sh(debug;WEIGHTS=0,0 那一组另用 release 跑过,结果相同);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。代理在 19390,两个后端 python3 -m http.server 在 19391、19392,权重从环境变量 WEIGHTS 读。

现象

权重从配置读进来,配置里有人写了 0,想先「摘掉」一台:

for (i, w) in weights.split(',').enumerate() {
  let addr = format!("127.0.0.1:{}", 19391 + i);
  let w: usize = w.trim().parse().map_err(|e| format!("{addr}: bad weight {w:?}: {e}"))?;
  set.insert(Backend::new_with_weight(&addr, w).map_err(|e| e.to_string())?);
}
let lb = LoadBalancer::<S>::from_backends(Backends::new(Static::new(set)));
futures::executor::block_on(lb.update())  // build the selector
// upstream_peer: self.0.select(&key, 256)

RoundRobin,WEIGHTS=0,0(两台都摘掉,比如灰度切换的中间状态),三个 curl:

/fast 000 exit=52
/slow 000 exit=52
/name 000 exit=52
## proxy
building the selector
selector built

thread 'Pingora HTTP Proxy Service' (5898850) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-load-balancing-0.9.0/src/selection/weighted.rs:93:47:
attempt to calculate the remainder with a divisor of zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'Pingora HTTP Proxy Service' (5898850) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-load-balancing-0.9.0/src/selection/weighted.rs:93:47:
attempt to calculate the remainder with a divisor of zero
...

启动正常,每个请求在 select 里 panic,客户端空回复(curl 52)。select 返回的是 Option,可这里根本走不到返回 None 那一步。

KetamaHashing(ALGO=ketama),WEIGHTS=1,0:

/fast 000 exit=7
/slow 000 exit=7
/name 000 exit=7
## proxy
building the selector

thread 'main' (5898907) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-ketama-0.9.0/src/lib.rs:95:9:
weight must be at least one
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

只要有一个 0,建环时主线程 panic,进程退出,端口没人监听(curl 7)。选择器是在 update() 里建的;用后台服务定期 update() 时,服务发现哪天返回了一个带 0 权重的集合,panic 就发生在跑 update() 的那个任务里。

同样的 WEIGHTS=1,0 用 RoundRobin,三个请求都是 200:权重 0 的那台只是不当首选。

出处

Weighted::build 按权重把下标放进加权表,权重 0 一格都不放;全是 0 时表是空的。WeightedIterator::next 只检查了后端表是不是空的:

    fn next(&mut self) -> Option<&Backend> {
        if self.backend.backends.is_empty() {
            // short circuit if empty
            return None;
        }

        if self.first {
            // initial hash, select from the weighted list
            self.first = false;
            let len = self.backend.weighted.len();
            let index = self.backend.weighted[self.index as usize % len];

后端有两个,不空;weighted 是空的,len == 0,第 93 行的 % len 就是那个除以零。整数除以零在 Rust 里不受溢出检查开关影响,release 一样 panic。

一致性哈希走 KetamaHashing::build_with_config,每个后端变成一个 Bucket,Bucket::new 第一行就是 assert!(weight != 0, "weight must be at least one");,文档注释的 # Panics 一节写明了这一点。同一个 weight: usize 字段,Backend::new_with_weight 不检查,Weighted 把 0 当「不当首选」,Ketama 把 0 当非法。tag 之后到 84f21ee,两个 crate 里这几行都没有改。

修法

读配置时拒绝 0,错误在启动时、带着是哪个后端报出来:

let w: usize = w.trim().parse().map_err(|e| format!("{addr}: bad weight {w:?}: {e}"))?;
if w == 0 {
  // weight 0 is not "drained": reject it here instead of panicking later
  return Err(format!("{addr}: weight must be at least 1 (remove the backend to drain it)"));
}

MODE=fix WEIGHTS=1,0:

/fast 000 exit=7
/slow 000 exit=7
/name 000 exit=7
## proxy
config error: 127.0.0.1:19392: weight must be at least 1 (remove the backend to drain it)

进程以退出码 2 结束,日志说清楚了哪一行配置错了。合法的配置(MODE=fix WEIGHTS=3,1 ALGO=ketama)照常起来,三个请求都是 200。

想摘掉一台后端,有两个办法都不用碰权重:从服务发现返回的集合里删掉它;或者 Backends::set_enable(backend, false)(经 lb.backends() 拿到),被禁用的后端 ready() 为假,select 跳过它。权重 0 在 Weighted 下也不是真的摘掉:首选不可用时,备选候选不看权重,它照样会被选中(选择算法里的实测)。

怎么确认没中招

  • 搜 new_with_weight( 和直接给 Backend { weight, .. } 赋值的地方:权重来自外部输入时,有没有拒绝 0。
  • 日志里 selection/weighted.rs:93:47 或 pingora-ketama-0.9.0/src/lib.rs:95:9:前者是全部权重为 0,后者是至少一个为 0 且用了一致性哈希。
  • 把配置里的权重全改成 0、或者改一个为 0 换成一致性哈希,各起一次服务。

小结

  • Backend 的权重可以是 0;Weighted(轮询、随机、FNV)在全部为 0 时加权表为空,每次 select 取模 panic,debug 和 release 都一样。
  • KetamaHashing 只要一个权重为 0,Bucket::new 的断言在建环时 panic,启动时建就起不来。
  • 修法:读配置时拒绝 0;摘流量用删除或 set_enable(false),不要用权重 0。

💬 关联讨论

发起讨论
加载中...