基於 WASM 的無侵入式全鏈路 A/B Test 實踐

1 背景介紹

我們都知道,服務網格(ServiceMesh)可以爲運行其上的微服務提供無侵入式的流量治理能力。通過配置VirtualService和DestinationRule,即可實現流量管理、超時重試、流量複製、限流、熔斷等功能,而無需修改微服務代碼。

流量管理的前提是一個服務存在多個版本,我們可以按部署多版本的目的進行分類,簡述如下,以方便理解余文。

  • traffic routing:根據請求信息(Header/Cookie/Query Params),將請求流量路由到指定服務(Service)的指定版本(Deployment)的端點上(Pod[])。就是我們所說的A/B測試(A/B Testing)。
  • traffic shifting:通過灰度/金絲雀(Canary)發佈,將請求流量無差別地按比例路由到指定服務(Service)的各個版本(Deployment[])的端點上(Pod[])。
  • traffic switching/mirroring:通過藍綠(Blue/Green)發佈,根據請求信息按比例進行流量切換,以及進行流量複製。

本文所述的實踐是根據請求Header實現全鏈路A/B測試。

1.1 功能簡述

從Istio社區的文檔,我們很容易找到關於如何根據請求Header將流量路由到一個服務的特定版本的文檔和示例。但是這個示例只能在全鏈路的第一個服務上生效。

舉例來說,一個請求要訪問A-B-C三個服務,這三個服務都有en版本和fr版本。我們期待:

  • header值爲user:en的請求,全鏈路路由爲A1-B1-C1
  • header值爲user:fr的請求,全鏈路路由爲A2-B2-C2

相應的VirtualService配置如下所示:

http:
- name: A|B|C-route
  match:
  - headers:
      user:
        exact: en
  route:
  - destination:
      host: A|B|C-svc
      subset: v1
- route:
  - destination:
      host: A|B|C-svc
      subset: v2

我們通過實測可以發現,只有A這個服務的路由是符合我們預期的。B和C無法做到根據Header值路由到指定版本。

這是爲什麼呢?對於服務網格其上的微服務來說,這個header是憑空出現的,也就是微服務代碼無感知。因此,當A服務請求B服務時,不會透傳這個header;也就是說,當A請求B時,這個header已經丟失了。這時,這個匹配header進行路由的VirtualService配置已經毫無意義。

要解決這個問題,從微服務方的業務角度看,只能修改代碼(枚舉業務關注的全部header並透傳)。但這是一種侵入式的修改,而且無法靈活地支持新出現的header。

從服務網格的基礎設施角度看,任何header都是沒有業務意義且要被透傳的kv pair。只有做到這點,服務網格才能實現無差別地透傳用戶自定義的header,從而支持無侵入式全鏈路A/B Test功能。

那麼該怎樣實現呢?

1.2 社區現狀

前面已經說明,在header無法透傳的情況下,單純地配置VirtualService的header匹配是無法實現這個功能的。

但是,在VirtualService中是否存在其他配置,可以實現header透傳呢?如果存在,那麼單純使用VirtualService,代價是最小的。

經過各種嘗試(包括精心配置header相關的set/add),我發現無法實現。原因是VirtualService對header的干預發生在inbound階段,而透傳是需要在outbound階段干預header的。而微服務workload沒有能力對憑空出現的header值進行透傳,因此在路由到下一個服務時,這個header就會丟失。

因此,我們可以得出一個結論:無法單純使用VirtualService實現無侵入式全鏈路A/B Test,進一步地說,社區提供的現有配置都無法做到直接使用就能支持這個功能。

那麼,就只剩下EnvoyFilter這個更高級的配置了。這是我們一開始很不希望的結論。原因有兩個:

  1. EnvoyFilter的配置太過複雜,一般用戶很難在服務網格中快速學習和使用,即便我們提供示例,一旦需求稍有變化,示例對修改EnvoyFilter的參考價值甚微。
  2. 就算使用EnvoyFilter,目前Envoy內置的filter也沒有直接支持這個功能的,需要藉助Lua或者WebAssembly(WASM)進行開發。

1.3 實現方案

