NomadのゲートウェイをNginxからEnvoyに置き換える

この記事はさくらインターネット Advent Calendar 2020 5 日目の記事です。 https://qiita.com/advent-calendar/2020/sakura

現在社内にて Nomad と Consul を利用してサービス開発を行っています。 Nomad はアプリケーションのスケジューリングを行い、ざっくりと Kubernetes,Marathon 等と同様の機能を提供しています。 Consul はサービスの死活監視・サービス検出・サービスメッシュ等を提供しています。

このような構成では、外部からの通信を適切にアプリケーションへ転送するゲートウェイが必要となります。 Nomad+Consul を利用している環境の多くは、consul-template で Nginx の設定を自動生成し、変更があった際には reload を行うという構成を採用しているかと思います。 consul-template は Consul で登録されているサービスの変更をリアルタイムに受信し、サービス情報を任意のテンプレートに展開をするコンポーネントです。

しかしながら Nginx と consul-template を組み合わせた LB はいくつかの問題を抱えており、現在のチームでは Envoy を利用した構成へと移行する事となりました。

LB として使う Nginx

今回 Nginx はアプリケーションへのリバースプロキシを行うロードバランサーとして利用しています。 LB として利用した際の Nginx のメリットは、設定がシンプルで導入が非常に容易という点でしょう。

導入は多くの環境で apt install nginx とパッケージマネージャーを利用したインストールが行えます。 設定も以下の例のように、非常に簡潔で人が読み書きしやすいものとなっています。 例では Nginx へ http://www.example.com/api 以下への通信が発生した際に、 10.0.0.1:80 または 10.0.0.2:80 のアップストリームへとリバースプロキシを行うという設定を行っています。

http {
  upstream apps {
    server 10.0.0.1;
    server 10.0.0.2;
  }
  server {
    listen 80;
    server_name www.example.com;
    location /api {
      proxy_pass http://apps;
    }
  }
}

先に述べた consul-template は、この設定の server 等の部分を動的に書き換えることによって、アップストリームが動的に変動する環境での利用をサポートしています。

しかし、このような用途で Nginx が抱える問題はいくつかあります。 代表的なものを以下に挙げました。

  • http_stub_status_module にて監視できる項目数がかなり少ない
  • Circuit Breaker の設定が行えない
  • エンドポイント毎のコネクション制限等を設定できない
  • 動的に設定が変化する環境には向いていない
  • Upstream には HTTP1 系のみサポート

標準モジュールの http_stub_status_module では、 http://localhost/status などにアクセスすることで Nginx プロセス全体のメトリクスを取得することが出来ます。 しかし項目数は限られており、ある程度の規模のリクエストを捌く際に問題になることがあるかと思います。 項目は connections accepts handled requests Reading Writing Waiting がサポートされています。 例えば一部のバックエンドに障害が発生した際、どのバックエンドに障害が発生しているのかをこれらのメトリクスから知ることは出来ません。

また、バックエンドのサービスが過負荷状態になった際に通信を遮断する Circuit Breaker のサポートも限定的です。 一部 Late Limit 等はサポートされていますが、エンドポイント毎に細かいコネクション数を制限することは難しいです。 なので、一部のバックエンドに障害が発生した際には性能への影響が全体に及ぶ可能性が高いと言えるでしょう。

動的に設定が変化する環境に対しての標準サポートは無く、設定ファイルを書き込んで SIGHUP を送信するというインタフェースを採用しています。 多くの環境では問題になることは無いのですが、内部プロセスの再起動が行われているので高頻度で書き換えられる際には問題になることが有るかと思います。

なお、こういった制限の一部は有償版の Nginx Plus を利用することで緩和される箇所も有ります。

Envoy の特徴

Envoy は L3/L4,L7 のロードバランサーとして利用可能で、多くのモダンな仕組みをサポートしています。 HTTP,gRPC のような汎用プロトコルの他に Redis,MySQL 等の一部のアプリケーションプロトコルもサポートしていて、固有のメトリクス情報等を収集することができます。

