Rust in Everything
基于 pingora-proxy 0.9.0

写一个迷你代理

主线位置:把请求主线自己走一遍。accept 一条下游连接(对应服务与监听)→ 带超时读请求头(对应 HTTP/1 服务端)→ request_filter、upstream_peer(对应 ProxyHttp)→ 从池里取一条上游连接或新建(对应连接池)→ 请求体往上、响应体往下同时转发,中间调 response_filter(对应 proxy_1to1)→ 上游连接回池,下游连接回到读下一个请求头。

flowchart TB
  acc["accept 每连接一任务"] --> head["读请求头 带超时"]
  head -->|超时或关闭| bye["关下游"]
  head --> rf["request_filter"]
  rf -->|Some Reply| reply["自己回应"]
  rf -->|None| peer["upstream_peer"]
  peer --> take{"池里有吗"}
  take -->|有| duplex["send 与 recv 同时跑"]
  take -->|没有| conn["connect"]
  conn --> duplex
  duplex --> resf["response_filter"]
  resf --> put["上游连接回池"]
  put --> head
  reply --> head

mini 是一个库 hands-mini-proxy,只依赖 tokio 和 httparse:

[dependencies]
httparse = "=1.10.1"
tokio = { version = "=1.53.2", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync"] }

pingora 只出现在另一个 crate hands-pingora-cmp 里:一个 40 多行的 ProxyHttp,监听 19010,转发到同一个上游,在 connected_to_upstream 里打印和 mini 同样格式的一行日志。mini 不用 BufReader,不受 tokio 1.54 那个断言影响;两个 crate 都钉在 tokio 1.53.2,是为了和对照组一致(原因见怎么读这一卷)。平台 M2 Max、macOS 27、rustc 1.99.0;测试是 debug 构建,对照实验是 release。库里没有 unwrap / expect,测试里随便用。hyper 卷的动手页从零写过一个服务端,这里的下游一侧和它是同一个思路,多出来的是上游一侧。

范围

文件做什么代码行(不含空行和注释)
src/lib.rsFilter、Proxy、下游连接循环、一次请求的转发、连接池200
src/head.rsHeaders、请求头和响应头的解析(httparse)与编码、keep-alive 判断、逐跳头143
src/body.rs读缓冲 Reader、读头、Framing 的判断、body 透传148
tests/proxy.rs10 个测试,上游是测试里手写的 TCP 服务327
examples/mini.rs19000 → 19001 的演示代理21
examples/rawup.rs手写的上游:按客户端端口打印连接,三种模式(照常回、空闲多久就关、回完第一个请求读到第二个就关)91
examples/seq.rs、examples/idleprobe.rs并发 keep-alive 客户端;只连不发和发一个请求后不再发的两条连接66、40
hands-pingora-cmp/src/main.rspingora 0.9.0 的对照代理45

库 491 行代码,加上空行和注释 590 行(rustfmt,2 格缩进,max_width = 120)。计划里写的「约 400 行」只够核心:格式化之前是 425 行,多出来的是 rustfmt 把方法链拆成了多行。

三个阶段:Filter

/// A response the proxy writes itself instead of asking an upstream.
pub struct Reply {
  pub status: u16,
  pub body: Vec<u8>,
}

/// The three phases a request goes through.
pub trait Filter: Send + Sync + 'static {
  /// Runs right after the request head is read. `Some` answers downstream and skips the upstream.
  fn request_filter(&self, _req: &mut RequestHead) -> Option<Reply> {
    None
  }
  /// Picks the upstream for this request; connections are pooled per address.
  fn upstream_peer(&self, req: &RequestHead) -> SocketAddr;
  /// Runs on the upstream response head before it goes downstream.
  fn response_filter(&self, _req: &RequestHead, _resp: &mut ResponseHead) {}
}

