Envoy Proxy の Composite Filter で HTTP filter を選択的に適用する

Envoy Proxy の Config を手書きしていくにあたって HTTP filter をより便利に記述するための方法論をまとめておく。 利用した Envoy Proxy は v1.30.4 で、記事中のリンクはすべてそのバージョンのドキュメントを指している。

Envoy Proxy の HTTP filter を使うと、HTTP レイヤでリクエストに対して様々な前処理が可能になる。 upstream へリクエストを送る前に header や body を parse した処理を書けるため、例えば認証や stats の収集、データの convert など様々が実現できる。 Envoy Proxy 本体にも標準で多様な filter が実装されており、良く知られた用途はだいたい前例が存在するくらいになってきた。

HTTP filter は基本的には設定した順に評価されていく。 一部の filter には pass through したり skip するための matcher が実装されているが、そうでない場合は任意の HTTP リクエストに対して filter が評価されてしまう。 リクエストに対して複雑な条件で filter の適用有無を判断したい場合、filter の実装依存になってしまうため、

ExtensionWithMatcher

これに対して、特定のフィルタに対して wrap することで matcher を評価した上で filter を適用できる extension が存在している。 つまり、特定の条件を付与して HTTP filter を適用できる extension である。 記事記載時点では HTTP filter のみのサポートと書いてあるため、Listener filter や Network filter でも将来的に利用できるかもしれない。

下記の例は ExtensionWithMatcher を使って HTTPFault Filter を / のアクセスのうち 10% に適用している1。 一見便利なように思えるが、ExtensionWithMatcher では単一の filter に対する matching しか実現しておらず、このままでは条件の数だけ ExtensionWithMatcher を並べる必要がある。

