EnvoyのWASM Filterをproxy-wasm-go-sdkで書いてたらGet Wildしてた話

公開:2020/12/21
更新:2020/12/21
10 min読了の目安(約9800字TECH技術記事

この記事はCyberAgent Developers Advent Calendar 2020(非公式)の記事になります。

はじめに

2020年5月に発表されたIstio1.5へのメジャーアップデート Istio 1.5 Releaseでは、WebAssembly(以後WASM)を使用してIstioの拡張性モデルをEnvoyと統合して新たな拡張性モデルとすることが発表されています。
このアップデートにより、version 1.5以降のIstioにはWASMをサポートしたEnvoyが同梱され、ユーザーはAlpha機能(デフォルト無効)としてこの拡張性モデルを利用することができるようになりました。
そこで今回はEnvoyのWASMによる拡張に着目して、EnvoyでWASMがサポートされるに至った背景を軽く説明したのち、実際にWASM Filterを開発した際の軌跡について書こうと思います。

EnvoyがWASMサポートするまでの背景

Service MeshのData Planeの機能を拡張すると以下のことができるようになります。

  • 独自のアクセス制御システムとの統合
  • 独自のメトリクスを使用したObservabilityの向上
  • 独自のプロトコルのサポート

これに対して過去、IstioとEnvoyの両プロジェクトは異なるアプローチを用意していました。
Istioでは、Mixerプラグインを用いたポリシーやテレメトリのカスタマイズを行いMixerプラグインをリビルドする方法があり、EnvoyではEnvoy filterをC++で独自開発してEnvoy自体をリビルドする方法がありました。

しかしこれらのアプローチには各々課題が存在します。
Mixerプラグインを用いるアプローチでは、毎回EnvoyがMixerに問い合わせにいくためにレイテンシーの問題やリソース効率が良くない等のパフォーマンスへの悪影響がありました。
一方、Envoy FilterをC++で独自開発するアプローチではFilterをC++で開発しなければならないという言語面での制約があったことや、機能追加やバグ修正などでリリースする際は新しいバイナリの配布をする必要があるため動的にプラグインをloadできないない、などの課題がありました。

そこでこれらの課題を解決しながらEnvoy拡張を行える実装として挙がってきたのがEnvoyにおけるWASMサポートという新機能なのです。

ちなみに、この辺の背景に関する話はこちらの記事でより詳細に説明されているのでこちらを参照するとよく理解できます。
また、Istioコミュニティから出ているこちらのブログも参考になります。

WASM入門

WASMに親しみのない方向けに軽くWASMについても紹介します。
まず、WASMとはWebブラウザにおいてネイティブコード並の実行速度で動くバイナリフォーマット(バイトコードの仕様)です。
バイナリフォーマットであるため、C/C++、Go言語、Rust、TypeScriptなどの様々な言語から生成可能となっており、開発者は好きな言語を選択して開発を行うことができます。
また、WASMはハードウェア、言語、プラットフォームからの独立性を追求しており、WASMで記述されたプログラムは、ブラウザに組み込んだり、スタンドアロンのVMとして動作させたり、他の環境へ統合することも可能でポータビリティに優れています。
こうした特性から、WASMはブラウザ以外の多様な環境でも積極的に使われ始めています。

しかし、様々な言語からコンパイル可能ではあったもののJavascriptへの組み込みが前提であり、WASMが登場した当初はコンパイラが共通して使えるHost環境はブラウザ以外に存在しませんでした。

そこで新たに定義されたのが、WASMのVMとWASMを実行するHost環境との間のインターフェースを定義したApplication Binary Interface(以後ABI)であるWebAssembly System Interface(以降WASI)です。
WASIが定義されたことにより、WASIを実装したランタイムではWASMを実行することが可能になりました。
実際にWASIを実装しているランタイムの例には下記のようなものが存在します。

WASIと同じ土俵にあるものとして、WebAssembly for Proxies(Proxy-WASM)というABIも存在します。
Proxy-WASMとはWASMとプロキシサーバーの間で利用されるABIであり、WASMモジュールをproxyの中で動かして拡張するための仕様です。

EnvoyはこのProxy-WASMのリファレンス実装として存在しており、EnvoyのWASM拡張ではEnvoyの中でV8エンジン(WASMを動かすVM)を動作することでWASMを処理しています。

WASM Filter開発におけるエコシステム

今回のWASM Filterの開発で使用するツール群を紹介します。

  • WebAssembly Hub
    Solo.io, Google, Istioコミュニティが連携して開発したWebAssembly用のリモートレジストリ。
    WebAssembly HubはWASM FilterをOCIイメージとして管理/配布するためのプラットフォームです。
  • wasme
    WebAssembly Hubを使用する際に使う専用のCLI。
    wasmeと組み合わせてWebAssembly Hubを使うことで、Docker並みに簡単にWASM Filterのbuild/ push/ pull/ deploy等の操作を行うことができます。

尚、wasmeのインストールからWASM Filterのデプロイまでの流れはこちらの記事を参考にしました。
この辺の話は長くなってしまうので今回は割愛します🙇‍♀️

WASM Filterの実装

今回はTinyGoベースで書かれているproxy-wasm-go-sdkを使ってGo言語で開発しています。
wasmeでプロジェクトを作成する際にTinyGoを指定すると、proxy-wasm-go-sdkを使ってWASM Filterの雛形を生成してくれます。
尚、デフォルトではhelloというresponse headerを追加する内容となっています。
main.goの主要なメソッドだけ抜粋したものが以下です。

// OnHttpRequestHeaders リクエストヘッダーが来たときに呼ばれるメソッド
func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
	hs, err := proxywasm.GetHttpRequestHeaders()
	if err != nil {
		proxywasm.LogCriticalf("failed to get request headers: %v", err)
	}

	for _, h := range hs {
		proxywasm.LogInfof("request header: %s: %s", h[0], h[1])
	}
	return types.ActionContinue
}

