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_template | sni 非空就是 TLS | 只哈希地址 |
HttpPeer | ProxyHttp::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_uds | Unix 域套接字路径,返回 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 / false | L4 连接 |
| 超时 | connection_timeout、total_connection_timeout | None | L4 与 TLS 握手 |
| 超时 | read_timeout、write_timeout | None | proxy_h1.rs / proxy_h2.rs 拿到会话后设进去 |
| 超时 | idle_timeout | None | 连接回池时交给 idle_poll |
| TLS | verify_cert、verify_hostname | true | TLS 连接器 |
| TLS | alternative_cn、ca、curves | None | TLS 连接器 |
| TLS | second_keyshare | true(注释:不用后量子曲线时无作用) | TLS 连接器 |
| TLS | alpn | ALPN::H1 | TLS 握手发的 ALPN 列表,也决定 HTTP 连接器查哪个池 |
| h2 | max_h2_streams | 1 | h2 连接器 |
| h2 | h2_ping_interval、h2_stream_window_size、h2_connection_window_size | None(窗口 None 即 8 MiB) | h2 握手 |
| HTTP | http_upstream_request_policy | 清理逐跳头(0.9.0 新增) | proxy_h1.rs / proxy_h2.rs 整理上游请求头时,见双向转发 |
| HTTP | allow_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);
}
}
这个哈希值有四个用处,都以「同一个哈希 = 同一类连接」为前提:
- h1 连接池的键:
reused_stream用它取,v1::Connector::release_http_session用它还; - h2 的
in_use_pool和idle_pool的键; PreferredHttpVersion:某个上游 h2 出错降级后,「以后都用 h1」记在这个哈希下;- 开了 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" });
}
}
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)。这个值不能存下来跨进程用,也不能拿来和别的服务对账。
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 线程池的键。