{
  static_resources: {
    listeners: [
      {
        address: {
          socket_address: {
            address: '0.0.0.0',
            port_value: 8181,
          },
        },
        filter_chains: [
          {
            filters: [
              {
                name: 'envoy.filters.network.http_connection_manager',
                typed_config: {
                  '@type': 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
                  stat_prefix: 'ingress_http',
                  access_log: {
                    name: 'envoy.access_loggers.stdout',
                    typed_config: {
                      '@type': 'type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog',
                    },
                  },
                  route_config: {
                    virtual_hosts: [
                      {
                        name: 'upstream',
                        domains: ['*'],
                        routes: [
                          {
                            match: { prefix: '/' },
                            route: { cluster: 'upstream' },
                          },
                        ],
                      },
                    ],
                  },
                  http_filters: [
                    {
                      name: 'envoy.extensions.common.matching',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.common.matching.v3.ExtensionWithMatcher',
                        extension_config: {
                          name: 'envoy.extensions.filters.http.fault',
                          typed_config: {
                            '@type': 'type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault',
                            abort: {
                              http_status: 503,
                              percentage: {
                                numerator: 10,
                                denominator: 'HUNDRED',
                              },
                            },
                          },
                        },
                        matcher: {
                          matcher_list: {
                            matchers: [
                              {
                                predicate: {
                                  not_matcher: {
                                    single_predicate: {
                                      input: {
                                        name: 'envoy.type.matcher.request-headers',
                                        typed_config: {
                                          '@type': 'type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput',
                                          header_name: ':path',
                                        },
                                      },
                                      value_match: {
                                        exact: '/',
                                      },
                                    },
                                  },
                                },
                                on_match: {
                                  action: {
                                    name: 'envoy.extensions.filters.common.matcher.skip',
                                    typed_config: {
                                      '@type': 'type.googleapis.com/envoy.extensions.filters.common.matcher.action.v3.SkipFilter',
                                    },
                                  },
                                },
                              },
                            ],
                          },
                        },
                      },
                    },
                    {
                      name: 'envoy.filters.http.router',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
                      },
                    },
                  ],
                },
              },
            ],
          },
        ],
      },
    ],
    clusters: [
      {
        name: 'upstream',
        type: 'LOGICAL_DNS',
        dns_lookup_family: 'V4_ONLY',
        load_assignment: {
          cluster_name: 'upstream',
          endpoints: [
            {
              lb_endpoints: [
                {
                  endpoint: {
                    address: {
                      socket_address: {
                        address: 'app',
                        port_value: 8080,
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      },
    ],
  },
}

jsonnet で書いているのは趣味。jsonnet config.jsonnet | yq -P で yaml に変換して使っている。

Composite Filter

ExtensionWithMatcher で困るようなケースに対して有用なのが Composite Filter である。 ExtensionWithMatcher と同じく 特定の filter を wrap して利用することができ、 マッチの結果において ExecuteFilterAction の extension で指定した filter に対して処理を移譲することができる。

ExtensionWithMatcher 単体のケースでは、現状マッチしたら Skip する程度しかすることがなかったが、 Composite Filter を利用すれば 例えば上で紹介した config と等価な config を次のように記述することができる(主要な変更箇所はハイライトしている)。

{
  static_resources: {
    listeners: [
      {
        address: {
          socket_address: {
            address: '0.0.0.0',
            port_value: 8181,
          },
        },
        filter_chains: [
          {
            filters: [
              {
                name: 'envoy.filters.network.http_connection_manager',
                typed_config: {
                  '@type': 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
                  stat_prefix: 'ingress_http',
                  access_log: {
                    name: 'envoy.access_loggers.stdout',
                    typed_config: {
                      '@type': 'type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog',
                    },
                  },
                  route_config: {
                    virtual_hosts: [
                      {
                        name: 'upstream',
                        domains: ['*'],
                        routes: [
                          {
                            match: { prefix: '/' },
                            route: { cluster: 'upstream' },
                          },
                        ],
                      },
                    ],
                  },
                  http_filters: [
                    {
                      name: 'envoy.extensions.common.matching',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.common.matching.v3.ExtensionWithMatcher',
                        extension_config: {
                          name: 'composite',
                          typed_config: {
                            '@type': 'type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite',
                          },
                        },
                        matcher: {
                          matcher_list: {
                            matchers: [
                              {
                                predicate: {
                                  single_predicate: {
                                    input: {
                                      name: 'envoy.type.matcher.request-headers',
                                      typed_config: {
                                        '@type': 'type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput',
                                        header_name: ':path',
                                      },
                                    },
                                    value_match: {
                                      exact: '/',
                                    },
                                  },
                                },
                                on_match: {
                                  action: {
                                    name: 'composite-action',
                                    typed_config: {
                                      '@type': 'type.googleapis.com/envoy.extensions.filters.http.composite.v3.ExecuteFilterAction',
                                      typed_config: {
                                        name: 'envoy.filters.http.fault',
                                        typed_config: {
                                          '@type': 'type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault',
                                          abort: {
                                            http_status: 503,
                                            percentage: {
                                              numerator: 10,
                                              denominator: 'HUNDRED',
                                            },
                                          },
                                        },
                                      },
                                    },
                                  },
                                },
                              },
                            ],
                          },
                        },
                      },
                    },
                    {
                      name: 'envoy.filters.http.router',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
                      },
                    },
                  ],
                },
              },
            ],
          },
        ],
      },
    ],
    clusters: [
      {
        name: 'upstream',
        type: 'LOGICAL_DNS',
        dns_lookup_family: 'V4_ONLY',
        load_assignment: {
          cluster_name: 'upstream',
          endpoints: [
            {
              lb_endpoints: [
                {
                  endpoint: {
                    address: {
                      socket_address: {
                        address: 'app',
                        port_value: 8080,
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      },
    ],
  },
}

ExecuteFilterAction で指定できる filter は設定から察せるとおり、任意の filter で良い。 つまり、matcher_tree などを使って特定の要素に関する場合分けなども可能になる。

{
  static_resources: {
    listeners: [
      {
        address: {
          socket_address: {
            address: '0.0.0.0',
            port_value: 8181,
          },
        },
        filter_chains: [
          {
            filters: [
              {
                name: 'envoy.filters.network.http_connection_manager',
                typed_config: {
                  '@type': 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
                  stat_prefix: 'ingress_http',
                  access_log: {
                    name: 'envoy.access_loggers.stdout',
                    typed_config: {
                      '@type': 'type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog',
                    },
                  },
                  route_config: {
                    virtual_hosts: [
                      {
                        name: 'upstream',
                        domains: ['*'],
                        routes: [
                          {
                            match: { prefix: '/' },
                            route: { cluster: 'upstream' },
                          },
                        ],
                      },
                    ],
                  },
                  http_filters: [
                    {
                      name: 'envoy.extensions.common.matching',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.common.matching.v3.ExtensionWithMatcher',
                        extension_config: {
                          name: 'composite',
                          typed_config: {
                            '@type': 'type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite',
                          },
                        },
                        matcher: {
                          matcher_tree: {
                            input: {
                              name: 'envoy.type.matcher.request-headers',
                              typed_config: {
                                '@type': 'type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput',
                                header_name: ':path',
                              },
                            },
                            exact_match_map: {
                              map: {
                                '/': {
                                  action: {
                                    name: 'composite-action',
                                    typed_config: {
                                      '@type': 'type.googleapis.com/envoy.extensions.filters.http.composite.v3.ExecuteFilterAction',
                                      typed_config: {
                                        name: 'envoy.filters.http.fault',
                                        typed_config: {
                                          '@type': 'type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault',
                                          abort: {
                                            http_status: 503,
                                            percentage: {
                                              numerator: 10,
                                              denominator: 'HUNDRED',
                                            },
                                          },
                                        },
                                      },
                                    },
                                  },
                                },
                                '/delay': {
                                  action: {
                                    name: 'composite-action',
                                    typed_config: {
                                      '@type': 'type.googleapis.com/envoy.extensions.filters.http.composite.v3.ExecuteFilterAction',
                                      typed_config: {
                                        name: 'envoy.filters.http.fault',
                                        typed_config: {
                                          '@type': 'type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault',
                                          delay: {
                                            fixed_delay: '1s',
                                            percentage: {
                                              numerator: 100,
                                              denominator: 'HUNDRED',
                                            },
                                          },
                                        },
                                      },
                                    },
                                  },
                                },
                              },
                            },
                          },
                        },
                      },
                    },
                    {
                      name: 'envoy.filters.http.router',
                      typed_config: {
                        '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
                      },
                    },
                  ],
                },
              },
            ],
          },
        ],
      },
    ],
    clusters: [
      {
        name: 'upstream',
        type: 'LOGICAL_DNS',
        dns_lookup_family: 'V4_ONLY',
        load_assignment: {
          cluster_name: 'upstream',
          endpoints: [
            {
              lb_endpoints: [
                {
                  endpoint: {
                    address: {
                      socket_address: {
                        address: 'app',
                        port_value: 8080,
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      },
    ],
  },
}
 

おわりに

Envoy Proxy の特に filter 周りは設定が不足していたりして痒いところに手が届かないことが多いが、 それぞれの extension を wrap する形式で進化が進んでいるので、困ったらドキュメントをざっくり眺めるのが良いと思う。

こうも表現力高く様々なことが実現できるが、インターネットに設定例や知見が落ちていることが少ない分野なので少しでも糧になればと思った。

諸注意

この記事で紹介しているいくつかの filter やその機能は under development だったり、 unknown security posture を含む場合があるので、公式ドキュメントの案内に従って用法用量を守ること。

Footnotes

  1. ExtensionWithMatcher の matcher は将来的に xds_matcher に変わられて deprecated になる ref