Prxoy-Wasm で Envoy を拡張して AWS CloudWatch にメトリクスを送る

最近 Envoy の拡張手段の1つである Wasm filter に興味をもったので Proxy-Wasm の spec 読んだり、関連ドキュメントを読んでました。ただ折角やるなら学ぶだけでなく学んだことの確認もかねて何か作りたいなと思って1つ Wasm module を作成したので、この記事ではそれを紹介します。また作成した Wasm module を Istio 上で動かすところも書くのですが、それは次回書きます。

※ もし内容が間違っていたら @hatappi で教えていただけると嬉しいです!

作ったもの

何かを作ると決めたもののあまり良いネタが思いつかず悩んだ末に今回はリクエスト・レスポンス情報 (Host, Path, Method, Status code) を AWS CloudWatch にメトリクスを送る Wasm module を作ることにしました。ソースコードは下記のリポジトリに置いてあります。

github.com

この Wasm module を作成した理由として自分は個人サービスのすべてを AWSKubernetes 上で運用しておりさらに Istio もすでに使用していたので作った Wasm module をすぐに試せる環境ができていたからです。また今回は自分が学んだことの確認のためでもあったので Proxy-Wasm で定義されている仕様をできるだけ使ったものを考えた結果このネタを思いつきましたw

構成

今回作成した Wasm module は下記の図のような構成で作成されています。

構成

まず Envoy 自身の話をすると Envoy は1つのプロセスで複数のスレッドを使う Threading model を採用しています。Main Thread では xDS API のハンドリングやランタイムの管理をしており Woker thread が受信したリクエストを処理します。このあたりの話を詳しく知りたい時は「Life of a Request — envoy 1.23.0-dev-f79bca documentation」を見ると良さそうです。

Envoy では Wasm module はスレッド上でメモリセーフな VM の中で動作します。 C++ で書かれた Wasm filter が本体にバンドルされており、そのフィルターがリクエストヘッダを受け取った、リクエストボディを受け取ったなどのイベントを Proxy-Wasm の仕様に従って Wasm module とコミュニケーションしていきます。このあたりの話は日本語だと個人的には下記の動画が分かりやいと感じました。

www.youtube.com

今回作った Wasm module は main と woker の両スレッドで動くことによって機能するものになっています。ここからは各スレッド上で動く Wasm module の役割を Proxy-Wasm の spec を使って紹介していきます。

Wasm module on Worker Thread

Worker thread 上の Wasm module の主な役割として大きく2つあります。

  1. リクエスト・レスポンスヘッダから CloudWatch Metric におくりたい情報を取り出す
  2. リクエスト・レスポンス情報を Shared queue に対して enqueue する

1つ目に関してはリクエストヘッダーを受け取った時は proxy_on_http_request_headers、レスポンスヘッダーを受け取った時は proxy_on_http_response_headers が呼び出されます。またヘッダーの値を取り出すための関数は別途定義されており proxy_get_mapproxy_get_map_value を使用します。

2つ目に関しては Proxy-Wasm では Shared queue と呼ばれるキューの仕組みが定義されています。キューはまず受け手で proxy_register_shared_queue でキューの登録ができるので送り手となる worker thread では proxy_resolve_shared_queue でさきほど登録したキューがあるかを確認します。そして proxy_enqueue_shared_queue を使ってデータをエンキューします。

Wasm module on Main Thread

Main thread 上の Wasm module の主な役割として大きく2つあります。

  1. Shared queue からリクエスト・レスポンス情報を dequeue する
  2. CloudWatch API を HTTP リクエストで呼び出してメトリクスを送る

1つ目に関してはさきほど Worker thread 側はエンキューする側でしたが Main thread はデキューする側になるので proxy_register_shared_queue でキューを登録して、proxy_dequeue_shared_queue を使って Worker thread でエンキューされたデータをデキューして受け取ります。

