先日 Protocol Buffers + gRPC で我が家の非常食を管理する API サーバーを立ててみました。今まで Python で基本的に Web サーバーを作ってきた筆者が勉強もかねて GoLang + protobuf + gRPC というイマドキな構成で組んでみました。
これを(最近構築した) Kubernetes 環境で本番運用していくにあたって TLS 終端のプロキシを入れようと少し調べたところ Envoy というプロキシが gRPC と親和性が高くよく使われているようなので試してみました。まだ最新の Web 上の情報が少なかったのではまりどころも含めてまとめていきます。
Protocol Buffers / gRPC / Envoy とは
Protocol Buffers とは Google が策定した言語・プラットフォーム中立のデータフォーマットで、 API のサーバー・クライアント間で共通の宣言を使って通信するためなどに使われます。 .proto
拡張子のファイルにメッセージ(データ構造)を定義して使います。
gRPC もまた Google によって作られた言語共通で使える RPC (Remote Procedure Call) の仕組みです。よく Protocol Buffers と組み合わせて使われます。Protobuf + gRPC を使った API 構築についてはこの記事では割愛します。
Envoy は 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 によって実現できます。もし検証したらまた記事にしたいと思います。
やった
まずは最終形から見せていきます。
サーバー側
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
FROM envoyproxy/envoy:v1.21-latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
RUN chmod go+r /etc/envoy/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 は無効になっているので、必要な場合は加筆してください。
基本的には gRPC 公式のクイックスタートに従っていけば 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 証明書、クライアント証明書・秘密鍵を読み込みます。
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 のリクエスト処理をデバッグするには以下の環境変数を設定します。
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: {}
を設定することで直ります。
clusters:
- name: service_grpc
...
http2_protocol_options: {} # protocol error を回避するために必要
missing selected ALPN property
Cannot check peer: missing selected ALPN property.
というエラーが認証設定時に出ることがあります。これは Python の grpcio が使っているライブラリが ALPN を要求しているため(GoLang では必要とされていない)1に発生しているエラーなので、サーバー側で ALPN を有効にすることで直ります。
公式ドキュメントにもある通り2、 alpn_protocols
を指定しない限りは Envoy は ALPN を有効にしません。
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 を要求する
おわり
以上、 gRPC を Envoy でプロキシして Python から使うときの設定と注意点をまとめてみました。日々どんどん新しくなっている世界なのでこの情報の鮮度もすぐに落ちてしまうかもしれませんが、少しでもお役に立てば幸いです。
それでは、よいクラウドネイティブライフを!
参考文献
-
Python gRPC server requires ALPN (Golang gRPC server doesn't). https://github.com/envoyproxy/envoy/issues/4291#issuecomment-417292285 ↩
-
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 ↩