Rust in Everything
基于 hyper-util 0.1.21

本站的服务端路径

主线位置:服务端主线的入口和出口。字节 → 请求 → 响应 → 字节这一段,本站全交给 hyper;本页看的是两头:谁 accept 连接、给 auto::Builder 设了什么,以及进程退出时连接上还没写完的响应怎么办。

flowchart TB
  mainrs["main.rs:dioxus::serve"] --> sr["serve_router"]
  sr -->|release| axs["axum::serve"]
  sr -->|debug| devloop["自己的 accept 循环"]
  axs --> hc["handle_connection"]
  hc --> ab["auto::Builder::new"]
  devloop --> ab
  ab --> rv["ReadVersion:无超时"]
  rv --> h1["http1 Connection:无 timer"]
  rv --> h2["http2 Connection"]
  h1 --> tth["TowerToHyperService"]
  h2 --> tth
  tth --> router["axum Router"]
  gw["Pingora 网关"] -.->|"127.0.0.1:8080"| axs

本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 dioxus-server 0.7.9、axum 0.8.9、hyper 1.10.1、hyper-util 0.1.20;不是本卷的 1.12.0 / 0.1.21,差别放在本页最后一节。依赖的源码按「crate + 版本 + 文件」引用,hyper-util 的链接仍指向 0.1.21。

从 main.rs 到 axum::serve

站点的入口在 crates/app/src/main.rs 第 186 行:

  dioxus::serve(|| async move {
    use axum::routing::get;
    // ...
    let router = dioxus::server::router(App)
      // S3:健康检查(ok/degraded),供 LB / 监控 / 部署脚本判活。
      .route("/healthz", get(crate::server::health::healthz));
    // ...
    Ok(router)
  });

闭包只负责组装 Router(四层中间件见 tower 卷的 本站的中间件栈),监听和服务连接都在 dioxus-server 里。dioxus-server 0.7.9 的 src/launch.rs,serve 建一个多线程 tokio 运行时,block_on 调 serve_router:

