Rust in Everything
基于 pingora-core 0.9.0

L4 连接

主线位置:请求主线的第四步,连接池里没有可复用连接的那条岔路。get_stream 先问池子,池子说没有,就调 new_stream 新建:TCP 三次握手,需要的话再做 TLS 握手,得到一个 Stream。域名解析不在这条路上,它更早,发生在用户的 upstream_peer 里构造 HttpPeer 的那一刻。

flowchart TB
  peer["HttpPeer::new"] --> dns["to_socket_addrs:同步解析"]
  dns --> getstream["get_stream:池里没有"]
  getstream --> newstream["new_stream"]
  newstream --> off{"offload?"}
  off -- 是 --> rt["另一个运行时"]
  off -- 否 --> doc["do_connect"]
  rt --> doc
  doc --> total["total_connection_timeout"]
  total --> l4["l4::connect"]
  l4 --> sock["建 socket、设选项、connect"]
  sock --> tls{"peer.tls?"}
  tls -- 是 --> hs["tls::connect:握手"]
  tls -- 否 --> done["Box Stream"]
  hs --> done

本页的源码在 connectors/mod.rs、connectors/l4.rs 和 protocols/l4/ext.rs 里建连接的那几个函数。Stream 本身(BufStream、回退缓冲、摘要)和 TCP 选项的实现在协议部分的 L4 流,TLS 握手的细节在 TLS。

实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建。

域名解析:在 HttpPeer::new 里同步完成

pingora 的连接器只认已经解析好的地址:Peer::address() 返回 &SocketAddr,l4::connect 拿它直接 connect()。域名在 HttpPeer::new 里解析:

    /// Create a new [`HttpPeer`] with the given socket address and TLS settings.
    pub fn new<A: ToInetSocketAddrs>(address: A, tls: bool, sni: String) -> Self {
        let mut addrs_iter = address.to_socket_addrs().unwrap(); //TODO: handle error
        let addr = addrs_iter.next().unwrap();
        Self::new_from_sockaddr(SocketAddr::Inet(addr), tls, sni)
    }

ToInetSocketAddrs 是 std::net::ToSocketAddrs 改了个名字。三件事:

  1. 同步。 参数是 "host:port" 这样的字符串时,标准库调 getaddrinfo,在当前线程上阻塞到解析结束。upstream_peer 是 async fn,但这一行没有 .await,它就跑在代理服务的工作线程上。IP 字面量("127.0.0.1:18081")先按地址解析,成功就不碰 getaddrinfo。
  2. 只取第一个地址。 解析出几个地址都只留 next() 那一个,后面的直接丢掉;连不上也不会换下一个试。
  3. 失败就 panic。 两个 unwrap:解析出错,或者解析成功但一个地址都没有。

下面这个代理用一个自定义的 ToSocketAddrs 模拟慢解析:先 std::thread::sleep 1 秒,再解析 IP。它和真实的 getaddrinfo 一样是同步调用。/slow 走慢解析,/name 用 localhost:18481,/bad 用一个不存在的域名,其他路径用 IP 字面量。上游是 python3 -m http.server 18481 --bind 127.0.0.1。服务用默认配置,ServerConf 的默认值是 threads: 1:

