反向代理听起来很简单:收到请求,转发给后端,把响应带回来。
但作为中间人,代理要同时面对两侧的失败。客户端慢、后端慢、后端连接被复用、需要升级协议……这些问题在直连场景下都不存在,只有站在中间才会遇到。
这篇从零写一个 HTTP 反向代理,重点放在那些"写起来才会发现"的地方。
最小版本:一个请求一个连接
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
pub fn serve(listen: &str, backend: &str) -> std::io::Result<()> {
let listener = TcpListener::bind(listen)?;
for client in listener.incoming() {
let mut client = client?;
let backend = backend.to_string();
std::thread::spawn(move || {
let _ = handle(client, &backend);
});
}
Ok(())
}
fn handle(mut client: TcpStream, backend: &str) -> std::io::Result<()> {
// 1. 读客户端请求
let request = read_request(&mut client)?;
// 2. 连后端,转发
let mut upstream = TcpStream::connect(backend)?;
upstream.write_all(&request.raw)?;
// 3. 把响应原样带回去
let mut buf = [0u8; 8192];
loop {
let n = upstream.read(&mut buf)?;
if n == 0 { break; }
client.write_all(&buf[..n])?;
}
Ok(())
}
这版能跑,但有五个问题,下面逐个解决。
问题一:每个请求都新建一条到后端的连接。 高并发下会耗尽后端端口。
问题二:没有超时。 后端卡住,代理线程永久挂起。
问题三:read 到 0 就 break 的判断不对。 用 keep-alive 时后端不会关连接,代理会一直等。
问题四:头部没改写。 后端看不到真实客户端 IP。
问题五:不支持协议升级。 WebSocket 一转就断。
问题一:连接池
复用后端连接是代理性能的第一杠杆。TCP 三次握手 + TLS 握手的开销,在大 QPS 下非常可观。
最朴素但有效的实现是每后端一个空闲连接队列:
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
pub struct ConnectionPool {
backend: String,
idle: Mutex<VecDeque<TcpStream>>,
max_idle: usize,
}
impl ConnectionPool {
pub fn new(backend: &str, max_idle: usize) -> Self {
Self {
backend: backend.to_string(),
idle: Mutex::new(VecDeque::new()),
max_idle,
}
}
pub fn get(&self) -> std::io::Result<TcpStream> {
// 从空闲队列里取一个还活着的
loop {
let stream = self.idle.lock().unwrap().pop_front();
match stream {
None => return TcpStream::connect(&self.backend),
Some(s) => {
// 检查连接是否已被对端关闭
if is_alive(&s) {
return Ok(s);
}
}
}
}
}
pub fn put(&self, stream: TcpStream) {
let mut idle = self.idle.lock().unwrap();
if idle.len() < self.max_idle {
idle.push_back(stream);
}
// 超出上限就直接 drop,关闭连接
}
}
关键点:从池里取出的连接可能已经被对端关闭了。 HTTP 服务器通常有 idle timeout(比如 60 秒),超过就自己关掉。代理不知情,拿出来直接用会写失败。
检测连接存活有两种方式:
fn is_alive(stream: &TcpStream) -> bool {
// 方式一:非阻塞 peek,读到 0 说明对端已关闭
stream.set_nonblocking(true).ok();
let mut buf = [0u8; 1];
let alive = match stream.peek(&mut buf) {
Ok(0) => false, // 对端关闭
Ok(_) => true, // 有数据(可能是关闭前的残留)
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => true, // 没数据,活着
Err(_) => false,
};
stream.set_nonblocking(false).ok();
alive
}
peek 而不是 read:peek 不消费数据,只是看一眼。用 read 会把后端发来的第一个字节吃掉——如果是数据就丢包了。
连接池最难的不是"放进去"和"拿出来",而是"怎么知道拿出来的还可用"。 HTTP 的 keep-alive 让连接的生命周期不由自己掌控。
问题二:超时——代理必须有三类超时
一个没配超时的代理,在后端 hang 住时会把所有工作线程耗光,然后整个服务不可用。这是最典型的级联故障。
pub struct Timeouts {
/// 建连超时:后端不可达时快速失败
pub connect: std::time::Duration,
/// 读超时:后端迟迟不返回响应
pub read: std::time::Duration,
/// 写超时:客户端接收慢(慢客户端攻击)
pub write: std::time::Duration,
}
应用方式:
upstream.set_read_timeout(Some(timeouts.read))?;
upstream.set_write_timeout(Some(timeouts.write))?;
client.set_write_timeout(Some(timeouts.write))?;
client.set_read_timeout(Some(timeouts.read))?;
三者的语义完全不同,不能用一个值:
| 超时 |
典型值 |
太短会怎样 |
太长会怎样 |
| connect |
1-3s |
后端偶发慢时误杀 |
连接堆积 |
| read |
按后端 P99 × 2 |
正常请求被切断 |
线程被占用 |
| write |
5-30s |
大响应传不完 |
慢客户端耗资源 |
代理的所有超时都要有值。 "不设超时"在代理里等于"把后端的故障放大到全站"。
set_read_timeout 触发的错误是 WouldBlock 或 TimedOut,要当正常分支处理:
match upstream.read(&mut buf) {
Ok(0) => break, // 后端关闭
Ok(n) => client.write_all(&buf[..n])?,
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
// 后端超时,返回 504
respond_gateway_timeout(&mut client)?;
break;
}
Err(e) => return Err(e),
}
注意:Ok(0) 只表示后端关了连接,不表示响应结束。 响应是否结束,要看 Content-Length 或 chunked 的终止块。
问题三:响应怎么才算结束
这正是最小版本的根本缺陷。用一个正确的判断逻辑:
enum BodyLength {
/// Content-Length 指定了多少字节
Fixed(usize),
/// chunked 编码,靠 0 长度块结束
Chunked,
/// 没有长度信息,只能读到连接关闭
UntilClose,
/// 无 body(如 204、304、HEAD 请求)
None,
}
fn body_length(headers: &[(String, String)], status: u16, method: &str) -> BodyLength {
// 204/304 和 HEAD 响应永远没有 body
if status == 204 || status == 304 || method == "HEAD" {
return BodyLength::None;
}
// Transfer-Encoding 优先于 Content-Length
let te = header(headers, "transfer-encoding").unwrap_or("");
if te.to_lowercase().contains("chunked") {
return BodyLength::Chunked;
}
match header(headers, "content-length").and_then(|v| v.parse().ok()) {
Some(n) => BodyLength::Fixed(n),
None => BodyLength::UntilClose,
}
}
Transfer-Encoding 的优先级高于 Content-Length——这是 HTTP 规范的明确要求,也是为了防请求走私(request smuggling)。如果两者同时存在,说明要么是错误,要么是攻击,正确做法是拒绝这个响应。
if has_te && has_cl {
// 两个都存在,这是可疑的,直接拒
return Err(Smuggling);
}
请求走私是代理必须防的一类攻击:攻击者构造 Content-Length 和 Transfer-Encoding 不一致的请求,让前端和后端对"请求边界"产生不同理解。 严格按规范处理这两者,是防它的基础。
按长度读取:
fn forward_body(
upstream: &mut TcpStream,
client: &mut TcpStream,
length: BodyLength,
) -> std::io::Result<()> {
match length {
BodyLength::None => Ok(()),
BodyLength::Fixed(n) => {
let mut remaining = n;
let mut buf = [0u8; 16384];
while remaining > 0 {
let want = remaining.min(buf.len());
let read = upstream.read(&mut buf[..want])?;
if read == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"upstream closed before sending full body",
));
}
client.write_all(&buf[..read])?;
remaining -= read;
}
Ok(())
}
BodyLength::Chunked => forward_chunked(upstream, client),
BodyLength::UntilClose => {
let mut buf = [0u8; 16384];
loop {
let n = upstream.read(&mut buf)?;
if n == 0 { break; }
client.write_all(&buf[..n])?;
}
Ok(())
}
}
}
Fixed 分支里那个 UnexpectedEof 很关键:后端声称发 N 字节但提前关闭了,代理不能把这个残缺响应当成功——必须让客户端感知到失败(通过关闭连接),否则客户端会拿到半截数据当成功。
问题四:头部改写
代理至少要改这几处:
1. Host 头。 客户端发的 Host 是代理的地址,后端通常需要知道自己的真实主机名。
2. X-Forwarded-For。 追加客户端 IP:
fn rewrite_headers(request: &mut HttpRequest, client_ip: std::net::IpAddr) {
// 追加而不是覆盖:保留链路
let existing = header(&request.headers, "x-forwarded-for")
.map(|v| format!("{v}, {client_ip}"))
.unwrap_or_else(|| client_ip.to_string());
set_header(&mut request.headers, "X-Forwarded-For", &existing);
// 协议和端口
set_header(&mut request.headers, "X-Forwarded-Proto", "http");
set_header(&mut request.headers, "X-Forwarded-Host", &request.host);
}
X-Forwarded-For 必须是"追加"。如果直接覆盖,多级代理链路上的信息就丢了。
3. Connection 头。 代理是独立连接,不能把客户端的 Connection: keep-alive 原样传给后端:
// 逐跳头部(hop-by-hop)不能转发
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
fn strip_hop_by_hop(headers: &mut Vec<(String, String)>) {
headers.retain(|(k, _)| !HOP_BY_HOP.iter().any(|h| k.eq_ignore_ascii_case(h)));
}
这里有个安全约束:X-Forwarded-For 不能盲目信任客户端传入的值。
如果攻击者直接发 X-Forwarded-For: 1.2.3.4,而后端用它做限流或鉴权,就能被轻易伪造。正确做法是:
- 代理追加自己的观测值
- 后端只信任最右边(离它最近)的那个 IP,或者信任"代理数量"个右侧条目
// 后端侧的解析:从右往左数,跳过可信代理的数量
fn real_client_ip(xff: &str, trusted_proxies: usize) -> Option<&str> {
let parts: Vec<&str> = xff.split(',').map(|s| s.trim()).collect();
parts.get(parts.len().checked_sub(trusted_proxies + 1)?).copied()
}
问题五:协议升级(WebSocket)
WebSocket 要经过代理,必须处理 101 响应,并把连接切换成双向透传。
fn forward_upgrade(
mut client: TcpStream,
mut upstream: TcpStream,
) -> std::io::Result<()> {
// 双向透传:两个线程,各管一个方向
let mut client_read = client.try_clone()?;
let mut upstream_read = upstream.try_clone()?;
let t = std::thread::spawn(move || {
// 客户端 -> 后端
let _ = std::io::copy(&mut client_read, &mut upstream_read);
// 一个方向结束,通知另一个方向(Half-Close)
let _ = upstream_read.shutdown(std::net::Shutdown::Write);
});
// 后端 -> 客户端
let _ = std::io::copy(&mut upstream, &mut client);
let _ = client.shutdown(std::net::Shutdown::Write);
let _ = t.join();
Ok(())
}
Half-Close(半关闭)是这里的关键。 WebSocket 关闭时,一方发 close 帧后可能还需接收对方的数据。如果直接 shutdown(Both),就会把还在路上的数据切断。
Shutdown::Write 只关闭写方向,读方向继续工作——这正是 TCP 四次挥手的语义。
代理转发升级协议时,超时必须撤掉或者设得很长。 WebSocket 连接可能空闲几十分钟,用 HTTP 的 30 秒读超时会把正常连接全掐断。
一个完整的转发流程
把上面五块拼起来:
fn proxy_request(client: &mut TcpStream, pool: &ConnectionPool, cfg: &Config) -> std::io::Result<()> {
loop {
// 1. 读客户端请求(含 keep-alive 循环)
let mut request = match read_request(client)? {
Some(r) => r,
None => return Ok(()), // 客户端关闭
};
// 2. 改写头部
strip_hop_by_hop(&mut request.headers);
rewrite_headers(&mut request, client.peer_addr()?.ip());
set_header(&mut request.headers, "Host", &cfg.backend_host);
// 3. 从池里取连接
let mut upstream = pool.get()?;
upstream.write_all(&request.serialize())?;
if !request.body.is_empty() {
upstream.write_all(&request.body)?;
}
// 4. 读响应头
let response = read_response_head(&mut upstream)?;
// 5. 检查是否升级
if response.status == 101 {
client.write_all(&response.serialize())?;
return forward_upgrade(client.try_clone()?, upstream);
}
// 6. 转发响应
client.write_all(&response.serialize())?;
let body_len = body_length(&response.headers, response.status, &request.method);
forward_body(&mut upstream, client, body_len)?;
// 7. 决定连接去向
if response_allows_reuse(&response) && request_allows_keepalive(&request) {
pool.put(upstream);
} else {
let _ = upstream.shutdown(std::net::Shutdown::Both);
}
if !client_allows_keepalive(&request) {
return Ok(());
}
}
}
连接是否可复用的判断,要同时看两侧。 客户端说 Connection: close,或者后端响应带 Connection: close,都不能复用。任意一侧不可复用,整条路径就断开。
结论
反向代理是"石头缝里求生存"的典型:
连接池要能识别死连接;三类超时缺一不可;响应结束靠 Content-Length / chunked / 连接关闭三者判断,且 TE 与 CL 并存必须拒绝;逐跳头部不能转发,XFF 只能追加不能信任;升级协议后要撤掉 HTTP 超时并支持 Half-Close。
这五条每一条都对应一类线上事故。写完这个代理,你再看 Nginx 的 proxy_* 配置项,会明白它们为什么存在——每一个参数背后,都是一次真实的故障。