gRPC を Envoy でプロキシして Python から使う【2022】

先日 Protocol Buffers + gRPC で我が家の非常食を管理する API サーバーを立ててみました。今まで Python で基本的に Web サーバーを作ってきた筆者が勉強もかねて GoLang + protobuf + gRPC というイマドキな構成で組んでみました。
これを（最近構築した） Kubernetes 環境で本番運用していくにあたって TLS 終端のプロキシを入れようと少し調べたところ Envoy というプロキシが gRPC と親和性が高くよく使われているようなので試してみました。まだ最新の Web 上の情報が少なかったのではまりどころも含めてまとめていきます。

## Protocol Buffers / gRPC / Envoy とは

[Protocol Buffers](https://developers.google.com/protocol-buffers) とは Google が策定した言語・プラットフォーム中立のデータフォーマットで、 API のサーバー・クライアント間で共通の宣言を使って通信するためなどに使われます。 `.proto` 拡張子のファイルにメッセージ（データ構造）を定義して使います。

[gRPC](https://www.grpc.io/) もまた Google によって作られた言語共通で使える RPC (Remote Procedure Call) の仕組みです。よく Protocol Buffers と組み合わせて使われます。Protobuf + gRPC を使った API 構築についてはこの記事では割愛します。

[Envoy](https://www.envoyproxy.io/) は Lyft によって開発された L7 プロキシで、 CNCF Graduated となったプロジェクトです。 L7 プロキシということで立ち位置的には nginx と似ていますが、使われるシーンが違います。 Envoy はマイクロサービスな構成で組むときに各アプリに sidecar 的に置くことで、アプリで使っている言語ごとの差異を吸収したり負荷を分散したりする使い方をするようです（筆者自身このあたりは勉強中なので間違いがあればご指摘ください）。

## 今回やりたいこと

* gRPC サーバー (GoLang) に Python クライアントからアクセスしたい
* Kubernetes ネットワーク外からもアクセスするので mTLS (mutual TLS) で認証したい
  * Kubernetes 化されていない既存リソースからもアクセスするので

他にも Envoy を使えば http/2 をしゃべれないブラウザからでも使えるように http/1.1 + JSON に変換してくれる機能も gRPC Web によって実現できます。もし検証したらまた記事にしたいと思います。

## やった

まずは最終形から見せていきます。

### サーバー側

```yaml:docker-compose.yml
version: '3'
services:
  app:
    image: hoge
    expose: 9090
    # app の設定
  proxy:
    build:
      context: ./proxy
      dockerfile: prd.dockerfile
    container_name: storage
    environment:
      - ENVOY_UID=1000
      - ENVOY_GID=1000
    ports:
      - 443:443
    volumes:
      - type: bind
        source: ./proxy/certs
        target: /etc/envoy/certs
        read_only: true
```

```Dockerfile:proxy/prd.dockerfile
FROM envoyproxy/envoy:v1.21-latest

COPY ./envoy.yaml /etc/envoy/envoy.yaml

RUN chmod go+r /etc/envoy/envoy.yaml
```

```yaml:envoy.yaml
static_resources:
  # Envoy Proxy のリスナー
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 443
    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
          scheme_header_transformation:
            scheme_to_overwrite: https
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              # "/" に該当するルート（すべて）は service_grpc にプロキシする
              - match:
                  prefix: "/"
                route:
                  cluster: service_grpc
          http_filters:
          - name: envoy.filters.http.grpc_web
          - name: envoy.filters.http.router
      # TLS 終端のための設定
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          # mTLS のための設定
          require_client_certificate: true
          common_tls_context:
            validation_context:
              trusted_ca:
                filename: /etc/envoy/certs/ca.crt
              match_typed_subject_alt_names: []  # SAN の検証をする場合はここに書く
            tls_certificates:
            - certificate_chain:
                filename: /etc/envoy/certs/server.crt
              private_key:
                filename: /etc/envoy/certs/server.key
            alpn_protocols: ["h2,http/1.1"]  # Python クライアントは ALPN を要求する
clusters:
  - name: service_grpc
    connect_timeout: 0.25s
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    http2_protocol_options: {}  # protocol error を回避するために必要
    load_assignment:
      cluster_name: service_grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: app
                port_value: 9090
```

`envoy.yaml` はその名の通り Envoy の設定ファイルです。上記では [admin interface](https://www.envoyproxy.io/docs/envoy/latest/operations/admin) は無効になっているので、必要な場合は加筆してください。

基本的には [gRPC 公式のクイックスタート](https://grpc.io/docs/platforms/web/basics/)に従っていけば gRPC + Envoy の概要がつかめます（**envoy.yaml の API バージョンが古いので注意**）。

### クライアント側

```
.
├── certs
│   ├── ca.crt
│   ├── client.crt
│   └── client.key
├── sample.py
├── pb
│   ├── hello_pb2.py
│   ├── hello_pb2_grpc.py
└── app.dockerfile
```

HelloRequest というメッセージと Hello という RPC がある想定のコードです。 mTLS のために CA 証明書、クライアント証明書・秘密鍵を読み込みます。

```python:sample.py
import os

import grpc

from pb.hello_pb2 import HelloRequest
from pb.hello_pb2_grpc import HelloServiceStub

if __name__ == "__main__":
    address = os.environ.get("GRPC_SERVER_ADDRESS")
    if address is None:
        raise ValueError("Environment variable GRPC_SERVER_ADDRESS is not defined.")
    with open("./certs/ca.crt", "rb") as f:
        ca_cert = f.read()
    with open("./certs/client.key", "rb") as f:
        client_key = f.read()
    with open("./certs/client.crt", "rb") as f:
        client_cert = f.read()
    credentials = grpc.ssl_channel_credentials(ca_cert, client_key, client_cert)
    channel = grpc.secure_channel(address, credentials)
    stub = HelloServiceStub(channel)
    req = HelloRequestHelloRequest()
    res = stub.Hello(req)
    print(res)
```

## はまりどころ

Python クライアントから Envoy Proxy を介して繋ぐときにいくつか設定方法ではまったので書いておきます。
ちなみに gRPC のリクエスト処理をデバッグするには以下の環境変数を設定します。

```sh
export GRPC_TRACE=all
export GRPC_VERBOSITY=DEBUG
```

### Protocol Error

`upstream connect error or disconnect/reset before headers. reset reason: protocol error` というエラーが認証・非認証関係なく出ることがあります。 `http2_protocol_options: {}` を設定することで直ります。

```yaml:envoy.yaml(抜粋)
clusters:
  - name: service_grpc
    ...
    http2_protocol_options: {}  # protocol error を回避するために必要
```

https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto

### missing selected ALPN property

`Cannot check peer: missing selected ALPN property.` というエラーが認証設定時に出ることがあります。これは Python の grpcio が使っているライブラリが ALPN を要求しているため（GoLang では必要とされていない）[^1]に発生しているエラーなので、サーバー側で ALPN を有効にすることで直ります。

公式ドキュメントにもある通り[^2]、 `alpn_protocols` を指定しない限りは Envoy は ALPN を有効にしません。

```yaml:envoy.yaml(抜粋)
static_resources:
  listeners:
  - name: listener_0
    ...
    filter_chains:
    - filters: [...]
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          ...
          common_tls_context:
            ...
            alpn_protocols: ["h2,http/1.1"]  # Python クライアントは ALPN を要求する
```

[^1]: Python gRPC server requires ALPN (Golang gRPC server doesn't). https://github.com/envoyproxy/envoy/issues/4291#issuecomment-417292285

[^2]: There is no default for this parameter. If empty, Envoy will not expose ALPN. https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/transport_sockets/tls/v3/tls.proto#extensions-transport-sockets-tls-v3-commontlscontext

## おわり

以上、 gRPC を Envoy でプロキシして Python から使うときの設定と注意点をまとめてみました。日々どんどん新しくなっている世界なのでこの情報の鮮度もすぐに落ちてしまうかもしれませんが、少しでもお役に立てば幸いです。

それでは、よいクラウドネイティブライフを！

## 参考文献

https://www.envoyproxy.io/docs/envoy/latest/

https://grpc.io/docs/platforms/web/basics/

https://blog.rollie.dev/posts/grpc-with-mutual-tls-between-go-and-python/

https://github.com/grpc/grpc/issues/23172

https://github.com/envoyproxy/envoy/issues/4291

https://stackoverflow.com/questions/69444526/python-grpc-failed-to-pick-subchannel

https://stackoverflow.com/questions/70431088/protocol-error-when-trying-to-connect-envoy-to-grpc-service-using-grpc-web

https://stackoverflow.com/questions/37125975/how-to-debug-grpc-call