EnvoyでgRPCをロードバランスする

gRPCサーバをサーバサイドでロードバランスしようと思う.

なお,この記事はKubernetesを前提にしている. ECSの場合これと同じ方法でうまく行かない気がしているので注意.

gRPCサーバとクライアントを作る

適当にレスポンスを返してくれるgRPCサーバが必要になる. というわけで以前適当に作ったこいつを使う.

github.com

gRPCサーバと

https://cloud.docker.com/u/h3poteto/repository/docker/h3poteto/grpc_example-server-python

gRPCクライアントのDocker imageがある.

https://cloud.docker.com/u/h3poteto/repository/docker/h3poteto/grpc_example-client-python

gRPCサーバを動かす

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grpc-server-deployment
  namespace: envoy-grpc-example
  labels:
    app: grpc-server
spec:
  replicas: 2
  selector:
    matchLabels:
      app: grpc-server
  strategy:
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 1
  template:
    metadata:
      labels:
        app: grpc-server
    spec:
      volumes:
        - name: envoy-config
          configMap:
            name: server-sidecar-envoy
      containers:
        - name: envoy
          image: envoyproxy/envoy:latest
          volumeMounts:
            - name: envoy-config
              mountPath: /var/opt/envoy
          command: ["envoy", "-c", "/var/opt/envoy/envoy.yaml"]
          resources:
            limits:
              memory: 512Mi
          ports:
            - name: app
              containerPort: 15001
            - name: envoy-admin
              containerPort: 8001

        - name: python
          image: h3poteto/grpc_example-server-python:master
          imagePullPolicy: Always
          ports:
            - name: grpc
              containerPort: 50051
              protocol: TCP
          env:
            - name: SERVER_IP
              value: 0.0.0.0
            - name: SERVER_PORT
              value: "50051"
          resources:
            requests:
              memory: 200Mi
              cpu: 500m
      terminationGracePeriodSeconds: 60

pythonのgRPCサーバは50051ポートでリクエストを受け付ける.

ただし外からのリクエストは一度envoyを通ってからpythonのコンテナに届くことになる. で,envoyの設定だが,これはConfigMapで注入にしている.

というわけでConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: server-sidecar-envoy
  namespace: envoy-grpc-example
data:
  envoy.yaml: |
    admin:
      access_log_path: /tmp/admin_access.log
      address:
        socket_address: { address: 0.0.0.0, port_value: 8001 }
    static_resources:
      listeners:
        - name: listener_grpc
          address:
            socket_address: { address: 0.0.0.0, port_value: 15001 }
          filter_chains:
            - filters:
                - name: envoy.http_connection_manager
                  config:
                    stat_prefix: ingress_http
                    codec_type: AUTO
                    route_config:
                      name: local_route
                      virtual_hosts:
                        - name: service
                          domains: ["*"]
                          routes:
                            - match: { prefix: "/" }
                              route: { cluster: backend_grpc }
                    http_filters:
                      - name: envoy.router
      clusters:
        - name: backend_grpc
          connect_timeout: 0.25s
          type: STATIC
          lb_policy: ROUND_ROBIN
          http2_protocol_options: {}
          health_checks:
            - timeout: 5s
              interval: 10s
              unhealthy_threshold: 2
              healthy_threshold: 2
              tcp_health_check: {}
          load_assignment:
            cluster_name: backend_grpc
            endpoints:
              lb_endpoints:
                - endpoint:
                    address:
                      socket_address:
                        address: 127.0.0.1
                        port_value: 50051

envoyは15001ポートで受け付けたリクエストを 127.0.0.1:50051 に流している. envoyとpythonのgRPCサーバは同じPodに配置している.同一Pod内の通信は,すべて127.0.0.1にバインドされるので,この設定だけでEnvoyが受け付けたリクエストはpythonのgRPCサーバに流れる.

gRPCサーバをアクセス可能な状態にする

Serviceを定義する.ここがKubernetesの良いところで,内部でService Discoveryを持っているとコンテナのIPやポートをいちいちこちらで調べたり管理する必要がない.

apiVersion: v1
kind: Service
metadata:
  name: grpc-server-service
  namespace: envoy-grpc-example
spec:
  clusterIP: None
  selector:
    app: grpc-server
  ports:
    - name: grpc
      port: 15001
      targetPort: 15001
      protocol: TCP

こうしておくと,このnamespace内では grpc-server-service:15001 でgRPCサーバにアクセスできる.

gRPCクライアントを動かす

今回は外からアクセスするのではなく,クラスタ内からアクセスするだけとする.

というわけでgRPCクライアントのコンテナを立てる.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grpc-client-deployment
  namespace: envoy-grpc-example
  labels:
    app: grpc-client
spec:
  replicas: 1
  selector:
    matchLabels:
      app: grpc-client
  template:
    metadata:
      labels:
        app: grpc-client
    spec:
      volumes:
        - name: envoy-config
          configMap:
            name: client-sidecar-envoy
      containers:
        - name: envoy
          image: envoyproxy/envoy:latest
          volumeMounts:
            - name: envoy-config
              mountPath: /var/opt/envoy
          command: ["envoy", "-c", "/var/opt/envoy/envoy.yaml"]
          resources:
            limits:
              memory: 512Mi
          ports:
            - name: app
              containerPort: 15001
            - name: envoy-admin
              containerPort: 8001

        - name: client
          image: h3poteto/grpc_example-client-python:master
          imagePullPolicy: Always
          env:
            - name: SERVER_IP
              value: "127.0.0.1"
            - name: SERVER_PORT
              value: "9001"

gRPCクライアントは,127.0.0.1:9001にgRPCのリクエストを投げる. ただし,これは同一Pod内のenvoyに拾われる.

で,envoyの設定.

apiVersion: v1
kind: ConfigMap
metadata:
  name: client-sidecar-envoy
  namespace: envoy-grpc-example
data:
  envoy.yaml: |
    admin:
      access_log_path: /tmp/admin_access.log
      address:
        socket_address: { address: 0.0.0.0, port_value: 8001 }
    static_resources:
      listeners:
        - name: listener_grpc
          address:
            socket_address: { address: 0.0.0.0, port_value: 9001 }
          filter_chains:
            - filters:
                name: envoy.http_connection_manager
                config:
                  stat_prefix: egress_http
                  codec_type: AUTO
                  route_config:
                    name: local_route
                    virtual_hosts:
                      - name: grpc-server
                        domains: ["*"]
                        routes:
                          - match: { prefix: "/" }
                            route: { cluster: grpc_server }
                  http_filters:
                    - name: envoy.router
      clusters:
        - name: grpc_server
          connect_timeout: 0.25s
          type: STRICT_DNS
          lb_policy: ROUND_ROBIN
          http2_protocol_options: {}
          load_assignment:
            cluster_name: grpc_server
            endpoints:
              lb_endpoints:
                - endpoint:
                    address:
                      socket_address:
                        address: grpc-server-service
                        port_value: 15001

これは9001に来たアクセスを grpc-server-service:15001 に流す.

これだけでロードバランスできる

github.com

envoyの設定は特に動的な設定項目を指定していないが,Headless Serviceのお陰でこれだけでロードバランスできる.

ただ,Kubernetes以外,例えばECSでこれをやろうと思うと,envoyのバックエンドエンドポイントをうまいこと指定するのが結構難しい. 仮にネットワークモードとしてawsvpcを使っていれば,ECS Service DiscoveryでRoute53にコンテナアクセス可能なAレコードを作成してくれるので,そこをエンドポイントに指定することで可能になるかもしれない.