和 ProxyHttp 比,少的不只是方法数(pingora 42 个,必须实现的是 new_ctx 和 upstream_peer):

  • 短路的写法。pingora 的 request_filter 返回 Result<bool>,Ok(true) 表示「我已经写过响应了」,写没写由调用者自己保证;只返回 true 不写,客户端就一直等。mini 返回 Option<Reply>:要短路就得交出一个响应,由代理去写,「短路了却没有响应」这种状态在类型上不存在。代价是 mini 的过滤器不能流式写一个大响应,也不能写完响应后再做别的事。
  • 没有 CTX。pingora 每个请求有一个 new_ctx 建出来的 CTX,在各个钩子之间传;mini 的三个方法之间只靠 RequestHead 本身传数据。
  • 全是同步的。pingora 有 17 个钩子是 async,upstream_peer 里可以查服务发现、读远端配置;mini 的三个方法都不能 .await。要改成异步,可以写成返回 impl Future<Output = SocketAddr> + Send 的方法,Send 是为了 tokio::spawn。
  • response_filter 只能改头。框架在调它之前已经按原来的头决定了 body 怎么分界(见下文 Framing),过滤器改了 Content-Length 也不会改变转发多少字节。pingora 有同样的问题,另有 response_body_filter 能改 body。

下游连接:keep-alive 与空闲超时

每条下游连接一个任务。循环里每一次读请求头都套着同一个 timeout:

  async fn downstream(&self, mut stream: TcpStream) -> io::Result<()> {
    let mut buf = Vec::new();
    loop {
      let mut reader = Reader::new(&mut stream, &mut buf);
      let req = match timeout(self.idle_timeout, reader.read_head(parse_request)).await {
        Err(_) | Ok(Ok(None)) => return Ok(()), // idle too long, or the client closed between requests
        Ok(Ok(Some(req))) => req,
        Ok(Err(e)) => {
          write_reply(&mut stream, 400, b"", false).await?;
          return Err(e);
        }
      };
      if !self.one_request(&mut stream, &mut buf, req).await? {
        return Ok(());
      }
    }
  }
  • buf 跨请求保留。请求头和请求体读剩下的字节留在 buf 里,下一圈 read_head 先解析它们。所以 mini 顺带支持了 pipelining:一次写出两个请求,两个响应依次回来。pingora 0.9.0 的 pipelining 默认关,同样的两个请求只回第一个,然后关连接(HTTP/1 服务端),实测一样:
## 19000
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive

conn #1
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive

conn #1

## 19010
HTTP/1.1 200 OK
Content-Length: 8
Date: Thu, 29 Oct 2026 06:05:21 GMT
Connection: keep-alive

conn #2
<FIN>
  • 超时管的是整个请求头。timeout 包住的是 read_head 整体,不是第一次 read:客户端发半个头就停,同样会在 idle_timeout 时被关掉。它同时当了「等下一个请求」和「读请求头」两种超时,pingora 把这两者分成 keepalive_timeout 和 read_timeout。
  • 第一个请求和以后的请求一样。pingora 新连接上的第一个会话由 process_new 设成 60 秒;读到 HTTP/1.1 请求后,respect_keepalive 把它改成「无限」,read_request_buf 在无限时不设任何超时(详见 HTTP/1 服务端)。

两边各开两条连接,一条连上后什么都不发,一条发一个 GET 收完响应后不再发(examples/idleprobe.rs;mini 的 idle_timeout 设成 2 秒,pingora 用默认配置):

## mini, idle_timeout 2000 ms
one: FIN after 2.0s, got 70 bytes
silent: FIN after 2.0s, got 0 bytes
## pingora, 70 s limit
silent: FIN after 60.0s, got 0 bytes
one: still open after 70.0s, got 107 bytes

mini 两条都在 2.0 秒时被关;pingora 只连不发的那条 60.0 秒被关,发过一个请求的那条 70 秒后还开着。

📝NOTE
冷知识:0.5.x 里每次读请求头都有 60 秒上限

