<div> 

簡介

Istio 1.5 迴歸單體架構,並拋卻原有的 out-of-process 的數據面(Envoy)擴展方式,轉而擁抱基於 WASM 的 in-proxy 擴展,以期獲得更好的性能。本文基於網易杭州研究院輕舟雲原生團隊的調研與探索,介紹 WASM 的社區發展與實踐。 超簡單版解釋: > –> Envoy 內置 Google V8 引擎,支持WASM字節碼運行,並開放相關接口用於和 WASM 虛擬機交互數據; > –> 使用各種語言開發相關擴展並編譯爲 .WASM 文件; > –> 將擴展文件掛載或者打包進入 Envoy 容器鏡像,通過xDS動態下發文件路徑及相關配置由虛擬機執行。

WebAssembly 簡述

Istio 最新發布的 1.5 版本,架構發生了巨大調整,從原有的分佈式結構迴歸爲單體,同時拋卻了原有的 out-of-process 的 Envoy 擴展方式,轉而擁抱基於 WASM 的 in-proxy 擴展,以期獲得更好的性能,同時減小部署和使用的複雜性。所有的 WASM 插件都在 Envoy 的沙箱中運行,相比於原生 C++ Envoy 插件,WASM 插件具有以下的優點:
  • 接近原生插件性能(存疑,待驗證,社區未給出可信測試結果,但是 WASM 字節碼和機器碼比較接近,它的性能極限確實值得期待);
  • 沙箱運行,更安全,單個 filter 故障不會影響到 Envoy 主體執行,且 filter 通過特定接口和 Envoy 交互數據,Envoy 可以對暴露的數據進行限制(沙箱安全性對於 Envoy 整體穩定性保障具有很重要的意義);
  • 可動態分發和載入運行(單個插件可以編譯爲 .WASM 文件進行分發共享,動態掛載,動態載入,且沒有平臺限制);
  • 無開發語言限制,開發效率更高(WASM 本身支持語言衆多,但是限定到 Envoy 插件開發,必然依賴一些封裝好的 SDK 用於和 Envoy 進行交互,目前只有 C++ 語言本身、Rust 以及 AssemblysScript 有一定的支持)。
WASM 的誕生源自前端,是一種爲了解決日益複雜的前端 web 應用以及有限的 JavaScript 性能而誕生的技術。它本身並不是一種語言,而是一種字節碼標準,一個“編譯目標”。WASM 字節碼和機器碼非常接近,因此可以非常快速的裝載運行。任何一種語言,都可以被編譯成 WASM 字節碼,然後在 WASM 虛擬機中執行(本身是爲 web 設計,必然天然跨平臺,同時爲了沙箱運行保障安全,所以直接編譯成機器碼並不是最佳選擇)。理論上,所有語言,包括 JavaScript、C、C++、Rust、Go、Java 等都可以編譯成 WASM 字節碼並在 WASM 虛擬機中執行。

社區發展及現狀

Envoy & WASM

Envoy 提供了一個特殊的 Http 七層 filter,名爲 wasm,用於載入和執行 WASM 字節碼。該七層 filter 同樣也負責 WASM 虛擬機的創建和管理,使用的是 Google 內部的 v8 引擎(支持 JS 和 WASM)。當前 filter 未進入 Envoy 主幹,而是在單獨的一個 工程 中。該工程會週期性從主幹合併代碼。從機制看,WASM 擴展和 Lua 擴展機制非常相似,只是 Lua 載入的是原始腳本,而 WASM 載入的是編譯後的 WASM 字節碼。Envoy 暴露相關的接口如獲取請求頭、請求體,修改請求頭,請求體,改變插件鏈執行流程等等,用於 WASM 插件和 Envoy 主體進行數據交互。 對於每一個 WASM 擴展插件都可以被編譯爲一個 *.WASM 文件,而 Envoy 七層提供的 wasm Filter 可以通過動態下發相關配置(指定文件路徑)使其載入對應的文件並執行:前提是對應的文件已經在鏡像中或者掛載進入了對應的路徑。當然,WASM Filter 也支持從遠程獲取對應的 *.WASM 文件(和目前網易輕舟 API 網關對 Lua 腳本擴展的支持非常相似)。

Istio & WASM

