Rust in Everything
基于 pingora-core 0.9.0

Peer

主线位置:请求主线的第四步。HttpProxy::proxy_to_upstream 调用户的 upstream_peer,拿到一个 Box<HttpPeer>,原样交给连接器。连接器从它读出地址、要不要 TLS、各种超时和 TCP 选项,再用它的 reuse_hash 去连接池里找能复用的连接。

flowchart TB
  up["upstream_peer"] --> hp["HttpPeer"]
  hp --> addr["_address / scheme / sni"]
  hp --> opts["PeerOptions"]
  hp --> rh["reuse_hash"]
  rh --> pool["连接池的键"]
  rh --> pref["prefer_h1 记录"]
  rh --> off["offload 运行时"]
  opts --> l4["TCP 选项与超时"]
  opts --> tls["TLS 校验与 ALPN"]
  opts --> h2["h2 流数与窗口"]

本页的源码几乎都在 upstreams/peer.rs,920 行,其中三分之一是 cfg 分支和文档注释。peer 本身不做任何 I/O,它是一份「连接说明书」。

本页的实验在 M2 Max、macOS 27、rustc 1.99.0 上跑,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。

Peer trait:四个必须的方法

Peer 的开头:

pub trait Peer: Display + Clone {
    /// The remote address to connect to
    fn address(&self) -> &SocketAddr;
    /// If TLS should be used;
    fn tls(&self) -> bool;
    /// The SNI to send, if TLS is used
    fn sni(&self) -> &str;
    /// To decide whether a [`Peer`] can use the connection established by another [`Peer`].
    ///
    /// The connections to two peers are considered reusable to each other if their reuse hashes are
    /// the same
    fn reuse_hash(&self) -> u64;
    /// Get the proxy setting to connect to the remote server
    fn get_proxy(&self) -> Option<&Proxy> {
        None
    }
    /// Get the additional options to connect to the peer.
    ///
    /// See [`PeerOptions`] for more details
    fn get_peer_options(&self) -> Option<&PeerOptions> {
        None
    }
    // ...

必须实现的只有 address、tls、sni、reuse_hash 四个。后面二十多个方法(verify_cert、connection_timeout、idle_timeout、get_alpn、tcp_keepalive……)都有默认实现,统一的写法是「get_peer_options() 有值就读对应字段,没有就返回 None 或 false」,例如 connection_timeout。所以一个实现了 Peer 却不给 PeerOptions 的类型,等于「不校验证书、没有超时、不设 ALPN」。

Peer 要求 Clone,连接器在两处真的克隆它:开了 offload 线程池时,TransportConnector::new_stream 要把 peer 搬进另一个运行时的任务里;Display 用在所有错误信息的上下文里,下一页的报错 Fail to connect to addr: 127.0.0.1:18489, scheme: HTTP 就是 HttpPeer 的 Display。

pingora 自带两个实现:

类型用在哪tls()reuse_hash()
BasicPeer纯 TCP / TLS 的场景,例如负载均衡的 TcpHealthCheck 的 peer_templatesni 非空就是 TLS只哈希地址
HttpPeerProxyHttp::upstream_peer 的返回值看 scheme哈希十几个字段,见下文

BasicPeer::new 是 address.parse().unwrap(),只认 IP 字面量,注释写着 TODO: check error。它的 impl Peer 还把 bind_to() 改写成恒返回 None,不过 0.9.0 里没有任何代码调用 Peer::bind_to():连接器选源地址时直接读 get_peer_options() 里的 bind_to 字段(下一页的 bind_to_random),这个改写实际上不起作用。

HttpPeer 的字段

pub struct HttpPeer {
    pub _address: SocketAddr,
    pub scheme: Scheme,
    pub sni: String,
    pub proxy: Option<Proxy>,
    pub client_cert_key: Option<Arc<CertKey>>,
    /// a custom field to isolate connection reuse. Requests with different group keys
    /// cannot share connections with each other.
    pub group_key: u64,
    pub options: PeerOptions,
}

字段全是 pub,构造之后直接改。四个构造函数:

构造函数地址从哪来
HttpPeer::new任何 std::net::ToSocketAddrs,同步解析,取第一个地址,失败就 panic,见下一页
HttpPeer::new_udsUnix 域套接字路径,返回 Result
HttpPeer::new_proxy目标 IP 和端口,外加一个 Proxy:先连 next_hop 这个 UDS,发 HTTP CONNECT 建隧道
HttpPeer::new_mtls同 new,再带上客户端证书,强制 TLS

_address 的类型是 pingora 自己的 SocketAddr(Inet 或 Unix),不是 std::net::SocketAddr。sni 只在 TLS 时用到,TLS 握手和证书校验的细节在协议部分的 TLS 页。

PeerOptions:默认值

PeerOptions::new 给的默认值,按用途分组(s2n 专属的字段略去):

组字段默认谁读
建连bind_to、tcp_keepalive、tcp_recv_buf、dscp、tcp_fast_open、custom_l4、upstream_tcp_sock_tweak_hook都是 None / falseL4 连接
超时connection_timeout、total_connection_timeoutNoneL4 与 TLS 握手
超时read_timeout、write_timeoutNoneproxy_h1.rs / proxy_h2.rs 拿到会话后设进去
超时idle_timeoutNone连接回池时交给 idle_poll
TLSverify_cert、verify_hostnametrueTLS 连接器
TLSalternative_cn、ca、curvesNoneTLS 连接器
TLSsecond_keysharetrue(注释:不用后量子曲线时无作用)TLS 连接器
TLSalpnALPN::H1TLS 握手发的 ALPN 列表,也决定 HTTP 连接器查哪个池
h2max_h2_streams1h2 连接器
h2h2_ping_interval、h2_stream_window_size、h2_connection_window_sizeNone(窗口 None 即 8 MiB)h2 握手
HTTPhttp_upstream_request_policy清理逐跳头(0.9.0 新增)proxy_h1.rs / proxy_h2.rs 整理上游请求头时,见双向转发
HTTPallow_h1_response_invalid_content_length、extra_proxy_headers、tracer、proxy_digest_user_data_hook关 / 空各自的调用处

两个默认值决定了「什么都不配」时的行为:

