Cloudflare 開源最強網絡框架 Pingora

Pingora  /pɪŋˈgɔːrə/ ,是位於美國懷俄明州的一座山峯。

我們今天暫時不卷「 Rust 與 LLM」主題了,正好昨天 Cloudflare 開源了 Nginx 的平替 Pingora ,我們來講講 Pingora。 這在業界算是大事件,很多人可能不看好 Pingora ,那麼就先閱讀一下本文先了解下 Pingora 再做評判。

Why Pingora

早在 2022 年 Cloudflare (下面簡稱 CF)就發過一篇文章: 將 Cloudflare 連接到互聯網的代理——Pingora 的構建方式 [1] 。

在該文章中披露,CF 已經在內部生產使用一個 Rust 實現的名爲 Pingora 的 HTTP 代理,每天處理請求超過一萬億個,並且只佔用以前代理基礎架構的三分之一的 CPU 和內存資源。 這個代理服務用語 CF 的 CDN、Workers fetch、Tunnel、Stream、R2 以及許多其他功能和產品。

CF 之前的 HTTP 代理是基於 Nginx 構建的。

Nginx 之前一直運行良好。但是隨着 CF 的業務規模逐漸擴大,Nginx 的瓶頸也凸顯,無法再滿足 CF 的性能和複雜環境下所需功能的需求。

CF 團隊對 Nginx 也做了很多優化。然而,Nginx 的 Worker 進程架構是 CF 性能瓶頸的根源

除了架構問題,使用 Nginx 還面臨有些類型的功能難以添加的問題。

例如,當重試請求 / 請求失敗 [5] 時,有時希望將請求發送到具有不同請求標頭集的不同源服務器。但 NGINX 並不允許執行此操作。在這種情況下,CF 需要花費時間和精力來解決 NGINX 的限制。

另外,NGINX 完全由 C 語言編寫的這在設計上不是內存安全的。使用這樣的第 3 方代碼庫非常容易出錯。即使對於經驗豐富的工程師來說,也很容易陷入內存安全問題 [6], CF 希望儘可能避免這些問題。

CF 用來補充 C 語言的另一種語言是 lua。它的風險較小,但性能也較差。此外,在處理複雜的 Lua 代碼和業務邏輯時,CF 經常發現自己缺少靜態類型 [7]。

而且 NGINX 社區也不是很活躍,開發往往是 “閉門造車”[8]。

經過團隊綜合評估,包括評估使用像 envoy 這種第三方代理庫,最終決定自建代理。於是就有了 Pingora。

經過兩年的內部使用,到 2024 年的今天,我們纔看到開源的 Pingora 。畢竟要注重安全性,無論是內存安全還是信息安全,經過實際檢驗審查後開源對 CF 來說更穩妥。

Cloudbleed 安全漏洞回顧

在 2017 年 2 月 的某個週五,Cloudflare 團隊接到了 Google Project Zero 團隊成員 Tavis Ormandy 的安全報告,他發現通過 Cloudflare 運行的一些 HTTP 請求返回了損壞的網頁。

事實證明,在一些異常情況下,CF 的邊緣服務器有內存泄露,並返回了包含隱私信息的內存,例如 HTTP cookies、身份驗證令牌、HTTP POST 主體和其他敏感數據。

CF 在發現問題後的 44 分鐘內停止了這個漏洞,並在 7 小時內完全修復了這個問題。然而,更糟的是,一些保存的安全信息被像 Google、Bing 和 Yahoo 這樣的搜索引擎緩存了下來

這就是知名的 Cloudbleed[9] 安全漏洞。它是 CF 的一個重大安全漏洞,泄露了用戶密碼和其他可能敏感的信息給數千個網站,持續了六個月

《The Register》將其描述爲「坐在一家餐廳裏,本來是在一個乾淨的桌子上,除了給你遞上菜單,還給你遞上了上一位用餐者的錢包或錢包裏的東西」。Cloudbleed 這個名字是 Tavis Ormandy 命名的,一種玩笑式地紀念 2014 年的安全漏洞 Heartbleed。然而,Heartbleed 影響了 50 萬個網站。

那麼這個安全漏洞的根源在哪裏呢

因爲 Cloudflare 的許多服務依賴於在其邊緣服務器上解析和修改 HTML 頁面,所以使用了一個 Ragel[10](一個狀態機編譯器) 編寫的解析器,後來又自己實現了一個新的解析器 cf-html。這兩個解析器共同被 CF 作爲 Nginx 模塊直接編譯到了 Nginx 中。

舊的 Ragel 實現的解析器實際上包含了一個隱藏了多年的內存泄露 Bug,但是由於 CF 團隊使用這個舊解析器的方式(正好避免了內存泄露)沒有把這個 Bug 暴露出來。引入新解析器 cf-html 之後,改變了舊解析器的使用方式,從而導致內存泄露發生了。

