写点什么

基于 WASM 的无侵入式全链路 A/B Test 实践

用户头像
韩陆
关注
发布于: 2021 年 02 月 25 日
基于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/feuyeux/rust-wasm-4-envoy/tree/propaganda/propagate-headers-filter

2.1 使用 RUST 实现 WASM

1 定义依赖


WASM 工程的核心依赖 crates 只有一个,就是proxy-wasm,这是使用 Rust 开发 WASM 的基础包。此外,还有用于反序列化的包[serdejson](https://crates.io/crates/serdejson)和用于打印日志的包[log](https://crates.io/crates/log)Cargo.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,如果你对此感兴趣可以阅读如下参考:

  • https://github.com/istio/istio/issues/30545#issuecomment-783518257

  • https://github.com/proxy-wasm/spec/issues/16

  • https://www.elvinefendi.com/2020/12/09/dynamic-routing-envoy-wasm.html

2.2 本地验证(基于 Envoy)

1 WASM 构建


使用如下命令构建 WASM 工程。需要强调的是wasm32-unknown-unknown这个 target 目前只存在于nightly中,因此在构建之前需要临时切换构建环境。


rustup override set nightlycargo 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 conpomse:


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-v1proxy_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 solo 的 wasme 框架


solo提供了一整套 WASM 的开发框架 wasme,基于该框架可以开发-构建-分发 WASM 包(OCI image)并部署到


Webassembly Hub。同时,该框架以 CRD 的形式对 EnvoyFilter 进行了封装,且可以让相关 Pod 从 Webassembly Hub 拉取 WASM 包。



这个方案的优点很明显,完整地支持了 WASM 的开发到上线的生命周期。但这个方案的缺点也非常明显,wasme 的自包含导致了很难将其拆分,并扩展到 solo 体系之外。


wasme 的方向是正确的,比如通过 CRD 来封装 EnvoyFilter 以及 Webassembly Hub 这种 WASM 中央仓库的思路。我的观点是,各大厂商顺着这个思路发展下去,提供成熟的定制和插拔能力后,WASM 分发的终态就会浮出水面。


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 分发的未来方向。因此,本例最终放弃这种方式。


  • https://stackoverflow.com/questions/65871312/how-to-set-the-sha256-hex-in-envoy-wasm-remote-config


  • https://envoyproxy.slack.com/archives/C78M4KW76/p1611496672017500


3 Configmap+Envoy 的 local 方式


虽然这种方式不是 WASM 分发的终态,但是本例最终选择了这个方案。虽然 configmap 的本职工作不是存 WASM 的,但是 configmap 和 Envoy 的 local 模式都很成熟,两者结合恰能满足当前需求。


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


ls -hl target/wasm32-unknown-unknown/release/propaganda_filter.wasmwasm-gc ./target/wasm32-unknown-unknown/release/propaganda_filter.wasm ./target/wasm32-unknown-unknown/release/propaganda-header-filter.wasmls -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.wasmkubectl -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.4 集群验证(基于 Istio)


1 实验示例


WASM 分发到 Kubernetes 的 configmap 后,我们可以进行集群验证了。示例(源代码)包含 3 个 Service:hello1-hello2-hello3,每个服务包含 2 个版本:v1/env2/fr


每个 Service 配置了 VirtualService 和 DestinationRule 用来定义匹配 Header 并路由到指定版本。


apiVersion: networking.istio.io/v1alpha3kind: VirtualServicemetadata:  name: hello2-vsspec:  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/v1alpha3kind: DestinationRulemetadata:  name: hello2-drspec:  host: hello2-svc  subsets:    - name: hello2v1      labels:        version: v1    - name: hello2v2      labels:        version: v2
复制代码


Envoyfilter 示意如下:


apiVersion: networking.istio.io/v1alpha3kind: EnvoyFiltermetadata:  name: hello1v2-propaganda-filterspec:  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 >>resultdonecheck=$(grep -o "Bonjour eric" result | wc -l)if [[ "$check" -eq "15" ]]; then    echo "pass"else    echo "fail"    exit 1fi
复制代码


Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182Bonjour eric@hello1:172.17.68.205<Bonjour eric@hello2:172.17.68.206<Bonjour eric@hello3:172.17.68.182Bonjour 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](https://github.com/rakyll/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.6317和**1%** (1.3290-1.2078)/1.2078

  • lua 11%(0.7012-0.6317)/0.6317和**20%** (1.4593-1.2078)/1.2078

  1. WASM 版本的性能明显优于 LUA 版本


注:相比 LUA 版本,WASM 的实现是一套代码多份配置。因此 WASM 的执行过程还比 LUA 多出一个获取配置变量的过程。


4 展望


4.1 如何使用


本文从技术实现角度,讲述了如何实现并验证一个透传用户自定义 Header 的 WASM,从而支持无侵入式全链路 A/B Test 这个需求。


但是,作为服务网格的使用者,如果按照本文一步步去实现,是非常繁琐且容易出错的。


服务网格的提供者应当提供一种类似 solo wasme 的方式,将功能所需的配置进行封装,并发布到插件目录。用户只需在插件目录中选择插件,并为插件提供自定义的 Header 等极少数量的 kv 配置,即可自动生成和部署相关的 EnvoyFilter+WASM+VirtualService+DestinationRule。


4.2 如何扩展


本例只展示了基于 Header 的匹配路由功能,如果我们希望根据 Query Params 进行匹配和路由该如何扩展呢?


一种方式是开发并替换本例中的 WASM,一种方式是由服务网格的提供者发布更多的插件目录。前者更灵活,但非托管无法保证稳定性和兼容性;后者更健壮,但会有滞后。


以上。


发布于: 2021 年 02 月 25 日阅读数: 19
用户头像

韩陆

关注

https://github.com/feuyeux 2012.12.21 加入

Alibabacloud ServishMesh

评论

发布
暂无评论
基于WASM的无侵入式全链路A/B Test实践