接下來進入技術選型。我用一句話來概括:

  • Lua的優點是小巧,缺點是性能不理想
  • WASM的優點是性能好,缺點是開發和分發相比Lua要困難。
  • WASM的實現主流是C++和Rust,其他語言的實現尚不成熟或者存在性能問題。本文使用的是Rust。

我們使用Rust開發一個WASM,在outbound階段,獲取用戶在EnvoyFilter中定義的header並向後傳遞。

WASM包的分發使用Kubernetes的configmap存儲,Pod通過annotation中的定義獲取WASM配置並加載。(爲什麼使用這種分發形式,後面會講。)

2 技術實現

本節所述的相關代碼:
https://github.com/AliyunContainerService/rust-wasm-4-envoy/tree/master/propagate-headers-filter

2.1 使用RUST實現WASM

1 定義依賴

WASM工程的核心依賴crates只有一個,就是proxy-wasm,這是使用Rust開發WASM的基礎包。此外,還有用於反序列化的包serde_json和用於打印日誌的包logCargo.toml定義如下:

[dependencies]
proxy-wasm = "0.1.3"
serde_json = "1.0.62"
log = "0.4.14"

2 定義構建

WASM的最終構建形式是兼容c的動態鏈接庫,Cargo.toml定義如下:

[lib]
name = "propaganda_filter"
path = "src/propagate_headers.rs"
crate-type = ["cdylib"]

3 Header透傳功能

首先定義結構體如下,head_tag_name是用戶自定義的header鍵的名稱,head_tag_value是對應值的名稱。

struct PropagandaHeaderFilter {
    config: FilterConfig,
}

struct FilterConfig {
    head_tag_name: String,
    head_tag_value: String,
}

{proxy-wasm}/src/traits.rs中的trait HttpContext定義了on_http_request_headers方法。我們通過實現這個方法來完成Header透傳的功能。

impl HttpContext for PropagandaHeaderFilter {
    fn on_http_request_headers(&mut self, _: usize) -> Action {
        let head_tag_key = self.config.head_tag_name.as_str();
        info!("::::head_tag_key={}", head_tag_key);
        if !head_tag_key.is_empty() {
            self.set_http_request_header(head_tag_key, Some(self.config.head_tag_value.as_str()));
            self.clear_http_route_cache();
        }
        for (name, value) in &self.get_http_request_headers() {
            info!("::::H[{}] -> {}: {}", self.context_id, name, value);
        }
        Action::Continue
    }
}

第3-6行是獲取配置文件中用戶自定義的header鍵值對,如果存在就調用set_http_request_header方法,將鍵值對寫入當前header。

第7行是對當前proxy-wasm實現的一個workaround,如果你對此感興趣可以閱讀如下參考:

2.2 本地驗證(基於Envoy)

1 WASM構建

使用如下命令構建WASM工程。需要強調的是wasm32-unknown-unknown這個target目前只存在於nightly中,因此在構建之前需要臨時切換構建環境。

rustup override set nightly
cargo build --target=wasm32-unknown-unknown --release

構建完成後,我們在本地使用docker compose啓動Envoy,對WASM功能進行驗證。

2 Envoy配置

本例需要爲Envoy啓動提供兩個文件,一個是構建好的propaganda_filter.wasm,一個是Envoy配置文件envoy-local-wasm.yaml。示意如下:

volumes:
  - ./config/envoy/envoy-local-wasm.yaml:/etc/envoy-local-wasm.yaml
  - ./target/wasm32-unknown-unknown/release/propaganda_filter.wasm:/etc/propaganda_filter.wasm

Envoy支持動態配置,本地測試採用靜態配置:

static_resources:
  listeners:
    - address:
        socket_address:
          address: 0.0.0.0
          port_value: 80
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
...
                http_filters:
                  - name: envoy.filters.http.wasm
                    typed_config:
                      "@type": type.googleapis.com/udpa.type.v1.TypedStruct
                      type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
                      value:
                        config:
                          name: "header_filter"
                          root_id: "propaganda_filter"
                          configuration:
                            "@type": "type.googleapis.com/google.protobuf.StringValue"
                            value: |
                              {
                                "head_tag_name": "custom-version",
                                "head_tag_value": "hello1-v1"
                              }
                          vm_config:
                            runtime: "envoy.wasm.runtime.v8"
                            vm_id: "header_filter_vm"
                            code:
                              local:
                                filename: "/etc/propaganda_filter.wasm"
                            allow_precompiled: true
