Go+gRPC-WebのアプリケーションをGKE+Envoyで負荷分散する


概要

今回はGo+gRPC-Webで実装したバックエンドサーバをGKE+Envoyで負荷分散していきます。
gRPCとEnvoyを使用した記事は多く見かけたのですが、gRPC-Webを用いた記事は見つからなかったので、実際に構築してみました。基本的には従来のgRPCを使った場合と一緒です。
因みに、詳しくは後述しますが、gRPC-Webはclient→envoy間はhttp/1.1なので、「その間にALB挟めば普通に負荷分散できるんじゃね?🤔」 …と思い一応検証してみましたが、問題なく動作致しました。当たり前か…。
ただ、後学の為にも勉強をしておいて損はないはずです!

僕のスペックとしてはKubernetes含め、インフラに関しては殆ど初心者ですので、誤りや不備に対するご意見・ご指摘大歓迎です!

アーキテクチャ図

今回構築する環境の簡単なアーキテクチャ図です。

今回説明しないこと

  • KubernetesやEnvoy等の基礎知識
  • gRPC-Webを使用したクライアントの作成
  • Goを使用したgRPCサーバの作成
  • kubectlやgcloudなどのCLIツールのインストール

この辺の準備は各自でよろしくお願いします🙇‍♂️

前提知識

GKEのL7LBはgRPCに対応していません。(AWSのALB等も同様です)
また、gRPCの通信は永続化される(HTTP/2)のでスケール時に負荷分散されないという問題もあります。
そこで、Envoyが登場します。Envoyはクライアント→Envoy、Envoy→バックエンドサーバ間の両方ともHTTP/2とgRPCをサポートします。

ただ、gRPC-Web自体はクライアント→バックエンド間はHTTP/1.1になりますので、間にプロキシを挟む必要があります。(EnvoyやNginx等で可能です)

なぜプロキシを挟む必要があるのか

ここら辺に関しては以下のサイトで分かりやすくご説明されています。

長いこと Web では HTTP/1.1 が使われてきました。なので Web サーバが HTTP/1.1 で通信できることは期待できます(というかそれが Web サーバの定義か)が、 HTTP/2 で通信できるかどうかは不明です。ブラウザは HTTP/2 で通信できればそうしますが、できない場合は HTTP/1.1 を勝手に使います。
また通信プロトコルはレイヤーアーキテクチャなので、上位に位置する Web アプリケーションが下位で使われているのが HTTP/2 なのか、あるいは HTTP/1.1 なのかを意識するべきではありません。つまり HTTP/1.1 と HTTP/2 を透過的に扱えなければならない以上、片方にしか存在しない機能を操作する API が提供されることはない、そして gRPC ではその領域に属する機能を使っているわけなのです。

引用: gRPC-Web がプロキシを必要とする理由

なるほど…🤔
ただ、公式によると、今後プロキシをなくして言語レベルでgRPC-Webに対応する可能性もあるようなので、可能なら是非実現して欲しいですね!

環境構築

それでは実装していきましょう!
流れはざっくりこんな感じです!

  • EnvoyをgRPCサーバのSidecarコンテナとしてデプロイ
  • ServiceをHeadlessにする
  • Envoyをロードバランサーとしてデプロイ

EnvoyをgRPCサーバのSidecarコンテナとしてデプロイ

EnvoyをgRPCサーバのSidecarプロキシとしてデプロイします。
Sidecarは下記の様に spec.template.containers に続けて書いていけば実現できます。

~~ 略 ~~

      containers:
        - name: golang # golang
          resources:
            requests:
              cpu: 50m
              memory: 10Mi
            limits:
              cpu: "1"
              memory: 20Mi
          image: gRPCサーバのイメージ
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 9000
              name: golang-service
        - name: envoy # envoy sidecar
          resources:
            requests:
              cpu: 50m
              memory: 10Mi
            limits:
              cpu: "1"
              memory: 20Mi
          image: envoyproxy/envoy:v1.12.2
          imagePullPolicy: IfNotPresent
          volumeMounts:
            - name: sidecar-service-config
              mountPath: /etc/envoy
          command:
            - "/usr/local/bin/envoy"
          args:
            - "--config-path /etc/envoy/sidecar-service.yaml"
          ports:
            - containerPort: 8080
              name: envoy-sidecar
            - containerPort: 9901
              name: envoy-admin

Envoyの設定ファイルです。

admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
  listeners:
    - address:
        socket_address: { address: 0.0.0.0, port_value: 8080 }
      filter_chains:
        - filters:
            - name: envoy.http_connection_manager
              config:
                codec_type: auto
                stat_prefix: ingress_http
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: golang_service
                            max_grpc_timeout: 0s
                      cors:
                        allow_origin:
                          - "*"
                        allow_methods: GET, PUT, DELETE, POST, OPTIONS
                        allow_headers: authorization,deadline,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                        max_age: "1728000"
                        expose_headers: custom-header-1,grpc-status,grpc-message
                http_filters:
                  - name: envoy.grpc_web
                  - name: envoy.cors
                  - name: envoy.router
  clusters:
    - name: golang_service
      connect_timeout: 0.25s
      type: static
      http2_protocol_options: {}
      upstream_connection_options:
        tcp_keepalive:
          keepalive_time: 300
      lb_policy: round_robin
      # win/mac hosts: Use address: host.docker.internal instead of address: localhost in the line below
      hosts: [{ socket_address: { address: 127.0.0.1, port_value: 9000 } }]

今回はSidecarとしてデプロイするので、clusters.hosts.address127.0.0.1 で大丈夫です。後はConfigMapを作成するなり、イメージに直接組み込むなりして下さい🙆‍♂️

ServiceをHeadlessにする

下記の様に ClusterIP: None とするとHeadlessになります。

Headless Serviceを使うと背後にあるPodに直接アクセスできるレコードがクラスタ内部DNSに作成されます。EnvoyはこのDNSレコードからPodのIPを取得し、Envoy側の構成にしたがって負荷分散を行います。

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

apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  clusterIP: None
  ports:
    # Actually, no port is needed.
    # but set it because of the following bug.
    # https://github.com/kubernetes/kubernetes/issues/55158
    - name: headless
      port: 12345
      protocol: TCP
      targetPort: 12345
  selector:
    app: user-service

これでバックエンドサーバ側の設定は終了です!

Envoyをロードバランサーとしてデプロイ

Envoyの設定ファイルを作ります。

admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 80 }
      filter_chains:
        - filters:
            - name: envoy.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
                codec_type: auto
                stat_prefix: ingress_http
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: user
                            max_grpc_timeout: 0s
                      cors:
                        allow_origin_string_match:
                          - safe_regex:
                              google_re2: {}
                              regex: \*
                        allow_methods: GET, PUT, DELETE, POST, OPTIONS
                        allow_headers: authorization,deadline,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                        max_age: "1728000"
                        expose_headers: custom-header-1,grpc-status,grpc-message
                access_log:
                  - name: envoy.file_access_log
                    config:
                      path: /dev/stdout
                http_filters:
                  - name: envoy.grpc_web
                  - name: envoy.cors
                  - name: envoy.router
  clusters:
    - name: user
      connect_timeout: 0.25s
      type: strict_dns
      http2_protocol_options: {}
      lb_policy: round_robin
      health_checks:
        - timeout: 5s
          interval: 10s
          unhealthy_threshold: 2
          healthy_threshold: 2
          tcp_health_check: {}
      hosts: [{ socket_address: { address: user-service, port_value: 8080 } }]

clusters.hosts にはEnvoyが負荷分散する為にも、各PodのIPアドレスを知っている必要があるのですが、ここで先程作成したHeadless Serviceを使用します。今回は user-service となっていますね。これで設定ファイルの記述は終了です!

因みにEnvoyロードバランサーのServiceはこんな感じです。(Deploymentは省略します。)

apiVersion: v1
kind: Service
metadata:
  name: lb-service
spec:
  type: LoadBalancer
  selector:
    app: envoy-lb
  ports:
    - name: proxy
      protocol: TCP
      port: 80
      targetPort: 80
    - name: admin
      protocol: TCP
      port: 9901
      targetPort: 9901

そして、最後に下記の様な感じでクラスタを作成し、kubectl applyすると環境構築は完了です!

$ gcloud container clusters create --num-nodes=3 hoge-cluster \
--zone asia-northeast1-a \
--machine-type g1-small \
--enable-autoscaling --min-nodes=3 --max-nodes=6

あとは kubectl get svc 等で、エンドポイントを調べ、実際にリクエストを送ってみれば負荷分散が完了しているはずです!

終わりに

僕自体はよくAWSを利用するので、元々はEKSを使いたかったのですが、クラスターひとつに付き固定で0.1$/hかかってしまうので、実際に運用するとなると貧乏学生の僕にはちょっと厳しいですね…。その辺GKEはお値段も比較的お安いので、流石のk8sの生みの親Googleって感じですね😊

参考文献

この記事は以下の情報を参考にして執筆しました。