Cloud EndpointsとGKEを用いてgRPC APIを公開する

## 目的

システムの規模が大きくなってくると、モノリシックな構成からマイクロサービス化を検討し始めます。その際にサービス間の連携はどのようにするでしょうか。
チームやネットワークインフラが同じであれば、普段はローカルに環境を用意して開発を行い、プロダクションではプライベートに閉じた通信を行えば問題ないかもしれません。
しかし、インフラ構成を分けたかったり手軽に開発環境を用意したい場合であれば、今回の記事で紹介するGoogle Cloud Endpoints(以下、Cloud Endpoints)は使いやすいと思います。

この記事では、通信のプロトコルにはgRPC with HTTP/2を使った上で、Cloud Endpointsを用いてpublicなエンドポイントをセキュアに提供する方法をご紹介します。

なお、サンプルのprotoファイルや構成はこちらの公式ドキュメントをベースにしていますが、本番向けや自動化するには不親切な設定がいくつかあるので加筆しています。

https://cloud.google.com/endpoints/docs/grpc/get-started-kubernetes-engine-espv2

## 環境

- Mac OS: Big Sur
- gRPC Client: Kotlin
- GKE: 1.21.5

## 新しいEndpointを作成する

まず、新しいEndpointの設定を行いましょう。

```shell:terminal

# reserve IP Address for assigning to our Endpoint
gcloud compute addresses create <IP_NAME> \
    --global \
    --ip-version IPV4

# generate Descriptor file
# (protocコマンドはもしなければbrewなどでインストールしてください。)
protoc --descriptor_set_out=api_descriptor.pb \
  --include_imports \
  --include_source_info \
  bookstore.proto

# create Endpoint
gcloud endpoints services deploy api_descriptor.pb api_config.yaml --project <project id>
```

```yaml:api_config.yaml
# The configuration schema is defined by service.proto file
# https://github.com/googleapis/googleapis/blob/master/google/api/service.proto
type: google.api.Service
config_version: 3

name: bookstore.endpoints.<project id>.cloud.goog
endpoints:
  - name: bookstore.endpoints.<project id>.cloud.goog
    target: <reserved ip address>

title: Bookstore gRPC API
apis:
  - name: endpoints.examples.bookstore.Bookstore


usage:
  rules:
    # ListShelves methods can be called without an API Key.
    - selector: endpoints.examples.bookstore.Bookstore.ListShelves
      allow_unregistered_calls: true
```

endpintを作成するときの設定ファイルとなるapi_config.yamlですが、以下のように記述します。

- `endpoints.name` はendpointのドメインとなり、 `<service name>.endpoints.<project id>.cloud.goog` という命名規則になる様子
- `target` を設定することで、上記ドメインへGoogle側でDNSのAレコードを作成してくれます。
  - このIP Addressは後ほどIngressに設定します。
- `apis.name` はprotoファイルのpackage名が入ります。
- `usage.rules` はAPIの権限管理をすることが出来ます。
  - ここではサンプルを引用したのでAPI key無しのアクセスも設定されていますが、全て許可しないのが一般的ではないでしょうか。

## (Serviceの有効化 ServiceAccountの権限設定)

これらは基本的に必要ありませんが、もし下記APIがenableになっていなければ設定します。

```shell:terminal
gcloud services enable servicemanagement.googleapis.com
gcloud services enable servicecontrol.googleapis.com
gcloud services enable endpoints.googleapis.com
```

また、GKEがAutopilotモードの場合、k8sのServiceAccountにGCEデフォルトのService Accountの権限が入っていないため、別途Service Accountの追加と権限付与が必要です。

```shell:terminal
gcloud iam service-accounts create esp-v2-sa --display-name="SA for Cloud Endpoints SideCar Container" --project <project id>

gcloud iam service-accounts keys create ~/sa-private-key.json \
    --iam-account=esp-v2-sa@<project id>.iam.gserviceaccount.com

gcloud endpoints services add-iam-policy-binding bookstore.endpoints.<project id>.cloud.goog \
                --member serviceAccount:esp-v2-sa@<project id>.iam.gserviceaccount.com \
                --role roles/servicemanagement.serviceController

gcloud projects add-iam-policy-binding <project id> \
                --member serviceAccount:esp-v2-sa@<project id>.iam.gserviceaccount.com \
                --role roles/cloudtrace.agent
```

## k8sのDeploymentの設定