標準で API 経由の設定書き換えがサポートされており監視できるメトリクスの種類が多いことからも、Cloud Native に必要となる Istio 等のサービスメッシュや Gloo 等の API ゲートウェイの内部にも利用されています。

  • L3/L4,L7 LB
    • TCP,UDP
    • HTTP,gRPC
    • Redis,MySQL
  • API 経由での設定書き換え
    • yaml ファイルの iNotify
    • REST のポーリング, gRPC のストリーム
  • 監視出来る項目が多い
    • Prometheus で収集出来るエンドポイントを公開
  • 利用例
    • Service Mesh: Istio, Consul Connect
    • API Gateway: Gloo, Apigee

https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_envoy

サービスメッシュと一緒に語られることが多いですが、今回は Consul サービスのゲートウェイとして利用してみたいと考えているので一旦切り離して概要を確認していきたいと思います。

Envoy 設定例

Envoy の設定は Yaml,JSON,ProtocolBuffer 等のファイルが指定可能です。 実行バイナリにオプションで ./envoy -c config.yaml のように渡すことで設定ファイルが読み込まれます。 先に載せた nginx の設定例と同様の挙動を行う Envoy 設定ファイルがこちらです。

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 80 }
      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
                codec_type: AUTO
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/api/" }
                          route: { cluster: api_servers, prefix_rewrite: "/" }
                http_filters:
                  - name: envoy.filters.http.router

  clusters:
    - name: api_servers
      connect_timeout: 0.25s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address: { address: 10.0.0.1, port_value: 80 }
              - endpoint:
                  address:
                    socket_address: { address: 10.0.0.2, port_value: 80 }

Nginx と比べると記述方法が複雑で、人が積極的に読み書きしたいフォーマットではなさそうです。 しかし、Listener, Route, Cluster の重要なリソースを中心に読み解いていけば読めるようになってくるんじゃないかと思います。

Listener, Route, Cluster

まず Listener ですが、TCP/UDP・待受ポート・TLS 設定・レート制限などを記述します。 この例では単に 80 番ポートでの Listen を行っています。

listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 80 }

次に Route ですが、nginx で言うところの location だとかの記述を行う箇所です。 HTTP の L7 ルーティングの記述を行い、記述場所は Listener の中です。 ホスト名・パス文字列・ヘッダのマッチング/書き換えの他に、タイムアウト・リトライ・利用クラスタ(アップストリーム)の記述を行います。

route_config:
  name: local_route
  virtual_hosts:
    - name: local_service
      domains: ["*"]
      routes:
        - match: { prefix: "/api/" }
          route: { cluster: api_servers, prefix_rewrite: "/" }

次は Upstream ですが、利用するバックエンドサーバ・ウエイト・ディスカバリ方法・ヘルスチェックなどを指定します。 lb_policy フィールドはトラフィックの振り分け方法の指定し、 ROUND_ROBIN LEAST_REQUEST RING_HASH などが選択可能です。

clusters:
  - name: api_servers
    connect_timeout: 0.25s
    type: STATIC
    lb_policy: ROUND_ROBIN # https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancers
    health_checks:
      - timeout: 1s
        interval: 15s
        unhealthy_threshold: 3
        healthy_threshold: 3
        http_health_check: { path: /health }
    load_assignment:
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address: { address: 10.0.0.1, port_value: 80 }
            - endpoint:
                address:
                  socket_address: { address: 10.0.0.2, port_value: 80 }

Listener, Route, Cluster を理解すると、 もはや nginx の設定と同じ様に見えてくるのではないでしょうか。

http {
  upstream apps {
    server 10.0.0.1;
    server 10.0.0.2;
  }
  server {
    listen 80;
    server_name www.example.com;
    location /api {
      proxy_pass http://apps;
    }
  }
}

xDS での動的な設定

