以前の記事でも紹介した通り、一休では、gRPCを使ったサービスを導入し始めています。
この記事では、このサービスをAmazon EKSで提供するための設計や気をつけたポイントについて紹介します。
背景
一休では、ウェブアプリケーションの実行環境としてAWS Elastic Beanstalkを採用しています。 そして、この4月からElastic BeanstalkをAmazon EKSへ移行するプロジェクトを進めています。 このgRPCサービスもElastic Beanstalkで運用をしていましたが、以下の問題を抱えていました。
- 適切にロードバランシングできない。
- Elastic BeanstalkでgRPCサービスを運用しようとするとNetwork Load Balancer(NLB)を使うことになります。NLBはレイヤ4のロードバランサです。一方で、gRPCはhttp/2で動作し、ひとつのコネクションを使いまわします。このため、NLBでは、特定のサーバに負荷が偏ってしまう場合があります。
また、EC2サーバのdockerdのcpu利用率が突然上昇する、アプリケーションのデプロイに時間がかかるというような問題も起き始めていたため、Amazon EKSへ移行することでこれらの問題の解決を試みました。
KubernetesでgRPCサーバを動かす場合の注意点
Amazon EKSは、AWSが提供するマネージドなKubernetesです。KubernetesでgRPCサーバ動かす場合の注意点は上述のNLBの場合と同様、以下の点になります。
- KubernetesのServiceはL4のロードバランサであり適切に負荷分散できない。
このため、KubernetesのServiceではなく別の仕組みを使って負荷分散を実現する必要あります。
Envoyを導入する
多くの事例でEnvoyを使ってこの問題を解決しています。
AWSのApplication Load Balancer(ALB)がhttp/2に対応しているのはクライアントとALB間の通信でありALBからバックエンドへの通信はhttp/2に対応していない、だから、gRPCサーバの場合はNLBを使う必要がある。そしてNLBを使うと上述の問題にぶつかる、、、ということでしたが、Envoyは、公式サイトに書かれている通り、クライアントとEnvoyの間もEnvoyとバックエンドサーバの間も両方ともHTTP/2とgRPCをサポートします。一休でもEnvoyを使うことに決めました。
構成
GitHub - GoogleCloudPlatform/grpc-gke-nlb-tutorial: gRPC load-balancing on GKE using Envoy
このGKEでの事例を参考にしつつ、以下のような配置構成にしました。
Kubernetesクラスタ外部からのアクセスを受ける必要があるため、type: LoadBalancer
のService経由でEnvoyが外からのアクセスを受けます。EnvoyのバックエンドはHeadless Serviceにします。Headless Serviceを使うと背後にあるPodに直接アクセスできるレコードがクラスタ内部DNSに作成されます。EnvoyはこのDNSレコードからPodのIPを取得し、Envoy側の構成にしたがって負荷分散を行います。
Envoyの設定
Envoyの構成情報はKubernetesのComfigMapで設定します。内容な以下の通り。
apiVersion: v1 kind: ConfigMap metadata: name: grpc-service-proxy data: envoy.yaml: | static_resources: listeners: - address: socket_address: address: 0.0.0.0 port_value: 50051 filter_chains: - filters: - name: envoy.http_connection_manager config: access_log: - name: envoy.file_access_log config: path: "/dev/stdout" codec_type: AUTO stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: https domains: - "*" routes: - match: prefix: "/" route: cluster: grpc-service http_filters: - name: envoy.health_check config: pass_through_mode: false headers: - name: ":path" exact_match: "/healthz" - name: "x-envoy-livenessprobe" exact_match: "healthz" - name: envoy.router config: {} clusters: - name: grpc-service connect_timeout: 0.5s type: STRICT_DNS dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN drain_connections_on_host_removal: true http2_protocol_options: {} hosts: - socket_address: address: grpc-service.default.svc.cluster.local port_value: 50051 health_checks: timeout: 3s interval: 5s unhealthy_threshold: 2 healthy_threshold: 2 grpc_health_check: { service_name: Check } admin: access_log_path: /dev/null address: socket_address: address: 127.0.0.1 port_value: 8001
注意点としては、以下の通りです。
drain_connections_on_host_removal: true
を設定すること。- この設定がない場合、バックエンド側のServiceからPodが外れても、そのPodのヘルスチェックが失敗するまで、Envoyは外れたPodにトラフィックを流し続けます。その結果、デプロイ時にリクエストを落としてしまう可能性が発生します。
grpc_health_check
を指定すること。- バックエンド側がgRPCサービスなのでこれを設定します。
gRPCサービス側の構成
gRPCサービスはElastic BeanstalkでもDockerを使って動かしていました。したがって、EKSに動かすために大きな変更は必要ありませんでした。唯一必要だったのがヘルスチェックです。
上述の通りNLBではgRPCサービスのヘルスチェックをすることができません。NLBのヘルスチェックはTCPのポートの疎通確認ですが、厳密にいえばこれはアプリケーションの死活監視になりません。一方、Kubernetesではヘルスチェック( livenessprobe
とreadinessprobe
)は重要な役割を果たしますので、設定する必要があります。
gRPCサービスに対するヘルスチェックについては以下の記事に方法が書いてあります。
Health checking gRPC servers on Kubernetes - Kubernetes
この記事に従って、以下の2点を行いました。
- gRPCサービスにヘルスチェックのエンドポイントを実装する。
- Dockerイメージ内にgrpc-health-probeを同梱する。
そして、gRPCサービスのPodの定義を以下のようにしました。
...略... spec: containers: - name: grpc-service image: ["ImageName"] imagePullPolicy: IfNotPresent ports: - containerPort: 50051 readinessProbe: exec: command: ["/bin/grpc_health_probe", "-addr=:50051","-service=Check"] initialDelaySeconds: 5 livenessProbe: exec: command: ["/bin/grpc_health_probe", "-addr=:50051","-service=Check"] initialDelaySeconds: 10 ...略...
リクエストを落とさずにデプロイをする
ここまで構築してリクエストを流してみると、ひとつのコネクションで多数のリクエストを送信しても、きちんと複数のPodにトラフィックが分散されることがわかりました。
あとは、EnvoyもgRPCサービスもリクエストを落とさずにデプロイできるように設定する必要があります。
Envoyの場合
Envoyの場合は、以下のissueがとても参考になりました。
Graceful HTTP Connection Draining During Shutdown? · Issue #7841 · envoyproxy/envoy · GitHub
このissueによれば、/healthcheck/fail
というエンドポイントにPOSTリクエストをすることでEnvoyはそれ以後受け付けたリクエストの接続をクローズするようです。これを活用して、EnvoyのPodの定義のlifecycle
に以下の通り書けばリクエストを落とさずにPodの入れ替えができそうです。
...略... lifecycle: preStop: exec: command: - /bin/sh - '-c' - >- wget -qO- --post-data='' http://127.0.0.1:8001/healthcheck/fail && sleep 10 ...略...
preStop
フックで wgetを使ってhttp://127.0.0.1:8001/healthcheck/fail
にPOSTリクエストを行い、その後10秒待っています。 127.0.0.1:8001
には Envoyの管理用のAPIが立ち上がっています。
この設定によって、Podが停止する流れは大まかに以下のようになります。
- PodがTerminating 状態になる。同時にpreStopフックが動き、
healthcheck/fail
にPOSTリクエストがされたあと、10秒待つ。 - Podには、リクエストは来るが、レスポンスを返すときにコネクションは閉じられる。
- 10秒待っている間に、ServiceからPodが外れる。以後、新しいリクエストは来なくなる。接続中のリクエストも10秒間ですベて処理される、はず。
- 10秒停止後、SIGTERMで、コンテナが停止される。すでに接続がない状態なので、リクエストは落とさない。
この設定をして、トラフィックを流しつつ、Envoyのデプロイを行ってみたところリクエストを落とさずにPodが入れ替わることが確認できました。
gRPCサーバの場合
注意点は以下のふたつです。
- 上述の通り、Envoyの構成で
drain_connections_on_host_removal: true
の設定をする。- この設定をしないとEnvoyはヘルスチェックが通り続ける限り、古いReplicaSetに属するPodにもトラフィックを流し続けます。
- アプリケーションはSIGTERMを受け取ったら、グレースフルにシャットダウンする処理を実行する。
- 今回のサービスはGoのgRPCサービスなので、GracefulStop()を呼び出すようにしています。
このふたつをやっておけば、ローリングアップデートでもBlue/greenでもリクエストを落とさずにPodの入れ替えができることを確認できました。
まとめ
今回は、gRPCサービスのEKSでの動かした方を紹介しました。 一休では、現在、主要サービスのAmazon EKSへの移行を進めています。この移行の全体像については、別途、紹介するつもりです。
参考資料
文中に紹介したリンク以外にも以下のサイトを参考にさせて頂きました。
Kubernetes上でgRPCサービスを動かす | SOTA
kubernetesでgRPCするときにenvoy挟んでみたよ - Qiita
Kubernetes: 詳解 Pods の終了 - Qiita
spinnaker/solutions/bluegreen at master · spinnaker/spinnaker · GitHub
Manage Traffic Using Kubernetes Manifests - Spinnaker
この記事の筆者について
- システム本部CTO室所属の 徳武 です。
- サービスの技術基盤の開発運用、開発支援、SREを行なっています。