```yaml:deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: esp-grpc-bookstore
spec:
  replicas: 1
  selector:
    matchLabels:
      app: esp-grpc-bookstore
  template:
    metadata:
      labels:
        app: esp-grpc-bookstore
    spec:
      volumes:
        - name: esp-ssl
          secret:
            secretName: esp-ssl
        - name: google-cloud-key-esp2
          secret:
            secretName: esp2-sa-secret
      containers:
        - name: esp
          image: gcr.io/endpoints-release/endpoints-runtime:2
          args: [
              "--listener_port=9000",
              "--service=bookstore.endpoints.<project id>.cloud.goog",
              "--rollout_strategy=managed",
              "--backend=grpc://127.0.0.1:8000",
              "--healthz=/",
              "--service_account_key=/etc/sa/key.json",
              "--ssl_server_cert_path=/etc/esp/ssl",
          ]
          ports:
            - containerPort: 9000
          volumeMounts:
            - mountPath: /etc/esp/ssl
              name:  esp-ssl
              readOnly: true
            - mountPath: /etc/sa
              name:  google-cloud-key-esp2
              readOnly: true
        - name: bookstore
          image: gcr.io/endpointsv2/python-grpc-bookstore-server:1
          ports:
            - containerPort: 8000
```

ここで、

- `gcr.io/endpoints-release/endpoints-runtime:2` (ESP v2)をSideCar Containerとして用意しています。
  - これがProxyの役割を果たし、 `gRPC Backend(port:8000) -> ESP V2(port:8000 -> 9000) -> External` という流れで繋がっています。
  - ログを見る限り、ESP V2は中でEnvoyが動いているようです。公式によるとここのオーバーヘッドは1ms以下だそうなのでパフォーマンスを気にすることはなさそうです。
- `rollout_strategy=managed` を設定すると、endpointのdeployが行われると自動で各SideCarの設定を反映してくれます（5分間隔？）
  - https://cloud.google.com/endpoints/docs/openapi/specify-proxy-startup-options
- `esp-ssl` secretは以下のように自己署名証明書を作成します。
  - 後述しますが、これはHTTP/2を有効にするためにつけているもので、 `Ingress <-> Deployment` 間の内部の通信に使われるものなので特に問題はないです。

```shell:terminal
# create self-signed cert file with any duration
openssl req -x509 -nodes -days 4650 -newkey rsa:2048 \
-keyout ./server.key -out ./server.crt

kubectl create secret generic line-service-esp-ssl --from-file=server.crt=./server.crt --from-file=server.key=./server.key
```

## k8sのServiceの設定

```yaml:service.yaml
apiVersion: v1
kind: Service
metadata:
  name: esp-grpc-bookstore
  annotations:
    service.alpha.kubernetes.io/app-protocols: '{"esp-grpc-bookstore":"HTTP2"}'
    cloud.google.com/neg: '{"ingress": true, "exposed_ports": {"443":{}}}'
    cloud.google.com/backend-config: '{"default": "esp-grpc-bookstore"}'
spec:
  ports:
    # Port that accepts gRPC and JSON/HTTP2 requests over TLS.
    - port: 443
      targetPort: 9000
      protocol: TCP
      name: esp-grpc-bookstore
  selector:
    app: esp-grpc-bookstore
  type: ClusterIP
```

```yaml:backend-config.yaml
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
  name: esp-grpc-bookstore
spec:
  healthCheck:
    type: HTTP2
    requestPath: /
    port: 9000
```

ここで、

- `service.alpha.kubernetes.io/app-protocols` の箇所で、今回のportへHTTP/2のprotocolを有効にしています。
- `cloud.google.com/neg` はNEG(Network Endpoint Group)と呼ばれるGCPの通信経路最適化の機能です。無くても動くとは思います。
  - https://cloud.google.com/blog/products/containers-kubernetes/container-native-load-balancing-on-gke-now-generally-available
- `cloud.google.com/backend-config` は、 `backend-config.yaml` を反映する設定です。
    - ここではHealthCheckの設定を入れていて、Ingressとつないだ際にLBのリクエストを流していいかの判断に使われます。
    - Deployment側のreadinessProbeとはおそらく少し役割が異なっていて、死活監視ではなくServiceを経由してのHTTP/2の疎通確認のためかと思います。

# k8s Ingressの設定