7883c22(#539,首次进入 0.5.0)之前,读请求头时只有 KeepaliveStatus::Timeout 带超时,Infinite 和 Off 都走 _ => read_event.await,read_timeout 默认是 None。提交说明写的是「The initial read(s) for reading in the HTTP request headers from the client did not make use of the read_timeout, and a client could cause a connection to hang forever.」它把 read_timeout 的默认值改成 Some(Duration::from_secs(60))(「matches NGINX's default value for client_header_timeout and client_body_timeout」),让 _ 分支用上它。0.5.0 里同一连接上的每个请求都从一个新的 HttpSession 开始,状态是 Off,所以每一次读请求头都有这 60 秒。

0.6.0 里两个提交把它改回了无限。6f44ac7(「Persist keepalive_timeout between requests on same stream」,#541)让上一个请求的 keepalive 状态传给下一个会话,HTTP/1.1 请求经 respect_keepalive 变成 Infinite,下一个会话也就是 Infinite。337f878(「Allow explicit infinite keepalive timeout to be respected」)把 Infinite 从 _ 里拆出来,直接 read_event.await,加上那行 // FIXME: this should only apply to reads between requests;提交说明的第二段是「Note that a necessary follow up is to refactor the infinite keepalive timeout to only apply to first read between requests on reused conns.」这个后续到 0.9.0 和 main 的 84f21ee 都没有做,FIXME 还在。上面那条「70 秒后还开着」的连接,就是 0.6.0 起的行为。手头只有 0.8.0 和 0.9.0 的 crate,0.5.0 的行为是读源码得出的,没有跑过。mini 只有一个超时,对每一次读请求头都生效。

请求头:httparse 与大小写

httparse 在栈上的 64 个槽里解析,交出的是指向缓冲区的切片。mini 把它们拷成一个按到达顺序排列的 Vec,名字照原样存:

/// Headers in arrival order. Names keep the spelling they arrived with; lookups ignore case.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Headers(pub Vec<(String, Vec<u8>)>);

impl Headers {
  pub fn get(&self, name: &str) -> Option<&[u8]> {
    self.values(name).next()
  }

  pub fn values<'s, 'n>(&'s self, name: &'n str) -> impl Iterator<Item = &'s [u8]> + use<'s, 'n> {
    self
      .0
      .iter()
      .filter(move |(n, _)| n.eq_ignore_ascii_case(name))
      .map(|(_, v)| v.as_slice())
  }
  // ... remove、set、has_token、encode_into
}

大小写保留因此是白来的:mini 从不把头放进一个键为小写的表,编码时按 Vec 的顺序原样写出。测试 header_case_kept_and_hop_by_hop_dropped 发一个 HTTP/1.0 请求,上游收到的整个请求头是:

GET /h HTTP/1.1\r\nhost: Example.COM\r\nX-MiXeD-Case: 1\r\n\r\n

host、X-MiXeD-Case 的拼写没变,版本改成了 1.1(和 pingora 的 proxy_h1 一样,发上游一律 HTTP/1.1)。请求里的 Connection: keep-alive, X-Drop、X-Drop: 1、Keep-Alive: timeout=5 都没了,删它们的是 strip_hop_by_hop:

/// Drops headers that describe one hop, plus whatever `Connection` names.
/// `Transfer-Encoding` stays: the body is passed through with its framing.
pub(crate) fn strip_hop_by_hop(headers: &mut Headers) {
  let named: Vec<String> = headers
    .values("Connection")
    .flat_map(|v| v.split(|&b| b == b','))
    .map(|t| String::from_utf8_lossy(t.trim_ascii()).into_owned())
    .collect();
  for name in named.iter().map(String::as_str).chain([
    "Connection",
    "Keep-Alive",
    "Proxy-Connection",
    "TE",
    "Trailer",
    "Upgrade",
  ]) {
    if !name.eq_ignore_ascii_case("transfer-encoding") {
      headers.remove(name);
    }
  }
}

Transfer-Encoding 按 RFC 也是逐跳头(hop-by-hop header),mini 却留着它:mini 不解码 body,chunked 原样往下传,头也得原样留着。pingora 0.9.0 默认也删逐跳头(HTTP/1 服务端),删掉 Transfer-Encoding 之后再按自己重新编码的方式补一个。Upgrade 被删,所以 WebSocket 过不了 mini:上游看不到升级请求,不会回 101。

响应方向也一样。上游 rawup 回 Content-Length 时首字母大写,curl 看到的两边的响应头:

## 19000
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive

## 19010
HTTP/1.1 200 OK
Content-Length: 8
Date: Thu, 29 Oct 2026 06:03:31 GMT
Connection: keep-alive

两边都保留了上游的拼写;pingora 自己补了 Date。所以「大小写保留」不是 mini 缺的东西。mini 缺的是 pingora 那套结构:RequestHeader / ResponseHeader 里是一个 http::HeaderMap(名字一律小写,查找是哈希),旁边另存一张 CaseMap 记原始拼写(请求头与错误)。过滤器拿到的是 http 生态的类型,查找不用逐个比;mini 每次 get 都要从头扫一遍 Vec,头一多就是平方级。

body:按分界原样透传

请求头读完,先按头决定 body 怎么结束:

/// How a message body ends.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Framing {
  Empty,
  Length(u64),
  Chunked,
  /// Response only: the body runs until the upstream closes.
  UntilClose,
}