其實內存泄露本身不算內存安全(Safety)問題。但是因爲 CF 泄露的內存(未正常回收)中包含了敏感數據,那就造成了信息泄露,屬於信息安全問題(Security)。

那麼這個內存泄露的根源又在哪裏呢?

Ragel 代碼會生成 C 代碼,然後進行編譯。C 代碼使用指針來解析 HTML 文檔,並且 Ragel 本身允許用戶對這些指針的移動有很多控制。問題根源正是由於指針錯誤引起的。

/* generated code */
if ( ++p == pe )
    goto _test_eof;

錯誤的根本原因是使用等號運算符來檢查緩衝區的末尾,並且指針能夠越過緩衝區的末尾。這被稱爲緩衝區溢出

如果使用>=進行檢查而不是 ==,就能夠捕捉到越過緩衝區末尾的情況。等號檢查是由 Ragel 自動生成的,不是 CF 編寫的代碼的一部分。這表明 CF 沒有正確使用 Ragel

CF 編寫的 Ragel 代碼中存在一個錯誤,導致指針跳過了緩衝區的末尾,並超出了== 檢查的能力,未發現緩衝區溢出。

這段包含緩衝區溢出的代碼,在 CF 的生產環境運行了很多年,從未出過問題。但是當新解析器被增加的時候,代碼架構和環境發生了變化,潛藏多年的緩衝區溢出 Bug 終於得到了 “甦醒的機會”

總的來說,這次內存泄露導致的信息安全問題,本質還是因爲內存安全引發的。

這次嚴重的安全問題對於 Cloudflare 來說,幾乎是致命的。

因爲 Cloudflare 的使命是:“我們保護整個企業網絡,幫助客戶高效構建互聯網規模的應用程序,加速任何網站或互聯網應用程序,抵禦分佈式拒絕服務攻擊,防止黑客入侵,並可以幫助您在零信任的道路上前進”。

一家偉大的技術服務公司,如果因爲小小的緩衝區溢出而倒下,是多麼地可惜呢

River: 基於 Pingora 的反向代理

目前 Pingora 剛開源,還沒有形成開箱即用的生態。

不過不用擔心。

由 ISRG 主導開發 Prossimo 項目(也主導開發了 sudo-rs[11] )宣佈,將與 Cloudflare、Shopify 和 Chainguard 合作,計劃構建一個新的高性能和內存安全的反向代理 river[12],將基於 Cloudflare 的 Pingora 構建。

River 的預計包括以下重要特性

該項目計劃本年度第二個季度啓動,感興趣的可以去圍觀或參與。

Pingora 介紹

CloudFlare Pingora[13] 現已開源,代碼量大約是 3.8 萬行 Rust 代碼。

Pingora 是一個用於構建快速、可靠和可編程網絡系統的 Rust 框架。它經過了 “戰鬥” 的考驗,因爲它已經連續幾年每秒處理超過 4000 萬 個互聯網請求

特色亮點

Pingora 的一些重要組件

15 分鐘定製負載均衡器

官方給出了一個示例:pingora-proxy/examples/load_balancer.rs[16] 。看上去可以非常快速地定製一個負載均衡器。代碼不到 100 行。

use async_trait::async_trait;
use log::info;
use pingora_core::services::background::background_service;
use std::{sync::Arc, time::Duration};
use structopt::StructOpt;

use pingora_core::server::configuration::Opt;
use pingora_core::server::Server;
use pingora_core::upstreams::peer::HttpPeer;
use pingora_core::Result;
use pingora_load_balancing::{health_check, selection::RoundRobin, LoadBalancer};
use pingora_proxy::{ProxyHttp, Session};

// 定義一個 load-balance 對象類型
pub struct LB(Arc<LoadBalancer<RoundRobin>>);


// 任何實現 `ProxyHttp` trait 的對象都是一個 HTTP 代理
#[async_trait]
impl ProxyHttp for LB {
    type CTX = ();
    fn new_ctx(&self) -> Self::CTX {}

    // 唯一需要的方法是 `upstream_peer()` ,它會在每個請求中被調用
    // 應該返回一個 `HttpPeer` ,其中包含要連接的源IP以及如何連接到它
    async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
        // 實現輪詢選擇
        // pingora框架已經提供了常見的選擇算法,如輪詢和哈希
        // 所以這裏只需使用它
        let upstream = self
            .0
            .select(b"", 256) // hash doesn't matter
            .unwrap();

