これはZOZO #1 Advent Calendar 2021 18日目の記事。
本記事では、WebAssembly (以下、WASM)を活用したIstio Proxy (Envoy)の拡張で押さえておきたい基礎知識を解説する。途中、サンプルWASM Filterのデプロイを通じて、関連する仕組みやカスタムリソースについても解説する。
なお、基礎知識といっても、著者がWASM Filterのクイックスタート記事などを参考に実際にIstioで動かしてみたときに調べたことを独断と偏見でまとめた記事に過ぎないので、体系的に整理されていなこと予めご理解いただければと思う。
WASM Filterを理解する
Filterによる機能拡張の選択肢
Filterによる機能拡張には複数の選択肢がある。WASM Filterもその中の1つ。
方法 | 内容 |
---|---|
ビルトインFiltersの利用 | WASM Filterで拡張を考えている人には、まずビルトインなFiltersにどういうものがあるのか理解しておくことをオススメする。すでにある機能は利用し、車輪の再発明を避けよう。 |
Lua FilterでLuaスクリプト実行 | ビルトインFiltersの中にHTTP Lua FilterというFilter設定のYAMLファイルにLuaスクリプトをインラインで記述可能なFilterがある。スクリプトの内容がシンプルな場合はこのアプローチは有効かもしれない(See 下記sample-lua-filter.yaml) |
C++ FilterのEnvoyへの組み込み | FilterをEnvoyのネイティブ言語であるC++で書いて、Envoyの一部としてビルドしパッケージ化するアプローチ。ネイティブなので高速であるものの、Envoyを再コンパイルし、独自バージョンをメンテナンスしていく必要があるので運用管理コストが高くなる |
WASM Filter (記事の本題) | 独立したWASMモジュールをEnvoyがダイナミックにロードする方式。本記事で詳細を後述 |
name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
-- Called on the request path.
function envoy_on_request(request_handle)
-- Do something.
end
-- Called on the response path.
function envoy_on_response(response_handle)
-- Do something.
end
# sample code from:
# https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter
WASMとは?
WebAssembly(WASM)とは、Webブラウザでプログラムを、高速、高効率、そしてポータブルに実行することを目指し策定されたバイナリフォーマットである。開発者は好みの言語でコーディングし、それをWASMモジュールにコンパイルして実行する。 また、WASM はVirtual Machine(以下VM)と呼ばれるサンドボックス化されたメモリーセーフな実行環境で動作するように設計されている。
WASMは、もともとWebブラウザで実行するよう設計されたものであるが、VMを他のホストアプリケーションに埋め込んで実行することもできる。WASM Filterは、このVMをEnvoyに埋め込んで実行する。Envoyは、V8 Wasm Runtime VM(以下、V8)を組み込んでいる。 V8は、C++で記述された高性能のJavaScriptおよびWASMエンジンであり、Chrome、Node.js、その他のプラットフォームで使用されているという背景がある。
なぜWASM Filterなのか?
WASM Filterを選択する理由について、以下のような事が挙げらる。
- アジリティの高さ - WASM Filterは、停止や再コンパイルすることなく、実行中のEnvoyプロセスに動的にロードが可能
- 運用管理性 - 独立したWasmモジュールをダイナミックにロードする仕組みであるため機能拡張のためにEnvoyのコードベースを変更する必要がない
- 言語選択肢の豊富さ - C++、Rust、Go言語などメジャーなプログラミング言語をWASMにコンパイルできる。開発者はこれらプログラミング言語を使用してFilterの実装が可能
一方、気になるWASMのパフォーマンスについては、Istio公式ページに以下の記述があり、改善の余地がある状況のようだ。これは実際に負荷試験をして確かめてみたいところ。
There are several known limitations with Wasm-based telemetry generation:
Proxy CPU usage will spike during Wasm module loading time (i.e. when the aforementioned configuration is >applied). Increasing proxy CPU resource limit will help to speed up loading.
Proxy baseline resource usage increases. Based on preliminary performance testing result, comparing to the >default installation, running Wasm-based telemetry will cost 30%~50% more CPU and double the memory usage.
The performance will be continuously improved in the following releases.
Filter Chainについて
本記事の後半では実際にWASM Filterをデプロイして、Kubernetes/Istio上のサンプルアプリのサイドカーであるIstio Proxy(Envoy)のHTTP Filter Chainに組み込んでみる。ここでは、WASM FilterがEnvoyのリクエスト処理メカニズムにおいてどういう位置づけにあるのか理解するためEnvoyのFilter Chainについて簡単に解説する。
そもそもFilterとは、リクエスト処理パイプライン内のモジュールのことで、Filter Chainとそのモジュール(Filter)による一連の処理の繋がりを意味する。Unixパイプで複数の処理をパイプでつなげてまとまった処理を行うが、それで言うところの1つの処理がFilterで、パイプでつながった一連の処理がFilter Chainにあたる。
Envoyでは、リクエスト処理を行う部分は次の2つサブシステムに分けられる。
- Listener: ダウンストリームからのリクエストを処理するサブシステム。ダウンストリームからのリクエストのライフサイクル管理、クライアントへの応答パス、ダウンストリームのHTTP/2 codecなどはここで行う。
- Cluster: エンドポイントへのアップストリーム接続の選択と構成を担当するサブシステム。クラスターとエンドポイントの正常性、負荷分散、および接続プールなどの管理はここで行う。
<Envoyのイベントドリブンのワーカースレッドモデルについて>
Envoyはイベントドリブンのスレッドモデルを採用しており、メインスレッドは、サーバーのライフサイクル、構成、統計情報などを管理し、それ以外のワーカースレッドでリクエストを処理を行う。すべてのスレッドはlibeventによるイベントループで運用され、あるダウンストリームからのTCP接続につき1つのワーカースレッドで処理される。
[Downstream] -> [Listener] -> [Filters] -> [Cluster] -> [Upstream]
[Filters] = [Listener FC] -> [Network FC] -> [HTTP FC]
各[Filter Chain] = [Filter] -> [Filter] -> [Filter] -> [Filter] -> ...
全体の流れをざっくり説明すると、ダウンストリームからのリクエストは、上図のようにListenerで受付られ、複数のFiltersによる処理を経て、対応するClusterにマッピングされる仕組みになっている。
Filtersには大きく次の3種類があり、上図のようにListener Filter Chain、 Network Filter Chain、 HTTP Filter Chainの順番でリクエスト処理が行われる。
- Listener Filter: L4接続におけるメタデータを処理する
- Network Filter: L4接続におけるrawデータを処理する。Network Filter Chainにおける最後のNetwork FilterはHTTP connection manager (HCM)と呼ばれ、HCPはHTTP/2 codecや、HTTP Filter Chainの管理を行う
- HTTP Filter: L7接続におけるHTTPリクエスト&レスポンスを処理する。
Envoyのリクエスト処理については、「Envoy Life of a Request」に詳しく書かれているので興味のある人は熟読いただければと思う。
Proxy-Wasm
Proxy-Wasmとは、WASMモジュールとホスト環境(Envoy/Istio Proxy、etc)の間のApplication Binary Interface(ABI)を定める仕様で、これによりホスト環境に依存せず、共通の仕組みでWASMモジュールによりホスト環境の拡張を可能になる。
Envoyはこれをリファレンス実装しており、また先述のとおりEnvoyはWASM Runtime VMであるV8を内部に組み込んでいる。WASMモジュールがEnvoyにロードされると、WASM Runtime(V8)を使用してインスタンス化され、イベント発生毎にProxy-Wasmインターフェースを通じてEnvoyと連携する。
(イメージ参照元: https://github.com/proxy-wasm/spec/blob/master/docs/WebAssembly-in-Envoy.md)
Proxy-Wasmについては下記資料がとてもわかりやすく参考になるかと思う。
WASM OCIイメージ仕様
WASMモジュールをOCIイメージとしてパッケージ化して配布するための仕様が定められている。仕様には次の2つのバリエーションがある。
- WASM Artifact Image Specification v0.0.0 (以下、OCIイメージ)
- Wasm Image Specification v0.0.0(以下、OCI互換イメージ)
それぞれの仕様でイメージのビルドとpushに利用できるツールに違いがあるので注意が必要となる。
なお、本記事後半でIstioへのWASMモジュールのデプロイ方法を紹介するが、Istioは両方の仕様をサポートしておりいづれのイメージ仕様でもデプロイが可能となっている。また、デプロイ方法の紹介では、AssemblyScriptで書かれたスクリプトをWASMモジュール(バイナリファイル)にコンパイルするが、そのイメージのビルドとPushにはdocker CLIを使用する。つまりOCI互換なイメージの利用ということになる。
WASM FilterをIstioにデプロイしてみる
それでは実際に、サンプルWASM FilterをKuberenetes(以下, k8s)上のIstioにデプロイしてみて、動きを確かめてみる。合わせて関連する仕組みやカスタムリソースについても解説していく。
なお、サンプル実行で使用する環境情報は以下の通り。
local OS: macOS 11
tools: npm 8.0.0, docker-ce 20.10.8, kubectl v1.19.13, kind v0.11.1
k8s: v1.19.11 (KIND)
istio: 1.12.1
事前準備
デプロイ先となるk8sクラスタとIstioの構築を行う。 また、そのクラスタにWASM Filterのデプロイターゲットとなるサンプルアプリのapplyも行う。
KINDでKubernetesクラスタ作成
k8sクラスタをkind (Kubernetes in Docker)でローカルに構築する。
まずは、次のようなcontrol planeノード x 1、 workerノード x 1のk8sクラスタのためのkindクラスタ構成ファイル(cluster.yaml)を作成する。
cat << EOF | > cluster.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
EOF
次に、kind cliコマンドで、k8s v1.19.11
のノードイメージでk8sクラスタを作成。
K8S_NODE_IMAGE=v1.19.11
kind create cluster --name my-kind-cluster \
--image=kindest/node:${K8S_NODE_IMAGE} \
--config cluster.yaml
クラスタ構築が完了したら念の為にクラスタにアクセスしてみる。
kubectl version
Client Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.13", GitCommit:"53c7b65d4531a749cd3a7004c5212d23daa044a9", GitTreeState:"clean", BuildDate:"2021-07-15T20:58:11Z", GoVersion:"go1.15.14", Compiler:"gc", Platform:"darwin/amd64"}
Server Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.11", GitCommit:"c6a2f08fc4378c5381dd948d9ad9d1080e3e6b33", GitTreeState:"clean", BuildDate:"2021-05-27T23:47:11Z", GoVersion:"go1.15.12", Compiler:"gc", Platform:"linux/amd64"}
kubectl get node
NAME STATUS ROLES AGE VERSION
my-kind-cluster-control-plane Ready master 37m v1.19.11
my-kind-cluster-worker Ready <none> 37m v1.19.11
Istioインストールとサンプルアプリのデプロイ
上記で作成したk8sクラスタにIstio 1.12.1
をインストールする。IstioのConfiguration profileにはdemo
を指定。
curl -L https://git.io/getLatestIstio | ISTIO_VERSION=1.12.1 sh
cd istio-1.12.1
./bin/istioctl install --set profile=demo -y
Istioインストールが完了したら念の為にバージョン情報を出力。
./bin/istioctl version
client version: 1.12.1
control plane version: 1.12.1
data plane version: 1.12.1 (4 proxies)
次に、Istioインストールパッケージに含まれているサンプルアプリをクラスタにデプロイする。ここではnamespace testns
に、Istioではおなじみのクライアントのリクエスト情報などを出力してくれるhttpbinと、実体はcurlコマンドのイメージであるsleepという2つのアプリをデプロイする。
# namespace testnsを作成
kubectl create ns testns
# namespace testnsのPodをIstioサイドカープロキシ (istio-proxy)注入対象とするためのラベルを付与
kubectl label namespace testns istio-injection=enabled --overwrite
# サンプルアプリ (httpbinとsleep)をtestnsにデプロイ
kubectl apply -f samples/httpbin/httpbin.yaml -n testns
kubectl apply -f samples/sleep/sleep.yaml -n testns
次のように、デプロイされたsleepアプリのコンテナからcurlを叩いてhttpbinアプリにリクエストを送信して、httpbinが動作しているか確認する。
SLEEP_POD=$(kubectl get pod -l app=sleep -n testns -o jsonpath={.items..metadata.name})
kubectl exec ${SLEEP_POD} -c sleep -n testns -- curl --head -s httpbin:8000/headers
# リクエストヘッダの出力結果
HTTP/1.1 200 OK
server: envoy
date: Sat, 18 Dec 2021 05:52:51 GMT
content-type: application/json
content-length: 525
access-control-allow-origin: *
access-control-allow-credentials: true
x-envoy-upstream-service-time: 5
WASMモジュールの作成とWASM FilterのIstioへのデプロイ
ここでは、AssemblyScriptを元にWASMモジュールして、WASM FilterとしてIstioにデプロイする方法を紹介する。サンプルWASM Filterは以下の流れでデプロイする。
- AssemblyScriptをWASMモジュールにコンパイル
- WASMモジュールをOCI互換イメージとしてパッケージ化
- OCI互換のコンテナレジストリ(ここではGitHub Container Registry)にPush
- WASMモジュールのデプロイ先を定義するWasmPluginカスタムリソースのマニフェスト作成
- WasmPluginをapplyしてWASMモジュールをターゲットアプリのサイドカー(Istio Proxy/Envoy)にデプロイ
AssemblyScript SDKによる開発用雛形の作成
AssemblyScriptでのFilter開発のためにAssemblyScript用のSDKを使用する。AssemblyScript SDKを使った開発方法については下記リンクが参考になる。
ここではwasme CLIというWASM Filter開発のユーティリティコマンドを使って、AssemblyScript SDKによる開発用雛形を作成する方法を紹介する。
まずは、次のようにwasme CLIをインストールする。
curl -sL https://run.solo.io/wasme/install | sh
export PATH=$HOME/.wasme/bin:$PATH
次に、wasme CLIでプロジェクトの雛形を作成する。下記のようにwasme initの引数にcustom-filter
を指定する。これでcustom-filterというディレクトリに雛形が作成される。
wasme init custom-filter
# wasme initを実行するとインタラクションな選択が表示させる。ここでは下記の選択を行う
✔ assemblyscript
✔ gloo:1.3.x, gloo:1.5.x, gloo:1.6.x, istio:1.5.x, istio:1.6.x, istio:1.7.x, istio:1.8.x, istio:1.9.x
custom-filterディレクトリに雛形が作成されたのでファイル構造を見てみる。
cd custom-filter
tree
.
├── assembly
│ ├── index.ts
│ └── tsconfig.json
├── package-lock.json
├── package.json
└── runtime-config.json
assembly/index.ts
がAssemblyScriptでのWASM Filterの実装にあたるファイル。内容は次の通りで、HTTPレスポンスに といヘッダーhelloを追加する処理となっている。
export * from "@solo-io/proxy-runtime/proxy";
import { RootContext, Context, RootContextHelper, ContextHelper, registerRootContext, FilterHeadersStatusValues, stream_context } from "@solo-io/proxy-runtime";
class AddHeaderRoot extends RootContext {
configuration : string;
onConfigure(): bool {
let conf_buffer = super.getConfiguration();
let result = String.UTF8.decode(conf_buffer);
this.configuration = result;
return true;
}
createContext(): Context {
return ContextHelper.wrap(new AddHeader(this));
}
}
class AddHeader extends Context {
root_context : AddHeaderRoot;
constructor(root_context:AddHeaderRoot){
super();
this.root_context = root_context;
}
onResponseHeaders(a: u32): FilterHeadersStatusValues {
const root_context = this.root_context;
if (root_context.configuration == "") {
stream_context.headers.response.add("hello", "world!");
} else {
stream_context.headers.response.add("hello", root_context.configuration);
}
return FilterHeadersStatusValues.Continue;
}
}
registerRootContext(() => { return RootContextHelper.wrap(new AddHeaderRoot()); }, "add_header");
なお、こちらにAssemblyScriptのサンプルがいくつかあるので参考にいただければと思う。
WASMモジュールにコンパイル
下記npmコマンドを実行して、WASMモジュールにコンパイルする。
# 必要な依存パッケージをダウンロード
npm install
# WASMモジュールにコンパイル
npm run asbuild
ビルドが成功すると、結果はbuildフォルダに作成される。
$ tree build
build
├── optimized.wasm
├── optimized.wasm.map
├── optimized.wat
├── untouched.wasm
├── untouched.wasm.map
└── untouched.wat
0 directories, 6 files
untouched.wasm
、optimized.wasm
はコンパイルされたバイナリファイル。optimized.wasm.map
、untouched.wasm.map
はソースマップファイル。次のステップのOCI互換イメージビルドで使うのはoptimized.wasm
。
OCI互換イメージのビルドとコンテナレジストリへのPush
ここでは、docker CLIを使って、前ステップで生成されたWASMモジュールバイナリファイル(optimized.wasm
)を元にOCI互換イメージを作成する。
既に、WASM OCIイメージ仕様については紹介しているが、OCI互換イメージ仕様であるWasm Image Specification v0.0.0によると、イメージには次の2つのファイルを含む仕様となっている。
- plugin.wasm - (必須う) WASMバイナリ
- runtime-config.json - (オプショナル) ランタイム設定を記述したファイルで、イメージのメタデータとして使用される。このファイルは、OCIイメージ仕様であるWASM Artifact Image Specification v0.0.0にて指定されている。
それでは、上記ファイル仕様に従い、OCI互換イメージを作成していく。
まずはコンパイルされたWASMモジュールバイナリファイルをplugin.wasmとして保存する。
cp build/optimized.wasm plugin.wasm
次のように、plugin.wasmとruntime-config.jsonファイルの追加を定義したDockerfileを作成する。
cat << EOD > Dockerfile
FROM scratch
COPY runtime-config.json plugin.wasm ./
EOD
続いて、イメージのビルドとPushになるが、ここではOCIイメージのコンテナレジストリとしてGitHub Container Registry(以下、GHCR)を使用する。
[補足] 当然ながら、Docker Hub, Amazon Elastic Container Registry、やGoogle Container Registry, Azure Container Registry, など他のOCIイメージサポートのレジストリも利用可能
まずは、GCHRにログインする(著者利用ユーザー = yokawasa)。GHCRへのログインではパスワードとしてGitHubアカウントのPersonal Access Token(以下、PAT)を利用する。
# GitHubアカウントのPATを指定
export PAT=xxxxxxxxxxxxxxxxx
# GHCRにログイン
echo $PAT | docker login ghcr.io -u yokawasa --password-stdin
Login Succeeded
それでは、docker CLIを使って、OCI互換イメージのビルドとGCHRへのPushを行う。
# OCI互換イメージのビルド
docker build . -t ghcr.io/yokawasa/sample-wasm-filters:0.1.0
# GCHRにPush
docker push ghcr.io/yokawasa/sample-wasm-filters:0.1.0
これで、ダウンロード先にghcr.io/yokawasa/sample-wasm-filters:0.1.0を指定することでイメージpullが可能となる。
なお、このイメージの公開設定はデプロイ先のKINDクラスタからpullできるようにPublic公開としている。GHCRパッケージのアクセスコントロールについては、こちらのページが参考になる。
WasmPlugin カスタムリソースの定義
ここでは、WASMモジュールのデプロイ先を定義するカスタムリソースであるWasmPluginのマニフェストを作成する。
ここでは以下の内容のWasmPluginマニフェストを作成する。主に、どのWasmモジュール(ここではOCIイメージ)を、どのPodのサイドカーで、filter chainの中のどの位置に注入するかを定義している。
apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
name: httpbin-custom-filter
namespace: testns
spec:
selector:
matchLabels:
app: httpbin
pluginName: add_header
url: oci://ghcr.io/yokawasa/sample-wasm-filters:0.1.0
phase: STATS
- matchLabelsフィールドで指定したラベルに一致するPodのサイドカーがデプロイ先の対象となる
- phaseフィールドで、filter chainの中のどの位置に注入するかを定義。ここではSTATSなので、Istio stats filterの前で、Istio auth filtersの後に注入ということになる。
- urlフィールドでスキーマ指定が
oci://
なので、OCIイメージghcr.io/yokawasa/sample-wasm-filters:0.1.0
をダウンロード。他にはWASMモジュールファイルのローカルからのダウンロード用のfile://
や、 リモートからのHTTP/HTTPSダウンロード用のhttp[s]://
の指定が可能
この設定がapplyされると、Istiodは対応する構成をmatchLabelsフィールドで指定したapp:httpbin
ラベルに一致するサイドカーにプッシュする。サイドカーのIstioエージェントはWASMモジュールをダウンロードし、EnvoyランタイムにおいてWASMエンジンにロードし実行する。
WASMモジュールをターゲットアプリのサイドカーにデプロイ
それでは、前ステップで作成したWasmPluginのマニフェストを次のようにクラスタにapplyする。
# WasmPluginのapply
kubectl apply -f wasm-plugin.yaml
# WasmPluginリソースの取得
kubectl get wasmplugin -n testns
NAME AGE
httpbin-custom-filter 23s
デプロイが完了後、さきほどと同じ様にhttpbinアプリにリクエスト送信すると、期待通りhelloヘッダが追加されていることが確認される。
SLEEP_POD=$(kubectl get pod -l app=sleep -n testns -o jsonpath={.items..metadata.name})
kubectl exec ${SLEEP_POD} -c sleep -n testns -- curl --head -s httpbin:8000/headers
HTTP/1.1 200 OK
server: envoy
date: Sat, 18 Dec 2021 06:51:23 GMT
content-type: application/json
content-length: 525
access-control-allow-origin: *
access-control-allow-credentials: true
x-envoy-upstream-service-time: 17
hello: world! <<<<<< ヘッダが追加!!
ためしに、WasmPluginリソースをdeleteしてから再びhttpbinアプリにリクエスト送信すると、今度はさきほど追加されていたヘッダがなくなっていることを確認。
HTTP/1.1 200 OK
server: envoy
date: Sat, 18 Dec 2021 06:52:51 GMT
content-type: application/json
content-length: 525
access-control-allow-origin: *
access-control-allow-credentials: true
x-envoy-upstream-service-time: 5
EnvoyのHTTP Filter Chain設定内容を確認
再びWasmPluginリソースをapplyして、デプロイしたWASM FilterがどのようにEnvoyのHTTP Filter Chainに反映されているのか確認してみる。Envoyの設定情報は次のIstio proxyのエンドポイントにリクエストして取得する。
HTTPBIN_POD=$(kubectl get pod -l app=httpbin -n testns -o jsonpath={.items..metadata.name})
kubectl exec -it ${HTTPBIN_POD} -n testns -c istio-proxy -- curl localhost:15000/config_dump > envoy_config_dump.json
中身をみてみると、今回デプロイしたtestns.httpbin-custom-filter
が、http_connection_managerにおけるHTTP Filtersに組み込まれており、WasmPluginリソース定義でのphaseフィールド(値はSTATS)で指定したようにIstio stats filterの前に注入されていることが分かる。
...omit...
{
"name": "envoy.filters.network.http_connection_manager",
"typed_config": {
"@type": "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager",
"stat_prefix": "InboundPassthroughClusterIpv4",
"route_config": {
"name": "InboundPassthroughClusterIpv4",
"virtual_hosts": [...],
},
"http_filters": [
{
"name": "testns.httpbin-custom-filter",
"config_discovery": {
"config_source": {
"ads": {},
"initial_fetch_timeout": "0s",
"resource_api_version": "V3"
},
"type_urls": [
"type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm"
]
}
},
{
"name": "istio.metadata_exchange",
...omit...
},
{
"name": "envoy.filters.http.cors",
...omit...
},
{
"name": "envoy.filters.http.fault",
...omit...
},
{
"name": "istio.stats",
...omit...
},
{
"name": "envoy.filters.http.router",
...omit...
}
],
...omit...
まとめ
WASM Filterはいいぞ。今後のさらなる発展に期待したい。
以上