nginx ではファイルを書き換えて reload を行うことで、動的な設定変更を行っていました。 Envoy では、主に 3 つの方法がサポートされています。

  • json,yaml ファイルの変更を inotify で検知
  • HTTP エンドポイントへ設定情報を一定時間ごとにポーリング
  • gRPC エンドポイントからストリームで設定情報を受け取る

HTTP,gRPC を利用した設定変更は以下のようなサービスに分類されており、全て合わせて xDS と呼んでいます。 余談ですが、HTTP のポーリングを利用している実装はあまり見ず、ほとんど gRPC で利用しているよう感じます。

  • Listener: Listener Discovery Service (LDS)
  • RouteConfiguration: Route Discovery Service (RDS)
  • Cluster: Cluster Discovery Service (CDS)
  • ClusterLoadAssignment: Endpoint Discovery Service (EDS)
  • ScopedRouteConfiguration: Scoped Route Discovery Service (SRDS)
  • VirtualHost: Virtual Host Discovery Service (VHDS)
  • Secret: Secret Discovery Service (SDS)
  • Runtime: Runtime Discovery Service (RTDS)

gRPC サーバで xDS サービス実装は、ライブラリが用意されており簡単です。 xDS の各サービスごとに対応する設定ファイルの pb 定義がライブラリに入ってるので、設定情報を組み立てて Envoy に返すという流れです。 https://github.com/envoyproxy/go-control-plane

xDS を実際に利用したデモ

https://github.com/kamijin-fanta/envoy-xds-sample

実際に xDS サーバを利用したでも環境を用意しました。 docker-compose up で起動が行えるかと思います。 以下のポートで各管理画面が閲覧可能です。

内部の構成は以下のようになっています。

  • envoy proxy
    • 主役・dummy_cluster へ通信をリバースプロキシ
  • dummy_cluster
    • ランダムなポートで HTTP サーバが建つ Go アプリケーション・1 秒毎に追加/削除する
    • 2%の確率で高レイテンシ・2%の確率で 500 番エラー
  • xds_service
    • dummy_cluster で建てられたサーバアドレスを envoy に伝える xDS サーバ
  • prometheus, grafana
    • envoy のメトリクスを収集
  • hey
    • HTTP リクエストジェネレータ

Envoy の設定はこのようになっており、上で例示した設定ファイルの 10.0.0.1:80 10.0.0.2:80 のアップストリームを動的に書き換えるという構成です。 アップストリームを書き換えるので利用する Envoy の設定サービスは EDS(Endpoint Discovery Service)となり、リソースは ClusterLoadAssignment となります。

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 8123 }
      filter_chains:
        - filters:
            - name: envoy.http_connection_manager
              config:
                stat_prefix: ingress_http
                route_config:
                  name: route
                  virtual_hosts:
                    - name: app_service
                      domains: ["app.local"]
                      routes:
                        - match: { prefix: "/" }
                          route: { cluster: app_cluster, timeout: 0.7s }
                http_filters:
                  - name: envoy.router
  clusters:
    - name: app_cluster
      type: EDS
      eds_cluster_config:
        eds_config:
          api_config_source:
            api_type: GRPC
            grpc_services:
              envoy_grpc: { cluster_name: xds_cluster }
    - name: xds_cluster
      load_assignment:
        cluster_name: xds_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address: { address: 127.0.0.1, port_value: 20000 }

dummy_cluster は、1 秒毎にランダムなポートで HTTP サーバが建つ Go で作ったアプリケーションです。 起動中のサーバが 10 台以上になったら一番古いサーバが終了します。 また、変動するメトリクスを収集したいので一定確率でエラーが出るロジックも追加しています。 2%の確率で高レイテンシ・2%の確率で 500 番エラーが発生します。

$ go run ./cmd/dummy-cluster/.
http listen on [::]:5000
add: 127.0.0.1:37631    remove: 127.0.0.1:45589
add: 127.0.0.1:40271    remove: 127.0.0.1:40367

