造你自己的 HTTP 代理

Posted on May 29, 2024

前面的话

最近在亲近 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: UpgradeUpgrade: 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 fndyn 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

simple-gateway-flamegraph

智能总结

本文介绍了如何使用 Pingora 构建 HTTP 代理服务,以及如何使用 oha 进行负载测试,最后对比了 Caddy 和 Gatekeeper 的性能表现。在实际生产环境中,我们可能会遇到更多的问题,比如安全性、稳定性、可扩展性等,这些都需要我们不断地去探索和实践。

本文首发于 https://h2cone.github.io/

参考资料