bind() failed, after retries, ... still in use
主线位置:服务主线的「起监听」。
start_service先查监听 fd 表,没有就bind,机制见服务与监听器。本页讲bind一直失败时进程是什么状态。
flowchart TB
st["start_service"] --> b["bind_tcp"]
b --> ok{"AddrInUse"}
ok -->|否| lis["监听"]
ok -->|是| n{"满 30 次"}
n -->|否| sl["睡 1 秒"]
sl --> b
n -->|是| er["Err BindError"]
er --> pan["expect panic"]
pan --> zom["服务线程结束"]
zom --> alive["进程还在"]
实验 crate exp-pingora/pita-lab,bin svc,脚本 tools/portuse.sh;M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2。
现象
第一个 svc 监听 127.0.0.1:19222。1 秒后用同样的配置再起一个,不带 -u。第二个进程的日志:
[ 0.001s INFO pingora_core::server] Starting service: lab
[ 0.003s WARN pingora_core::listeners::l4] 127.0.0.1:19222 is in use, will try again
...
[ 28.063s WARN pingora_core::listeners::l4] 127.0.0.1:19222 is in use, will try again
thread 'lab' (5863086) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.9.0/src/services/listening.rs:293:14:
Failed to build listeners: Error { etype: BindError, esource: Unset, retry: Decided(false), cause: Some(Os { code: 48, kind: AddrInUse, message: "Address already in use" }), context: Some(Owned("bind() failed, after retries, 127.0.0.1:19222 still in use")) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[runq: killed after 40s]
is in use, will try again 一共 29 行,第 29 秒报错。第 35 秒时检查第二个进程:
second pid=42330
-- at 35 s: second alive? yes
-- listening sockets of second:
(lsof exit=1)
-- curl: /x pid=42321
- 第二个进程活着,一个监听 socket 都没有;端口上应答的仍然是第一个进程(
pid=42321)。最后是runq在第 40 秒把它 SIGKILL 掉的。 - panic 发生在服务
lab自己的线程里(线程名就是服务名),主线程还在main_loop里等信号。 - 前 29 秒只有 WARN,没有 ERROR。部署脚本如果「起新进程、看它没退出就算成功」,会把这个状态当成成功。
出处
bind_tcp 设 SO_REUSEADDR 后 bind,失败是 AddrInUse 就睡 TCP_LISTENER_TRY_STEP(1 秒)重试,满 TCP_LISTENER_MAX_TRY(30)次返回错误:
Err(e) => {
if e.kind() != ErrorKind::AddrInUse {
break Err(e).or_err_with(BindError, || format!("bind() failed on {addr}"));
}
try_count += 1;
if try_count >= TCP_LISTENER_MAX_TRY {
break Err(e).or_err_with(BindError, || {
format!("bind() failed, after retries, {addr} still in use")
});
}
warn!("{addr} is in use, will try again");
tokio::time::sleep(TCP_LISTENER_TRY_STEP).await;
}
30 次尝试之间睡 29 次,所以是第 29 秒放弃。错误一路返回到 start_service,那里是 .expect("Failed to build listeners")。它在服务运行时的一个任务里 panic,tokio 把 panic 留在 JoinHandle 里,Server 的主线程不知道,进程就这样留下来。macOS 上两个 socket 都设了 SO_REUSEADDR 也不能同时监听同一个地址,bind 返回 errno 48。Linux 上开了 SO_REUSEPORT 的两个进程可以同时绑上,本页没有 Linux 机器验证这条路。
重试本身是有用的:旧进程退出、新进程接着绑的时候,空窗只有不到一秒,macOS 上不带 -u 的升级就靠它,见 Upgrade is not currently supported outside of Linux platforms。问题只在最后放弃的方式。main 上到 84f21ee,这两处都没变。
修法
想在端口被占时立刻失败,就在交给 pingora 之前自己试绑一次(svc 的 SVC_PREFLIGHT=1):
if let Err(e) = std::net::TcpListener::bind(&addr) {
eprintln!("cannot bind {addr}: {e}");
std::process::exit(1);
}
let mut lab = ListeningService::new("lab".to_string(), Lab);
lab.add_tcp(&addr);
第一个进程占着端口时:
cannot bind 127.0.0.1:19222: Address already in use (os error 48)
exit=1
试绑的 socket 在 if let 结束时就关了,没有连接,不留 TIME_WAIT,pingora 随后照常 bind。代价是放弃了那 30 秒的等待:要靠它做「旧进程退出、新进程接上」的部署,就不能加这一步,而要改成在外面等端口可用之后再启动,或者启动后按端口确认它真的在听。
怎么确认没中招
- 健康检查按端口做,不按进程做:
lsof -nP -a -p <pid> -iTCP -sTCP:LISTEN或者直接发一个请求。 - 日志里搜
still in use和Failed to build listeners;在那之前的 29 行is in use, will try again就是预警。 - 一个进程里有多个服务时,只有绑不上的那个服务没了,其余服务照常监听。
小结
- 端口被占时
bind_tcp每秒重试,第 29 秒放弃;start_service的expect只让那个服务的线程 panic。 - 结果是一个活着、但不监听这个端口的进程;只看进程的监控和部署脚本看不出来。
- 要当场失败就在
add_tcp之前自己试绑一次;要保留重试,就按端口确认服务真的起来了。