        info!("upstream peer is: {:?}", upstream);
        // 連接到一個 HTTPS 服務器還需要設置 SNI
        // 如果需要,證書、超時和其他連接選項也可以在HttpPeer對象中設置
        let peer = Box::new(HttpPeer::new(upstream, true, "one.one.one.one".to_string()));
        Ok(peer)
    }
    // 該過濾器在連接到源服務器之後、發送任何HTTP請求之前運行
    // 可以在這個過濾器中添加、刪除或更改HTTP請求頭部。
    async fn upstream_request_filter(
        &self,
        _session: &mut Session,
        upstream_request: &mut pingora_http::RequestHeader,
        _ctx: &mut Self::CTX,
    ) -> Result<(){
        upstream_request
            .insert_header("Host""one.one.one.one")
            .unwrap();
        Ok(())
    }
}

// RUST_LOG=INFO cargo run --example load_balancer
fn main() {
    env_logger::init();

    // read command line arguments
    let opt = Opt::from_args();
    let mut my_server = Server::new(Some(opt)).unwrap();
    my_server.bootstrap();

    // 127.0.0.1:343" is just a bad server
    // 硬編碼了源服務器的IP地址
    // 實際工作負載中,當調用 `upstream_peer()` 時或後臺中也可以動態地發現源服務器的IP地址
    let mut upstreams =
        LoadBalancer::try_from_iter(["1.1.1.1:443", "1.0.0.1:443", "127.0.0.1:343"]).unwrap();

    // We add health check in the background so that the bad server is never selected.
    let hc = health_check::TcpHealthCheck::new();
    upstreams.set_health_check(hc);
    upstreams.health_check_frequency = Some(Duration::from_secs(1));

    let background = background_service("health check", upstreams);

    let upstreams = background.task();

    let mut lb = pingora_proxy::http_proxy_service(&my_server.configuration, LB(upstreams));
    lb.add_tcp("0.0.0.0:6188");

    let cert_path = format!("{}/tests/keys/server.crt", env!("CARGO_MANIFEST_DIR"));
    let key_path = format!("{}/tests/keys/key.pem", env!("CARGO_MANIFEST_DIR"));

    let mut tls_settings =
        pingora_core::listeners::TlsSettings::intermediate(&cert_path, &key_path).unwrap();
    tls_settings.enable_h2();
    lb.add_tls_with_settings("0.0.0.0:6189", None, tls_settings);

    my_server.add_service(lb);
    my_server.add_service(background);
    my_server.run_forever();
}

測試服務:

curl 127.0.0.1:6188 -svo /dev/null
< HTTP/1.1 200 OK

下圖展示了在這個示例中請求是如何通過回調和過濾器流動的。Pingora 代理框架目前在請求的不同階段提供了更多的過濾器和回調,允許用戶修改、拒絕、路由和 / 或記錄請求(和響應)。

Pingora 代理框架在底層負責連接池、TLS 握手、讀取、寫入、解析請求和其他常見的代理任務,以便用戶可以專注於對他們重要的邏輯

Pingora 負載均衡算法實現

本文先來閱讀一下 Pingora 負載均衡算法的 Rust 代碼。從上面介紹中看得出來, Pingora 負載均衡算法應該在 Pingora-ketama  crate 中實現,它採用 Ketama[17] 一致性算法。

Pingora-ketama 實際上 Nginx 負載均衡算法的 Rust 移植。從這個狹隘的角度看,Pingora 也算是用 Rust 重寫的 Nginx。

負載均衡算法概要

負載均衡簡單來說就是從 n 個候選服務器中選擇一個進行通信的過程。這個過程講究的就是一個均衡,不能十個服務器,總是把請求落到其中某一個服務器,而其他服務器空閒。

負載均衡常用算法就是一致性哈希算法(Consistent Hashing Algorithm)。一致性哈希負載均衡需要保證的是 “相同的請求儘可能落到同一個服務器上 “。

在 Nginx、Memcached、Key-Value Store、Bittorrent DHT、LVS 、Netflix 視頻分發 CDN、discord 服務器集羣等都採用了一致性哈希算法。

一致性哈希是一種分佈式系統技術,通過在虛擬環結構(哈希環)上爲數據對象和節點分配位置來運作。一致性哈希在節點總數發生變化時最小化需要重新映射的鍵的數量。

Ketama 是一種一致性哈希算法的實現。具體來說,該算法工作機制如圖展示:

虛擬節點(Virtual Nodes) 是一致性哈希算法中的一個關鍵概念,主要用來提高分佈式系統中的負載均衡性和系統的彈性。

虛擬節點的抽象和操作系統虛擬內存空間抽象很相似。

