Please enable Javascript to view the contents

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

 ·  ☕ 7 分钟

logo

故事发生在公元 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-20220306085636137

解释出错发生在 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 设计

坚持一惯风格,少说话,多上图:

WASM Network Filter 设计图

图:WASM Network Filter 设计图

没太多可说的,下面介绍一下实现。

WASM Network Filter 实现

由于各种原因,不打算 copy 所有代码上来,以下只是用为本文特别改写的伪代码来说明。

由于使用到 https://github.com/nodejs/http-parser 的源码,其实就是两个文件: http_parser.hhttp_parser.c 。先下载并保存到新项目目录。假设叫 $REPAIRER_FILTER_HOME 。这个 http-parser 解释器最大的好处是无依赖和实现简单。

现在开始编写核心代码,我假设叫:$repairer_fitler.cc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#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;
  }
};

然后是核心类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
在每个 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())); }

最后注册:

1
2
3
static RegisterContextFactory register_ExampleContext(CONTEXT_FACTORY(MainContext),
                                                      ROOT_FACTORY(ExampleRootContext),
                                                      "myparser_id");

由于解释 Buffer ,HTTP Request/Header 跨 Buffer 等情况均需要考虑。还需要支持 HTTP 1.1 keepalive 长连接。加上上次做 C++ 项目已经是 17 年前的事了,这个程序员花了一周(加班)的时间才实现了一个可以工作的原型。并且,未优化和对性能影响的测试。Sandbox VM 的实现方式注定对服务延时有影响的。可见我之前的一个分析:

记一次 Istio 冲刺调优:

Flame Graph(火焰图)中的 WASM

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

WASM Network Filter 使用

其实就是在 Envoy 的 FilterChain 中加入 这个 Network Filter 了。当然,我这里讲的是 Istio。所以用 Istio 的 EnvoyFilter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
kubectl apply -f - <<"EOF"

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: myparser-envoy-wasm-net-filter
spec:
  workloadSelector:
    labels:
      istio: ingressgateway
      app: istio-ingressgateway
  configPatches:
  - applyTo: NETWORK_FILTER
    match:
      context: GATEWAY
      listener:
        portNumber: 8443
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.filters.network.wasm
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.wasm.v3.Wasm
          config:
            name: "myparser"
            root_id: "myparser_id"
            vm_config:
              runtime: "envoy.wasm.runtime.v8"
              code:
                local:
                  filename: "/etc/istio/myparser-envoy-wasm-net-filter/myparser.wasm"
              allow_precompiled: true 

EOF      

这是一个最好的年代,架构师们有各种开源组件,只需要简单粘合,就可以实现需求。

这是一个最坏的年代,开箱即用宠坏了架构师们,利用别人的东西我们飞得很高也很自信,认为自己掌握了魔法。但一个不幸踩到坑掉下时,也因为对现实的无知而重重的受伤。

我的 yysd —— Brendan Gregg 曾经说过:

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

你永远不会认清一家公司(或个人),直到你在他们最糟糕的一天看到他们。

真正考验一个程序员或架构师的时候,不是去为一个新项目绘画宏伟蓝图(PPT)的时候,更不是他懂得多少新概念,新技术。而是在现有架构出现问题时,在没有前人经验的情况下,如何在各种技术、非技术条件受限的情况下,去探索一条解决之道,并且为解决问题而引起的新问题作好准备。

ending

分享

Mark Zhu
作者
Mark Zhu
An old developer