現有的 Istio 提供了名爲 Mixer 插件模型用於擴展 Envoy 數據面功能,具體來說,在 Envoy 內部,Istio 開發了一個原生 C++ 插件用於收集和獲取運行時請求信息並通過 gRPC 將信息上報給 Mixer,外部 Mixer 則調用各個 Mixer Adapter 用於監控、授權控制、限流等等操作,相關處理結果如有必要再返回給 Envoy 中 C++ 插件用於做相關控制。 Mixer 模型雖然提高了極高的靈活性,且對 Envoy 侵入性極低,但是引入了大量的額外的外部調用和數據交互,帶來了巨大的性能開銷(相關的測試結果很多,按照 istio 社區的數據:移除 Mixer 可以使整體 CPU 消耗減少 50%)。而且 Istio 插件擴展模型和 Envoy 插件模型整體是割裂的,Istio 插件在 out-of-process 中執行,通過 gRPC 進行插件與 Envoy 主體的數據交互,而 Envoy 原生插件則是 in-proxy 模式,在同一個進程中通過虛函數接口進行調用和執行。 因此在 Istio 1.5 中,Istio 提供了全新的插件擴展模型:WASM in proxy。使用 Envoy 支持的WASM機制來擴展插件:兼顧性能、多語言支持、動態下發動態載入、以及安全性。唯一的缺點就是現有的支持還不夠完善。 爲了提升性能,Istio 社區在 1.5 發佈中,已經將幾個擴展使用 in-proxy 模型(基於 WASM API 而非原生 Envoy C++ HTTP 插件 API)進行實現。但是目前考慮到 WASM 還不夠穩定,所以相關擴展默認不會執行在 WSAM 沙箱之中(在所謂 NullVM 中執行)。雖然 istio 也支持將相關擴展編譯爲 WASM 模塊,並在沙箱中執行,但是不是默認選項。 所謂 Mixer V2 其最終目標就是將現有的 out-of-process 的插件模型最終用基於 WASM 的 in-proxy 擴展模型來替代。但是目前舉例目標仍舊有較長一段路要走,畢竟即使 Istio 社區本身的插件,也未能完全在 WASM 沙箱中落地。但從 Istio 1.5 開始,Istio 社區應該會快速推動 WASM 的發展。

solo.io & WASM

solo.io 推出了 WebAssembly Hub,用於構建、發佈以及共享 Envoy WASM 擴展。WebAssembly Hub 包括一套用於簡化擴展開發的 SDK(目前 solo.io 提供了AssemblysScript SDK,而 Istio/Envoy 社區提供了 Rust/C++ SDK),相關的構建、發佈命令,一個用於共享和複用的擴展倉庫。具體的內容可以參考 solo.io 提供的教程

WASM 實踐

下面簡單實現一個 WASM 擴展作爲演示 DEMO,可以幫助大家對 WASM 有進一步瞭解。此處直接使用了 solo.io 提供的構建工具,避免環境搭建等各個方面的一些冗餘工作。 該擴展名爲 path_rewrite,可以根據路由原始的 path 值匹配,來將請求 path 重寫爲不同值 。 執行以下命令安裝 wasme:
curl -sL https://run.solo.io/wasme/install | sh
export PATH=$HOME/.wasme/bin:$PATH
wasme 是 solo.io 提供的一個命令行工具,一個簡單的類比就是:docker cli 之於容器鏡像,wasme 之於 WASM 擴展。
ping@ping-OptiPlex-3040:~/Desktop/wasm_example$ wasme init ./path_rewrite
Use the arrow keys to navigate: ↓ ↑ → ←
? What language do you wish to use for the filter:
  ▸ cpp
    assemblyscript