  • 所有超时都是 None。 连不上就等操作系统放弃,TLS 握手不回就一直等,上游不回响应头也一直等;回池的连接没有空闲上限,只等对端关闭或者被 LRU 挤出去。下一页有一个上游只接受 TCP、不回 TLS 的实验。
  • alpn 是 H1,max_h2_streams 是 1。 不改的话 pingora 对上游只说 HTTP/1.1;就算用 set_http_version(2, 1) 打开了 h2,一条 h2 连接上同时也只跑一个流。这两项怎么影响复用,见 HTTP 连接器。

set_http_version(max, min) 就是 self.alpn = ALPN::new(max, min):max == 1 得 H1,min == 2 得 H2,其余 H2H1。

reuse_hash:谁跟谁共用连接

连接池里的连接按一个 u64 分组。HttpPeer 的 reuse_hash 调 peer_hash,用 AHasher::default() 跑一遍手写的 impl Hash for HttpPeer:

impl Hash for HttpPeer {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self._address.hash(state);
        self.scheme.hash(state);
        self.proxy.hash(state);
        self.sni.hash(state);
        // client cert serial
        self.client_cert_key.hash(state);
        // origin server cert verification
        self.verify_cert().hash(state);
        self.verify_hostname().hash(state);
        self.alternative_cn().hash(state);
        #[cfg(feature = "s2n")]
        self.get_psk().hash(state);
        self.group_key.hash(state);
        // max h2 stream settings
        self.options.max_h2_streams.hash(state);
        // h2_stream_window_size and h2_connection_window_size are intentionally excluded
        // from the reuse hash for now. These are per-connection settings applied at handshake
        // time and may be revisited alongside other h2 settings that could be dynamically
        // adjusted over the lifetime of a connection.
        self.options.curves.hash(state);
        self.options.second_keyshare.hash(state);
    }
}

