Envoyのフィルターを自作してみる

仕事でEnvoyのフィルターを書いているので、学んだことをまとめておく。

Envoyとは

github.com

Cloud-native high-performance edge/middle/service proxy ということでこれ自体はnginxと同じようなproxyなのだけれど、多様な機能を持っていて、サービスメッシュのデータプレーンに用いられていたりする(とプロダクションで使ったことないけど理解している)。

最近だとDropBoxがnginxからEnvoyに切り替えて色々めちゃよくなりましたというブログが投稿されて話題になっていたりした。

dropbox.tech

Envoyのフィルターについて

Envoyのコア機能に影響を与えず、Envoyの機能を拡張できるのがフィルター。

f:id:suzan2go:20200828103455p:plain 引用 : 今日から始める Envoy | Lizan Zhou

Envoyは多様なフィルターがデフォルトで組み込まれている(下記もほんの一部)が、実行時に動的に自作のプラグインを足したりといったことはできない。 f:id:suzan2go:20200828101405p:plain

そのため自作のフィルターを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++用のチュートリアルをやっておくと雰囲気がつかめてよい。

Build Tutorial - C++ - Bazel

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というリポジトリが用意されているので、これをベースに開発を進めていくのがよさそう。

github.com

このリポジトリは中身を見るとわかるように、以下のような構成になっている。

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
    • フィルターの実際の中身の処理を記述する。 httplistnernetwork といった種類のフィルタがあり、それぞれベースとなるクラスが用意されている。フィルターはそれらのクラスを継承して実装を行う。
  • config.cc / config.h
    • configというとこのフィルタ自体の設定値を管理するようなイメージがあるが、実際にやっていることは envoy.yaml から設定値を読み込みフィルターのコンストラクタに渡すこと、REGISTER_FACTORY というマクロを呼んでEnvoyにこのフィルタを登録すること、である。
  • xxxx.proto
    • Protocol Buffersというファイルで、ここにフィルターで使う設定値の定義を行う。このファイルをもとにbazelによりC++ファイルに変換される。ソースコード上で #include "envoy/extensions/filters/http/aws_lambda/v3/aws_lambda.pb.h" のように pb.* となっているファイルはprotoファイルをもとに生成されたC++のコード。

例えばAWS Lambdaを起動するenvoyのフィルタは以下のようなファイルから構成されている。

f:id:suzan2go:20200828101519p:plain

ここには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の名前になる

あとは、既存のフィルターの実装などを参考にしながら頑張るしかない。サンプルリポジトリで実装されているフィルターはシンプルなので全容を把握するのによいと思う。

参考資料

一緒に仕事している @tomoyaamachiさんの資料。色々と教えていただきました :bow: scrapbox.io