Envoyのフィルターを自作してみる
仕事でEnvoyのフィルターを書いているので、学んだことをまとめておく。
Envoyとは
Cloud-native high-performance edge/middle/service proxy ということでこれ自体はnginxと同じようなproxyなのだけれど、多様な機能を持っていて、サービスメッシュのデータプレーンに用いられていたりする(とプロダクションで使ったことないけど理解している)。
最近だとDropBoxがnginxからEnvoyに切り替えて色々めちゃよくなりましたというブログが投稿されて話題になっていたりした。
Envoyのフィルターについて
Envoyのコア機能に影響を与えず、Envoyの機能を拡張できるのがフィルター。
引用 : 今日から始める Envoy | Lizan Zhou
Envoyは多様なフィルターがデフォルトで組み込まれている(下記もほんの一部)が、実行時に動的に自作のプラグインを足したりといったことはできない。

そのため自作のフィルターをEnvoyに足すためには、自分たちで自作のフィルターを含める形でEnvoy自体を1からビルドする必要がある。
Envoyのビルド環境を作成する
EnvoyはC++で記述されているが、ビルドにはbazelというGoogle産のツールを使用しているのと、ビルドにあたってはいくつかのライブラリをインストールしておく必要がある。 以下に詳細がまとまっているので確認するとよい。 https://github.com/envoyproxy/envoy/blob/7b8ab2dcd21c14b412e88e847b246d0a8159890b/bazel/README.md#building-envoy-with-bazel
注意することとして、bazelを直接インストールするのではなく、READMEに沿って bazelisk というツールを使ってbazelを使用したほうがよい。というのもbazelのバージョンを指定してインストールするのがちょっと面倒なのとバージョン差分でビルドできないとめっちゃハマるので…(実際ハマった…)
bazeliskを使うことでワークスペースに応じたbazelを自動でインストールして実行してくれる。
bazel自体もそれなりに複雑なプロダクトなので、C++用のチュートリアルをやっておくと雰囲気がつかめてよい。
Envoyのビルドは割と高スペックなMacでもクリーンビルドだと1時間近くかかるので、手元のマシンが貧弱な場合は高スペックなEC2インスタンスを立てて使ってみるのもありかもしれない。実際Envoyにコミットされているエンジニアの方はビルド用の自作PCを組んだりしているらしい…
どのエディタ/IDEを使うか
普段からJetBrains製のIDEを使っているので、自分はJetBrainsが出しているCLionというC と C++用のIDEを使っている。ただしCLionはbazelのビルドに対応していないので、デフォルトだと補完や定義ジャンプがうまく動かない。
そこでCLionがサポートしているcmakelistsをbazelから出力する以下のスクリプトがある。が、このスクリプトを使っても後述する *.proto ファイルから自動生成されるC++ファイルのへ参照はうまくいかない。
https://github.com/lizan/bazel-cmakelists
VSCodeにちょっと浮気してみたところ、上記のcmakelistsなしでも定義ジャンプ、補完、さらには自動生成ファイルへの参照も動いたので、自分はVSCodeに移行するかもしれない。
Envoyのカスタムフィルターを作成する
envoyproxy/envoy-filter-exampleというリポジトリが用意されているので、これをベースに開発を進めていくのがよさそう。
このリポジトリは中身を見るとわかるように、以下のような構成になっている。
bazelのBUILDファイル自体については説明しないが、以下のような形で自分たちの書いたコードとEnvoyを一緒にビルドするような感じになっている(雰囲気だけお伝え)
envoy_cc_binary(
name = "envoy",
repository = "@envoy",
deps = [
":http_filter_config", <= 自作のフィルタ
"@envoy//source/exe:envoy_main_entry_lib",
],
)
api_proto_package()
envoy_cc_library(
name = "http_filter_lib",
srcs = ["http_filter.cc"],
hdrs = ["http_filter.h"],
repository = "@envoy",
deps = [
":pkg_cc_proto",
"@envoy//source/extensions/filters/http/common:pass_through_filter_lib",
],
)
envoy_cc_library(
name = "http_filter_config",
srcs = ["http_filter_config.cc"],
repository = "@envoy",
deps = [
":http_filter_lib",
"@envoy//include/envoy/server:filter_config_interface",
],
)
Envoyのフィルターの構成を理解する
中身の実装についての詳細な解説はたぶんないので、実際にEnvoyのリポジトリにあるフィルターのソースコードを見ながらどんなことをやっているか把握するのがよい(正直自分も全然わからない俺たちは雰囲気で…という状態...)。フィルタのソースコードは以下のパスの配下にある。
envoy/source/extensions/filters
フィルターはだいたい以下のファイルから構成されている
- xxx_filter.cc / xxx_filter.h
- フィルターの実際の中身の処理を記述する。
http、listner、networkといった種類のフィルタがあり、それぞれベースとなるクラスが用意されている。フィルターはそれらのクラスを継承して実装を行う。
- フィルターの実際の中身の処理を記述する。
- config.cc / config.h
- configというとこのフィルタ自体の設定値を管理するようなイメージがあるが、実際にやっていることは
envoy.yamlから設定値を読み込みフィルターのコンストラクタに渡すこと、REGISTER_FACTORYというマクロを呼んでEnvoyにこのフィルタを登録すること、である。
- configというとこのフィルタ自体の設定値を管理するようなイメージがあるが、実際にやっていることは
- xxxx.proto
例えばAWS Lambdaを起動するenvoyのフィルタは以下のようなファイルから構成されている。