body_framing 的规则:同时有 Transfer-Encoding 和 Content-Length 就拒绝;最后一个编码是 chunked 才算 chunked,请求不是就拒绝、响应不是就读到关闭;几个 Content-Length 值不一致就拒绝;请求两样都没有就是没有 body,响应两样都没有就读到关闭。请求被拒绝时回 400,连接关掉,测试 length_and_chunked_together_is_rejected 里上游一次连接都没收到。pingora 0.9.0 在两处和 mini 一样拒绝:不以 chunked 结尾的 Transfer-Encoding(87e2e2f,「Reject non-chunked transfer-encoding」,首次进入 0.8.0)、不一致的 Content-Length。Content-Length 和 chunked 并存时它不拒绝:按 chunked 读,删掉 Content-Length 再转发,回完响应关连接(HTTP/1 消息体的 b13)。mini 选了更简单的一条,直接 400。HEAD 请求的响应、204、304 不看头,一律没有 body:测试 head_response_has_no_body 里上游对 HEAD 回了 Content-Length: 5 不带 body,同一条连接上的下一个 GET 照常拿到 ok。

转发只认分界,不解码:

  /// Passes one body to `dst` byte for byte, framing included, and stops where it ends.
  pub(crate) async fn forward<W: AsyncWrite + Unpin>(&mut self, framing: Framing, dst: &mut W) -> io::Result<()> {
    match framing {
      Framing::Empty => Ok(()),
      Framing::Length(n) => self.copy_exact(n, dst).await,
      Framing::UntilClose => {
        dst.write_all(self.buf).await?;
        self.buf.clear();
        tokio::io::copy(&mut self.io, dst).await.map(|_| ())
      }
      Framing::Chunked => loop {
        let size_line = self.line().await?;
        let Ok(httparse::Status::Complete((_, size))) = httparse::parse_chunk_size(&size_line) else {
          return Err(invalid("bad chunk size line"));
        };
        dst.write_all(&size_line).await?;
        if size == 0 {
          // Trailer fields, then the empty line that ends the body.
          loop {
            let line = self.line().await?;
            dst.write_all(&line).await?;
            if line == b"\r\n" {
              return Ok(());
            }
          }
        }
        self.copy_exact(size, dst).await?;
        let crlf = self.line().await?;
        if crlf != b"\r\n" {
          return Err(invalid("chunk data longer than its size"));
        }
        dst.write_all(&crlf).await?;
      },
    }
  }

块头那一行交给 httparse::parse_chunk_size 解析出大小,然后整行原样写出,扩展跟着一起走;结尾的 trailer 也一行一行原样写出。mini 只需要知道这个 body 在哪里结束,好让同一条连接上的下一个消息从对的地方开始。copy_exact 读到多少写多少,不攒整个 body。

同一个带扩展和 trailer 的 chunked 请求发给两边,上游 rawup 的 dump 模式打印收到的全部字节,并回一个同样带扩展和 trailer 的 chunked 响应:

== mini
client got 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n3;up-ext=1\r\nabc\r\n0\r\nX-Up-Trailer: 1\r\n\r\n'
up: #1 got "POST /chunked HTTP/1.1\r\nHost: t\r\nTransfer-Encoding: chunked\r\n\r\n5;foo=bar\r\nhello\r\n0\r\nX-T: 1\r\n\r\n"
== pingora
client got 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nDate: Thu, 29 Oct 2026 06:06:40 GMT\r\nConnection: keep-alive\r\n\r\n3\r\nabc\r\n0\r\n\r\n'
up: #1 got "POST /chunked HTTP/1.1\r\nHost: t\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n"

(两次运行的输出各取了客户端和上游的那一行。)mini 两个方向都一个字节不差;pingora 两个方向都把 ;foo=bar、;up-ext=1 和 trailer 丢了,块边界是它自己的。这和 HTTP/1 消息体里 22 种请求体的结果一致,那一页只测了请求方向。

📝NOTE
没有蠢问题

问: 原样透传既简单又不丢东西,pingora 为什么要先解码再重新编码?

答: 因为 pingora 的 body 中途要经过别人的手。h1 转发里,上下游两半之间传的是 HttpTask,body 是 HttpTask::Body(Option<Bytes>, bool):一段去掉了分界的数据加「是不是最后一段」。response_body_filter 拿到的是 &mut Option<Bytes>,压缩模块(HTTP 模块与压缩)会把 body 变短,缓存存的也是数据本身;下游是 h2 时根本没有 chunked 这回事。body 一旦能被改,原来的块大小和 Content-Length 就不再对,只能在写出去的那一侧按新的数据重新分界。mini 的过滤器碰不到 body,才能原样透传。