...

Envoy的配置重點關注如下3點:

  • 15行 我們在http_filters中定義了一個名稱爲header_filtertype.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
  • 32行 本地文件路徑爲/etc/propaganda_filter.wasm
  • 20-26行 相關配置的類型是type.googleapis.com/google.protobuf.StringValue,值的內容是{"head_tag_name": "custom-version","head_tag_value": "hello1-v1"}。這裏自定義的Header鍵名爲custom-version,值爲hello1-v1

3 本地驗證

執行如下命令啓動docker compose:

docker-compose up --build

請求本地服務:

curl -H "version-tag":"v1" "localhost:18000"

此時Envoy的日誌應有如下輸出:

proxy_1        | [2021-02-25 06:30:09.217][33][info][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1152] wasm log: ::::create_http_context head_tag_name=custom-version,head_tag_value=hello1-v1
proxy_1        | [2021-02-25 06:30:09.217][33][info][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1152] wasm log: ::::head_tag_key=custom-version
...
proxy_1        | [2021-02-25 06:30:09.217][33][info][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1152] wasm log: ::::H[2] -> custom-version: hello1-v1

2.3 WASM的分發方式

WASM的分發是指將WASM包存儲於一個分佈式倉庫中,供指定的Pod拉取的過程。

1 Configmap + Envoy的Local方式

雖然這種方式不是WASM分發的終態,但是因爲它較爲容易理解且適合簡單的場景,本例最終選擇了這個方案作爲示例講解。雖然configmap的本職工作不是存WASM的,但是configmap和Envoy的local模式都很成熟,兩者結合恰能滿足當前需求。

阿里雲服務網格ASM產品已經提供了這種類似的方式,具體可以參考 爲Envoy編寫WASM Filter並部署到ASM中

要把WASM包塞到配置中,首要考慮的是包的尺寸。我們使用wasm-gc進行包裁剪,示意如下:

ls -hl target/wasm32-unknown-unknown/release/propaganda_filter.wasm
wasm-gc ./target/wasm32-unknown-unknown/release/propaganda_filter.wasm ./target/wasm32-unknown-unknown/release/propaganda-header-filter.wasm
ls -hl target/wasm32-unknown-unknown/release/propaganda-header-filter.wasm

執行結果如下,可以看到裁剪前後,包的尺寸對比:

-rwxr-xr-x  2 han  staff   1.7M Feb 25 15:38 target/wasm32-unknown-unknown/release/propaganda_filter.wasm
-rw-r--r--  1 han  staff   136K Feb 25 15:38 target/wasm32-unknown-unknown/release/propaganda-header-filter.wasm

創建configmap:

wasm_image=target/wasm32-unknown-unknown/release/propaganda-header-filter.wasm
kubectl -n $NS create configmap -n $NS propaganda-header --from-file=$wasm_image

爲指定Deployment打Patch:

patch_annotations=$(cat config/annotations/patch-annotations.yaml)
kubectl -n $NS patch deployment "hello$i-deploy-v$j" -p "$patch_annotations"

patch-annotations.yaml如下:

spec:
  template:
    metadata:
      annotations:
        sidecar.istio.io/userVolume: '[{"name":"wasmfilters-dir","configMap": {"name":"propaganda-header"}}]'
        sidecar.istio.io/userVolumeMount: '[{"mountPath":"/var/local/lib/wasm-filters","name":"wasmfilters-dir"}]'

2 Envoy的Remote方式

Envoy同時支持localremote形式的資源定義。對比如下:

vm_config:
  runtime: "envoy.wasm.runtime.v8"
  vm_id: "header_filter_vm"
  code:
    local:
      filename: "/etc/propaganda_filter.wasm"
vm_config:
  runtime: "envoy.wasm.runtime.v8"
  code:
    remote:
      http_uri:
        uri: "http://*.*.*.216:8000/propaganda_filter.wasm"
        cluster: web_service
        timeout:
          seconds: 60
      sha256: "da2e22*"

