一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Amazon EKSでgRPCサーバを運用する

以前の記事でも紹介した通り、一休では、gRPCを使ったサービスを導入し始めています。

user-first.ikyu.co.jp

この記事では、このサービスを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を使ってこの問題を解決しています。

www.envoyproxy.io

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での事例を参考にしつつ、以下のような配置構成にしました。

f:id:s-tokutake:20190826085145p:plain

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ではヘルスチェック( livenessprobereadinessprobe)は重要な役割を果たしますので、設定する必要があります。 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が停止する流れは大まかに以下のようになります。

  1. PodがTerminating 状態になる。同時にpreStopフックが動き、healthcheck/fail にPOSTリクエストがされたあと、10秒待つ。
  2. Podには、リクエストは来るが、レスポンスを返すときにコネクションは閉じられる。
  3. 10秒待っている間に、ServiceからPodが外れる。以後、新しいリクエストは来なくなる。接続中のリクエストも10秒間ですベて処理される、はず。
  4. 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を行なっています。