pub async fn serve_router(
    mut serve_callback: impl FnMut() -> Pin<Box<dyn Future<Output = Result<Router, anyhow::Error>>>>,
    addr: SocketAddr,
) {
    dioxus_logger::initialize_default();

    let listener = TcpListener::bind(addr)
        .await
        .with_context(|| format!("Failed to bind to address {addr}"))
        .unwrap();

    // If we're not in debug mode, just serve the app normally
    if !cfg!(debug_assertions) {
        axum::serve(listener, serve_callback().await.unwrap())
            .await
            .unwrap();
        return;
    }
    // ...

地址来自 dioxus-cli-config 0.7.9 的 fullstack_address_or_localhost():读 IP、PORT 环境变量,默认 127.0.0.1:8080。镜像里 Dockerfile 设的是 IP=0.0.0.0、PORT=8080。

分支只看 cfg!(debug_assertions)。生产镜像用 dx bundle --platform web --release 构建,release 不开 debug 断言,所以线上走的是第一支:axum::serve(listener, router),没有任何额外配置。

debug 构建(本地 dx serve 默认如此)走下面那个手写的 accept 循环,为的是在同一个 select! 里接收热补丁消息。它对每条连接做的事和 axum 差不多:

                    // upgrades needed for websockets
                    let builder = HyperBuilder::new(TokioExecutor::new());
                    let connection = builder.serve_connection_with_upgrades(
                        tcp_stream,
                        TowerToHyperService::new(
                            make_service
                                .call(())
                                .await
                                .unwrap()
                                .map_request(|req: Request<Incoming>| req.map(Body::new)),
                        ),
                    );

                    tokio::select! {
                        res = connection => {
                            // ...
                        }
                        _res = shutdown_rx.recv() => {}
                    }

和 release 路径比,有两处不同:没调 http2().enable_connect_protocol();收到热补丁时 shutdown_tx.send(()),每条连接的 select! 走第二支,connection future 被直接 drop,不经过 graceful_shutdown。开发时改一次代码,正在进行的请求会断,这是有意为之的取舍,线上碰不到。

axum::serve 给每条连接设了什么

axum 0.8.9 src/serve/mod.rs 的 handle_connection,每 accept 一条连接调一次:

    let hyper_service = TowerToHyperService::new(tower_service);
    let signal_tx = signal_tx.clone();
    let close_rx = close_rx.clone();

    tokio::spawn(async move {
        #[allow(unused_mut)]
        let mut builder = Builder::new(TokioExecutor::new());
        // CONNECT protocol needed for HTTP/2 websockets
        #[cfg(feature = "http2")]
        builder.http2().enable_connect_protocol();

        let mut conn = pin!(builder.serve_connection_with_upgrades(io, hyper_service));
        let mut signal_closed = pin!(signal_tx.closed().fuse());

        loop {
            tokio::select! {
                result = conn.as_mut() => {
                    if let Err(_err) = result {
                        trace!("failed to serve connection: {_err:#}");
                    }
                    break;
                }
                _ = &mut signal_closed => {
                    trace!("signal received in task, starting graceful shutdown");
                    conn.as_mut().graceful_shutdown();
                }
            }
        }

        drop(close_rx);
    });

dioxus-server 0.7.9 依赖 axum 时开了 http1、http2、ws 等特性,所以上面 #[cfg(feature = "http2")] 那一行在本站是编译进去的。逐项对照:

环节本站的取值效果
协议auto::Builder::new + serve_connection_with_upgrades先由 ReadVersion 判断 HTTP/1 还是 HTTP/2,见 auto
执行器TokioExecutor只有 HTTP/2 用它 spawn 流任务,见 Executor 与 Timer
timer没设header_read_timeout 的默认 30 秒静默失效
HTTP/2enable_connect_protocol()允许 HTTP/2 上用扩展 CONNECT 建 WebSocket
其余 HTTP/1、HTTP/2 选项全是默认keep-alive 开、读缓冲上限 417 792 字节等,见 服务端 Builder
服务TowerToHyperService 包住 Router每个请求克隆一次服务,见 Service 与 Body 和 tower 卷的 Steer 与 MakeService

axum 0.8.9 的 Serve 公开的方法只有 with_graceful_shutdown 和 local_addr,拿不到里面的 Builder。想设 timer 或改任何 hyper 选项,只能像 dioxus-server 的 debug 分支那样自己写 accept 循环。

没有 timer:慢连接不会被关

后果在本卷已经讲过两次:Time::check 遇到「默认值 + 没有 timer」只打一条(普通构建里根本不存在的)warn! 就返回 None,见 Executor 与 Timer;ReadVersion 读连接前言这一步本来就没有超时,有 timer 也管不到,见 auto。现象和复现见常见坑 header_read_timeout 不生效。

这里只在本站的配置上核对一次:一边是 axum::serve,一边是同样的 auto::Builder 只多一句 http1().timer(TokioTimer::new())。每边两个客户端,一个连上什么都不发,一个只发请求行 GET / HTTP/1.1\r\n;35 秒后补齐请求,看能不能拿到响应。

axum = "=0.8.9"
hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio", "server-auto", "service"] }
http = "1"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "io-util"] }
use std::time::{Duration, Instant};

use axum::{Router, routing::get};
use hyper::body::Incoming;
use hyper_util::{
  rt::{TokioExecutor, TokioIo, TokioTimer},
  server::conn::auto::Builder,
  service::TowerToHyperService,
};
use tokio::{
  io::{AsyncReadExt, AsyncWriteExt},
  net::{TcpListener, TcpStream},
};
use tower::ServiceExt;

fn app() -> Router {
  Router::new().route("/", get(|| async { "ok" }))
}

// 本站 release 路径:axum::serve,内部 auto::Builder::new(TokioExecutor::new()),不设 timer
async fn axum_serve() -> u16 {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let port = listener.local_addr().unwrap().port();
  tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });
  port
}

// 对照:同样的 auto::Builder,只多一句 http1().timer(TokioTimer::new())
async fn auto_with_timer() -> u16 {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let port = listener.local_addr().unwrap().port();
  tokio::spawn(async move {
    loop {
      let (stream, _) = listener.accept().await.unwrap();
      tokio::spawn(async move {
        let mut builder = Builder::new(TokioExecutor::new());
        builder.http1().timer(TokioTimer::new());
        let svc = app().map_request(|req: http::Request<Incoming>| req.map(axum::body::Body::new));
        let _ = builder
          .serve_connection_with_upgrades(TokioIo::new(stream), TowerToHyperService::new(svc))
          .await;
      });
    }
  });
  port
}

