Rust in Everything
基于 pingora-core 0.9.0

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 之前自己试绑一次;要保留重试,就按端口确认服务真的起来了。

💬 关联讨论

发起讨论
加载中...