健康检查与后台服务
主线位置:服务主线上的一个后台服务。
Server::run_forever给background_service("lb", ...)建一个只有一个工作线程的运行时,LoadBalancer::run在上面循环:发现后端、做健康检查、睡到下一次。请求主线上的select只读它写下的健康状态,两边不加锁。
sequenceDiagram participant S as Server participant L as LoadBalancer run participant H as Health participant P as 代理线程 S->>L: start_with_ready_notifier L->>L: update 发现后端 L->>S: notify_ready L->>H: run_health_check P->>H: select 读 ready L->>L: sleep_until 下一轮 S->>L: shutdown 变成 true L-->>S: 立即返回
本页的源码在 pingora-load-balancing 的 health_check.rs(正文 405 行)和 background.rs(正文 482 行;0.8.0 时整个文件 92 行),后台服务的框架在 pingora-core 的 services/background.rs。服务怎么启动、ShutdownWatch 从哪来,见 服务与监听器 和 平滑升级与停机。
实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0(对照组 0.8.0),tokio 钉在 1.53.2(0.8.0 那组是本站网关锁文件里的 1.52.3,原因见怎么读这一卷),release 构建。
HealthCheck trait
pub trait HealthCheck {
/// Check the given backend.
///
/// `Ok(())`` if the check passes, otherwise the check fails.
///
/// A health-check pass drops this future to cancel the check on shutdown,
/// so an implementation must not rely on running to completion once polled.
async fn check(&self, target: &Backend) -> Result<()>;
/// Called when the health changes for a [Backend].
async fn health_status_change(&self, _target: &Backend, _healthy: bool) {}
/// Called when a detailed [Backend] summary is needed.
fn backend_summary(&self, target: &Backend) -> String {
format!("{target:?}")
}
/// This function defines how many *consecutive* checks should flip the health of a backend.
// ...
fn health_threshold(&self, success: bool) -> usize;
}
check 返回 Ok 就算这一次成功。health_threshold(true) 是从不健康翻成健康要连续成功几次,health_threshold(false) 反过来。健康翻转时调 health_status_change,并打一条日志:变坏是 warn!("{summary} becomes unhealthy, {error}"),变好是 info!;summary 默认是 Backend 的 Debug 输出。
两个现成的实现:
TcpHealthCheck | HttpHealthCheck | |
|---|---|---|
| 构造 | TcpHealthCheck::new(),new_tls(sni) 再握一次 TLS | HttpHealthCheck::new(host, tls) |
| 做什么 | TransportConnector::get_stream:连上就算成功 | 发 GET /、Host: host,读响应头,排干响应体 |
| 成功条件 | 连接建立 | 状态码 200,或者自定义的 validator 返回 Ok |
| 超时 | 连接 1 s | 连接 1 s、读 1 s |
| 连续阈值 | 成功 1 次、失败 1 次 | 成功 1 次、失败 1 次 |
| 复用连接 | 不放回池子 | reuse_connection 默认 false,每次重新握手 |
| 其他 | peer_template 里可以绑源地址等 | port_override 换端口检查;backend_summary_callback |
两者的 peer_template 地址都是占位的 0.0.0.0:1,check 里换成目标的地址。HttpHealthCheck 的文档注释解释了为什么默认不复用连接:「Established connections sometimes hide the issue of firewalls and L4 LB」。它在写请求前设写超时,是 0.9.0 的 cb397dd:之前只设了读超时,提交说明写的是自定义协议的连接器在 finish 里等握手信号时会无限期挂住,「A single stuck backend can wedge the entire health check batch」。
Health:默认健康,连续几次才翻转
每个后端的健康状态是一个 Health,内部是 Arc<ArcSwap<HealthInner>>,HealthInner 只有两个字段:healthy 和连续计数 consecutive_counter。它的 Default:
impl Default for Health {
fn default() -> Self {
Health(Arc::new(ArcSwap::new(Arc::new(HealthInner {
healthy: true, // TODO: allow to start with unhealthy
consecutive_counter: 0,
}))))
}
}
新后端一律从「健康」开始,第一次检查之前,select 照常会选它。observe_health 的逻辑:观测结果和当前状态相反,计数加一,到阈值就翻转、计数清零;结果和当前状态相同而计数不为零,计数清零(「no longer consecutive」)。读写都是整个 HealthInner 换掉,select 那边的 ready() 是一次 ArcSwap::load。
这个状态只由健康检查改。代理转发时连接失败、上游回 502,Health 都不知道:pingora 没有被动健康检查(passive health check)。
后台循环
LoadBalancer 实现了 BackgroundService。background_service(name, lb) 把它包成 GenBackgroundService,名字前面加 BG ,threads 是 Some(1);Server 给每个服务一个运行时,所以负载均衡器的循环和它 spawn 的检查任务都在一个只有一个工作线程的运行时上,和代理的工作线程分开。.task() 返回的 Arc<LoadBalancer> 交给代理用。LoadBalancer::run 的循环体:
loop {
if *shutdown.borrow() {
return;
}
if schedule.update_is_due(now) {
// TODO: log err
match race_shutdown(&mut shutdown, self.update()).await {
Raced::ShuttingDown => return,
Raced::Completed(_) => schedule.update_finished_at(now),
}
}
// After the first update, discovery and selection setup will be
// done, so dependent services can start receiving traffic.
if let Some(ready) = ready_opt.take() {
ServiceReadyNotifier::notify_ready(ready)
}
if schedule.health_check_is_due(now) {
let health_check = self.backends.run_health_check(self.parallel_health_check);
match race_shutdown(&mut shutdown, health_check).await {
Raced::ShuttingDown => return,
Raced::Completed(()) => schedule.health_check_finished_at(now),
}
}
if schedule.is_idle() {
return;
}
let to_wake = schedule.next(now);
match race_shutdown(&mut shutdown, tokio::time::sleep_until(to_wake.into())).await {
Raced::ShuttingDown => return,
Raced::Completed(()) => {}
}
now = Instant::now();
}
- 发现和检查各有一个时间表。启动时两者都立刻到期;
update_frequency或health_check_frequency为None的那一项只跑一次。两项都只跑一次时,循环第一轮之后就返回了,后台服务结束,健康状态从此不再变。 - 下一次的时间是
now + frequency,now是这一轮开始的时刻,不是检查结束的时刻。一轮检查比频率还长,下一轮会紧接着开始。 update()的错误被丢掉了(// TODO: log err)。发现失败时成员保持不变。- 就绪通知(
notify_ready)在第一次发现之后、第一次健康检查之前发出。别的服务声明依赖这个后台服务,等到的是「有后端了」,不是「后端检查过了」。 run_health_check默认(parallel_health_check: false)对目标一个接一个地await。打开并行后,每个目标一个任务,放在JoinSet里spawn_on(current_handle()),也就是这个后台服务自己的运行时;某个检查 panic 只打一条warn,那个后端保持上一轮的状态。
结合「默认健康」和「串行检查」,启动时有一段空档:一个一开始就死掉的后端,要等排在它前面的检查都做完、轮到它失败,才不再被选中。本机连 127.0.0.1 上没人监听的端口,拒绝是瞬间的:实测三次,代理日志里 B 都在 proxy up 之后 1 ms 内被标成不健康,客户端一个 502 也没看到,空档看不出来。把 TcpHealthCheck 包一层、每次检查先睡 300 ms,模拟一个远处的后端:
// a TCP check that takes 300 ms, like one to a far-away backend
struct SlowTcp(Box<TcpHealthCheck>);
#[async_trait]
impl HealthCheck for SlowTcp {
async fn check(&self, target: &Backend) -> Result<()> {
tokio::time::sleep(Duration::from_millis(300)).await;
self.0.check(target).await
}
fn health_threshold(&self, success: bool) -> usize {
self.0.health_threshold(success)
}
}
代理 lbproxy 在 127.0.0.1:18700,LoadBalancer<RoundRobin> 管 A(127.0.0.1:18701,活着)和 B(127.0.0.1:18702,没启动),两个上游都是 python3 -m http.server,各返回一个字母。客户端在代理端口一能连上就开始,每 3 ms 左右一个请求,每个请求一条新连接。
杀掉一个上游
同一个代理,两个上游都活着,用普通的 TcpHealthCheck,health_check_frequency 1 秒(上游 quick_start.md 的写法)。客户端每 20 ms 左右发一个请求,在第 2.25 秒 kill -9 掉 B,第 5 秒再把它启动起来。程序在 lb-lab/src/bin/{lbproxy,client}.rs,脚本 failover.sh。连续相同的结果合成一行,A、B 交替的合成 A|B:
500869 t=2265ms killed upstream B
503596 t=5015ms restarted upstream B
t= 0.. 2241ms x89 200 A|B
t= 2265.. 2265ms x1 502
t= 2312.. 2312ms x1 200 A
t= 2336.. 2336ms x1 502
...
t= 2979.. 2979ms x1 200 A
t= 3003.. 3003ms x1 502
t= 3026.. 6000ms x121 200 A
t= 6024.. 7999ms x80 200 A|B
killed 那两行的六位数是毫秒时间戳(取模 10⁶),和代理日志同一个时钟;t= 是客户端启动后的毫秒数,在发请求之前取。代理日志(cut -c1-200 截断):
497572 proxy up hc=tcp freq=1000ms retry=false
501585 WARN pingora_load_balancing Backend { addr: Inet(127.0.0.1:18702), weight: 1, ext: {} } becomes unhealthy, ConnectRefused context: Fail to connect to BasicPeer { _address: Inet(127.0.0.1:18702
504594 INFO pingora_load_balancing Backend { addr: Inet(127.0.0.1:18702), weight: 1, ext: {} } becomes healthy
--- error lines: 16
500869 ERROR pingora_proxy Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18702, scheme: HTTP cause: context: Fail to connect to 127.0.0.1:18702 cause: Connection
...
- 杀掉之后,轮询每隔一个请求就轮到 B,B 连接被拒,回 502,一共 16 个,代理日志里正好 16 条
Fail to proxy。轮询不知道 B 失败过,下一轮照样给它。 - 502 一直持续到下一次健康检查把 B 标成不健康。检查的节拍从代理启动算起(497572 之后每秒一次),这一次的
becomes unhealthy在 501585,离第一个 502 的错误日志(500869)716 ms。之后所有请求都落到 A 上,select遇到 B 就取备选。 - B 在 503596 重新启动,504594 的检查把它变回健康,请求重新交替。
换几个时刻杀掉 B,502 的个数取决于离下一次检查还有多远:
| 杀掉的时刻 | 检查方式 | 502 个数 | 第一个到最后一个 502 |
|---|---|---|---|
| 2001 ms | TCP | 0 | 杀掉后紧接着就是一次检查(两行日志相差 15 ms) |
| 2265 ms | TCP | 16 | 738 ms |
| 2511 ms | TCP | 11 | 495 ms |
| 2753 ms | TCP | 6 | 258 ms |
| 2255 ms | HTTP | 15 | 740 ms |
最坏情况是刚检查完就挂掉,要等满一个 health_check_frequency。quick_start.md 说加上健康检查后「all requests succeed and the broken peer is never used」,指的是一开始就坏掉、在第一次检查时就被标出来的后端;运行中坏掉的后端,在一个检查周期内还会收到请求。2511 和 2753 两次,被杀那一刻正在进行的请求得到的是 ReadError ... Connection reset by peer,也是 502。
问: 代理明明已经知道连 B 失败了,为什么下一个请求还给 B?
答: select 是无状态的,它只读健康检查写下的 Health;代理这边的失败不会反馈到那里。要让一次失败立刻生效,有两件事都得做:在 fail_to_connect 里 set_retry(true)(连接错误默认不重试,见 错误、重试与故障转移),再在 upstream_peer 里用 select_with 跳过这次请求已经失败过的后端。lbproxy 的 RETRY=1 就是这样写的,CTX 是一个 Vec<Backend>:
async fn upstream_peer(&self, _s: &mut Session, failed: &mut Self::CTX) -> Result<Box<HttpPeer>> {
let picked = self
.lb
.select_with(b"", 256, |b, healthy| healthy && !failed.contains(b));
let Some(b) = picked else {
return Error::e_explain(ErrorType::HTTPStatus(503), "no healthy backend");
};
failed.push(b.clone());
Ok(Box::new(HttpPeer::new(b, false, String::new())))
}
t= 0.. 2232ms x94 200 A|B
t= 2257.. 6019ms x158 200 A
t= 6042.. 7998ms x84 200 A|B
一个 502 都没有。失败的那次连接被重试,第二次 upstream_peer 跳过 B 选了 A。健康检查照样在 1 秒内把 B 标成不健康,之后就不用再重试了。这只对连接阶段的失败有效;请求已经写到上游之后的失败要不要重试,由 error_while_proxy 决定,0.9.0 起非幂等请求不重试。
0.9.0:停机时立刻退出
CHANGELOG 0.9.0 有两处提到这件事:Highlights 里的「responsive load-balancer shutdown」,Bug Fixes 里的「improve load-balancer shutdown responsiveness」。0.8.0 的循环只在每一轮开头看一眼 shutdown,发现、检查和睡眠期间都看不到停机信号。0.9.0 把这三处都包进 race_shutdown:
async fn race_shutdown<F: Future>(shutdown: &mut ShutdownWatch, work: F) -> Raced<F::Output> {
tokio::pin!(work);
loop {
tokio::select! {
biased;
change = shutdown_changed(&mut *shutdown) => match change {
WatchChange::ShuttingDown => return Raced::ShuttingDown,
WatchChange::Spurious => continue,
// Nothing can signal shutdown any more, so stop racing
// altogether and let the work finish.
WatchChange::Closed => break,
},
output = &mut work => return Raced::Completed(output),
}
}
Raced::Completed(work.await)
}
收到停机就丢掉正在进行的 work:进行到一半的发现或健康检查被取消。所以 trait 文档要求 discover 和 check 经得起中途取消。并行检查放在 JoinSet 里,JoinSet 被丢掉时会 abort 它还持有的任务,不会留下孤儿。
实验:一个后端,自定义的 HealthCheck 每次打印编号,health_check_frequency 200 ms;配置 grace_period_seconds: 2、graceful_shutdown_timeout_seconds: 1,启动 1 秒后发 SIGTERM。hang 模式下第 3 次检查永远不返回,检查被丢掉时打印 dropped。程序 lb-lab/src/bin/shutdown.rs,同一份源码链接 pingora 0.8.0 编成 lb-08。
0.9.0,hang:
631028 check #1 starts
631229 check #2 starts
631431 check #3 starts
632075 INFO SIGTERM received, gracefully exiting
632076 INFO Broadcasting graceful shutdown
...
632076 INFO Graceful shutdown: grace period 2s starts
632076 check #3 dropped
632076 INFO service 'BG lb' exited.
634081 INFO Graceful shutdown: grace period ends
...
634081 INFO All runtimes exited, exiting now
0.8.0,hang:
719906 check #1 starts
720108 check #2 starts
720311 check #3 starts
720937 INFO SIGTERM received, gracefully exiting
...
720937 INFO Graceful shutdown: grace period 2s starts
722938 INFO Graceful shutdown: grace period ends
722938 INFO Waiting for runtimes to exit!
722938 INFO Waiting for service runtime BG lb to exit
722938 check #3 dropped
723942 INFO All runtimes exited, exiting now
- 0.9.0 在 SIGTERM 的同一毫秒丢掉挂住的检查,后台服务退出。0.8.0 的检查一直挂到宽限期结束、运行时被关掉时才被丢掉,
service 'BG lb' exited这一行根本没有出现。 tick模式(检查都很快)下,0.8.0 在 SIGTERM 之后 191 ms 退出,是睡到下一轮开头才看到信号;health_check_frequency或update_frequency设成几分钟,就要等几分钟,或者等宽限期结束被运行时丢掉。- 进程退出的时刻两边由宽限期决定,差不多。0.8.0 最后多出来的 1 秒是关运行时那段代码里一个多余的
sleep,0.9.0 也修了,见 一个 Server 的一生。
所以这项改动让后台服务在宽限期里不再继续探测、不再刷新后端,其他依赖 ShutdownWatch 的逻辑能看到它及时退出;它不会让进程更早结束。
这件事在 0.9.0 里改了两次。98419e7(「Fix load balancer shutdown responsiveness」,同步自 PR #884)先在三处各写了一个 tokio::select!,判断函数是:
async fn shutdown_changed(shutdown: &mut ShutdownWatch) -> bool {
match shutdown.changed().await {
Ok(()) => *shutdown.borrow(),
Err(_) => true,
}
}
watch 的发送端被丢掉(Err)也当成停机;收到一个不是停机的变化时,分支里写的是 continue,回到循环开头,正在做的 update() 被丢掉、下一轮重新开始。
d46fbda(「Refactor LB race shutdown」)改成现在的样子:判断结果分成 ShuttingDown、Spurious、Closed 三种,注释写明发送端没了「Distinct from [WatchChange::ShuttingDown] on purpose. Treating a dropped sender as shutdown would make a service abandon work that nothing is ever going to ask it to stop doing.」;race_shutdown 把 work 钉住,非停机的变化之后继续 poll 同一个 future,「repeated watch changes can neither starve it nor repeat effects it already applied」。两个提交都首次发布于 0.9.0。
LoadBalancerGroup::run 和 HealthCheckService::run 用的是同一个 race_shutdown。HealthCheckService 还多一种情况:一次性模式(频率为 None)在 registry 里还没有任何目标时不跑检查,先等第一个目标出现,免得对一个空 registry 检查一遍就报告就绪。
动脑筋答案
t= 0.. 0ms x1 200 A
t= 6.. 6ms x1 502
t= 9.. 9ms x1 200 A
t= 12.. 12ms x1 502
...
t= 583.. 583ms x1 200 A
t= 587.. 587ms x1 502
t= 590.. 1499ms x291 200 A
大约 590 ms,期间 92 个 502、92 个 200。代理日志里 B 在打印 proxy up 之后 607 ms 才 becomes unhealthy:
- 两个后端都从默认的「健康」开始,轮询交替给出 A、B,给 B 的都是 502。
- 第一轮检查是串行的:先检查 A,睡 300 ms、连接成功;再检查 B,睡 300 ms、连接被拒,这时才翻成不健康。两个 300 ms 加起来约 600 ms。
parallel_health_check = true时两个检查同时睡,空档会缩到 300 ms 左右;后端越多,串行的空档越长。
小结
HealthCheck只有check和health_threshold必须实现;TcpHealthCheck连上就算健康,HttpHealthCheck要 200,两者默认 1 秒超时、一次成功或失败就翻转。- 新后端默认健康,第一次检查之前照样被选中;健康状态只由主动检查改,代理转发的失败不会反馈过来。
LoadBalancer::run先发现、再通知就绪、再检查,就绪通知早于第一次检查;默认串行检查,下一轮从这一轮开始的时刻算起。- 实测运行中杀掉一个上游,在下一次检查之前约一半的请求 502,最长一个
health_check_frequency;fail_to_connect里set_retry(true)加select_with跳过失败过的后端,502 降到 0。 - 0.9.0 用
race_shutdown让发现、检查和睡眠都能被停机打断,实测挂住的检查在 SIGTERM 的同一毫秒被丢掉;0.8.0 要等宽限期结束才被运行时丢掉。