双向转发:try_join! 两个 future

拿到上游连接后,两条 TCP 连接都用 split 拆成读写两半,组成两个 future:send 把请求头和请求体往上游写,recv 读响应头、调 response_filter、把响应往下游写。

    let (down_r, mut down_w) = down.split();
    let (up_r, mut up_w) = up.split();

    // Request body upstream and response downstream run together, like proxy_h1's two halves.
    let send = async {
      up_w.write_all(&up_head).await?;
      Reader::new(down_r, down_buf).forward(req_framing, &mut up_w).await
    };
    let recv = async {
      let mut reader = Reader::new(up_r, &mut up_buf);
      let mut resp = loop {
        let resp = reader
          .read_head(parse_response)
          .await?
          .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "upstream closed before the response"))?;
        if !(100..200).contains(&resp.status) {
          break resp;
        }
        started = true; // 1xx goes downstream as is, e.g. 100 Continue
        down_w.write_all(&resp.encode()).await?;
      };
      // ... 按响应头定 Framing 和两边的 keep-alive,删逐跳头
      self.filter.response_filter(&req, &mut resp);
      let connection = if down_keep { "keep-alive" } else { "close" };
      resp.headers.set("Connection", connection);
      started = true;
      down_w.write_all(&resp.encode()).await?;
      reader.forward(framing, &mut down_w).await?;
      io::Result::Ok((up_keep, down_keep))
    };
    let result = tokio::try_join!(send, recv);

pingora 的 proxy_1to1 也是 try_join! 两半,只是每一半是一个 select! 循环,两半之间隔着两条容量为 4 的 HttpTask 通道(双向转发);mini 的两半直接读一个 socket、写另一个 socket,中间没有通道,背压就是 TCP 本身:下游不读,down_w.write_all 就停住,recv 也就不再读上游。

「同时」是要测出来的。测试 bodies_flow_both_ways_at_once 的上游收到一半请求体(hello)就先回响应头和 early,客户端要看到 early 之后才发另一半(world),上游收齐再发 later。把 try_join! 换成先 send.await 再 recv.await,这个测试在 read_until 的 5 秒读超时处失败(客户端等 early,代理还在等请求体的后一半),其余 9 个照常通过:

test bodies_flow_both_ways_at_once ... FAILED
thread 'bodies_flow_both_ways_at_once' (5804141) panicked at tests/proxy.rs:40:8:
test result: FAILED. 9 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.01s

try_join! 在任一边出错时丢掉另一边。出错时如果响应头还没写给下游(started 为假),mini 回 502;已经写了一部分,就只能关掉下游连接。两种情况上游连接都不回池。正常结束时,两边都要说可以 keep-alive,上游连接才回池:上游响应是 HTTP/1.1、没有 Connection: close、body 不是读到关闭的,而且 up_buf 里没有多读到的字节。下游连接能不能留,看客户端的 keep-alive 和响应 body 是不是读到关闭的。

request_filter 短路时请求体没有读:留着它,它就会被当成下一个请求头解析。所以有 body 的请求被短路后,mini 关掉下游连接;没有 body 才 keep-alive(测试 request_filter_answers_without_the_upstream:403 之后同一条连接上的 GET 照常走上游)。

连接池:一个 HashMap 加一个 Vec

/// Idle upstream connections, grouped by address.
struct Pool {
  idle: Mutex<HashMap<SocketAddr, Vec<TcpStream>>>,
  per_peer: usize,
}

impl Pool {
  /// Newest first. Nothing checks whether the upstream closed the connection meanwhile.
  fn take(&self, addr: SocketAddr) -> Option<TcpStream> {
    self
      .idle
      .lock()
      .unwrap_or_else(PoisonError::into_inner)
      .get_mut(&addr)?
      .pop()
  }

  /// Keeps the connection if this address has room; otherwise it is dropped and closed.
  fn put(&self, addr: SocketAddr, stream: TcpStream) {
    let mut idle = self.idle.lock().unwrap_or_else(PoisonError::into_inner);
    let list = idle.entry(addr).or_default();
    if list.len() < self.per_peer {
      list.push(stream);
    }
  }
  // ... new、idle(测试用的计数)
}

