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 中,每個請求只能由單個 worker 處理。這會導致所有 CPU 內核之間的負載不平衡 [2],從而導致速度變慢 [3]。
-
對 CF 的應用場景來說,最關鍵的問題是糟糕的連接重用。NGINX 連接池 [4] 與單個 worker 相對應。當請求到達某個 worker 時,它只能重用該 worker 內的連接。當 CF 添加更多 NGINX worker 以進行擴展時,連接重用率會變得更差,因爲連接分散在所有進程的更多孤立的池中。這導致更慢的 TTFB 以及需要維護更多連接,進而消耗 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 的預計包括以下重要特性:
-
採用異步多線程模型。連接重用的效果要優於 Nginx 。
-
基於 WebAssembly 支持腳本功能。意味着,可以支持任何能夠編譯爲 WASM 的語言來寫腳本。
-
簡單的配置。吸取過去幾十年配置其他軟件的所有教訓。
-
用 Rust 實現。避免內存安全問題。
該項目計劃本年度第二個季度啓動,感興趣的可以去圍觀或參與。
Pingora 介紹
CloudFlare Pingora[13] 現已開源,代碼量大約是 3.8 萬行 Rust 代碼。
Pingora 是一個用於構建快速、可靠和可編程網絡系統的 Rust 框架。它經過了 “戰鬥” 的考驗,因爲它已經連續幾年每秒處理超過 4000 萬 個互聯網請求。
特色亮點:
-
異步 Rust:快速可靠
-
HTTP 1/2 端到端代理
-
TLS 支持( OpenSSL 或 BoringSSL,這些庫具備 FIPS 合規性和 post-quantum[14] 加密 )
-
gRPC 和 WebSocket 代理
-
零停機優雅重啓,在升級自身時不會丟失任何一個傳入的請求
-
可定製的負載均衡和故障轉移策略
-
支持各種可觀測性工具。Syslog、Prometheus、Sentry、OpenTelemetry 和其他必備的可觀測性工具也可以輕鬆地與 Pingora 集成
-
過濾器和回調函數,以允許用戶完全自定義服務應該如何處理、轉換和轉發請求 (這些 API 對於 OpenResty 和 NGINX 用戶來說尤其熟悉)
Pingora 的一些重要組件:
-
Pingora
:用於構建網絡系統和代理的 “公共 API”。Pingora 代理框架提供的 API 極具可編程性。方便用戶構建定製化和高級網關或負載均衡器。 -
Pingora-core
: 這個創建定義協議、功能和基本 trait。 -
Pingora-proxy
:構建 HTTP 代理的邏輯 和 API。 -
Pingora-error
: 在 Pingora 創建的各個 crate 中常見的錯誤類型 -
Pingora-HTTP
: HTTP 頭定義和 API -
Pingora-openssl
和pingora-boringssl
:與 SSL 相關的擴展和 API -
Pingora-ketama
: Ketama[15] 一致性算法 -
Pingora-limits
: 高效計數算法 -
Pingora-load-balancing
:Pingora 代理的負載均衡算法擴展 -
Pingora-memory-cache
:帶有緩存鎖的異步內存緩存,以防止緩存失效 -
Pingora-timeout
:一個更高效的異步定時器系統 -
TinyUfo
:pingora-memory-cache
背後的緩存算法
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 是一種一致性哈希算法的實現。具體來說,該算法工作機制如圖展示:
-
如圖,假如有服務器 IP 和端口 group 列表節點。對每個服務器字符串計算哈希得到幾個(100-200 個)無符號整數。這些整數會被存放在一個環形結構上。
-
當請求到來時,選擇離用戶最近的服務器,拿用戶 ID 作爲 hash key,服務器 ip 和端口的 hash 值作爲 value,映射起來。
-
當用戶請求量大,且物理服務器較少時,很可能大量的請求都會落在同一個物理節點。或者,當一個物理服務器故障時,它原本所負責的任務將全部交由順時針方向的下一個物理服務器處理,導致這個物理服務器瞬間壓力增大。所以引入了虛擬節點的概念。一個物理節點將會映射多個虛擬節點,這樣 Hash 環上的空間分割就會變得均勻。
-
每臺服務器對應的虛擬節點的數量(權重)取決於服務器的處理性能。例如,如果某臺服務器的處理性能是其他服務器的兩倍時,它可以分配其他服務器兩倍的虛擬節點。
-
物理服務器出現故障時,會導致和故障服務器相關虛擬服務器節點消失,留存的物理服務器將被重新分配虛擬節點。新增物理服務器也是類似的過程。
虛擬節點(Virtual Nodes) 是一致性哈希算法中的一個關鍵概念,主要用來提高分佈式系統中的負載均衡性和系統的彈性。
“
虛擬節點的抽象和操作系統虛擬內存空間抽象很相似。
虛擬節點的作用主要是平衡請求壓力:
-
增強負載均衡:通過增加虛擬節點的數量,可以使得物理節點在哈希環上的分佈更加均勻。即使物理節點的數量較少,通過合理設置虛擬節點,也能有效地分散請求或數據項,避免某個節點過載。
-
提高系統彈性:當物理節點加入或離開系統時,只有與這個物理節點相關的虛擬節點會受到影響,這意味着只有一小部分的請求需要被重新分配到其他節點。這減少了因節點變動導致的系統震盪,使得系統更加穩定。
-
簡化節點管理:虛擬節點簡化了節點的管理和擴展。例如,如果一個物理節點的處理能力比其他節點強,可以給它分配更多的虛擬節點,而無需改變整個系統的架構或重新平衡所有請求。
虛擬節點本身不處理請求。它們只是哈希環上的標記,用於將請求映射到實際的物理節點。每個虛擬節點都與一個物理節點關聯,真正處理請求的是這些物理節點。虛擬節點的作用主要是作爲負載均衡和系統彈性策略的一部分,而不是直接參與請求處理。
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
)。權重越高的節點,在哈希環上生成的點就越多。
這個點實際上就是前面說過的虛擬節點,具體來說:
-
對於每個物理節點,根據其權重和一個固定的倍數(
POINT_MULTIPLE
),確定需要在哈希環上生成的點的數量。在代碼中,POINT_MULTIPLE
被設定爲 160,這意味着每個權重單位會在哈希環上生成 160 個點。這個 160 是從 Nginx 那複製過來的。 -
對於每個點,通過對節點地址和一個遞增的哈希值(模擬不同的虛擬節點)進行哈希計算,生成多個不同的哈希值。每個哈希值對應哈希環上的一個點,這些點分散在整個哈希環上。
當需要定位一個鍵應該由哪個節點處理時,首先計算該鍵的哈希值,然後在哈希環上找到最近的一個點,該點所代表的物理節點就是目標節點。因爲每個物理節點都通過多個點(虛擬節點)在哈希環上有了廣泛的表示,這就實現了負載的均衡分配,同時也提高了系統的彈性。
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 哈希環實現了以下三個方法:
-
初始化 (
Continuum::new
): 根據傳入的Bucket
數組構建一致性哈希環。算法會根據每個節點的權重在環上生成相應數量的點,每個點都會通過 CRC32 哈希算法得到一個哈希值,這些點按哈希值排序後存儲在ring
數組中。 -
節點查找 (
Continuum::node
): 給定一個鍵,此方法會計算其哈希值,然後在哈希環上找到對應的節點。這是通過在ring
數組中進行二分查找實現的。找到的點對應的節點就是此鍵應該映射到的節點。 -
故障轉移 (
Continuum::node_iter
): 如果找到的節點不可用,可以使用這個方法來獲取一個迭代器,它會按順序遍歷哈希環上的其他節點,從而找到一個可用的故障轉移節點。 -
地址獲取(
Continuum::get_addr
):根據節點索引獲取真實 Socket 地址。
測試示例
貼一個兼容 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