また、以下のように GET /servers エンドポイントでサーバのリストが取得できます。

$ curl localhost:5000/server
127.0.0.1:36577
127.0.0.1:45459
127.0.0.1:34989

xds_service は、gRPC で xDS エンドポイントを公開し envoy にエンドポイントリストを伝えます。 dummy_clusterGET /servers からサーバリストを 100ms 毎にポーリングします。 サーバリストに変化が有れば xDS を通じて envoy に通知します。

メトリクス監視は、 http://localhost:8111/stats/prometheus でメトリクスが公開されるので、それを Prometheus でスクレイピングする設定を行います。 以下のようにメトリクスが出力され、メトリクス数は 700 を超えていました。 出力されるメトリクスの意味はリンク先で解説されています。 アップストリームのステータスコード・レスポンスタイム等、非常に細かい粒度でメトリクスが出力されていることが分かります。

# TYPE envoy_cluster_external_upstream_rq counter
envoy_cluster_external_upstream_rq{envoy_response_code="200",envoy_cluster_name="app_cluster"} 6839
envoy_cluster_external_upstream_rq{envoy_response_code="500",envoy_cluster_name="app_cluster"} 290
envoy_cluster_external_upstream_rq{envoy_response_code="504",envoy_cluster_name="app_cluster"} 92

# TYPE envoy_cluster_external_upstream_rq_completed counter
envoy_cluster_external_upstream_rq_completed{envoy_cluster_name="app_cluster"} 7221

# TYPE envoy_cluster_external_upstream_rq_xx counter
envoy_cluster_external_upstream_rq_xx{envoy_response_code_class="2",envoy_cluster_name="app_cluster"} 6839
envoy_cluster_external_upstream_rq_xx{envoy_response_code_class="5",envoy_cluster_name="app_cluster"} 382

Statistics — envoy 1.17.0-dev-7554d6 documentation

Consul+Nomad 環境で Envoy をゲートウェイとして利用

Nomad のサービスは全て IP:PORT の組み合わせが自動的に Consul に登録され、自動的にヘルスチェックが行われます。 Consul の HTTP API を利用することで、サービス内で正常にアクセス出来ると判断された IP:PORT のリストを得ることが出来ます。 また、API の Blocking Queries を利用すれば変更をリアルタイムに監視することも出来ます。

サービスの一覧は /agent/services 、各サービスのアドレス情報などは /agent/services/:service_id のエンドポイントを利用します。 10 個のサービスのアドレスを監視する場合、11 個のエンドポイントに対し監視を行うことになります。 Consul の開発元から Go 言語の API クライアントライブラリが提供されているので、多くの場合はこちらを利用することになると思います。

最初の目標として、Consul サービスのアドレスが変動した際に Envoy の設定変更を適切に行いたいと思います。 Consul に登録されているサービス情報を EDS(Endpoint Discovery Service)にて Envoy に伝えると、アップストリームが動的に設定可能になります。

コミュニティライブラリとして gojek/consul-envoy-xds が存在しますが、古い Envoy の API タイプである V2 を利用している・機能が豊富でコードが少し複雑になっているので今回は自作することにしました。 成果物はこちらで公開しています。 Consul API の /agent/services/agent/services/:service_id を Watch(内部的には Blocking Queries)を利用してサービスの情報を監視し、変更が有れば EDS を通じて Envoy へと配信します。

https://github.com/kamijin-fanta/consul-envoy-xds-server

Envoy の設定例は以下のようになります。 EDS の設定にて service_name: hoge-api-service と有りますが、Consul のサービス名をそのまま書きます。 EDS のクラスタは 127.0.0.1:15000 と指定しており、上の consul-envoy-xds-server を起動しています。

node:
  id: hostname
  cluster: cluster.local