和 pingora 的 ConnectionPool 比(连接池):

  • 键。mini 按 SocketAddr 分组;pingora 按 reuse_hash 分组,地址之外还有 TLS、SNI、证书校验这些选项(Peer),两个地址相同、TLS 设置不同的 peer 不能共用连接。
  • 顺序。mini 的 Vec::pop 后进先出,拿最近放回的那条;pingora 每组的 ArrayQueue 先进先出,拿最早放回的那条。下一节的 curl 序列里看得到:两条上游连接都空闲时,下一个请求 mini 拿的是 #2,pingora 拿的是 #1。
  • 上限。mini 每个地址最多 per_peer 条,满了就丢;pingora 的上限是全局的 upstream_keepalive_pool_size(默认 128)乘以线程数,满了挤掉全局最旧的。
  • 没人盯着空闲连接。pingora 每放回一条连接就 spawn 一个 idle_poll,对端一关、或空闲超时,就把它从池里删掉,取连接时还要再测一次。mini 放进 Vec 就不管了,上游关掉的连接照样留在池里,也没有空闲超时,上游不关它就永远占着。

和 pingora 跑同一组请求

上游是 examples/rawup.rs:每条连接回带 Content-Length 的 HTTP/1.1 响应,body 是 conn #N,打印每条连接的客户端端口。mini 监听 19000,pingora 监听 19010(默认配置,threads 为 1),都转发到 19001。tools/cmp.sh 依次起上游和代理,跑一组请求,再打印两边的日志。

curl 序列。一个 curl 进程发 /a、/b(同一条下游连接),新进程发 /c,两个 curl 同时发 /d、/e,最后发 /f:

== upstream
  830ms up: accept #1 from port 62663
  830ms up: #1 got "GET /a HTTP/1.1"
  832ms up: #1 got "GET /b HTTP/1.1"
  846ms up: #1 got "GET /c HTTP/1.1"
  863ms up: accept #2 from port 62667
  863ms up: #1 got "GET /e HTTP/1.1"
  863ms up: #2 got "GET /d HTTP/1.1"
  883ms up: #2 got "GET /f HTTP/1.1"
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /e -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /d -> upstream 127.0.0.1:19001, local 127.0.0.1:62667, reused false
mini: GET /f -> upstream 127.0.0.1:19001, local 127.0.0.1:62667, reused true
accepts: 2
== upstream
  820ms up: accept #1 from port 62674
  821ms up: #1 got "GET /a HTTP/1.1"
  822ms up: #1 got "GET /b HTTP/1.1"
  832ms up: #1 got "GET /c HTTP/1.1"
  843ms up: #1 got "GET /d HTTP/1.1"
  843ms up: accept #2 from port 62678
  843ms up: #2 got "GET /e HTTP/1.1"
  853ms up: #1 got "GET /f HTTP/1.1"
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /d -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /e -> upstream 127.0.0.1:19001, local 127.0.0.1:62678, reused false
pingora: GET /f -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
accepts: 2

两边都是 2 条上游连接:/c 换了下游连接,照样复用 /a、/b 那条,池是跨下游连接共享的;/d、/e 并发,第二个只能新建。/f 那一行就是上一节说的顺序:mini 拿最后放回的 #2,pingora 拿最早放回的 #1。

并发 keep-alive。examples/seq.rs 先开 8 条下游连接、每条顺序发 50 个请求,再开 64 条、每条 20 个,两轮之间不重启。两边各跑 5 次,全部是 200(400 个加 1280 个),上游接受的连接数:

第 1 次第 2 次第 3 次第 4 次第 5 次
mini6464646464
pingora6364646464

第一轮 8 条下游连接用 8 条上游连接;第二轮 64 条下游连接同时在飞,池里那 8 条不够,再新建 56 条。pingora 有一次少一条,没有单独查原因,最可能是那一次有一个请求取连接时,另一个请求刚好把连接放回了池里。两边的池上限都是 128,这个负载碰不到上限,连接数由同时在飞的请求数决定。每轮耗时两边都在 79 到 164 ms 之间,跑的时候 uptime 的负载在 44 左右,不拿来比快慢。

上游关掉空闲连接。rawup 换成空闲 200 ms 就关连接的模式。客户端发 /a、/b(各一个 curl 进程),等 500 ms 再发 /c。pingora 这边:

conn #1
 -> 200
conn #1
 -> 200
conn #2
 -> 200