2つ目のHTTP リクエストに関しても Proxy-Wasm には仕様が定義されており proxy_dispatch_http_call を使用します。またこの関数自体はレスポンスを返さず、レスポンスは proxy_on_http_call_response で受け取るようになっています。

構成の補足

ここまで各スレッド上の Wasm module の役割を説明しました。ここで構成に関して2点補足があります。

1つ目はメトリクスの送り方です。今回は Wasm module 内で HTTP リクエストを発行して CloudWatch にメトリクスを送っていました。しかし Proxy-Wasm には Stats/Metrics 関連の関数が定義されており、 Envoy だと Prometheus 形式のメトリクスを生成することができるので、実際に今回のようなことを実現したい時はこちらを使用するのが良いかなと思います。

2つ目として Main thread で proxy_dispatch_http_call を使用して CloudWatch API へリクエストしていました。しかし HTTP リクエスト自体は Worker thread でも出来ます。今回そうしなかった理由として元のリクエスト(クライアントからのリクエスト)をブロックしないためです。Worker thread で HTTP リクエストを行った時は元のリクエストの処理をブロックしておかないと元のリクエストに関するイベントの処理が完了した時に CloudWatch API へのリクエストに関するイベントを受け取ることができなくなります。ブロックするということはその分元のリクエストのレスポンスタイムも伸びます。これが好ましくないと思ったので今回は Main thread で処理するような構成にしました。

実装

ここまでは Proxy-Wasm の spec 上で今回の Wasm module をどのように実装したのか説明してきました。この spec を満たす Wasm module を作成するにはどうしたら良いのでしょうか?その1つが SDK を使う方法です。 SDK の一覧は Proxy-Wasm spec に記載されており現時点では AssemblyScript, C++, Go (TinyGo), Rust, Zig 用の SDK があるようです。今回は自分が書きなれているのもあり Go (TinyGo) の SDK である proxy-wasm-go-sdk を使用しました。また proxy-wasm-go-sdk を採用したもう1つの理由が example が豊富だったからです。実際に Proxy-Wasm の spec を読んでから example の一覧を見るとわかるのですが、各関数に対しての example が用意されているので、自分がやりたいことを実現するための出発点としてとても役立ちました。

ここからは実装時のポイントを紹介していきたいと思います。

CloudWatch API へのリクエス

CloudWatch API へのリクエストと言われて思いつくのは AWS SDK を使ったリクエストだと思います。自分も最初実装をはじめた時は Go の SDK を使用するつもりでした。

しかし結果として AWS SDK for Go は使用できませんでした。その理由としては2つあります。

1つ目が AWS SDK for Go は Go 用であって TinyGo 用ではないということです。TinyGo は公式のドキュメント にまとめてあるように、Go の標準パッケージをサポートしています。しかし現時点ではすべてをサポートしているわけではありません。そのため使用するパッケージが TinyGo がサポートしていないパッケージや関数を使用するとエラーになります。AWS SDK に関しても TinyGo に起票された issue#2784 にあるように reflect パッケージの未実装な関数を呼び出しているためエラーになります。今後 TinyGo の開発が進み AWS SDK for Go で使用しているすべてのパッケージがサポートされる日がくるかもしれません。しかしそれでも Proxy-Wasm では使用できない可能性が高いと思っています。

それが2つ目の理由です。構成でも説明したように Proxy-Wasm では HTTP リクエストを作成する時は proxy_dispatch_http_call を使います。そのため Go だと net/http package を使用して HTTP リクエストしたくはなりますが使うことができません。

これが AWD SDK for Go が使用できない理由です。そのため今回は自前でリクエストする処理を実装しました。まず proxy-wasm-go-sdk では HTTP リクエストは DispatchHttpCall を呼び出して使用します。また今までは SDK に頼っていましたが AWS API へのリクエストは署名する必要があるので、公式の Pythonの実装例 を参考に TinyGo に書き換えました。書き換えた実装は下記にあります。

github.com

Shared queue

