如何使用pingora構建自己反向代理

如何使用pingora構建自己反向代理

Cloudflare開源了其基於rust構建的反向代理框架pingora,已經在Cloudflare的雲服務中實際使用,每秒支撐起超過4000萬個互聯網請求(需要注意,此處並不是性能指標)。pingap使用pingora提供的各種模塊,基於toml的配置方式,提供更便捷的方式配置反向代理。主要特性如下:

  • 支持通過hostpath選擇對應的location
  • HTTP 1/2兩種方式的支持
  • 無請求中斷式的優雅更新
  • 模板式的請求日誌配置
  • 基於TOML形式的程序配置

pingap整體的流程簡化爲三步,接受到請求後基於hostpath選擇匹配的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,支持httptcp的形式,若未指定則默認以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的管理後臺配置部分,這幾部分較爲簡單可以直接查看源代碼的實現。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章