如何使用pingora構建自己反向代理
Cloudflare開源了其基於rust構建的反向代理框架pingora
,已經在Cloudflare的雲服務中實際使用,每秒支撐起超過4000萬個互聯網請求(需要注意,此處並不是性能指標)。pingap使用pingora提供的各種模塊,基於toml的配置方式,提供更便捷的方式配置反向代理。主要特性如下:
- 支持通過
host
與path
選擇對應的location - HTTP 1/2兩種方式的支持
- 無請求中斷式的優雅更新
- 模板式的請求日誌配置
- 基於TOML形式的程序配置
pingap
整體的流程簡化爲三步,接受到請求後基於host
與path
選擇匹配的location
,判斷是否重寫path以及設置相應的請求頭,之後將請求轉發至對應的upstream
,而pingora
的完整處理流程可以查看官方說明的phase_chart,下面我們從零開始構建pingap
。
Upstream
upstream比較簡單,支持配置多節點地址,根據指定的算法獲取對應的節點,完整的實現可查看代碼upstream.rs。主要介紹一下其new_http_peer
的實現:
pub fn new_http_peer(&self, _ctx: &State, header: &RequestHeader) -> Option<HttpPeer> {
let upstream = match &self.lb {
SelectionLb::RoundRobinLb(lb) => lb.select(b"", 256),
SelectionLb::ConsistentLb(lb) => {
let key = if let Some(value) = header.headers.get(&self.hash) {
value.as_bytes()
} else {
header.uri.path().as_bytes()
};
lb.select(key, 256)
}
};
upstream.map(|upstream| {
let mut p = HttpPeer::new(upstream, self.tls, self.sni.clone());
p.options.connection_timeout = self.connection_timeout;
p.options.total_connection_timeout = self.total_connection_timeout;
p.options.read_timeout = self.read_timeout;
p.options.idle_timeout = self.idle_timeout;
p.options.write_timeout = self.write_timeout;
p
})
}
從代碼可以看出,按指定的load balancer
的算法,選擇符合的upstream
,之後創建對應的HttpPeer
用於後續節點的連接。需要注意以下處理邏輯:
pingap
中對於upstream
有對應的health check,支持http
與tcp
的形式,若未指定則默認以tcp
的形式檢測端口是否可連接,因此若所有節點均檢測不通過時,則返回None
。- 與
upstream
的連接是可複用的,若不想複用則設置idle_timeout
爲0,默認None
表示無過期清除時長。而upstream_keepalive_pool_size
默認爲128的連接池,但是pingora
暫未提供直接設置該值的方式,只能通過yaml
配置加載,一般也不需要調整。
Location
每個location都有其對應的upstream,它主要處理以下的邏輯,判斷path與host是否符合該location,重寫請求的path,設置請求頭與響應頭。location支持四種path匹配規則,代碼如下:
enum PathSelector {
RegexPath(RegexPath),
PrefixPath(PrefixPath),
EqualPath(EqualPath),
Empty,
}
fn new_path_selector(path: &str) -> Result<PathSelector> {
if path.is_empty() {
return Ok(PathSelector::Empty);
}
let se = if path.starts_with('~') {
let re = Regex::new(path.substring(1, path.len())).context(RegexSnafu {
value: path.to_string(),
})?;
PathSelector::RegexPath(RegexPath { value: re })
} else if path.starts_with('=') {
PathSelector::EqualPath(EqualPath {
value: path.substring(1, path.len()).to_string(),
})
} else {
PathSelector::PrefixPath(PrefixPath {
value: path.to_string(),
})
};
Ok(se)
}
空路徑
: 未指定其匹配的路徑,所有請求路徑均符合正則匹配
: 以~開頭的配置,表示通過正則匹配判斷是否符合全等匹配
: 以=開頭的配置,表示請求路徑完全相等前綴匹配
: 默認爲前綴匹配,前綴匹配的性能比正則要好,建議使用
需要注意,因爲不同的location可以關聯到同一個server,因此需要注意各匹配規則是否有衝突,同一server下的所有location按以下方式計算權重排序:
pub fn get_weight(&self) -> u32 {
// path starts with
// = 65536
// prefix(default) 32768
// ~ 16384
// host exist 8192
let mut weighted: u32 = 0;
if let Some(path) = &self.path {
if path.starts_with('=') {
weighted += 65536;
} else if path.starts_with('~') {
weighted += 16384;
} else {
weighted += 32768;
}
weighted += path.len() as u32;
};
if self.host.is_some() {
weighted += 8192;
}
weighted
}
path匹配的權重規則如下全等匹配
> 前綴匹配
> 正則匹配
,相同類型的path匹配,若有配置host則權重較高,而相同權重的則按配置字符串長度排序,較長的配置權重較高。
location的path支持正則的方式改寫,如配置rewrite: Some("^/users/(.*)$ /$1".to_string())
,則針對以/users
開頭的請求將其刪除。請求頭與響應頭的處理則很簡單,僅簡單的請求發送至upstream
時設置請求頭,在響應到downupstream
則設置響應頭。
Server
Server關聯了其對應的所有location,在接收到downstream
的請求後,根據權重一一匹配其所有location,匹配符合的則請求對應的upstream
獲取響應數據,下面先來介紹一下Server的定義:
pub struct Server {
// 是否admin server,用於提供admin web ui
admin: bool,
// 監控的地址
addr: String,
// 記錄已接受的請求數
accepted: AtomicU64,
// 當前正在處理的請求數
processing: AtomicI32,
// 該server下的所有location
locations: Vec<Location>,
// 訪問請求的格式化
log_parser: Option<Parser>,
// 出錯的響應模板(html)
error_template: String,
// 獲取stats的路徑
stats_path: Option<String>,
// admin對應的路徑,可在對外的服務中指定前綴轉發至admin server
// 在支持鑑權前不建議對外網訪問的服務配置
admin_path: Option<String>,
// tls 證書
tls_cert: Option<Vec<u8>>,
// tls 證書
tls_key: Option<Vec<u8>>,
}
Server在執行時,根據所有的location生成對應的後臺服務(health check功能),監控對應地址的訪問請求,若配置了tls則設置對應的證書,具體邏輯如下:
pub fn run(self, conf: &Arc<configuration::ServerConf>) -> Result<ServerServices> {
let addr = self.addr.clone();
let mut bg_services: Vec<Box<dyn IService>> = vec![];
for item in self.locations.iter() {
// 生成對應的background service檢測
let name = format!("BG {}", item.upstream.name);
if let Some(up) = item.upstream.get_round_robind() {
bg_services.push(Box::new(GenBackgroundService::new(name.clone(), up)));
}
if let Some(up) = item.upstream.get_consistent() {
bg_services.push(Box::new(GenBackgroundService::new(name, up)));
}
}
// tls
let tls_cert = self.tls_cert.clone();
let tls_key = self.tls_key.clone();
// http proxy service服務,因此server需要實現ProxyHttp trait
let mut lb = http_proxy_service(conf, self);
// add tls
if tls_cert.is_some() {
// 代碼忽略初始化tls相關配置,具體可查看源碼
// 啓用支持http2
tls_settings.enable_h2();
lb.add_tls_with_settings(&addr, None, tls_settings);
} else {
lb.add_tcp(&addr);
}
Ok(ServerServices { lb, bg_services })
}
ProxyHttp
pingora
默認實現了ProxyHttp
大部分實現,完整的處理流程參考phase_chart,因此Server僅針對需求實現相應的邏輯,下面是簡化的說明:
request_filter
: 根據host與path選擇對應的location(後續支持配置location的請求可緩存)upstream_peer
: 根據location中是否配置path重寫與請求頭添加,處理相應的邏輯connected_to_upstream
: 記錄與upstream的一些連接信息,如連接是否複用,服務地址等upstream_response_filter
: 在請求響應至downupstream時觸發,根據是否配置了需要添加響應頭,處理相應的邏輯fail_to_proxy
: 轉發至upstream失敗時觸發,替換模板html的出錯信息後響應logging
: 請求日誌輸出,所有的請求最終均會經過此處理
#[async_trait]
impl ProxyHttp for Server {
type CTX = State;
fn new_ctx(&self) -> Self::CTX {
// 初始化自定義的state,用於記錄請求處理過程的數據
State::default()
}
async fn request_filter(
...
) -> pingora::Result<bool>
where
Self::CTX: Send + Sync,
{
// 省略部分代碼...
// 按權重從location列表中選擇符合的
// 後續支持設置location是否支持緩存(類似varnish)
// 因此在request filter中先匹配location
let (location_index, _) = self
.locations
.iter()
.enumerate()
.find(|(_, item)| item.matched(host, path))
.ok_or_else(|| pingora::Error::new_str(LOCATION_NOT_FOUND))?;
ctx.location_index = Some(location_index);
Ok(false)
}
async fn upstream_peer(
...
) -> pingora::Result<Box<HttpPeer>> {
// 省略部分代碼...
// 從context中獲取location
// 重寫path部分
if let Some(mut new_path) = lo.rewrite(path) {
if let Some(query) = header.uri.query() {
new_path = format!("{new_path}?{query}");
}
if let Ok(uri) = new_path.parse::<http::Uri>() {
header.set_uri(uri);
}
}
// 設置請求頭
lo.insert_proxy_headers(header);
let peer = lo
.upstream
.new_http_peer(ctx, header)
.ok_or(pingora::Error::new_str("Upstream not found"))?;
Ok(Box::new(peer))
}
async fn connected_to_upstream(
...
) -> pingora::Result<()>
where
Self::CTX: Send + Sync,
{
// 記錄upstream數據
ctx.reused = reused;
ctx.upstream_address = peer.address().to_string();
Ok(())
}
fn upstream_response_filter(
...
) {
// 省略部分代碼...
// 設置響應頭
if let Some(index) = ctx.location_index {
if let Some(lo) = self.locations.get(index) {
lo.insert_headers(upstream_response)
}
}
}
async fn fail_to_proxy(
...
) -> u16
where
Self::CTX: Send + Sync,
{
// 省略部分代碼...
let mut resp = match code {
502 => error_resp::HTTP_502_RESPONSE.clone(),
400 => error_resp::HTTP_400_RESPONSE.clone(),
_ => error_resp::gen_error_response(code),
};
let content = self
.error_template
.replace("{{version}}", utils::get_pkg_version())
.replace("{{content}}", &e.to_string());
let buf = Bytes::from(content);
ctx.response_body_size = buf.len();
let _ = resp.insert_header(http::header::CONTENT_TYPE, "text/html; charset=utf-8");
// 需要注意error_resp設置了Content-Length爲0,因此需要重新設置
// 否則無法設置響應數據
let _ = resp.insert_header(http::header::CONTENT_LENGTH, buf.len().to_string());
code
}
async fn logging(
...
)
where
Self::CTX: Send + Sync,
{
self.processing.fetch_add(-1, Ordering::Relaxed);
// 輸出請求日誌
if let Some(p) = &self.log_parser {
ctx.response_size = session.body_bytes_sent();
info!("{}", p.format(session.req_header(), ctx));
}
}
}
其它
pingap
還有配置讀取、訪問日誌格式化以及web的管理後臺配置部分,這幾部分較爲簡單可以直接查看源代碼的實現。