remote方式是最接近原始Enovy的,因此這種方式本來是本例的首選。但是實測過程中發現在包的hash校驗上存在問題,詳見下方參考。並且,Envoy社區的大牛周禮贊反饋我說remote不是Envoy支持WASM分發的未來方向。因此,本例最終放棄這種方式。

3 ORAS + Local方式

ORASOCI Artifacts項目的參考實現,可顯著簡化OCI註冊表中任意內容的存儲。

使用ORAS客戶端或者API/SDK的方式將具有允許的媒體類型的Wasm模塊推送到註冊庫(一個OCI兼容的註冊庫)中,然後通過控制器將Wasm Filter部署到指定工作負載對應的Pod中,以Local的方式進行掛載。

阿里雲服務網格ASM產品中提供了對WebAssembly(WASM)技術的支持,服務網格使用人員可以把擴展的WASM Filter通過ASM部署到數據面集羣中相應的Envoy代理中。通過ASMFilterDeployment Controller組件, 可以支持動態加載插件、簡單易用、以及支持熱更新等能力。具體來說,ASM產品提供了一個新的CRD ASMFilterDeployment以及相關的controller組件。這個controller組件會監聽ASMFilterDeployment資源對象的情況,會做2個方面的事情:

  • 創建出用於控制面的Istio EnvoyFilter Custom Resource,並推送到對應的asm控制面istiod中
  • 從OCI註冊庫中拉取對應的wasm filter鏡像,並掛載到對應的workload pod中

具體可以參考:基於Wasm和ORAS簡化擴展服務網格功能

後續的實踐分享將會使用這種方式進行WASM的分發,敬請期待。

類似地,業界其他友商也在推進這種方式,特別是http://Solo.io提供了一整套WASM的開發框架wasme,基於該框架可以開發-構建-分發WASM包(OCI image)並部署到Webassembly Hub。這個方案的優點很明顯,完整地支持了WASM的開發到上線的生命週期。但這個方案的缺點也非常明顯,wasme的自包含導致了很難將其拆分,並擴展到solo體系之外。

阿里雲服務網格ASM團隊正在與包括solo在內的業界相關團隊交流如何共同推進Wasm filter的OCI規範以及相應的生命週期管理,以幫助客戶可以輕鬆擴展Envoy的功能並將其在服務網格中的應用推向了新的高度。

2.4 集羣驗證(基於Istio)

1 實驗示例

WASM分發到Kubernetes的configmap後,我們可以進行集羣驗證了。示例(源代碼)包含3個Service:hello1-hello2-hello3,每個服務包含2個版本:v1/env2/fr

每個Service配置了VirtualService和DestinationRule用來定義匹配Header並路由到指定版本。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: hello2-vs
spec:
  hosts:
    - hello2-svc
  http:
  - name: hello2-v2-route
    match:
    - headers:
        route-v:
          exact: hello2v2
    route:
    - destination:
        host: hello2-svc
        subset: hello2v2
  - route:
    - destination:
        host: hello2-svc
        subset: hello2v1
----
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: hello2-dr
spec:
  host: hello2-svc
  subsets:
    - name: hello2v1
      labels:
        version: v1
    - name: hello2v2
      labels:
        version: v2

Envoyfilter示意如下:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: hello1v2-propaganda-filter
spec:
  workloadSelector:
    labels:
      app: hello1-deploy-v2
      version: v2
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_OUTBOUND
        proxy:
          proxyVersion: "^1\\.8\\.*"
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
              subFilter:
                name: envoy.filters.http.router
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.wasm
          typed_config:
            "@type": type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
            value:
              config:
                name: propaganda_filter
                root_id: propaganda_filter_root
                configuration:
                  '@type': type.googleapis.com/google.protobuf.StringValue
                  value: |
                    {
                      "head_tag_name": "route-v",
                      "head_tag_value": "hello2v2"
                    }
                vm_config:
                  runtime: envoy.wasm.runtime.v8
                  vm_id: propaganda_filter_vm
                  code:
                    local:
                      filename: /var/local/lib/wasm-filters/propaganda-header-filter.wasm
                  allow_precompiled: true

2 驗證方法