async fn client(server: &'static str, port: u16, case: &'static str, first: &'static [u8]) {
  let t0 = Instant::now();
  let mut s = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
  s.write_all(first).await.unwrap();
  tokio::time::sleep(Duration::from_secs(35)).await;
  let rest: &[u8] = if first.is_empty() { b"GET / HTTP/1.1\r\nhost: x\r\n\r\n" } else { b"host: x\r\n\r\n" };
  let _ = s.write_all(rest).await;
  let mut buf = [0u8; 256];
  let got = match tokio::time::timeout(Duration::from_secs(2), s.read(&mut buf)).await {
    Ok(Ok(0)) => "EOF (server closed)".to_string(),
    Ok(Ok(n)) => String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string(),
    Ok(Err(e)) => format!("error: {e}"),
    Err(_) => "no reply in 2s".to_string(),
  };
  println!("{server:<16} {case:<13} after {}s: {got}", t0.elapsed().as_secs());
}

#[tokio::main]
async fn main() {
  let a = axum_serve().await;
  let b = auto_with_timer().await;
  let partial: &[u8] = b"GET / HTTP/1.1\r\n";
  tokio::join!(
    client("axum::serve", a, "nothing sent", b""),
    client("axum::serve", a, "partial head", partial),
    client("auto + timer", b, "nothing sent", b""),
    client("auto + timer", b, "partial head", partial),
  );
}

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:

auto + timer     partial head  after 35s: EOF (server closed)
axum::serve      nothing sent  after 35s: HTTP/1.1 200 OK
axum::serve      partial head  after 35s: HTTP/1.1 200 OK
auto + timer     nothing sent  after 35s: HTTP/1.1 200 OK
  • axum::serve 的两条连接都撑过了 35 秒,补齐之后照常拿到 200。默认的 30 秒读头超时没有起作用。
  • 加了 timer 的对照组,半截请求头的连接在 35 秒之前就被关了(默认超时是 30 秒),客户端补发之后读到 EOF。
  • 什么都不发的连接,加不加 timer 都活着:它停在 ReadVersion,还没进 HTTP/1。

所以本站的 app 自己挡不住慢速连接:只发半个请求头、或者连上就不说话的客户端,可以一直占着一个连接任务和一个文件描述符。线上挡在前面的是网关。

前面的网关

生产环境里 app 容器只把端口发布在本机:docker-compose.yml 写的是 ${APP_BIND:-127.0.0.1}:${APP_PORT:-8080}:8080。公网连接先到 crates/gateway 的 Pingora 网关,网关终止 TLS,再用明文 HTTP/1.1 转给 127.0.0.1:8080。

网关代码里没有一处设超时。main 用 Server::new(None)(不读配置文件),443 端口 http_proxy_service 加 TLS 并 enable_h2(),80 端口只做跳转;upstream_peer 返回 HttpPeer::new(UPSTREAM_ADDR, false, ""),用的全是默认选项。所以起作用的是网关锁定的 pingora 0.8.0 的默认值:

位置默认值(pingora-core 0.8.0)管什么
下游 HTTP/1 会话src/apps/mod.rs 对每条新的 HTTP/1 下游连接 set_keepalive(Some(60))read_request 读请求头时,每一次 read 最多等 60 秒,到时直接结束这条连接
下游 HTTP/1 会话src/protocols/http/v1/server.rs 里 read_timeout: Some(60 秒)、write_timeout: None读请求 body 每次最多等 60 秒;写响应不限时
上游 HttpPeersrc/upstreams/peer.rs 里 PeerOptions 的 connection_timeout、read_timeout、idle_timeout、write_timeout 全是 None,alpn 是 ALPN::H1网关等 app 不限时;到 app 的连接只用 HTTP/1.1

对 app 来说这意味着三件事:

  • 连上不说话、或者请求头发一半就停的客户端,最多在网关那里耗 60 秒,碰不到 app。网关要先读完整个请求头才会走到 upstream_peer,app 收到的都是完整的请求头。
  • 这个 60 秒是每次读的上限,不是整个请求头的上限。每 59 秒发一个字节的客户端,网关这边也会一直等,直到请求头超过 pingora 的 MAX_HEADER_SIZE(1 048 575 字节)。pingora-proxy 0.8.0 读请求头时只和停机通知做 select!,外面没有总时限。
  • app 只会看到 HTTP/1.1。ReadVersion 每次都在第一个字节判成 HTTP/1,enable_connect_protocol 在线上用不到。

浏览器那一侧的 HTTP/2(enable_h2)走的是 pingora 的 HTTP/2 会话,超时规则不同,本页没有展开。

SIGTERM:在途请求被切断

Dockerfile 的入口是:

