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を利用することで緩和される箇所も有ります。
- Nginxの監視についての https://www.nginx.com/blog/monitoring-nginx/
- 有償版Nginxで利用可能なngxhttpapimoduleモジュール https://nginx.org/libxslt/en/docs/http/ngxhttpapimodule.html
- NginxにHTTP2系を追加するチケット Closeされている https://trac.nginx.org/nginx/ticket/923
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/whatisenvoy
サービスメッシュと一緒に語られることが多いですが、今回は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 admin: http://localhost:8111/
- envoy proxy: http://localhost:8123/
- grafana: http://localhost:3000/
内部の構成は以下のようになっています。
-
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_cluster
の GET /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クライアントライブラリが提供されているので、多くの場合はこちらを利用することになると思います。
- Service - Agent HTTP API: https://www.consul.io/api-docs/agent/service
- Blocking Queries: https://www.consul.io/api-docs/features/blocking
- https://github.com/hashicorp/consul/tree/master/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等の環境においては間接的に利用するのが良いと感じる