執行 wasme 初始化命令,會讓用戶選擇使用何種語言開發 WASM 擴展,目前 wasme 工具僅支持 C++ 和 AssemblyScript,當前仍舊選擇 cpp 進行開發(AssemblyScript 沒有開發經驗,後續有機會可以學習一下)。執行命令之後,會自動創建一個 bazel 工程,目錄結構如下:其中關鍵的幾個文件已經添加了註釋。從目錄結構看,solo.io 沒有在 wasme 中添加任何黑科技,生成的模板非常的乾淨,完整而簡潔。
.
├── bazel
│   └── external
│       ├── BUILD
│       ├── emscripten-toolchain.BUILD
│       └── envoy-wasm-api.BUILD      # 說明如何編譯envoy api依賴
├── BUILD                             # 說明如何編譯插件本身代碼
├── filter.cc                         # 插件具體代碼
├── filter.proto                      # 擴展數據面接口
├── README.md
├── runtime-config.json
├── toolchain
│   ├── BUILD
│   ├── cc_toolchain_config.bzl
│   ├── common.sh
│   ├── emar.sh
│   └── emcc.sh
└── WORKSPACE                         # 工程描述文件包含對envoy api依賴

filter.cc 中已經填充了樣板代碼,包括所有的插件需要實現的接口。開發者只需要按需修改某個接口的具體實現即可(此處列出了整個插件的全部代碼,以供參考。雖然該代碼沒有實現什麼特許功能,但是已經包含了一個 WASM 擴展(C++ 語言版)應當具備的所有結構,無論多麼複雜的插件,都只是在該結構的基礎上填充相關的邏輯代碼而已:

// NOLINT(namespace-envoy)
#include <string>
#include <unordered_map>

#include "google/protobuf/util/json_util.h"
#include "proxy_wasm_intrinsics.h"
#include "filter.pb.h"

class AddHeaderRootContext : public RootContext {
public:
  explicit AddHeaderRootContext(uint32_t id, StringView root_id) : RootContext(id, root_id) {}
  bool onConfigure(size_t /* configuration_size */) override;

  bool onStart(size_t) override;

  std::string header_name_;
  std::string header_value_;
};

class AddHeaderContext : public Context {
public:
  explicit AddHeaderContext(uint32_t id, RootContext* root) : Context(id, root), root_(static_cast<AddHeaderRootContext*>(static_cast<void*>(root))) {}

  void onCreate() override;
  FilterHeadersStatus onRequestHeaders(uint32_t headers) override;
  FilterDataStatus onRequestBody(size_t body_buffer_length, bool end_of_stream) override;
  FilterHeadersStatus onResponseHeaders(uint32_t headers) override;
  void onDone() override;
  void onLog() override;
  void onDelete() override;
private:

  AddHeaderRootContext* root_;
};
static RegisterContextFactory register_AddHeaderContext(CONTEXT_FACTORY(AddHeaderContext),
                                                      ROOT_FACTORY(AddHeaderRootContext),
                                                      "add_header_root_id");

bool AddHeaderRootContext::onConfigure(size_t) { 
  auto conf = getConfiguration();
  Config config;
  
  google::protobuf::util::JsonParseOptions options;
  options.case_insensitive_enum_parsing = true;
  options.ignore_unknown_fields = false;

  google::protobuf::util::JsonStringToMessage(conf->toString(), &config, options);
  LOG_DEBUG("onConfigure name " + config.name());
  LOG_DEBUG("onConfigure " + config.value());
  header_name_ = config.name();
  header_value_ = config.value();
  return true; 
}

bool AddHeaderRootContext::onStart(size_t) { LOG_DEBUG("onStart"); return true;}

void AddHeaderContext::onCreate() { LOG_DEBUG(std::string("onCreate " + std::to_string(id()))); }

FilterHeadersStatus AddHeaderContext::onRequestHeaders(uint32_t) {
  LOG_DEBUG(std::string("onRequestHeaders ") + std::to_string(id()));
  return FilterHeadersStatus::Continue;
}

FilterHeadersStatus AddHeaderContext::onResponseHeaders(uint32_t) {
  LOG_DEBUG(std::string("onResponseHeaders ") + std::to_string(id()));
  addResponseHeader(root_->header_name_, root_->header_value_);
  replaceResponseHeader("location", "envoy-wasm");
  return FilterHeadersStatus::Continue;
}

FilterDataStatus AddHeaderContext::onRequestBody(size_t body_buffer_length, bool end_of_stream) {
  return FilterDataStatus::Continue;
}

void AddHeaderContext::onDone() { LOG_DEBUG(std::string("onDone " + std::to_string(id()))); }

void AddHeaderContext::onLog() { LOG_DEBUG(std::string("onLog " + std::to_string(id()))); }

void AddHeaderContext::onDelete() { LOG_DEBUG(std::string("onDelete " + std::to_string(id()))); }
注意到生成的樣板代碼類型名稱仍舊以 AddHeader 爲前綴,而沒有根據提供的路徑名稱生成,此處是 wasme 可以優化的一個地方。此外, 自動生成的樣板代碼中已經包含了 AddHeader 的一些代碼,邏輯簡單,但是配置解析、API 訪問,請求頭修改等過程都具備,麻雀雖小,五臟俱全,正好可以幫助初次的開發者可以依葫蘆畫瓢熟悉 WASM 插件的開發過程 。對於入門是非常友好的。 針對 path_rewrite 具體的開發步驟如下: STEP ONE首先修改模板代碼中 filter.proto 文件,因爲 path rewrite 肯定不能簡單的只能替換固定值,修改後 proto 文件如下所示:
syntax = "proto3";

message PathRewriteConfig {
  message Rewrite {
    string regex_match = 1;      # path正則匹配時替換
    string custom_path = 2;      # 待替換值
  }
  repeated Rewrite rewrites = 1;
}
STEP TWO修改配置解析接口,具體方法名爲 onConfigure。修改後解析接口如下:
bool AddHeaderRootContext::onConfigure(size_t) {
  auto conf = getConfiguration();
  PathRewriteConfig config; // message type in filter.proto
  if (!conf.get()) {
    return true;
  }
  google::protobuf::util::JsonParseOptions options;
  options.case_insensitive_enum_parsing = true;
  options.ignore_unknown_fields = false;
  // 解析字符串配置並轉換爲PathRewriteConfig類型:配置反序列化
  google::protobuf::util::JsonStringToMessage(conf->toString(), &config,
                                              options);

  // 配置階段編譯regex避免請求時重複編譯,提高性能
  for (auto &rewrite : config.rewrites()) {
    rewrites_.push_back(
        {std::regex(rewrite.regex_match()), rewrite.custom_path()});
  }

  return true;
}
STEP THREE修改請求頭接口,具體方法名爲 onRequestHeaders,修改後接口代碼如下:
FilterHeadersStatus AddHeaderContext::onRequestHeaders(uint32_t) {
  LOG_DEBUG(std::string("onRequestHeaders ") + std::to_string(id()));
  // Envoy中path同樣存儲在header中,key爲:path
  auto path = getRequestHeader(":path");
  if (!path.get()) {
    return FilterHeadersStatus::Continue;
  }
  std::string path_string = path->toString();
  for (auto &rewrite : root_->rewrites_) {
    if (std::regex_match(path_string, rewrite.first) &&
        !rewrite.second.empty()) {
      replaceRequestHeader(":path", rewrite.second);
      replaceRequestHeader("location", "envoy-wasm");
      return FilterHeadersStatus::Continue;
    }
  }
  return FilterHeadersStatus::Continue;
}
從上述過程不難看出,整個擴展的開發體驗相當簡單,按需實現對應接口即可,擴展本身內容非常輕,內部具體的功能邏輯纔是決定擴展開發複雜性的關鍵。而且藉助 wasme 工具,自動生成代碼後,效率可以更高(和目前在內部使用的 filter_creator.py 有部分相似,樣板代碼自動生成)。 至此,插件已經開發完成,可以打包編譯了。wasm 同樣提供了打包編譯的功能,甚至可以類似於容器鏡像將編譯後結構推送到遠端倉庫之中,用於分享或者存儲。不過有一個提示,在開發之前,先直接執行 bazel 命令編譯,編譯過程中,一些基礎依賴會被自動拉取並緩存到本地,藉助 IDE 可以獲得更好的代碼提示和開發體驗。
bazel build :filter.wasm
接下來是 wasme 命令編譯:
wasme build cpp -t webassemblyhub.io/wbpcode/path_rewrite:v0.1 .
該命令會使用固定鏡像作爲編譯環境,但是本質和直接使用 bazel 編譯並無不同。具體的編譯日誌可以看出,實際上,該命令也是使用的 bazel build :filter.wasm
Status: Downloaded newer image for quay.io/solo-io/ee-builder:0.0.19
Building with bazel...running bazel build :filter.wasm
Extracting Bazel installation...
Starting local Bazel server and connecting to it...
注意,上述命令中 wbpcode 爲用戶名,具體實踐時提議替換爲自身用戶名,如果註冊了 webassemblyhub.io 賬號,甚至可以進行 push 和 pull 操作。此次就不做相關操作了,直接本地啓動帶 WASM 的 envoy。命令如下:
# --config參數用於指定wasm擴展配置
wasme deploy envoy webassemblyhub.io/wbpcode/path_rewrite:v0.1 --config "{\"rewrites\": [ {\"regex_match\":\"...\", \"custom_path\": \"/anything\"} ]}" --envoy-run-args "-l trace"
從 envoy 執行日誌可以看到:最終 envoy 會執行七層 Filter: envoy.filters.http.wasm ,相關配置爲:wasm 文件位置(docker 執行時掛載進入容器內部)、 wasm 文件對應插件配置、runtime 等等。通過在 http_filters 中重複添加多個 envoy.filters.http.wasm ,即可實現多個 WASM 擴展的執行。從下面的日誌也可以看出,即使不使用 solo.io 的工具,只需要爲 Envoy 指定編譯好的 wasm 文件,其執行結果是完全相同的。
[2020-03-31 08:41:24.831][1][debug][config] [external/envoy/source/extensions/filters/network/http_connection_manager/config.cc:388]       name: envoy.filters.http.wasm
[2020-03-31 08:41:24.831][1][debug][config] [external/envoy/source/extensions/filters/network/http_connection_manager/config.cc:390]     config: {
 "config": {
  "rootId": "add_header_root_id",
  "vmConfig": {
   "code": {
    "local": {
     "filename": "/home/ping/.wasme/store/e58ddd90347b671ad314f1c969771cea/filter.wasm"
    }
   },
   "runtime": "envoy.wasm.runtime.v8"
  },
  "configuration": "{\"rewrites\": [ {\"regex_match\":\"...\", \"custom_path\": \"/anything\"} ]}",
  "name": "add_header_root_id"
 }
}
之後使用對應 path 調用接口:可發現 WASM 插件已經生效:
':authority', 'localhost:8080'
':path', '/ab' # 原始請求path匹配"..."
':method', 'GET'
'user-agent', 'curl/7.58.0'
'accept', '*/*'
':authority', 'localhost:8080'
':path', '/anything'
':method', 'GET'
':scheme', 'https'
'user-agent', 'curl/7.58.0'
'accept', '*/*'
'x-forwarded-proto', 'http'
'x-request-id', '1009236e-ab57-4ded-a8ff-3d1b17c6787b'
'location', 'envoy-wasm'
'x-envoy-expected-rq-timeout-ms', '15000'

WASM 總結

WASM 擴展仍在快速發展當中,但是 Isito 使用 WASM API 實現了相關的插件,說明已經做好了遷移的準備。前景美好,值得期待,但有待進一步確定 WASM 沙箱本身穩定性和性能。 從開發體驗來說:
  • 藉助 solo.io 工具,簡單插件的開發幾乎沒有任何的難度,只是目前支持的語言只有 C++/AssemblyScript(Envoy 社區開發了 Rust 語言 SDK,但是正在開發當中而且使用 Rust 開發 WASM 擴展的價值存疑:Rust 相比於 C++ 最大的優勢是通過嚴格的編譯檢查來保證內存安全,但是也使得上手難度又提升了一個臺階,在有 WASM 沙箱爲內存安全兜底的情況下,使用 Rust 而不使用 JS、Go 等上手更簡易的語言來開發擴展,實無必要)。
  • 對於相對複雜的插件,如果使用 WASM 的話,測試相比於原生插件會更困難一些,WASM 擴展配置的輸入只能依賴手寫 JSON 字符串,希望未來能夠改善。
  • 缺少路由粒度的配置,所有配置都是全局生效,依賴插件內部判斷,但是這一部分如果確實有需要,支持起來應該很快,不存在技術上的阻礙,倒是不用擔心。

作者簡介

王佰平,網易杭州研究院輕舟雲原生團隊工程師,負責輕舟 Envoy 網關與輕舟 Service Mesh 數據面開發、功能增強、性能優化等工作,對 Envoy 數據面開發、增強、落地具有較爲豐富的經驗。
相關文章