虛擬節點的作用主要是平衡請求壓力

  1. 增強負載均衡:通過增加虛擬節點的數量,可以使得物理節點在哈希環上的分佈更加均勻。即使物理節點的數量較少,通過合理設置虛擬節點,也能有效地分散請求或數據項,避免某個節點過載。

  2. 提高系統彈性:當物理節點加入或離開系統時,只有與這個物理節點相關的虛擬節點會受到影響,這意味着只有一小部分的請求需要被重新分配到其他節點。這減少了因節點變動導致的系統震盪,使得系統更加穩定。

  3. 簡化節點管理:虛擬節點簡化了節點的管理和擴展。例如,如果一個物理節點的處理能力比其他節點強,可以給它分配更多的虛擬節點,而無需改變整個系統的架構或重新平衡所有請求。

虛擬節點本身不處理請求。它們只是哈希環上的標記,用於將請求映射到實際的物理節點。每個虛擬節點都與一個物理節點關聯,真正處理請求的是這些物理節點。虛擬節點的作用主要是作爲負載均衡和系統彈性策略的一部分,而不是直接參與請求處理。

Pingora-ketama 的實現是對 Nginx 一致性哈希算法的 Rust 語言移植,保持了與 Nginx 相同的行爲。

核心代碼解釋

Bucket結構體 表示一致性哈希環上的一個節點(或稱爲 "桶")。每個Bucket包含一個節點的地址 (SocketAddr) 和該節點的權重 (weight)。權重較高的節點在哈希環上會佔有更多的點,因此會接收到更多的請求。

/// A [Bucket] represents a server for consistent hashing
///
/// A [Bucket] contains a [SocketAddr] to the server and a weight associated with it.
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd)]
pub struct Bucket {
    // The node name.
    // TODO: UDS
    node: SocketAddr,

    // The weight associated with a node. A higher weight indicates that this node should
    // receive more requests.
    weight: u32,
}

Point 結構體表示哈希環上的一個點,包含一個指向節點地址數組的索引 (node) 和該點的哈希值 (hash)。這個結構體在內部使用,用於在哈希環上定位節點。

// A point on the continuum.
#[derive(Clone, Debug, Eq, PartialEq)]
struct Point {
    // the index to the actual address
    node: u32,
    hash: u32,
}

每個物理節點(Bucket)根據其權重,會在哈希環上生成多個點(Point)。權重越高的節點,在哈希環上生成的點就越多。

這個點實際上就是前面說過的虛擬節點,具體來說:

當需要定位一個鍵應該由哪個節點處理時,首先計算該鍵的哈希值,然後在哈希環上找到最近的一個點,該點所代表的物理節點就是目標節點。因爲每個物理節點都通過多個點(虛擬節點)在哈希環上有了廣泛的表示,這就實現了負載的均衡分配,同時也提高了系統的彈性。

Continuum 結構體代表了一致性哈希環本身,其中包含了兩個主要的字段:ring(一個Point數組,代表環上的所有點),和addrs(一個SocketAddr數組,存儲了所有節點的地址)。這個結構體提供了主要的功能,比如添加節點、查找給定鍵的節點等。

/// The consistent hashing ring
///
/// A [Continuum] represents a ring of buckets where a node is associated with various points on
/// the ring.
pub struct Continuum {
    ring: Box<[Point]>,
    addrs: Box<[SocketAddr]>,
}

Continuum 哈希環實現了以下三個方法:

測試示例

貼一個兼容 nginx 負載均衡測試用例:

#[test]
    fn matches_nginx_sample() {
        let upstream_hosts = ["127.0.0.1:7777""127.0.0.1:7778"];
        let upstream_hosts = upstream_hosts.iter().map(|i| get_sockaddr(i));

        let mut buckets = Vec::new();
        for upstream in upstream_hosts {
            buckets.push(Bucket::new(upstream, 1));
        }

        let c = Continuum::new(&buckets);

        // 可以看到不同的請求,被分配到了不同節點
        assert_eq!(c.node(b"/some/path"), Some(get_sockaddr("127.0.0.1:7778")));
        assert_eq!(
            c.node(b"/some/longer/path"),
            Some(get_sockaddr("127.0.0.1:7777"))
        );
        assert_eq!(
            c.node(b"/sad/zaidoon"),
            Some(get_sockaddr("127.0.0.1:7778"))
        );
        assert_eq!(c.node(b"/g"), Some(get_sockaddr("127.0.0.1:7777")));
        assert_eq!(
            c.node(b"/pingora/team/is/cool/and/this/is/a/long/uri"),
            Some(get_sockaddr("127.0.0.1:7778"))
        );
        assert_eq!(
            c.node(b"/i/am/not/confident/in/this/code"),
            Some(get_sockaddr("127.0.0.1:7777"))
        );
    }

後記

本文介紹了 Pingora 誕生的背景,以及介紹了 Pingora 框架的特性和基本用法,並且閱讀了 Pingora 負載均衡算法的 Rust 實現。

後面有時間再繼續深入 Pingora 的源碼實現,並且會關注 River 的實現進展。

感謝閱讀。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/q6S5qP10VOmqb147PFaZJQ