# tini 处理信号;server 自身就是入口可执行文件
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]

容器停止时,运行时给 PID 1(tini)发 SIGTERM,tini 把它转给子进程 /app/server。接下来发生什么,取决于 server 有没有处理 SIGTERM:

  • 在本站提交 89a61b1 的 crates/ 下搜 ctrl_c、SIGTERM、unix::signal、with_graceful_shutdown,没有一处命中;dioxus-server 0.7.9 和 axum 0.8.9 的 src/ 里也没有信号处理。
  • serve_router 的 release 分支是 axum::serve(listener, router).await,没有 with_graceful_shutdown。这时 axum 的 Serve::run 签名是 async fn run(self) -> !:accept 循环永不结束,上面 handle_connection 里的 signal_tx.closed() 永远不会完成,graceful_shutdown 那一支永远走不到。

没有处理函数,SIGTERM 按默认动作立即结束进程。连接任务没有机会写完响应,socket 由内核关掉。对端(线上是网关)读响应时看到的是连接被关。

📝NOTE
没有蠢问题

问: Linux 上 PID 1 收到没有处理函数的 SIGTERM 不是会忽略吗?那 server 应该一直跑到被 SIGKILL 才对。

答: 那条规则只对 PID 命名空间里的 init 进程(容器里的 PID 1)成立,见 pid_namespaces(7)。这里的 PID 1 是 tini,/app/server 是它的子进程,普通进程的默认动作照常生效:收到 SIGTERM 就结束。Dockerfile 注释写的「PID 1 信号转发」正是 tini 的用处。没有 tini、server 自己当 PID 1 时,SIGTERM 才会被忽略,那时在途请求也不会被优雅处理,只是等到宽限期满被 SIGKILL。

下面的实验在本机复现这件事,不碰真实的服务器和容器。sigterm_server 是一个 axum 0.8.9 程序,/slow 睡 2 秒再回 done;按参数选三种跑法:plain 和本站一样直接 axum::serve,graceful 收到 SIGTERM 后走 with_graceful_shutdown,deadline 在 graceful 的基础上把排空时间限成 1 秒。sigterm_drive 把它当子进程启动,用 hyper 的客户端发 /slow,500 ms 后 kill -TERM 它,相当于 tini 转过来的那个信号。

axum = "=0.8.9"
hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "signal", "sync"] }

src/bin/sigterm_server.rs:

use std::{
  sync::{Arc, OnceLock},
  time::{Duration, Instant},
};

use axum::{Router, routing::get};
use tokio::{
  net::TcpListener,
  signal::unix::{SignalKind, signal},
  sync::Notify,
};

#[tokio::main]
async fn main() {
  let mode = std::env::args().nth(1).unwrap_or_default();
  let app = Router::new().route(
    "/slow",
    get(|| async {
      tokio::time::sleep(Duration::from_secs(2)).await;
      "done"
    }),
  );
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  println!("{}", listener.local_addr().unwrap().port());

  if mode == "plain" {
    // 和 dioxus-server release 路径一样:axum::serve(listener, router)
    axum::serve(listener, app).await.unwrap();
    return;
  }

  // 修法的形状:收到 SIGTERM 才让 axum 开始优雅停机,另给排空设一个期限
  let drain_limit = if mode == "deadline" { Duration::from_secs(1) } else { Duration::from_secs(8) };
  let mut term = signal(SignalKind::terminate()).unwrap();
  let got_term = Arc::new(OnceLock::new());
  let started = Arc::new(Notify::new());
  let (mark, notify) = (got_term.clone(), started.clone());
  let server = axum::serve(listener, app).with_graceful_shutdown(async move {
    term.recv().await;
    mark.set(Instant::now()).unwrap();
    notify.notify_one();
    eprintln!("        server: SIGTERM received, draining");
  });
  tokio::select! {
    res = server => {
      res.unwrap();
      let waited = got_term.get().unwrap().elapsed().as_millis();
      eprintln!("        server: drained {waited}ms after SIGTERM, exiting");
    }
    _ = async { started.notified().await; tokio::time::sleep(drain_limit).await } => {
      eprintln!("        server: drain limit {drain_limit:?} hit, exiting anyway");
    }
  }
}

src/bin/sigterm_drive.rs:

use std::{
  io::{BufRead, BufReader},
  os::unix::process::ExitStatusExt,
  process::{Command, Stdio},
  time::{Duration, Instant},
};