这个哈希值有四个用处,都以「同一个哈希 = 同一类连接」为前提:

  1. h1 连接池的键:reused_stream 用它取,v1::Connector::release_http_session 用它还;
  2. h2 的 in_use_pool 和 idle_pool 的键;
  3. PreferredHttpVersion:某个上游 h2 出错降级后,「以后都用 h1」记在这个哈希下;
  4. 开了 offload 时,get_runtime(peer.reuse_hash()) 用它挑线程池,同一类连接总在同一个池里建。

哈希进去的是两类字段:连接通到哪(地址、scheme、CONNECT 代理、SNI、group_key)和这条连接验明过什么身份(客户端证书、要不要校验证书和主机名、备用 CN、TLS 曲线)。后一类不同的两个 peer 如果共用连接,一个要求校验证书的 peer 就可能拿到另一个不校验的 peer 建的连接。没哈希进去的是只影响「这一次请求怎么读写」的东西:超时、h2 窗口、TCP 选项。

用一个小程序把每个字段单独改一下,看哈希变不变:

use std::time::Duration;
use pingora_core::upstreams::peer::{HttpPeer, Peer, ALPN};

fn base() -> HttpPeer {
  HttpPeer::new("127.0.0.1:18481", true, "a.example".to_string())
}

fn main() {
  let h0 = base().reuse_hash();
  let cases: Vec<(&str, Box<dyn Fn(&mut HttpPeer)>)> = vec![
    ("sni = b.example", Box::new(|p| p.sni = "b.example".into())),
    ("group_key = 1", Box::new(|p| p.group_key = 1)),
    ("verify_cert = false", Box::new(|p| p.options.verify_cert = false)),
    ("max_h2_streams = 100", Box::new(|p| p.options.max_h2_streams = 100)),
    ("curves = X25519", Box::new(|p| p.options.curves = Some("X25519".into()))),
    ("alpn = H2H1", Box::new(|p| p.options.alpn = ALPN::H2H1)),
    ("idle_timeout = 5s", Box::new(|p| p.options.idle_timeout = Some(Duration::from_secs(5)))),
    ("read_timeout = 5s", Box::new(|p| p.options.read_timeout = Some(Duration::from_secs(5)))),
    ("ca = self-signed cert", Box::new(|p| {
      let cert = rcgen::generate_simple_self_signed(vec!["ca.example".to_string()]).unwrap();
      let x = pingora_core::tls::x509::X509::from_pem(cert.cert.pem().as_bytes()).unwrap();
      p.options.ca = Some(std::sync::Arc::new(vec![x].into_boxed_slice()));
    })),
    // ... scheme、alternative_cn、second_keyshare、connection_timeout、
    //     h2_stream_window_size、tcp_fast_open 同样各一行
  ];
  println!("base: {h0:016x}");
  for (name, f) in cases {
    let mut p = base();
    f(&mut p);
    let h = p.reuse_hash();
    println!("{:<28} {}", name, if h == h0 { "same" } else { "DIFFERENT" });
  }
}
❗IMPORTANT
动脑筋:alpn、idle_timeout、curves、ca 这四项,改哪几项会让 reuse_hash 变?

按上面的 Hash 实现先猜,再看文末的输出。

ALPN 不在哈希里,结果是:alpn = H1 的 peer 和 alpn = H2H1 的 peer 打到同一个上游时,拿的是同一个 h1 池里的连接。这不出错,因为两边都接受 HTTP/1.1;HTTP 连接器另外用 ALPN 决定「先不先查 h1 池」,见 HTTP 连接器。

每个 peer 自己的 ca 也不在哈希里。openssl 连接器的 connect 握手时用 peer.get_ca() 换掉校验用的证书库,所以它影响的是「这条连接验明过什么身份」,和 verify_cert 同一类。两个只差 ca 的 peer 会共用连接:用 CA 甲校验通过的连接,可以被要求 CA 乙的 peer 拿去用。需要隔开的话,用文档注释里说的 group_key:给不同信任配置的 peer 设不同的 group_key。