// OnHttpResponseHeaders レスポンスヘッダーが返却されるときに呼ばれるメソッド
func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
	if err := proxywasm.SetHttpResponseHeader("hello", "world"); err != nil {
		proxywasm.LogCriticalf("failed to set response header: %v", err)
	}
	return types.ActionContinue
}

これらのメソッドをoverrideして実装を進めていきます。

Try1: Firebase Authenticationと連携してリクエストのJWTを検証する

当初の目的は、Firebase Authenticationと連携してリクエストのJWTを検証するFilterを開発することでした。
そこで、main.goのOnHttpRequestHeadersの中身を以下のように書き換えます。

func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
	ictx := context.Background()
	app, err := firebase.NewApp(ictx, nil, option.WithCredentialsJSON([]byte(``)))
	if err != nil {
		return types.ActionPause
	}
	auth, err := app.Auth(ictx)
	if err != nil {
		return types.ActionPause
	}
	hs, err := proxywasm.GetHttpRequestHeaders()
	if err != nil {
		proxywasm.LogCriticalf("failed to get request headers: %v", err)
		return types.ActionPause
	}
	for _, h := range hs {
		proxywasm.LogInfof("request header: %s: %s", h[0], h[1])
		if h[0] == "Authorization" {
			// JWTを認証
			token, err := auth.VerifyIDToken(ictx, strings.TrimPrefix(h[1], "Bearer "))
			if err != nil {
				return types.ActionPause
			}
			counter.Increment(1)
			proxywasm.LogInfof("granted to access %s", token)
			return types.ActionContinue
		}
	}
	proxywasm.LogInfo("unauthorized request detected")

	return types.ActionPause
}

ビルドしたところ、以下のようなエラーが出ました🤔

$ wasme build tinygo -t webassemblyhub.io/mayusy/sample:dev ./
Building with tinygo...vendor/go.opencensus.io/trace/trace_go11.go:21:2: cannot find package "." in:
        /src/workspace/vendor/runtime/trace
package github.com/mayusy/envoy-wasm-firebase-sample
        imports firebase.google.com/go
        imports cloud.google.com/go/firestore
        imports cloud.google.com/go/firestore/apiv1
        imports google.golang.org/api/transport/grpc
        imports go.opencensus.io/plugin/ocgrpc
        imports go.opencensus.io/trace
Error: failed producing filter file: exit status 1

Firebase SDK内で利用しているopencensus-goライブラリを呼んでいるようですが、TinyGoでruntime/traceのサポートがないためビルドできないようです。
そこであまり良くないことですが、go mod vendorでローカルにvendoringしてtrace_go11.goからruntime/traceの依存コードを削除したところ、以下のエラーが出ました🤔

