上帝和 Istio 打架時,程式設計師如何自我救贖? —— 記一次 Envoy Filter 修正任性HTTP Header

MarkZhu發表於2022-05-30

image.png

故事發生在公元 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)]:

image.png

解釋出錯發生在 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=0http-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。列一下手頭上的重要參考:

WASM Network Filter 設計

堅持一慣風格,少說話,多上圖:

image.png

圖:WASM Network Filter 設計圖

沒太多可說的,下面介紹一下實現。

WASM Network Filter 實現

由於各種原因,不打算 copy 所有程式碼上來,以下只是用為本文特別改寫的虛擬碼來說明。

由於使用到 https://github.com/nodejs/htt... 的原始碼,其實就是兩個檔案: http_parser.hhttp_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 的實現方式註定對服務延時有影響的。可見我之前的一個分析:

記一次 Istio 衝刺調優:

image.png

圖:Flame Graph(火焰圖)中的 WASM

這是一個最好的年代,架構師們有各種開源元件,只需要簡單粘合,就可以實現需求。

這是一個最壞的年代,開箱即用寵壞了架構師們,利用別人的東西我們飛得很高也很自信,認為自己掌握了魔法。但一個不幸踩到坑掉下時,也因為對現實的無知而重重的受傷。

我的 yysd —— Brendan Gregg 曾經說過:

You never know a company (or person) until you see them on their worst day

你永遠不會認清一家公司(或個人),直到你在他們最糟糕的一天看到他們。

真正考驗一個程式設計師或架構師的時候,不是去為一個新專案繪畫巨集偉藍圖(PPT)的時候,更不是他懂得多少新概念,新技術。而是在現有架構出現問題時,在沒有前人經驗的情況下,如何在各種技術、非技術條件受限的情況下,去探索一條解決之道,並且為解決問題而引起的新問題作好準備。

image.png

相關文章