AHasher::default() 的种子每个进程随机:同一个 peer 在同一进程里哈希两次相同,换一个进程就不同(上面程序跑两次,base: 一行分别是 a486e725dfdf7749 和 d554d351e8550a23)。这个值不能存下来跨进程用,也不能拿来和别的服务对账。

📝NOTE
冷知识:TLS 曲线是 0.9.0 才进哈希的

0.8.0 的 impl Hash for HttpPeer 到 max_h2_streams 就结束了。PeerOptions 里早就有 curves(握手时公布哪些曲线)和 second_keyshare(后量子曲线时多发一个 key share),但它们不进哈希:两个只差曲线配置的 peer 共用同一组连接,一个要求后量子曲线的 peer 可能拿到另一个 peer 用普通曲线握手建的连接。

提交 2536867(「Adding curves and second keyshare setting to httppeer hash」)在哈希末尾加了两行 self.options.curves.hash(state); 和 self.options.second_keyshare.hash(state);,首次发布在 0.9.0;CHANGELOG 的 TLS 一节记为「Add curve and second-keyshare settings to HttpPeer hashing.」。同一版里 1f83d3c 把 curves 从 Option<&'static str> 改成 Option<Cow<'static, str>>,好让曲线列表可以运行时算出来,CHANGELOG 把它列在破坏性变更里:「PeerOptions::curve now uses Cow.」

group_key 和自定义 Peer

group_key 不影响怎么连,只影响哈希。它是给用户的一个开关:同一个上游,按租户、按请求类型分开用连接,就给 group_key 设不同的值。上面的输出里 group_key = 1 一行是 DIFFERENT。

要更细的控制,可以自己实现 Peer:reuse_hash 想哈希什么就哈希什么。但 ProxyHttp::upstream_peer 的返回类型写死了 Box<HttpPeer>,自定义 peer 只能用在直接调 TransportConnector / http::Connector 的代码里,代理的主流程用不上。

动脑筋答案

完整输出(完整版程序 hash.rs 改了 15 项):

base: a486e725dfdf7749
sni = b.example              DIFFERENT
scheme = HTTP (tls false)    DIFFERENT
group_key = 1                DIFFERENT
verify_cert = false          DIFFERENT
alternative_cn               DIFFERENT
max_h2_streams = 100         DIFFERENT
curves = X25519              DIFFERENT
second_keyshare = false      DIFFERENT
alpn = H2H1                  same
idle_timeout = 5s            same
read_timeout = 5s            same
connection_timeout = 1s      same
h2_stream_window_size        same
tcp_fast_open = true         same
ca = self-signed cert        same
same process, twice: same

四项里只有 curves 让哈希变了。alpn 和 idle_timeout 不在 Hash 实现里;ca 也不在,虽然它和 verify_cert 一样决定证书怎么校验。h2_stream_window_size 和源码注释说的一样被有意排除。

小结

  • Peer 只要求 address、tls、sni、reuse_hash,其余都从 PeerOptions 读,不给就是「无超时、不校验」。
  • HttpPeer::new 接受任何 ToSocketAddrs,取第一个地址;BasicPeer::new 只认 IP 字面量,失败都是 panic。
  • PeerOptions 默认:所有超时 None,verify_cert / verify_hostname 为真,ALPN 只有 h1,max_h2_streams 为 1。
  • reuse_hash 哈希地址、scheme、代理、SNI、客户端证书、证书校验开关、备用 CN、group_key、max_h2_streams、curves、second_keyshare;ALPN、所有超时、h2 窗口、TCP 选项、每个 peer 的 ca 不在内。
  • 哈希用随机种子的 AHasher,只在进程内有意义;它同时是连接池、h2 池、降级记录和 offload 线程池的键。

💬 关联讨论

发起讨论
加载中...