use http_body_util::{BodyExt, Empty};
use hyper::{Request, body::Bytes, client::conn::http1};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;

async fn run(mode: &str) {
  println!("--- {mode}");
  let exe = std::env::current_exe().unwrap().with_file_name("sigterm_server");
  let mut child = Command::new(exe).arg(mode).stdout(Stdio::piped()).spawn().unwrap();
  let mut line = String::new();
  BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
  let port: u16 = line.trim().parse().unwrap();
  let pid = child.id();

  let t0 = Instant::now();
  let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
  let (mut sender, conn) = http1::handshake(TokioIo::new(stream)).await.unwrap();
  tokio::spawn(conn);
  let req = Request::get("/slow").header("host", "x").body(Empty::<Bytes>::new()).unwrap();
  let resp = tokio::spawn(async move { sender.send_request(req).await });

  tokio::time::sleep(Duration::from_millis(500)).await;
  println!("{:>5}ms client: kill -TERM <server pid>", t0.elapsed().as_millis());
  Command::new("kill").args(["-TERM", &pid.to_string()]).status().unwrap();

  match resp.await.unwrap() {
    Ok(res) => {
      let status = res.status();
      let body = res.into_body().collect().await.unwrap().to_bytes();
      println!("{:>5}ms client: {status} {body:?}", t0.elapsed().as_millis());
    }
    Err(e) => println!(
      "{:>5}ms client: error: {e} (is_incomplete_message={})",
      t0.elapsed().as_millis(),
      e.is_incomplete_message()
    ),
  }
  let status = child.wait().unwrap();
  println!(
    "{:>5}ms server process: code={:?} signal={:?}",
    t0.elapsed().as_millis(),
    status.code(),
    status.signal()
  );
}

#[tokio::main]
async fn main() {
  run("plain").await;
  run("graceful").await;
  run("deadline").await;
}
❗IMPORTANT
动脑筋:三种跑法下,客户端各在第几毫秒收到什么?server 进程是怎么退出的?

请求在第 0 ms 发出,handler 要 2 秒;SIGTERM 在第 500 ms 到。plain 没有任何信号处理;graceful 收到 SIGTERM 后开始优雅停机,排空期限 8 秒;deadline 一样,但期限只有 1 秒。三种跑法各自的客户端结果是 200 OK 还是一个错误、大约在第几毫秒?进程的退出码是什么,还是被信号杀掉的?

改法的形状

这是本站现在的一个真实缺口:每次发版或重启容器,正在处理的请求都会被切断,tokio::spawn 出去的后台任务(比如支付对账)也停在半路。改不改、什么时候改,由站点决定;这里只说改法长什么样。

dioxus::serve 在 release 下只调 axum::serve(listener, router),不留插手的地方,所以要绕开它,自己调 axum。站点已经在用 dioxus::server::router(App) 拿 Router,地址可以用 dioxus 重新导出的 dioxus::cli_config::fullstack_address_or_localhost() 取,和 dioxus 自己用的是同一个函数。剩下的就是上面 sigterm_server 的 graceful / deadline 分支:

  • axum::serve(...).with_graceful_shutdown(signal),signal 等 SIGTERM。收到后 axum 停止 accept,对每条连接调 graceful_shutdown:空闲的 keep-alive 连接立即关,正在处理的请求做完再关。机制见 优雅停机 末尾的「axum 的 with_graceful_shutdown」。
  • 必须另加期限。with_graceful_shutdown 返回的 future 要等所有连接结束,SSE 之类的长连接会让它一直不完成,见 优雅停机一直不结束。期限要短于容器运行时发 SIGKILL 之前的宽限期,否则进程还是被强杀,等于没设。
  • 只在 release 下这样做。debug 分支的热重载依赖 dioxus-server 自己的 accept 循环,绕开它就没有热重载了。

deadline 那一组的结果说明期限的代价:到期时还没做完的请求一样被切断,期限只是把「立刻切断」变成「给它们一段时间」。

本站锁的版本与本卷的差别