```yaml:ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: esp-grpc-bookstore
  annotations:
#    kubernetes.io/ingress.allow-http: "false"
    kubernetes.io/ingress.global-static-ip-name: <IP_NAME>
    networking.gke.io/managed-certificates: bookstore-grpc-certificate
spec:
  defaultBackend:
    service:
      name: esp-grpc-bookstore
      port:
        number: 443
```

```yaml:ssl-certificate.yaml
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
  name: bookstore-grpc-certificate
spec:
  domains:
    - bookstore.endpoints.<project id>.cloud.goog

```

ここで、

- `kubernetes.io/ingress.allow-http` は後述するManaged SSLが取得出来たら無効にしても問題ないです。
- `networking.gke.io/managed-certificates` には上記に記載している `ManagedCertificate` のnameを入れてください。
    - ManagedCertificateは、Google ManagedのSSL証明書を用意してくれるResourceです。
    - dominsに設定しているドメインへのIngressを通じてのhttpの疎通が成功(ドキュメントが見つかりませんでしたが、おそらく `path=/` が200を返す)するとActiveになります。
    - これがあるためにESPコンテナで `--healthz=/` を設定していますが、証明書が取れたあとであれば変えても構いません。
- `kubernetes.io/ingress.allow-http` は、Managed証明書を取得するためにhttpを許可しています。
    - 証明書が取れた後であればコメントを外してHTTP portを閉じても構いません。

## Clientからアクセス出来るか確認

ここまででサーバーサイドの設定は全て完了です。最後にgRPCクライアントからの接続を試します。

まずAPI Keyを作成します。GCPの管理画面から作成しましょう。

![Screen Shot 2564-11-22 at 01.12.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7185/a8e0bc2e-dd55-94a8-c261-899222244e87.png)

API KeyのAPI restrictionsにて、作成したBookStore APIのみ許可します。

取得したAPI Keyを用いたClientのサンプル（Kotlin）がこちらです。

```kotlin:Client.kt
import com.google.endpoints.examples.bookstore.BookstoreGrpcKt
import com.google.endpoints.examples.bookstore.ListBooksRequest
import io.grpc.ClientInterceptors
import io.grpc.ManagedChannelBuilder
import io.grpc.Metadata
import io.grpc.stub.MetadataUtils

suspend fun callGrpc() {
    val metadata = Metadata()
    metadata.put(Metadata.Key.of("x-api-key",Metadata.ASCII_STRING_MARSHALLER), "<API key>")

    val channel = ClientInterceptors.intercept(
        ManagedChannelBuilder.forAddress("bookstore.endpoints.<project id>.cloud.goog", 443)
            .useTransportSecurity()
            .build(),
        MetadataUtils.newAttachHeadersInterceptor(metadata)
    )
    val stub = BookstoreGrpcKt.BookstoreCoroutineStub(channel)
    val res = stub.ListBooks(
        ListBooksRequest.newBuilder()
            .shelf(1)
            .build()
    )
    println("message: ${res}")
}
```

## プロダクションレディか？

さて、今回のインフラ構成において、end-to-endの通信の流れは以下のようになっています。

```
Client(gRPC) ->
cloud endpoints domain DNS(ookstore.endpoints.<project id>.cloud.goog) ->
k8s ingress -> k8s service ->
ESP SideCar Container (Envoy) ->
gRPC backend
```

このうち、Clientからk8s ingressまではGoogle Managed SSLが使われ、ingressからESP Containerまでは自己署名証明書が使われる形になります。公式ドキュメントにあるように、podをport-forwardしてgRPCを繋ごうとすると自己証明書のCAファイルが求められます。

ちなみに、HTTP/2が有効になっていることはブラウザのExtensionやチェックサイトで確認できます。LBやProxyを噛ませた場合の接続のtimeoutやStreamingに影響が出るかなどは調べきれてないので後日確認したいと思います。

次に応答速度については、今回のbootStoreの例だと、end-to-endのレイテンシはリージョンが同じであればおよそ100ms以内で返ってきます。Cloud Endpoints特有のインフラはESP Container部分のみで、上述の通り1ms以下で動くとされているため、サービス間通信に使用しても普通は問題ないでしょう。

また運用面においては、protoファイルに変更があった場合には以下の手順で更新を行います。

- protocでdescriptorファイルを生成
- `gcloud endpoints services deploy` で最新に反映
- ESP Contianerは自動で設定を反映
- gRPC Backendも、最新のImageをデプロイすることで反映

他、SSL証明書もManagedなものと長く期間を設定した自己署名のものしかないので、手間もコストもかからず運用出来ます。