$ wasme build tinygo -t webassemblyhub.io/mayusy/getwild:dev ./
Building with tinygo...# net
../../usr/local/go/src/net/cgo_linux.go:10:10: fatal: 'netdb.h' file not found
../../usr/local/go/src/net/cgo_resnew.go:14:10: fatal: 'netdb.h' file not found
../../usr/local/go/src/net/cgo_unix.go:14:10: fatal: 'netdb.h' file not found
/usr/local/go/src/net/cgo_unix.go:20:22: unexpected token ILLEGAL
Error: failed producing filter file: exit status 1

今度は恐らくnetwork周りが原因だと思われるエラーです。
Firebase SDKを使ってしまうと中でどのようなnetworkライブラリを使っているのか分かりにくいため、今度はシンプルにnet/httpパッケージを使ってビルドできるか検証してみます。

Try2: デバッグのためにnet/http単体でビルドできるか検証

net/httpを使って適当なURLにGETリクエストを送り、レスポンスをbodyに表示するコードです。

func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
	proxywasm.LogInfo("accessed to OnHttpRequestHeaders")
	// 適当なURLにGETリクエスト
	res, err := http.Get("https://httpbin.org/uuid")
	if err != nil {
		proxywasm.LogCriticalf("failed to request to httpbin.org : %v", err)
		return types.ActionContinue
	}
	// レスポンスをRead
	b, err := ioutil.ReadAll(res.Body)
	if err != nil {
		proxywasm.LogCriticalf("failed to read http response body : %v", err)
		return types.ActionContinue
	}
	// GETリクエストで受け取ったレスポンスをResponse BodyにSet
	proxywasm.SendHttpResponse(200, [][2]string{}, string(b))
	proxywasm.LogInfo("finished to execute OnHttpRequestHeaders function")
	return types.ActionContinue
}

しかしビルド結果はTry2で出ていたエラーと同様のものが出ていました。
暫く調べていると、net/httpをTinyGoがサポートしていなかったことが判明。(参考
つまりTinyGo環境では外部に対するHTTPリクエストは軒並み送れないことが分かりました。
ここで私はしぶしぶGo言語での開発を諦め、他の言語での開発を検討することにしました。

Try3: TinyGoを諦めて他の言語での実現可能性を模索

まず、Proxy-WASMのSDKが存在する言語の中でRustに目をつけて、Try2と同じことが実現できるのかを模索してみました。
また暫くしていたところ、こちらの記事の情報によるとRustのWASIはネットワーク機能をサポートしておらず、WASIにはファイルアクセス以外のリソースにアクセスする機能はない状況、とのことでした。
1年以上経過している記事なので今も同様の現状なのかは分かりませんが、RustのmasterブランチにあるWASIのnetworkライブラリのコードを見る限り、外部へのHTTPリクエストを送信することは難しそうです。

Try4: 脳死でGet Wild

デバッグに疲弊した私は、気づけば脳死でResponse bodyにGetWildを返すコードを書き始めていました。

コードは以下です。

func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
	proxywasm.LogInfo("accessed to OnHttpRequestHeaders")
	hs, err := proxywasm.GetHttpRequestHeaders()
	if err != nil {
		proxywasm.LogCriticalf("failed to get request headers: %v", err)
		return types.ActionContinue
	}
	for _, h := range hs {
		proxywasm.LogInfof("request header received key:%s\tval:%s", h[0], h[1])
		if strings.Contains(h[0], "method") && strings.EqualFold(h[1], "get") {
			proxywasm.LogInfof("get method detected key:%s\tval:%s", h[0], h[1])
			proxywasm.SendHttpResponse(418, [][2]string{{"powered-by", "proxy-wasm-go-sdk!!"}}, getWild)
			return types.ActionContinue
		}
	}
	proxywasm.LogInfo("finished to execute OnHttpRequestHeaders function")
	return types.ActionContinue
}

const getWild = `
// GetWildのアスキーアート
`

さあついに最後のデバッグの時です。
私は冴羽獠が銃をぶっ放すが如く、勢いよくEnterキーを押してcurlコマンドを叩きました。

................

WASI GET WILD!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

...現場からは以上です。

おわりに

最終的にはしょうもない成果物になってしまいましたが、色々とデバッグで右往左往したぶん、これからWASM Filter開発をされる方にとって少しは役に立つ情報をお伝えできていたら幸いです。
既に貼り付けている内容以上の情報はないですが、コードの全容はこちらに置いています。
長くなりましたが、最後まで読んでいただきありがとうございました。

参考文献