本站锁 hyper 1.10.1、hyper-util 0.1.20。和本卷的 1.12.0 / 0.1.21 比,这条路径上:

  • hyper-util 的 server/conn/auto/mod.rs、server/graceful.rs、service/glue.rs,hyper 的 common/time.rs,两个版本之间的差别只有 edition 2024 带来的写法(ref 模式、use<..> 捕获、#[allow(clippy::panic)])和文档测试的 cfg,行为相同。上面关于 timer、ReadVersion、graceful_shutdown 的结论对本站的版本同样成立。
  • hyper 的 CHANGELOG 里,1.11.0、1.11.1、1.12.0 三段中和 HTTP/1 服务端有关的修复,本站都还没有:
版本条目和本站的关系
1.11.0#4093 more strictly enforce max_buf_size when parsing读缓冲上限的检查更严,见 读写缓冲
1.11.0#4124 discard content-length header when received before transfer-encoding请求同时带两个长度头时的处理;线上请求先经过网关
1.11.0#4018 flush buffered data before shutdown关连接前先把写缓冲刷出去
1.11.1#4147 recognize \n\r\n as a head terminator in the partial-read fast path请求头分几次到达时的快速扫描
1.12.0#4201 let a Connection close token win over a later keep-aliveConnection 头里同时有 close 和 keep-alive 时以 close 为准
1.12.0#4183 add max_header_size limit for server and client新选项;axum::serve 拿不到 Builder,hyper-util 0.1.21 的 Http1Builder 也还没转发它,本站升级后也用不上

升级不会改变本页的两个结论:没有 timer,也没有优雅停机。这两件事都在 dioxus-server 和 axum 的调用方式里,不在 hyper 的版本里。

动脑筋答案

M2 Max、rustc 1.99.0、--release,连跑 3 次。三次只有毫秒数差几毫秒(plain 的错误在 505–509 ms,graceful 排空 1493–1498 ms,deadline 的错误在 1509–1510 ms),以下是其中一次:

--- plain
  503ms client: kill -TERM <server pid>
  507ms client: error: connection closed before message completed (is_incomplete_message=true)
  507ms server process: code=None signal=Some(15)
--- graceful
  502ms client: kill -TERM <server pid>
        server: SIGTERM received, draining
        server: drained 1493ms after SIGTERM, exiting
 2002ms client: 200 OK b"done"
 2004ms server process: code=Some(0) signal=None
--- deadline
  503ms client: kill -TERM <server pid>
        server: SIGTERM received, draining
        server: drain limit 1s hit, exiting anyway
 1509ms client: error: connection closed before message completed (is_incomplete_message=true)
 1510ms server process: code=Some(0) signal=None

(server: 开头的行是子进程的 stderr,没有时间戳。)

  • plain:SIGTERM 之后几毫秒,客户端就拿到 connection closed before message completed,is_incomplete_message() 为真;进程没有退出码,是被信号 15 杀掉的。这就是本站现在的行为。客户端这一侧的错误为什么是这一个,见 connection closed before message completed。
  • graceful:SIGTERM 之后 server 不再接新连接,但手上的请求照常做完:客户端在第 2002 ms 拿到 200 OK,serve 在 SIGTERM 之后约 1.5 秒返回,进程以 0 退出。
  • deadline:排空期限 1 秒先到,select! 走第二支,main 返回,运行时关闭时 drop 掉连接任务。客户端在 SIGTERM 之后 1 秒拿到同样的错误;进程以 0 退出。

小结

  • 线上路径:dioxus::serve → serve_router 的 release 分支 → axum::serve(listener, router) → 每条连接 auto::Builder::new(TokioExecutor::new())、开 HTTP/2 扩展 CONNECT、serve_connection_with_upgrades + TowerToHyperService。debug 分支是 dioxus-server 自己的 accept 循环,热补丁时直接 drop 连接。
  • 没有 timer:header_read_timeout 的默认 30 秒不生效,ReadVersion 本来就不计时;实测 35 秒的半截请求头在 axum::serve 上照样拿到 200。axum 0.8.9 不暴露 Builder,要改只能自己写 accept 循环。
  • 线上慢连接由 Pingora 网关挡:网关代码不设超时,pingora 0.8.0 默认每次读请求头最多等 60 秒;到 app 的上游连接是 HTTP/1.1,不限时。
  • 没有优雅停机:tini 把 SIGTERM 转给 server,server 没有处理函数,进程立即结束,在途请求的客户端得到 IncompleteMessage。改法是绕开 dioxus::serve,with_graceful_shutdown 等 SIGTERM,再加一个短于容器宽限期的排空期限。
  • 本站的 hyper 1.10.1 / hyper-util 0.1.20 在这条路径上的行为和本卷版本相同;升级拿到的是 HTTP/1 解析和 keep-alive 的几处修复,不改变上面两个结论。

💬 关联讨论

发起讨论
加载中...