造你自己的 HTTP 代理
前面的话
最近在亲近 Rust 生态,注意到 Cloudflare 开源了用于构建快速和可靠以及可演进的网络服务库 Pingora,了解了“最少必要知识”后,决定尝试基于 Pingora 构建 HTTP 代理服务来代替私有服务器上的 Caddy。
准备工作
老弟我的服务器配置文件 Caddyfile 类似于:
> cat /etc/caddy/Caddyfile
:8008
@websockets {
header Connection Upgrade
header Upgrade websocket
path /up
}
reverse_proxy @websockets :3000
简言之,Caddy 会监听端口 8008 的 HTTP 请求,若请求头中包含 Connection: Upgrade
和 Upgrade: websocket
,并且请求 URI 为 /up,则将请求转发到本地端口 3000 的服务。我们知道 WebSocket 建立连接的过程中使用了 HTTP,通过握手后可升级为全双工,就可以在客户端和服务器之间进行双向数据传输,而无需每次数据交换都建立新的连接。
为了方便试验先将配置简化为:
:8008
reverse_proxy :3000
使用 Cargo 新建项目:
.
├── Cargo.toml
├── .gitignore
├── README.md
└── src
体验过 Pingora 的快速开始指南知道 Cargo.toml
中依赖哪些(crate):
[package]
name = "gatekeeper"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
pingora = { version = "0.1", features = ["lb"] }
structopt = "0.3.26"
主要过程
Pingora 提供了一个 pingora::proxy::ProxyHttp
特性 (trait),我们可以通过实现这个接口来构建自己的 HTTP 代理服务。在 src/main.rs
中:
pub struct Ctx();
#[async_trait]
impl ProxyHttp for Gateway {
type CTX = Ctx;
fn new_ctx(&self) -> Self::CTX {
Ctx()
}
async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut Self::CTX) -> pingora::Result<Box<HttpPeer>> {
let peer = HttpPeer::new(self.peer_addr.as_str(), self.tls, self.sni.to_string());
return Ok(Box::new(peer));
}
async fn request_filter(&self, _session: &mut Session, _ctx: &mut Self::CTX) -> pingora::Result<bool> where Self::CTX: Send + Sync {
if self.ctx_path.as_str() == "/" || check_uri(&_session.req_header(), self.ctx_path.as_str()) {
return Ok(false);
}
let _ = _session.respond_error(404).await;
return Ok(true);
}
}
fn check_uri(req_header: &RequestHeader, prefix: &str) -> bool {
req_header.uri.path().starts_with(prefix)
}
对于 Pingora 来说,请求(request)有生命周期,在开发者面前则是一组生命周期函数,从请求发起到完成期间框架会阶段性回调。比如 upstream_peer
用于创建上游(upstream)连接,request_filter
用于过滤请求。在这里,我们只是简单地检查请求的 URI 是否匹配,若是则表示不拦截或放过,否则响应错误码 404。
其中 #\[async_trait\]
是实现 ProxyHttp 的编译时要求,Async trait methods 提供了这个宏(macro)用于使在 Trait 中的 async fn
与 dyn Trait
一起工作。
实现 ProxyHttp 的结构体 Gateway
的定义如下:
#[derive(StructOpt)]
pub struct Gateway {
/// Context path
#[structopt(long = "cp", default_value = "/")]
ctx_path: String,
/// Peer address
#[structopt(long = "pa")]
peer_addr: String,
/// TLS
#[structopt(long)]
tls: bool,
/// SNI
#[structopt(long, default_value = "")]
sni: String,
}
Pingora 提供了一些命令行参数,当需要拓展命令行参数时,第一次尝试集成看起来很火的命令行参数解析器 clap,但是由于 Opt::default()
和 Gateway::parse()
无法共存而挫败,没办法只好参考了 How to add custom CLI flags / custom config 中的方案。
另外一处不舒服的地方是被迫使用另一个结构体 App
包装 Gateway
,这样才能在主函数 main
中通过函数 pingora::proxy::http_proxy_service
创建 HTTP 代理服务:
fn main() {
let app = App::from_args();
let mut server = Server::new(Some(app.opt)).unwrap();
server.bootstrap();
let mut proxy = http_proxy_service(&server.configuration, app.gateway);
proxy.add_tcp(app.bind_addr.as_str());
server.add_service(proxy);
server.run_forever();
}
#[derive(StructOpt)]
pub struct App {
/// Bind address
#[structopt(long = "ba")]
bind_addr: String,
#[structopt(flatten)]
gateway: Gateway,
#[structopt(flatten)]
opt: Opt,
}
这么做主要是为了避免类似的编译错误:
error[E0505]: cannot move out of `gateway` because it is borrowed
--> src/main.rs:14:63
|
9 | let gateway = Gateway::parse();
| ------- binding `gateway` declared here
...
13 | let bind = gateway.bind.as_str();
| ------------ borrow of `gateway.bind` occurs here
14 | let mut proxy = http_proxy_service(&server.configuration, gateway);
| ^^^^^^^ move out of `gateway` occurs here
15 | proxy.add_tcp(bind);
| ---- borrow later used here
Some errors have detailed explanations: E0505, E0599.
For more information about an error, try `rustc --explain E0505`.
error: could not compile `gatekeeper` (bin "gatekeeper") due to 3 previous errors
看起来 http_proxy_service 会夺取 Gateway 的所有权(ownership),而我们在函数 main 中还需要 Gateway 的所有权,因此通过包装的方式来解决。
至此,我们的 HTTP 代理服务就构建完成了,接下来就是试运行:
cargo run -- --ba 0.0.0.0:8008 --pa 127.0.0.1:3000
完整的代码可以在 Gatekeeper 找到。
负载测试
书接上文,我们还差一个的 Upstream 服务,基于 Node.js 快速实现:
// server.mjs
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World!\n');
});
// starts a simple http server locally on port 3000
server.listen(3000, '127.0.0.1', () => {
console.log('Listening on 127.0.0.1:3000');
});
// run with `node server.mjs`
使用 HTTP 负载测试工具 oha 简单测试在本地代理 :3000 的 Caddy 和 Gatekeeper 应对负载增加的能力,重点关注指标 requests/sec
。
首先看看 Caddy 的表现,启动代理服务:
caddy run --config ./Caddyfile
然后使用 oha 进行测试:
> oha --no-tui http://127.0.0.1:8008
Summary:
Success rate: 100.00%
Total: 0.0139 secs
Slowest: 0.0132 secs
Fastest: 0.0003 secs
Average: 0.0028 secs
Requests/sec: 14409.7410
Total data: 2.54 KiB
Size/request: 13 B
Size/sec: 182.94 KiB
Response time histogram:
0.000 [1] |
0.002 [104] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.003 [13] |■■■■
0.004 [45] |■■■■■■■■■■■■■
0.005 [13] |■■■■
0.007 [8] |■■
0.008 [3] |
0.009 [0] |
0.011 [1] |
0.012 [2] |
0.013 [10] |■■■
Response time distribution:
10.00% in 0.0006 secs
25.00% in 0.0008 secs
50.00% in 0.0014 secs
75.00% in 0.0036 secs
90.00% in 0.0061 secs
95.00% in 0.0119 secs
99.00% in 0.0130 secs
99.90% in 0.0132 secs
99.99% in 0.0132 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0018 secs, 0.0004 secs, 0.0031 secs
DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0001 secs
Status code distribution:
[200] 200 responses
停止 Caddy server,创建配置文件 conf.yaml
:
version: 1
threads: 4
启动 Gatekeeper 时可指定配置文件:
cargo run -- -c conf.yaml --ba 0.0.0.0:8008 --pa 127.0.0.1:3000
即使调整了线程数 threads
,但是 Gatekeeper 的表现远不如前者,很难达到 10k+ 的 requests/sec,除非优化掉路由(routing)或其他不必要的操作,例如 simple-gateway。
智能总结
本文介绍了如何使用 Pingora 构建 HTTP 代理服务,以及如何使用 oha 进行负载测试,最后对比了 Caddy 和 Gatekeeper 的性能表现。在实际生产环境中,我们可能会遇到更多的问题,比如安全性、稳定性、可扩展性等,这些都需要我们不断地去探索和实践。
本文首发于 https://h2cone.github.io/