受け手側は RegisterSharedQueue を使って任意の名前を指定して Queue を登録します。そして DequeueSharedQueue を使ってエンキューされたデータを取り出した処理を書きます。今回の実装だとそれぞれ ここここ で使っています。

送り手側では ResolveSharedQueue を使って先程 RegisterSharedQueue で指定した Queue の名前と VM の ID を指定して Queue の ID を取り出します。その後 Queue の ID と送りたいデータを EnqueueSharedQueue を使ってエンキューします。今回の実装だとそれぞれ ここここ で使っています。

Shared Queue に関しては実際に動かしながら試すのがイメージが湧きやすいと思うので proxy-wasm-go-sdk の example を使用して試してみるのがおすすめです。

Foreign function の使用

構成では説明しませんでしたが Proxy-Wasm には proxy_call_foreign_function という関数が用意されています。これは Host に登録されている関数を呼び出すための関数です。例えば Envoy の場合は foreign.cc に記載されている関数を使うことができます。今回はせっかくなのでデータを圧縮、展開する compress, uncompress を使用して Queue を使う時に enqueue 時にデータを compress をして dequeue 時に uncompress するように実装しました。compress はここ でやっていて uncompress はここ でやっています。

Envoy 上への設定

ここまでは Wasm module 自体の実装を見てきました。最後に Envoy 上でこの Wasm module を使用していく部分を解説していきます。設定の全体は下記のリンクから参照できます。

github.com

Main thread での設定

Main thread 上で Wasm module を動かすために Bootstrap の Wasm - envoy.bootstrap.wasm を使用します。基本的にはドキュメントにある各フィールドで必要なものを設定していきます。1つポイントとしては Wasm module 内でホストである Envoy の環境変数を使用したい場合です。例えば今回だと CloudWatch API へリクエストするための AWS ACCESS KEY ID、AWS SECRET KEY が必要でした。Envoy に環境変数として渡してコードとしては Go の os.Getenv を呼び出すけなのですが、参照するためには Envoy 側で下記のように host_env_keys にどの環境変数を渡すかを指定する必要があります。これを使用しないとなぜか Wasm module 内で環境変数が参照できなくて悩むことになります。

- name: envoy.bootstrap.wasm
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.wasm.v3.WasmService
    singleton: true
    config:
      vm_config:
        ~~~
        environment_variables:
          host_env_keys:
            - AWS_ACCESS_KEY_ID
            - AWS_SECRET_ACCESS_KEY

Worker thread での設定

Woker thread 側の Wasm module は HTTP filter の1つである Wasm - envoy.filters.http.wasm を使用します。Wasm module 関連の設定は Main thread で使用した Boostrap の Wasm - envoy.bootstrap.wasm とだいたい同じです。ここでのポイントとしては HTTP リクエストのために Cluster の登録をする必要がある部分です。 Envoy 上で動作する Wasm module はあくまでも Envoy 上で動作するものなので Cluster を利用してリクエストをします。例えば CloudWatch API の場合は下記のように登録します。

clusters:
    - name: cloudwatch_api
      connect_timeout: 3s
      type: LOGICAL_DNS
      dns_lookup_family: V4_ONLY
      lb_policy: round_robin
      load_assignment:
        cluster_name: cloudwatch_api
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: monitoring.ap-northeast-1.amazonaws.com
                      port_value: 443
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
          sni: monitoring.ap-northeast-1.amazonaws.com

まとめ

この記事では最近作成した Proxy-Wasm の spec を満たした Wasm module の構成の説明を Proxy-Wasm の spec を使って解説しました。また proxy-wasm-go-sdk を使った実装のポイントを紹介して、最後に Envoy 上で動作するために必要な設定を紹介しました。

今回一通り作ってみて制約はあるものの自分の好きな言語を使って Envoy を拡張するコードをかけるのは体験が良かったです。次回は今回作った Wasm module を Istio 上で動かす部分を書きます。