== upstream
  818ms up: accept #1 from port 62687
  818ms up: #1 got "GET /a HTTP/1.1"
  828ms up: #1 got "GET /b HTTP/1.1"
 1031ms up: #1 idle 200ms, close
 1349ms up: accept #2 from port 62690
 1349ms up: #2 got "GET /c HTTP/1.1"
 1553ms up: #2 idle 200ms, close
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62687, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62687, reused true
pingora: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62690, reused false
accepts: 2

1031 ms 上游关掉 #1,idle_poll 读到 EOF 把它清出池子;/c 到的时候池是空的,新建 #2,三个请求都是 200。

❗IMPORTANT

动脑筋:同样的上游、同样三个 curl,换成 mini(池里没有 idle_poll),/c 拿到的状态码是多少?上游一共接受几条连接?

提示:mini 的 take 不看连接还活不活着,也不会重试。答案在文末。

上游在请求到达时关。rawup 换成「回完第一个请求,读到第二个就关、一个字节也不回」,模拟上游 keep-alive 超时和代理发请求撞在同一时刻:代理取连接时它还活着,idle_poll 也拦不住。客户端发 /a、/b。pingora(RUST_LOG=warn):

conn #1
 -> 200
conn #2
 -> 200
== upstream
  822ms up: accept #1 from port 62699
  822ms up: #1 got "GET /a HTTP/1.1"
  832ms up: #1 got "GET /b HTTP/1.1"
  832ms up: #1 close without answering
  832ms up: accept #2 from port 62701
  832ms up: #2 got "GET /b HTTP/1.1"
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62699, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62699, reused true
[... WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19001, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /b, Host: 127.0.0.1:19010
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62701, reused false
accepts: 2

mini:

conn #1
 -> 200
 -> 502
== upstream
  818ms up: accept #1 from port 62692
  818ms up: #1 got "GET /a HTTP/1.1"
  828ms up: #1 got "GET /b HTTP/1.1"
  828ms up: #1 close without answering
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62692, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62692, reused true
mini: GET /b via 127.0.0.1:19001: upstream closed before the response
accepts: 1

pingora 把「复用来的连接上、还没读到响应就被关」标成可重试,GET 是幂等的,于是新建一条连接再发一次,客户端拿到 200,上游收到两次 GET /b;换成 POST 就不重试,回 502(错误、重试与故障转移、连接池)。mini 没有重试,recv 读到 EOF,响应头还没写,回 502。要补这一条,最小的改法是在 one_request 里:reused 为真、started 为假、方法是 GET 或 HEAD、请求没有 body 时,新建一条连接再走一遍。

pingora 有而这里没有的

pingora 有mini 的做法讲它的页
缓存:request_cache_filter 打开后,同一个键的第二个请求不连上游,缓存锁只放一个请求去上游每个请求都去上游缓存状态机、缓存锁
HTTP/2:下游 h2 服务端,上游 h2 连接按 max_h2_streams 多路复用只有 HTTP/1.x;curl --http2-prior-knowledge 发来的连接前言被 httparse 拒绝,mini 回 400,日志是 invalid HTTP versionHTTP/2、HTTP 连接器
TLS:四个后端二选一的统一接口,下游 add_tls,上游 HttpPeer::new(addr, true, sni)两侧都是明文 TCPTLS
Server:配置文件、守护进程、信号;SIGQUIT 平滑升级(Linux 上把监听 fd 交给新进程),SIGTERM 平滑停机没有 Server,进程一停所有连接一起断一个 Server 的一生、平滑升级与停机
重试:错误带 retry 标记,复用连接上的失败对幂等请求重试,max_retries 是总尝试次数;重试时再调一次 upstream_peer,可以换一个 peer直接回 502错误、重试与故障转移
池里死连接的探测:每条空闲连接一个 idle_poll,对端关闭或空闲超时就删掉;取出时再 test_reusable_stream不探测,也没有空闲超时,上面那个 502 就是这么来的连接池
头的结构:http::HeaderMap 加 CaseMap,保留拼写,查找是哈希;RequestHeader 的方法、URI 是 http 的类型同样保留拼写,但是一个线性查找的 Vec,方法和目标是 String请求头与错误、HTTP/1 服务端
42 个钩子、CTX、17 个 async;body 也有钩子(request_body_filter、response_body_filter)3 个同步钩子,碰不到 bodyProxyHttp、process_request
上游一侧的超时:连接、TLS 握手、读、写、空闲各自可配(PeerOptions)上游没有任何超时,上游不回响应,这个下游连接就一直挂着Peer、L4 连接
请求校验:请求目标、Host、头的大小;0.9.0 收紧的部分只有 httparse 自己的检查,64 个头、64 KiB 的头HTTP/1 服务端
下游模块和压缩、上游模块没有HTTP 模块与压缩、上游模块与自定义转发
负载均衡、健康检查、限流upstream_peer 返回一个固定地址LoadBalancer、健康检查、限流
Upgrade / WebSocket删掉 Upgrade 头,升级不会发生双向转发

也有反过来的:mini 的 body 透传保留了 chunk 扩展和 trailer,pingora 不保留;mini 每次读请求头都有超时,pingora 从第二个请求起没有。前一条是 pingora 为了让 body 能被改而付的代价,后一条是源码里自己标着 FIXME 的。

做到这里能看清 pingora 请求主线的骨架:读头、三个决定(要不要短路、去哪、响应怎么改)、取连接、两半同时转发、两边各自决定能不能复用。mini 都写了,同一组请求下上游连接数也一样。多出来的那些——缓存、h2、TLS、平滑升级、重试和死连接探测、body 钩子、上游超时——都是为了「上游会坏、协议会变、进程要换」,mini 一样都没有,两个真实的故障场景就各回了一个 502。

动脑筋答案

conn #1
 -> 200
conn #1
 -> 200
 -> 502
== upstream
  817ms up: accept #1 from port 62683
  817ms up: #1 got "GET /a HTTP/1.1"
  827ms up: #1 got "GET /b HTTP/1.1"
 1031ms up: #1 idle 200ms, close
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused true
mini: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused true
mini: GET /c via 127.0.0.1:19001: upstream closed before the response
accepts: 1

502,上游只接受了 1 条连接。

  • 1031 ms 上游关掉 #1,mini 这边没有任何任务在读这条连接,它还在 Vec 里。
  • /c 到达,take 弹出它(reused true,本地端口还是 62683)。上游关闭时发来的 FIN 早就在这条连接的接收缓冲里了。send 把请求写出去,写进内核的发送缓冲照样成功;recv 读响应头,先读到的就是那个 FIN,也就是 EOF,报「upstream closed before the response」。
  • 这时响应头还没写给下游,started 为假,回 502;这条死连接被丢掉。上游的日志里没有 /c:它在 #1 关闭之后才到,没有被任何连接读到。
  • 测试 stale_pooled_connection_gives_502 把这件事固定下来:上游每条连接空闲 100 ms 就关,三个请求间隔 300 ms,状态码是 ["200", "502", "200"],上游接受 2 条连接。第三个请求成功,是因为第二个请求把死连接丢掉了,池又空了。

pingora 在同一个场景里没有这个 502,靠的是 idle_poll:每条空闲连接都有一个任务在读它,EOF 一到就从池里删掉。

小结

  • 一个能跑的 HTTP/1.1 反向代理,骨架是:读请求头(带超时)→ 三个决定(短路、选上游、改响应头)→ 取池里的连接或新建 → 请求体和响应体用 try_join! 同时转发 → 两边各自判断能不能复用。mini 库 491 行代码(rustfmt 之前 425 行)。
  • request_filter 返回 Option<Reply>,短路必须带一个响应;pingora 的 Ok(true) 由调用者保证已经写过响应。
  • mini 每次读请求头都套同一个超时;pingora 第一个请求 60 秒,之后无限(0.6.0 的 6f44ac7、337f878 起;0.5.x 每次读请求头都有 60 秒的 read_timeout)。
  • body 按 Framing 原样透传,chunk 扩展和 trailer 都留着;pingora 为了让 body 能被钩子和模块改,先解码再重新编码,这两样都丢了。大小写保留在 mini 里是白来的,pingora 多的是 HeaderMap 加 CaseMap 这套结构。
  • 同一组 curl 和并发请求下,两边的上游连接数相同(2、64);mini 的池后进先出,pingora 的先进先出。
  • 上游关掉空闲连接:pingora 的 idle_poll 提前清掉,mini 拿到死连接回 502。上游在请求到达时关:pingora 重试 GET 成功,mini 回 502。
  • mini 缺的是缓存、h2、TLS、平滑升级、重试、死连接探测、body 钩子和上游超时,每一样都有一页讲 pingora 怎么做。

💬 关联讨论

发起讨论
加载中...