攜帶header的請求curl -H "version:v1" "http://$ingressGatewayIp:8001/hello/xxx"通過istio-ingressgateway進入,全鏈路按header值,進入服務的指定版本。這裏,由於header中指定了versionv2,那麼全鏈路將
hello1 v2-hello2 v2-hello3 v2。效果如下圖所示。

驗證過程和結果示意如下。

for i in {1..5}; do
    curl -s -H "route-v:v2" "http://$ingressGatewayIp:$PORT/hello/eric" >>result
    echo >>result
done
check=$(grep -o "Bonjour eric" result | wc -l)
if [[ "$check" -eq "15" ]]; then
    echo "pass"
else
    echo "fail"
    exit 1
fi

result:

Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182
Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182
Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182
Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182
Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182

我們看到,輸出信息Bonjour eric來自各個服務的fr版本,說明功能驗證通過。

3 性能分析

新增EnvoyFilter+WASM後,功能驗證通過,但這會帶來多少延遲開銷呢?這是服務網格的提供者和使用者都非常關心的問題。本節將對如下兩個關注點進行驗證。

  • 增加EnvoyFilter+WASM後的增量延遲開銷情況
  • WASM版本和Lua版本的開銷對比

3.1 Lua實現

Lua的實現可以直接寫到EnvoyFilter中,無需獨立的工程。示例如下:

patch:
  operation: INSERT_BEFORE
  value:
    name: envoy.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
      inlineCode: |
        function envoy_on_request(handle)
          handle:logInfo("[propagate header] route-v:hello3v2")
          handle:headers():add("route-v", "hello3v2")
        end

3.2 壓測方法

1 部署

  • 分別在3個namespace上部署相同的Deployment/Service/VirtualService/DestinationRule
  • hello-abtest-lua中部署基於Lua的EnvoyFilter
  • hello-abtest-wasm中部署基於WASM的EnvoyFilter
hello-abtest        基準環境
hello-abtest-lua    增加EnvoyFilter+LUA的環境
hello-abtest-wasm   增加EnvoyFilter+WASM的環境

2 工具

本例使用hey作爲壓測工具。hey前身是boom,用來代替ab(Apache Bench)。使用相同的壓測參數分別對三個環境進行壓測。示意如下:

# 併發work數量
export NUM=2000
# 每秒請求數量
export QPS=2000
# 壓測執行時常
export Duration=10s

hey -c $NUM -q $QPS -z $Duration -H "route-v:v2" http://$ingressGatewayIp:$PORT/hello/eric > $SIDECAR_WASM_RESULT

請關注hey壓測結果文件,結果最後不能出現socket: too many open files,否則影響結果。可以使用ulimit -n $MAX_OPENFILE_NUM命令配置,然後再調整壓測參數,以確保結果的準確性。

3.3 報告

我們從三份結果報告中選取4個關鍵指標,如下圖所示:

3.4 結論

  1. 相對於基準版本,增加EnvoyFilter的兩個版本,平均延遲多出幾十個到幾百個毫秒,增加耗時比爲
  • wasm 1.2%(0.6395-0.6317)/0.63171%(1.3290-1.2078)/1.2078
  • lua 11%(0.7012-0.6317)/0.631720%(1.4593-1.2078)/1.2078
  1. WASM版本的性能明顯優於LUA版本
注:相比LUA版本,WASM的實現是一套代碼多份配置。因此WASM的執行過程還比LUA多出一個獲取配置變量的過程。

4 展望

4.1 如何使用

本文從技術實現角度,講述瞭如何實現並驗證一個透傳用戶自定義Header的WASM,從而支持無侵入式全鏈路A/B Test這個需求。

但是,作爲服務網格的使用者,如果按照本文一步步去實現,是非常繁瑣且容易出錯的。

阿里雲服務網格ASM團隊正在推出一種ASM插件目錄的機制,用戶只需在插件目錄中選擇插件,併爲插件提供自定義的Header等極少數量的kv配置,即可自動生成和部署相關的EnvoyFilter+WASM+VirtualService+DestinationRule。

4.2 如何擴展

本例只展示了基於Header的匹配路由功能,如果我們希望根據Query Params進行匹配和路由該如何擴展呢?

這是ASM插件目錄正在密切關注的話題,最終插件目錄將提供最佳實踐。

以上。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章