struct SlowResolve(&'static str);
impl ToSocketAddrs for SlowResolve {
  type Iter = std::vec::IntoIter<SocketAddr>;
  fn to_socket_addrs(&self) -> std::io::Result<Self::Iter> {
    std::thread::sleep(Duration::from_millis(1000));
    self.0.to_socket_addrs()
  }
}

#[async_trait]
impl ProxyHttp for P {
  type CTX = ();
  fn new_ctx(&self) {}
  async fn upstream_peer(&self, s: &mut Session, _: &mut ()) -> Result<Box<HttpPeer>> {
    let path = s.req_header().uri.path().to_string();
    let t = Instant::now();
    let thread = std::thread::current().name().unwrap_or("?").to_string();
    let peer = match path.as_str() {
      "/slow" => HttpPeer::new(SlowResolve("127.0.0.1:18481"), false, String::new()),
      "/name" => HttpPeer::new("localhost:18481", false, String::new()),
      "/bad" => HttpPeer::new("no-such-host.invalid:80", false, String::new()),
      _ => HttpPeer::new("127.0.0.1:18481", false, String::new()),
    };
    eprintln!("upstream_peer {path} on thread {thread:?}: HttpPeer::new took {:?}, addr {}", t.elapsed(), peer._address);
    Ok(Box::new(peer))
  }
}

客户端用 curl 依次发:/fast 单独一次;/slow 发出后 100ms 再发 /fast;两个 /slow 同时发;然后 /name、/bad,最后再来一次 /fast。

❗IMPORTANT
动脑筋:/slow 发出 100ms 后再发的那个 /fast,curl 记下的 time_total 大约是多少?两个同时发的 /slow 呢?

/fast 自己的 HttpPeer::new 只要一两微秒。先猜,答案在文末。

localhost 那一行在 macOS 上值得单独看。代理日志:

upstream_peer /name on thread "Pingora HTTP Proxy Service": HttpPeer::new took 2.378042ms, addr [::1]:18481
[2026-10-29T04:53:15Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: [::1]:18481, scheme: HTTP cause:  context: Fail to connect to [::1]:18481 cause: Connection refused (os error 61), status: 502, tries: 1, retry: false, GET /name, Host: 127.0.0.1:18480

localhost 解析出 ::1 和 127.0.0.1,第一个是 ::1。上游只绑了 127.0.0.1,于是 502,而 127.0.0.1 那个地址从头到尾没被试过。hyper-util 的 HttpConnector 在这里做的是另一套:getaddrinfo 放进阻塞线程池,拿到全部地址后做 Happy Eyeballs,见 hyper 卷的 HttpConnector。

/bad 让工作线程 panic:

thread 'Pingora HTTP Proxy Service' (5361160) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.9.0/src/upstreams/peer.rs:719:56:
called `Result::unwrap()` on an `Err` value: Custom { kind: Uncategorized, error: "failed to lookup address information: nodename nor servname provided, or not known" }

panic 发生在处理这个下游连接的任务里,tokio 接住它,任务结束,下游 socket 被关掉,curl 看到的是 Empty reply from server,没有任何 HTTP 响应。紧接着的 /fast 正常返回 200:服务没有停,只丢了这一个连接。

📝NOTE
没有蠢问题

问: 那 upstream_peer 里拿到的是域名,该怎么办?

答: 自己先异步解析成 SocketAddr,再交给 HttpPeer::new。参数已经是 SocketAddr 时,to_socket_addrs 只是把它包成一个单元素迭代器,不阻塞也不会失败。异步解析可以用 tokio::net::lookup_host,它把 getaddrinfo 丢进 tokio 的阻塞线程池(见 tokio 卷的阻塞线程池),工作线程在等待期间可以处理别的连接;解析结果要缓存、要轮换多个地址,也只能自己做。pingora 的负载均衡组件也是先解析、后使用:Backend 里存的是 SocketAddr,Static::try_from_iter 在构造时把每个名字解析出的所有地址都变成一个 Backend(同步,但只在启动时做一次),见 LoadBalancer。

new_stream 和 do_connect

TransportConnector::new_stream 做三件准备,然后调 do_connect:

  • 有 offload 线程池(ServerConf 的 upstream_connect_offload_threadpools 和 upstream_connect_offload_thread_per_pool 都大于 0)时,按 peer.reuse_hash() 选一个池,把整个建连过程 spawn 过去,当前任务只等结果。字段注释给的理由是 TCP 和 TLS 建连吃 CPU,放在服务线程上会拖慢所有流量,超时又引来更多新连接,「snowballs the issue」。
  • bind_to_random:peer 自己的 bind_to 里有源地址就用它;否则按目标是 IPv4 还是 IPv6,从 ServerConf 的 client_bind_to_ipv4 / client_bind_to_ipv6 列表里随机挑一个。
  • preferred_http_version.get(peer):这个 peer 以前 h2 出过错、被记成「以后用 h1」的话,这次 TLS 握手的 ALPN 换成只发 http/1.1。

do_connect 只管总超时,do_connect_inner 是两段:

async fn do_connect_inner<P: Peer + Send + Sync>(
    // ...
) -> Result<Stream> {
    let l4_connect_start = Instant::now();
    let mut stream = l4_connect(peer, bind_to).await?;
    stream.set_establishment_timing(l4_connect_start.elapsed(), offload_wait_duration);
    if peer.tls() {
        // Capture the local address before the handshake consumes the stream, so a TLS failure
        // can still report the address we connected from.
        // ...
        let tls_stream = tls::connect(stream, peer, alpn_override, tls_ctx)
            .await
            .map_err(|e| attach_connect_local_addr(e, local_addr))?;
        Ok(Box::new(tls_stream))
    } else {
        Ok(Box::new(stream))
    }
}

开了 offload 时,do_connect 是在 offload 运行时的任务里才被调用的,total_connection_timeout 从那时开始计时;在线程池队列里排队的时间单独记成 offload_wait,写进 TimingDigest,不算进总超时。

l4::connect

l4::connect 按 peer 分三条路:

  1. get_proxy() 有值:先连 next_hop 这个 Unix 域套接字,再发 HTTP CONNECT(proxy_connect,Windows 上直接 panic)。
  2. PeerOptions::custom_l4 有值:交给用户实现的 Connect trait。
  3. 否则按地址类型:Inet 走 TCP,Unix 走 connect_uds。

TCP 那条路在 connect() 之前给 socket 设选项,是一个闭包:

                    let connect_future = tcp_connect(
                        addr,
                        bind_to.as_ref(),
                        |socket| {
                            // ...
                            if peer.tcp_fast_open() {
                                set_tcp_fastopen_connect(raw)?;
                            }
                            if let Some(recv_buf) = peer.tcp_recv_buf() {
                                debug!("Setting recv buf size");
                                set_recv_buf(raw, recv_buf)?;
                            }
                            if let Some(dscp) = peer.dscp() {
                                debug!("Setting dscp");
                                set_dscp(raw, dscp)?;
                            }

                            if let Some(tweak_hook) = peer
                                .get_peer_options()
                                .and_then(|o| o.upstream_tcp_sock_tweak_hook.clone())
                            {
                                tweak_hook(socket)?;
                            }

                            Ok(())
                        },
                        &mut connect_attempt,
                    );

set_tcp_fastopen_connect、set_recv_buf、set_dscp 在 ext.rs 里都有两份:#[cfg(target_os = "linux")] 的那份真的调 setsockopt,其余平台的那份是空函数返回 Ok(()),例如 set_tcp_fastopen_connect 的非 Linux 版本。所以在 macOS 上 tcp_fast_open = true 不报错,也不起作用。upstream_tcp_sock_tweak_hook 是留给用户的口子,拿到的是 tokio 的 TcpSocket,想设什么自己设。

真正的 connect() 在 inner_connect_with:

    let socket = into_socket2(socket);
    if let Err(error) = socket.connect(&(*addr).into()) {
        attempt.record_local_addr(&socket);
        if !connect_in_progress(&error) {
            return Err(wrap_os_connect_error(
                error,
                format!("Fail to connect to {}", *addr),
                attempt.local_addr(),
            ));
        }
    } else {
        attempt.record_local_addr(&socket);
    }

    let stream = TcpStream::from_std(socket.into())
        .or_err(SocketError, "failed to register connecting socket")?;
    stream
        .writable()
        .await
        .or_err(ConnectError, "failed to wait for connecting socket")?;

    if let Some(error) = stream
        .take_error()
        .or_err(SocketError, "failed to get connecting socket error")?
    {
        return Err(wrap_os_connect_error(
            error,
            format!("Fail to connect to {}", *addr),
            attempt.local_addr(),
        ));
    }

这是非阻塞 connect 的标准写法:tokio 的 TcpSocket 创建出来就是非阻塞的,connect() 立即返回 EINPROGRESS,然后等 socket 可写,再用 SO_ERROR(take_error)看连接到底成没成。在这之前,函数先设 IP_BIND_ADDRESS_NO_PORT,bind_to 带端口范围时再设 IP_LOCAL_PORT_RANGE,两个都只在 Linux 上有实现(后者要 Linux 6.3)。

操作系统的错误在 wrap_os_connect_error 里映射成 pingora 的错误类型:ConnectionRefused → ConnectRefused,TimedOut → ConnectTimedout,AddrNotAvailable → BindError,ENETUNREACH / EHOSTUNREACH → ConnectNoRoute,其余 ConnectError。l4::connect 再把 SocketError 和 BindError 包成 InternalError:这两种是本机的问题,不是上游的。错误类型决定代理要不要换个上游重试,那部分在错误、重试与故障转移。

连上之后,l4::connect 还做三件事:有 tcp_keepalive 就设(同样只在 Linux 有实现);无条件设 TCP_NODELAY;建一个 SocketDigest,把对端地址和本地地址填进去,后面 connected_to_upstream 钩子和日志里看到的本地端口就来自这里。

📝NOTE
冷知识:为了在失败时还能说出本地端口,0.9.0 换掉了 TcpSocket::connect

0.8.0 的 inner_connect_with 最后一行是 socket.connect(*addr).await。tokio 的 TcpSocket::connect 按值拿走 socket,连接失败时 socket 已经被消费,内核分配的本地端口再也问不到;超时更糟,future 被外层的 timeout 直接丢弃。排查「某个源端口总是连不上」这类问题时,日志里没有这个端口。

提交 0d6bf54(「Refactor connect() so we can preserve port we bound to on failures」)把这一步改成上面看到的样子:先转成 socket2::Socket,自己调非阻塞的 connect,调完立刻 record_local_addr 记进 ConnectAttempt。ConnectAttempt 由调用方持有,所以即使外层的 pingora_timeout::timeout 把 future 丢掉,l4::connect 也还能把本地地址塞进 ConnectTimedout 错误。接着的 b319fed(「Preserve bind port on TLS failures」)把同样的信息带到 TLS 握手失败的错误里,就是 do_connect_inner 里的 attach_connect_local_addr。两个提交都首次发布在 0.9.0,CHANGELOG 记为「Preserve bound ports in digests after TLS failures.」。下一节实验的报错里 cause: local address 127.0.0.1:62762 就是它。

两个建连超时

PeerOptions 里管建连的超时有两个,文档注释分别说:

  • connection_timeout:「How long connect() call should be wait before it returns a timeout error.」
  • total_connection_timeout:「How long the overall connection establishment should take before a timeout error is returned.」

第一个的注释只说了 connect(),代码里它其实用了两次:l4::connect 用它包住 TCP 连接,TLS 连接器又用它包住握手。openssl 连接器最后:

    let connect_future = handshake(ssl_conf, peer.sni(), stream, complete_hook);

    match peer.connection_timeout() {
        Some(t) => match pingora_timeout::timeout(t, connect_future).await {
            Ok(res) => res,
            Err(_) => Error::e_explain(
                ConnectTimedout,
                format!("connecting to server {}, timeout {:?}", peer, t),
            ),
        },
        None => connect_future.await,
    }

rustls 和 s2n 两个后端的 connect 结尾是同样的写法。所以 connection_timeout = 1s 的意思是「TCP 最多 1 秒,TLS 握手再最多 1 秒」,最坏 2 秒;要给整段一个上限,用 total_connection_timeout,它在 do_connect 里包住 do_connect_inner 整体。两个都不设,就没有上限。

实验直接调 TransportConnector::new_stream,不经过代理。一个上游端口没人监听;另一个上游只 accept,把 socket 攥在手里,一个字节也不回,TCP 能连上,TLS 的 ServerHello 永远不来。客户端自己用 tokio::time::timeout 包一层 3 秒,免得真的永远等下去:

async fn try_connect(label: &str, c: &TransportConnector, peer: &HttpPeer) {
  let t = Instant::now();
  // our own 3s guard, so a hang shows up as "still waiting"
  match tokio::time::timeout(Duration::from_secs(3), c.new_stream(peer)).await {
    Ok(Ok(_)) => println!("{label}: connected after {:?}", t.elapsed()),
    Ok(Err(e)) => println!("{label}: {:?} after {:?}\n    {e}", e.etype(), t.elapsed()),
    Err(_) => println!("{label}: still waiting after {:?}", t.elapsed()),
  }
}

#[tokio::main]
async fn main() {
  let c = TransportConnector::new(None);

  // 1. nothing listens on 18489
  let p = HttpPeer::new("127.0.0.1:18489", false, String::new());
  try_connect("refused", &c, &p).await;

  // 3. TCP accepted, TLS ServerHello never comes
  let l = TcpListener::bind("127.0.0.1:18483").await.unwrap();
  tokio::spawn(async move {
    let mut keep = vec![];
    loop {
      let (s, _) = l.accept().await.unwrap();
      keep.push(s); // hold it open, never answer
    }
  });
  let mut p = HttpPeer::new("127.0.0.1:18483", true, "silent.example".to_string());
  try_connect("tls silent, no timeouts", &c, &p).await;
  p.options.connection_timeout = Some(Duration::from_millis(300));
  try_connect("tls silent, connection_timeout 300ms", &c, &p).await;
  p.options.connection_timeout = None;
  p.options.total_connection_timeout = Some(Duration::from_millis(500));
  try_connect("tls silent, total_connection_timeout 500ms", &c, &p).await;
}

一次运行的输出(连跑 3 次,结论相同,超时那两行的毫秒数在 305–315ms 和 507–513ms 之间):

refused: ConnectRefused after 698.834µs
     ConnectRefused context: Fail to connect to addr: 127.0.0.1:18489, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18489 cause: Connection refused (os error 61)
tls silent, no timeouts: still waiting after 3.004976208s
tls silent, connection_timeout 300ms: ConnectTimedout after 314.547958ms
     ConnectTimedout context: connecting to server addr: 127.0.0.1:18483, scheme: HTTPS, sni: silent.example, timeout 300ms cause: local address 127.0.0.1:62762
tls silent, total_connection_timeout 500ms: ConnectTimedout after 512.804459ms
     ConnectTimedout context: connecting to server addr: 127.0.0.1:18483, scheme: HTTPS, sni: silent.example, total-connection timeout 500ms
  • 端口没人听:内核立刻回 RST,ConnectRefused,不到 1ms。外层的 Fail to connect to addr: ..., scheme: HTTP 是 l4::connect 加的上下文(peer 的 Display),里层的 Fail to connect to 127.0.0.1:18489 是 wrap_os_connect_error 加的。
  • 两个超时都不设:TCP 已经连上,TLS 握手没有上限,3 秒时还在等。PeerOptions::new() 的默认就是这样,HttpPeer::new 出来的 peer 碰上这种上游会一直挂着。
  • 只设 connection_timeout:报错文字是 connecting to server ... timeout 300ms,这是 TLS 连接器里那段代码的格式,不是 l4::connect 的 timeout ... connecting to server。TCP 那一段早就成功了,300ms 花在 TLS 上。cause: local address 127.0.0.1:62762 是上面冷知识里 attach_connect_local_addr 补上的本地地址。
  • 只设 total_connection_timeout:500ms 时 do_connect 的超时触发,格式是 total-connection timeout 500ms。这一层在 attach_connect_local_addr 外面,所以没有本地地址。

动脑筋答案

curl 的输出(-w "$1 %{http_code} %{time_total}s\n")。前三组又跑了两次,/fast 是 0.891 秒,两个 /slow 是 2.013 和 2.023 秒,和这次差 15ms 以内:

--- /fast alone
/fast 200 0.006213s
--- /slow, then /fast 100ms later
/slow 200 1.013188s
/fast 200 0.885383s
--- two /slow together
/slow 200 2.020329s
/slow 200 2.020173s
--- /name
/name 502 0.004284s
--- /bad
* Empty reply from server
* Closing connection
--- /fast after /bad
/fast 200 0.001504s

同一次的代理日志:

upstream_peer /fast on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.375µs, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.010022166s, addr 127.0.0.1:18481
upstream_peer /fast on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.25µs, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.01003575s, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.004554125s, addr 127.0.0.1:18481
...
  • 晚到 100ms 的 /fast 用了 0.885 秒,几乎就是 /slow 剩下的那 0.9 秒。它自己的 HttpPeer::new 只花了 1.25µs,可唯一的工作线程正在 /slow 的 sleep 里,连它的请求头都没人读。
  • 两个同时发的 /slow 都用了 2.02 秒:两次慢解析在同一个线程上排队,各 1 秒。第一个 /slow 的响应其实在 1 秒时就可以写了,但写响应的也是这个线程,它在第二个 sleep 里。
  • 所有日志行都是 thread "Pingora HTTP Proxy Service":upstream_peer 就在服务的工作线程上跑。这里只有一个工作线程;线程数和 work stealing 怎么影响「一个任务卡住线程」,见 运行时。

小结

  • 域名解析发生在 HttpPeer::new:std::net::ToSocketAddrs,同步,只取第一个地址,失败 panic。实测慢解析把同一工作线程上的其他请求一起卡住;localhost 在 macOS 上先解析出 ::1;panic 只断掉当前下游连接。
  • new_stream:可选 offload 到独立线程池,按 reuse_hash 选池;选源地址;读「以后用 h1」的记录。
  • l4::connect:CONNECT 代理、自定义 L4、TCP / UDS 三条路;TCP 先设选项再非阻塞 connect,TCP_FASTOPEN、接收缓冲、DSCP、keepalive、IP_BIND_ADDRESS_NO_PORT 只在 Linux 有实现;连上后总是设 TCP_NODELAY。
  • connection_timeout 分别限制 TCP 连接和 TLS 握手,total_connection_timeout 限制整段,默认都没有;实测 TLS 不回时三种配置分别是一直等、300ms、500ms。
  • 0.9.0 起建连失败的错误里带着本地端口(0d6bf54、b319fed)。

💬 关联讨论

发起讨论
加载中...