ここにはprotoファイルが定義されていないが、それはEnvoyのソースコード上では別ディレクトリで管理されているため。ちなみに AWS Lambdaの.protoファイルは以下のようになっている。
syntax = "proto3";
package envoy.extensions.filters.http.aws_lambda.v3;
import "udpa/annotations/status.proto";
import "udpa/annotations/versioning.proto";
import "validate/validate.proto";
option java_package = "io.envoyproxy.envoy.extensions.filters.http.aws_lambda.v3";
option java_outer_classname = "AwsLambdaProto";
option java_multiple_files = true;
option (udpa.annotations.file_status).package_version_status = ACTIVE;
// [#protodoc-title: AWS Lambda]
// AWS Lambda :ref:`configuration overview <config_http_filters_aws_lambda>`.
// [#extension: envoy.filters.http.aws_lambda]
// AWS Lambda filter config
message Config {
option (udpa.annotations.versioning).previous_message_type =
"envoy.config.filter.http.aws_lambda.v2alpha.Config";
enum InvocationMode {
// This is the more common mode of invocation, in which Lambda responds after it has completed the function. In
// this mode the output of the Lambda function becomes the response of the HTTP request.
SYNCHRONOUS = 0;
// In this mode Lambda responds immediately but continues to process the function asynchronously. This mode can be
// used to signal events for example. In this mode, Lambda responds with an acknowledgment that it received the
// call which is translated to an HTTP 200 OK by the filter.
ASYNCHRONOUS = 1;
}
// The ARN of the AWS Lambda to invoke when the filter is engaged
// Must be in the following format:
// arn:<partition>:lambda:<region>:<account-number>:function:<function-name>
string arn = 1 [(validate.rules).string = {min_len: 1}];
// Whether to transform the request (headers and body) to a JSON payload or pass it as is.
bool payload_passthrough = 2;
// Determines the way to invoke the Lambda function.
InvocationMode invocation_mode = 3 [(validate.rules).enum = {defined_only: true}];
}
// Per-route configuration for AWS Lambda. This can be useful when invoking a different Lambda function or a different
// version of the same Lambda depending on the route.
message PerRouteConfig {
option (udpa.annotations.versioning).previous_message_type =
"envoy.config.filter.http.aws_lambda.v2alpha.PerRouteConfig";
Config invoke_config = 1;
}
ここで定義されたprotoファイルのパッケージ名はenvoy.yamlでenvoyの設定値を書くときにも以下のように使われる。
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: ext-authz
Envoyのフィルターを実際に書いてみる
解説できるほどまだ理解できていないが、基本的な考え方としてベースとなるフィルターに定義されているメソッドがいくつかあるので、必要なものを実装していけばよい。例えばHTTP Filterなら以下のようなメソッドを実装して目的となる処理を実装すればよい。
HttpSampleDecoderFilterConfig::HttpSampleDecoderFilterConfig(
const sample::Decoder& proto_config)
HttpSampleDecoderFilter::HttpSampleDecoderFilter(HttpSampleDecoderFilterConfigSharedPtr config)
HttpSampleDecoderFilter::~HttpSampleDecoderFilter() {}
void HttpSampleDecoderFilter::onDestroy() {}
const LowerCaseString HttpSampleDecoderFilter::headerKey()
const std::string HttpSampleDecoderFilter::headerValue()
FilterHeadersStatus HttpSampleDecoderFilter::decodeHeaders(RequestHeaderMap& headers, bool)
FilterDataStatus HttpSampleDecoderFilter::decodeData(Buffer::Instance&, bool)
また FilterHeadersStatus を戻り値として指定されている関数は、戻す値によってその後のフィルタの挙動を制御できるようになっている。
// 次の処理に進む return Http::FilterTrailersStatus::Continue // フィルターでの処理を中断する return Http::FilterTrailersStatus::StopIteration
configについては、以下の点を抑えておけば既存のフィルターを参考にしつつかけると思う。
// https://github.com/envoyproxy/envoy-filter-example/blob/a994541170218e12129977539ef96b0f672f7369/http-filter-example/http_filter_config.cc
/**
* Return the Protobuf Message that represents your config incase you have config proto
*/
ProtobufTypes::MessagePtr createEmptyConfigProto() override {
return ProtobufTypes::MessagePtr{new sample::Decoder()}; // protobufから自動生成したクラスを渡す。これをやらないとyamlからのデシリアライズに失敗する
}
std::string name() const override { return "sample"; } // この名前がfilterの名前になる
あとは、既存のフィルターの実装などを参考にしながら頑張るしかない。サンプルリポジトリで実装されているフィルターはシンプルなので全容を把握するのによいと思う。
- HTTP Filter https://github.com/envoyproxy/envoy-filter-example/blob/master/http-filter-example/http_filter.cc
- Network Filter https://github.com/envoyproxy/envoy-filter-example/blob/master/echo2.cc
参考資料
一緒に仕事している @tomoyaamachiさんの資料。色々と教えていただきました :bow: scrapbox.io