引
故事發生在公元 2022 年的夏天。上帝(化名)在上線流量測試中,發現在未引入 Istio 前正常 HTTP 200 的請求,引入 Istio Gateway 後變為 HTTP 400 了。而出現問題的流量均帶有不合 HTTP 規範的 HTTP Header。如冒號前多了個 空格:
GET /headers HTTP/1.1\r\n
Host: httpbin.org\r\n
User-Agent: curl/7.68.0\r\n
Accept: */*\r\n
SpaceSuffixHeader : normalVal\r\n
在向上帝發出修正問題的請求後,“無辜”的程式設計師作好了應對最壞情況的打算,準備嘗試打造一條把控自己命運的諾亞方舟(希伯來語:יבת נח;英語:Noah's Ark)。
計劃 - 兩艘諾亞方舟
人們談論 Istio 時,人們大多數情況其實是在談論 Envoy。而 Envoy 用的 HTTP 1.1 直譯器是已經 2 年沒更新的 c 語言寫的庫 nodejs/http-parser 。最直接的思路是,讓直譯器去相容問題 HTTP Header。好,程式設計師開啟了搜尋引擎。
1號方舟 - 讓直譯器相容
如果說選擇搜尋引擎是個條件問題,那麼搜尋關鍵字的選用才是個技術+經驗的活兒。這裡不細說程式設計師如何搜尋了。總之,結果是被引擎帶到:White spaces in header fields will cause parser failed #297
然後當然是喜憂參半地讀到:
Set the HTTP_PARSER_STRICT=0
solved my issue, thanks.
即需要在 istio-proxy / Envoy / http-parser 編譯期加入上面引數,就可以相容後帶空格的 Header 名。
由於所在的廠還算大廠,有自己的基礎架構部,一般大廠都會定製編譯開源專案,而不是直接使用二進位制 Release。所以程式設計師折騰數天,才定制編譯了公司基礎架構部的這個 istio-proxy,加入了 HTTP_PARSER_STRICT=0
。測試結果也的確解決了相容性的問題。
但這個解決方法有幾個問題:
- 重編譯是個讓基礎架構部不支援後面其它問題解決的理由。容易背鍋和引入比較多未知風險
問題解決有個原本原則,就是控制問題本身的影響和解決方案本身的風險。避免為解決一個 bug 引入 n 個 bug 的情況。
- 如果 Istio Gateway 讓問題 Header 透傳了,那麼後面的各層 sidecar proxy 和應用服務,也要相容和透傳這個問題 Header。風險未知。
2號方舟 - 修正問題 Header
Envoy 自稱是個可程式設計的 Proxy。很多人知道,可以通過為它增加定製開發的 HTTP Filter 來實現各種功能,其中當然包括 HTTP Header 的定製和改寫。
But,請細心想想。如果你細心讀過我之前寫的《逆向工程與雲原生現場分析 Part2 —— eBPF 跟蹤 Istio/Envoy 之啟動、監聽與執行緒負載均衡》 或者是 Envoy 原作者 Matt Klein, Lyft 的 [Envoy Internals Deep Dive - Matt Klein, Lyft (Advanced Skill Level)]:
解釋出錯發生在 HTTP Codec,在 HTTP Filter 之前!所以不能用 HTTP Filter。
為求證這個問題,我 gdb 和斷點了 http-parser 的 http_parser_execute 函式,看 stack。gdb 的方法見 《gdb 除錯 istio proxy (envoy)》
HTTP Filter 不行,那麼 TCP Filter 呢?理論上當然可以,可以在 Byte Buffer 傳到 HTTP Codec 前,用 TCP Filter 去修正問題 Header。當然,不是簡單的覆蓋位元組,可能要刪減位元組……
於是又一個選擇來了,實現 TCP Filter(下文叫Network Filter) 有兩種方式:
Native C++ Filter
- 相對效能好,不需要 copy buffer。但要重新編譯 Envoy。
WASM Filter
- 因沙箱VM,需要 在 VM 和 Native 程式間 copy buffer,引入 cpu/記憶體使用和延遲
上面也說了,不能重新編譯 Envoy,可憐的程式設計師只能選擇 WASM Filter。
如果“無辜”的程式設計師是個純架構師,只要想通了路子,寫個 PPT架構圖就可以收工了,那麼是個 Happy Ending。可惜,“無辜”的程式設計師註定需要為“2號方舟”的建成付出數天的無眠。木板和針子都得親手來……
WASM Network Filter 學步
WASM 語言的選擇
編寫 WASM Filter 有幾種可選語言。時髦的 Rust,不愁找工的 Go,昨日黃花的 C++。無論是出於記憶體自動和安全考慮,還是刷簡歷考慮,最不應該選擇的都是 C++。但,“無辜”的程式設計師選擇了 C++。除了不值一文的情懷,還有一個深度考慮後的原因:
—— 重用 Envoy 相同的、開啟相容模式編譯期配置HTTP_PARSER_STRICT=0
的http-parser
。
要修正有問題的 HTTP Header,首先要在 Byte Buffer 中定位(或者說是解析到)Header。當然可以用更時髦的直譯器。以上幾種語言都有自己的 HTTP 直譯器。但,誰保證這些直譯器的結果和 Envoy 相容?會不會引入新問題?那麼,直接使用 Envoy 同樣的直譯器,是個不錯的選擇。如果直譯器有問題,就算不加這個 Fitler ,Envoy 本身也會有問題。即基本保證不在直譯器上引入新問題。
小眾的 WASM Network Filter
最幸運的程式總可以在搜尋引擎/Stackoverflow/Github上找到一個 copy/paste 的模板程式碼或神 Issue workaround 而輕鬆完成績效。而“倒黴”的程式設計師往往是去解決那些沒有標準答案的難題(雖然筆者喜歡後者),最後折騰自己且不一定有績效。
顯然,網上可以找到一堆 WASM HTTP Filter 的資料和參考實現,但 WASM Network Filter 極少,有也是讀一下 Buffer Bytes,做做簡單統計的功能。沒有一個是在 L3/4 層上修改位元組流的,更別提要解釋位元組流上的 HTTP 了。
Proxy WASM C++ SDK
開源開啟的不單單是程式碼,更應該是人們求真相的機會。“倒黴”的程式設計師記得 2002 年學習 Visual C++ MFC 時,只能看到 MSDN 上的文件,而不明其所以的痛苦。
小眾的 WASM Network Filter 再小眾,也是 Open Source 的。不單單 SDK Open Source,介面的定義 ABI Spec 也是 Open Source。列一下手頭上的重要參考:
Proxy WASM 介面規範 API 說明
Envoy 實現 WASM 的說明
https://github.com/proxy-wasm...
Proxy WASM 是個 Proxy 下使用 WASM擴充套件的規範。即除了 Envoy ,還有其它幾個 Proxy 也支援的。
C++ SDK 實現和簡單的使用文件
https://github.com/proxy-wasm...
包括如何編譯自己的 C++ WASM Filter 實現
網上僅有的 WASM Network Fitler 例子(Rust)
WASM Network Filter 設計
堅持一慣風格,少說話,多上圖:
圖:WASM Network Filter 設計圖
沒太多可說的,下面介紹一下實現。
WASM Network Filter 實現
由於各種原因,不打算 copy 所有程式碼上來,以下只是用為本文特別改寫的虛擬碼來說明。
由於使用到 https://github.com/nodejs/htt... 的原始碼,其實就是兩個檔案: http_parser.h
與 http_parser.c
。先下載並儲存到新專案目錄。假設叫 $REPAIRER_FILTER_HOME
。這個 http-parser 直譯器最大的好處是無依賴和實現簡單。
現在開始編寫核心程式碼,我假設叫:$repairer_fitler.cc
#include ...
#include "proxy_wasm_intrinsics.h"
#include "http_parser.h" //from https://github.com/nodejs/http-parser
/**
在每個 Filter 配置對應一個物件例項
**/
class ExampleRootContext : public RootContext
{
public:
explicit ExampleRootContext(uint32_t id, std::string_view root_id) : RootContext(id, root_id) {}
//Fitler 啟動事件
bool onStart(size_t) override
{
LOG_DEBUG("ready to process streams");
return true;
}
};
然後是核心類:
/**
在每個 downstream 連線對應一個物件例項
**/
class MainContext : public Context
{
public:
http_parser_settings settings_;
http_parser parser_;
...
//建構函式,在每個新 downstream 連線可用時呼叫。如 TLS 握手後,或 Plain text 時的 TCP 連線後。注意, HTTP 1.1 是支援長連線的,即這個 object 需要支援多個 Request。
explicit MainContext(uint32_t id, RootContext *root) : Context(id, root)
{
logInfo(std::string("new MainContext"));
// http_parser_settings_init(&settings_);
http_parser_init(&parser_, HTTP_REQUEST);
parser_.data = this;
//註冊 HTTP Parser 的回撥事件
settings_ = {
//on_message_begin:
[](http_parser *parser) -> int
{
MainContext *hpContext = static_cast<MainContext *>(parser->data);
return hpContext->on_message_begin();
},
//on_header_field
[](http_parser *parser, const char *at, size_t length) -> int
{
MainContext *hpContext = static_cast<MainContext *>(parser->data);
return hpContext->on_header_field(at, length);
},
//on_header_value
[](http_parser *parser, const char *at, size_t length) -> int
{
MainContext *hpContext = static_cast<MainContext *>(parser->data);
return hpContext->on_header_value(at, length);
},
//on_headers_complete
[](http_parser *parser) -> int
{
MainContext *hpContext = static_cast<MainContext *>(parser->data);
return hpContext->on_headers_complete();
},
...
}
}
//收到新 Buffer 事件,注意,一個 HTTP 請求由於網路原因,可以打散為多個 Buffer,回撥多次。
FilterStatus onDownstreamData(size_t length, bool end_of_stream) override
{
logInfo(std::string("onDownstreamData START"));
...
WasmDataPtr wasmDataPtr = getBufferBytes(WasmBufferType::NetworkDownstreamData, 0, length);
{
std::ostringstream out;
out << "onDownstreamData length:" << length << ",end_of_stream:" << end_of_stream;
logInfo(out.str());
logInfo(std::string("onDownstreamData Buf:\n") + wasmDataPtr->toString());
}
//這裡會執行各種 HTTP 解釋,呼叫相關的 HTTP 解釋回撥函式。我們實現了這些函式,記錄下問題 Header 的位置。並修正。
size_t parsedBytes = http_parser_execute(&parser_, &settings_, wasmDataPtr->data(), length); // callbacks
...
// because Envoy drain `length` size of buf require start=0 :
// see proxy-wasm-cpp-sdk proxy_wasm_api.h setBuffer()
// see proxy-wasm-cpp-host src/exports.cc set_buffer_bytes()
// see Envoy source/extensions/common/wasm/context.cc Buffer::copyFrom()
size_t start = 0;
// WasmResult setBuffer(WasmBufferType type, size_t start, size_t length, std::string_view data,
// size_t *new_size = nullptr)
// Ref. https://github.com/proxy-wasm/spec/tree/master/abi-versions/vNEXT#proxy_set_buffer
// Set content of the buffer buffer_type to the bytes (buffer_data, buffer_size), replacing size bytes, starting at offset in the existing buffer.
// setBuffer(WasmBufferType::NetworkDownstreamData, start, length, data);
setBuffer(WasmBufferType::NetworkDownstreamData, start, length, outputBuffer);
}
/**
* on HTTP Stream(Connection) closed
*/
void onDone() override { logInfo("onDone " + std::to_string(id())); }
最後註冊:
static RegisterContextFactory register_ExampleContext(CONTEXT_FACTORY(MainContext),
ROOT_FACTORY(ExampleRootContext),
"my_root_id");
由於解釋 Buffer ,HTTP Request/Header 跨 Buffer 等情況均需要考慮。還需要支援 HTTP 1.1 keepalive 長連線。加上上次做 C++ 專案已經是 17 年前的事了,這個程式設計師花了一週(加班)的時間才實現了一個可以工作的原型。並且,未優化和對效能影響的測試。Sandbox VM 的實現方式註定對服務延時有影響的。可見我之前的一個分析:
圖:Flame Graph(火焰圖)中的 WASM
悟
這是一個最好的年代,架構師們有各種開源元件,只需要簡單粘合,就可以實現需求。
這是一個最壞的年代,開箱即用寵壞了架構師們,利用別人的東西我們飛得很高也很自信,認為自己掌握了魔法。但一個不幸踩到坑掉下時,也因為對現實的無知而重重的受傷。
我的 yysd —— Brendan Gregg 曾經說過:
You never know a company (or person) until you see them on their worst day
你永遠不會認清一家公司(或個人),直到你在他們最糟糕的一天看到他們。
真正考驗一個程式設計師或架構師的時候,不是去為一個新專案繪畫巨集偉藍圖(PPT)的時候,更不是他懂得多少新概念,新技術。而是在現有架構出現問題時,在沒有前人經驗的情況下,如何在各種技術、非技術條件受限的情況下,去探索一條解決之道,並且為解決問題而引起的新問題作好準備。