头图

上帝和 Istio 打架时,程序员如何自我救赎? —— 记一次 Envoy Filter 修正任性HTTP Header

MarkZhu
English

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

阅读 573

graphql
about graphql

Blog: [链接]

49 声望
10 粉丝
0 条评论

Blog: [链接]

49 声望
10 粉丝
宣传栏