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

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

引 故事发生在公元 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)]:
上帝和 Istio 打架时,程序员如何自我救赎( —— 记一次 Envoy Filter 修正任性HTTP Header)
文章图片

解释出错发生在 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。列一下手头上的重要参考:
  • Proxy WASM 接口规范 API 说明
    • https://github.com/proxy-wasm...
  • Envoy 实现 WASM 的说明
    • 【上帝和 Istio 打架时,程序员如何自我救赎( —— 记一次 Envoy Filter 修正任性HTTP Header)】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)
    • https://github.com/layer5io/w...
WASM Network Filter 设计 坚持一惯风格,少说话,多上图:
上帝和 Istio 打架时,程序员如何自我救赎( —— 记一次 Envoy Filter 修正任性HTTP Header)
文章图片

图: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 = https://www.it610.com/article/this; //注册 HTTP Parser 的回调事件 settings_ = { //on_message_begin: [](http_parser *parser) -> int { MainContext *hpContext = static_cast(parser->data); return hpContext->on_message_begin(); }, //on_header_field [](http_parser *parser, const char *at, size_t length) -> int { MainContext *hpContext = static_cast(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(parser->data); return hpContext->on_header_value(at, length); }, //on_headers_complete [](http_parser *parser) -> int { MainContext *hpContext = static_cast(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 冲刺调优:
上帝和 Istio 打架时,程序员如何自我救赎( —— 记一次 Envoy Filter 修正任性HTTP Header)
文章图片

图:Flame Graph(火焰图)中的 WASM
悟 这是一个最好的年代,架构师们有各种开源组件,只需要简单粘合,就可以实现需求。
这是一个最坏的年代,开箱即用宠坏了架构师们,利用别人的东西我们飞得很高也很自信,认为自己掌握了魔法。但一个不幸踩到坑掉下时,也因为对现实的无知而重重的受伤。
我的 yysd —— Brendan Gregg 曾经说过:
You never know a company (or person) until you see them on their worst day
你永远不会认清一家公司(或个人),直到你在他们最糟糕的一天看到他们。
真正考验一个程序员或架构师的时候,不是去为一个新项目绘画宏伟蓝图(PPT)的时候,更不是他懂得多少新概念,新技术。而是在现有架构出现问题时,在没有前人经验的情况下,如何在各种技术、非技术条件受限的情况下,去探索一条解决之道,并且为解决问题而引起的新问题作好准备。
上帝和 Istio 打架时,程序员如何自我救赎( —— 记一次 Envoy Filter 修正任性HTTP Header)
文章图片

    推荐阅读