admin:
  access_log_path: /dev/null
  profile_path: /dev/null
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8080
static_resources:
  listeners:
    - name: listener_http
      address:
        socket_address: { address: 0.0.0.0, port_value: 80 }
      filter_chains:
        - filters:
            - name: envoy.http_connection_manager
              config:
                stat_prefix: ingress_http
                route_config:
                  name: route
                  virtual_hosts:
                    - name: consul
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route: { cluster: consul_hoge_api_service }
                http_filters:
                  - name: envoy.router
  clusters:
    - name: consul_envoy_xds_server
      connect_timeout: 0.25s
      lb_policy: ROUND_ROBIN
      http2_protocol_options: {}
      load_assignment:
        cluster_name: consul_envoy_xds_server
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address: { address: 127.0.0.1, port_value: 15000 }
    - name: consul_hoge_api_service
      type: EDS
      connect_timeout: 0.25s
      eds_cluster_config:
        service_name: hoge-api-service
        eds_config:
          resource_api_version: v3
          api_config_source:
            api_type: GRPC
            transport_api_version: v3
            grpc_services:
              envoy_grpc: { cluster_name: consul_envoy_xds_server }

Let’s Encrypt 証明書を Envoy に設定する

Envoy は標準では Let’s Encrypt の証明書取得プロセス ACME をサポートしていません。 Issue にもなっているのですが、通常は Envoy を直接触ることが無いので問題になることが無いため解決されていないと想像しています。

https://github.com/envoyproxy/envoy/issues/96

そこで、証明書を自動取得し SDS(Secret Discovery Service)で Envoy に自動設定するコンポーネントを作りました。 今回はワイルドカード証明書を取得する必要が有ったため、 Lego を利用した DNS01 認証を行っています。

https://github.com/kamijin-fanta/envoy-acme

1 時間毎に証明書の期限を確認し、指定した日付以下になれば DNS01 認証での証明書取得を開始します。 HA 構成をサポートするために Consul KVS へと証明書を保存し、同時に証明書の更新を開始しないようロック処理を行っています。 SDS サーバが 1 台のみで良い場合は、Consul KVS ではなくローカルファイルシステムに証明書を保存することも可能です。

実際に投入する Envoy の設定は以下のようになります。 SDS サーバは標準で 127.0.0.1:20000 で立ち上がるので、これをクラスターとして記述します。 そして filter_chains 中に transport_socket を設定することで、実際に証明書と秘密鍵を設定しています。

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 443 }
      filter_chains:
        - filters:
            - name: envoy.http_connection_manager
              config:
                stat_prefix: ingress_http
                route_config:
                  name: route
                  virtual_hosts:
                    - name: app_service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          direct_response:
                            status: 200
                            body:
                              inline_string: hello envoy
                http_filters:
                  - name: envoy.router
          transport_socket:
            name: envoy.transport_sockets.tls
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
              common_tls_context:
                tls_certificate_sds_secret_configs:
                  - name: "setting-name"
                    sds_config:
                      resource_api_version: v3
                      api_config_source:
                        api_type: GRPC
                        transport_api_version: v3
                        grpc_services:
                          envoy_grpc:
                            cluster_name: envoy_acme_sds_cluster
  clusters:
    - name: envoy_acme_sds_cluster
      connect_timeout: 0.25s
      http2_protocol_options: {}
      load_assignment:
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address: { address: 127.0.0.1, port_value: 20000 }

まとめ

  • Nomad+Consul の環境に適したゲートウェイは通常 Nginx と Consul Template を利用して構築する
  • 技術的な課題(可観測性)などがある場合 Envoy に置き換えることが出来る
  • Consul から Envoy にアップストリームを伝えるために kamijin-fanta/consul-envoy-xds-server を作成した
  • Envoy に Let’s Encrypt 証明書を設定するために kamijin-fanta/envoy-acme を作成した
  • Envoy を直接利用するためにはコードの記述などが必要となる場合が多いので、k8s 等の環境においては間接的に利用するのが良いと感じる