gRPC-Web を Cloud Run のサイドカー Envoy でやる
はじめに
Cloud Run のマルチコンテナがパブリック プレビューでリリースされました。本記事ではマルチコンテナを使って、1 つの Cloud Run サービスでサイドカー Envoy を使った gRPC-Web サービスを構築します。
本記事は前半が Cloud Run にサービスをデプロイして Web から呼び出してみるパート、後半が説明パートになっています。説明だけ読みたい場合は前半をスキップしてください。
マルチコンテナについては以下の記事もぜひ参考にしてください。
やってみる
環境など
新しいプロジェクトと Cloud Shell での操作を想定しています。ローカルで操作する場合は必要なツール等を適宜インストールしてください。
サンプルのコードはこちらを使います。
事前準備
こちらをクリックしてください。Cloud Shell が開いてリポジトリがクローンされます。エディタは使わないので閉じてしまってください。
プロジェクトを設定します。
gcloud config set project <YOUR-PROJECT-ID>
使用するサービスの API を有効化します。
gcloud services enable \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
run.googleapis.com
Docker イメージ用に Artifact Registry のリポジトリを作成します。
gcloud artifacts repositories create \
grpc-web \
--location us-central1 \
--repository-format DOCKER
Cloud Run サービス用の Service Account を作成します。
gcloud iam service-accounts create grpc-web
Cloud Build を使ってデプロイするので Cloud Build の Service Account に roles/run.admin
ロールを付与します。
gcloud projects add-iam-policy-binding \
"$(gcloud config get project)" \
--member "serviceAccount:$(gcloud projects describe $(gcloud config get project) --format 'value(projectNumber)')@cloudbuild.gserviceaccount.com" \
--role "roles/run.admin"
Cloud Build が Cloud Run サービスに Service Account を設定できるように roles/iam.serviceAccountUser
ロールを付与します。
gcloud iam service-accounts add-iam-policy-binding \
"grpc-web@$(gcloud config get project).iam.gserviceaccount.com" \
--member "serviceAccount:$(gcloud projects describe $(gcloud config get project) --format 'value(projectNumber)')@cloudbuild.gserviceaccount.com" \
--role "roles/iam.serviceAccountUser"
デプロイ
Cloud Build を使って以下のことを行います。
- gRPC サービス (Go アプリ) の Docker イメージのビルド・プッシュ
- Envoy の Docker イメージのビルド・プッシュ
- Cloud Run のサービス定義ファイルの描画
- Cloud Run サービスのデプロイ
cloudbuilld.yaml
が用意されているので実行するのはコマンドひとつです。
gcloud builds submit .
最後に、認証なしでアクセスできるように設定します。
gcloud run services add-iam-policy-binding \
grpc-web \
--region us-central1 \
--member "allUsers" \
--role "roles/run.invoker"
Web から呼び出す
簡単なクライアント アプリを用意したので、そちらから gRPC-Web でデプロイしたサービスを使ってみます。
web
ディレクトリに移動して npm install
します。
cd web
npm install
クライアント アプリを起動します。
GRPC_HOST="$(gcloud run services describe grpc-web --region us-central1 --format 'value(status.url)')" \
PORT=8080 \
npm start
Cloud Shell の右上の Web Preview メニューから Preview on port 8080 をクリックします。
こんな感じの画面が表示されます。
ListBooks, CreateBook, GetBook, DeleteBook, UpdateBook が実装されているメソッドで、パラメータを入力して Call ボタンをクリックするとリクエストが飛ぶようになっています。はじめは何も Book が登録されていないので適当なパラメータを入力して CreateBook しましょう。うまくいくと Response にレスポンスが表示されます。
その他のメソッドも同様に実行できます。
説明
サンプルアプリがどう実装されているかという観点で説明します。
gRPC 定義
gRPC の定義は Envoy のテストから Book リソースに関するものを抜き出して使っています。よくサンプルで使われる普通の gRPC 定義です。全文はこちら。
import "google/protobuf/empty.proto";
service BookstoreService {
rpc ListBooks(ListBooksRequest) returns (stream Book) {}
rpc CreateBook(CreateBookRequest) returns (Book) {}
rpc GetBook(GetBookRequest) returns (Book) {}
rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) {}
rpc UpdateBook(UpdateBookRequest) returns (Book) {}
}
message Book {
int64 id = 1;
string title = 2;
}
message ListBooksRequest {
int64 shelf = 1;
}
gRPC サービスの実装
バックエンドの gRPC サービスは Go で実装しています。作成された Book リソースはメモリに保存されているのでコンテナが再起動するとデータは消えます。全文はこちら。
Go の実装も Hello World 的なコードですが、Cloud Run 上で gRPC でのヘルスチェックを行うために Health Checking Protocol を実装する必要があるためその実装を追加しています。
import (
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
func main() {
// ...
s := grpc.NewServer()
svc := &bookstoreService{
shelves: make(map[int64]*shelf),
}
pb.RegisterBookstoreServiceServer(s, svc)
healthSvc := health.NewServer()
healthpb.RegisterHealthServer(s, healthSvc)
healthSvc.SetServingStatus("bookstore.BookstoreService", healthpb.HealthCheckResponse_SERVING)
reflection.Register(s)
// ...
}
Envoy の設定
Envoy の設定は概ね gRPC-Web の Hello World から持ってきています。その中で特に気になった部分のみ変更しています。1 点目の変更は Deprecation の Warning が出ていた CORS の設定です。CORS 設定の内容は Hello World から変えていません。2 点目の変更は cluster の設定です。サイドカーに DNS の解決は必要ないので type: STATIC
にしています。
static_resources:
listeners:
- name: listener_0
address:
socket_address: {address: 0.0.0.0, port_value: 8080}
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
codec_type: AUTO
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: {prefix: "/"}
route:
cluster: grpc
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
typed_per_filter_config:
envoy.filters.http.cors:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: 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.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: grpc
type: STATIC
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: grpc
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: {address: 127.0.0.1, port_value: 50051}
クライアント アプリの実装
まずはクライアント用のコード生成についてです。ストリームなメソッド (ListBooks) があるので Wire Format Mode には grpc-web-text
を選択しています。Import Style には commonjs
を選択しています。
Buf のコード生成の設定はこんな感じです。全文はこちら。
version: v1
plugins:
- plugin: buf.build/grpc/web
out: web/src/gen
opt:
- mode=grpcwebtext
- import_style=commonjs
- plugin: buf.build/protocolbuffers/js
out: web/src/gen
opt:
- import_style=commonjs
クライアントは index.html と client.js の 2 ファイルで実装しています。Vanilla な JavaScript で例えば CreateBook メソッドの Call ボタンをクリックしたときのコールバックは次のようになっています。
import {
Book,
CreateBookRequest,
} from "./gen/bookstore/bookstore_service_pb";
import { BookstoreServiceClient } from "./gen/bookstore/bookstore_service_grpc_web_pb";
const client = new BookstoreServiceClient(grpcHost);
function createBook(event) {
event.preventDefault();
const data = new FormData(event.target);
const book = new Book();
book.setId(data.get("book.id"));
book.setTitle(data.get("book.title"));
const req = new CreateBookRequest();
req.setShelf(data.get("shelf"));
req.setBook(book)
client.createBook(req, {}, renderResponseCallback("create-book-response"));
}
ListBooks はストリームで結果が返ってくるため、他とは実装が異なり次のようになっています。
function listBooks(event) {
event.preventDefault();
const data = new FormData(event.target);
const req = new ListBooksRequest();
req.setShelf(data.get("shelf"));
const books = [];
const stream = client.listBooks(req);
stream.on("data", (response) => {
books.push(response.toObject());
});
stream.on("end", (end) => {
document.getElementById("list-books-response").innerText = JSON.stringify(books, null, 2);
})
}
Cloud Run へのデプロイ
各 Docker イメージのビルドと Cloud Run へのデプロイは cloudbuild.yaml
にまとめています。この中で重要なのは Cloud Run サービスの定義ファイルとデプロイのコマンドです。
現時点でマルチコンテナなサービスをデプロイするためには YAML として Cloud Run のサービスを定義して gcloud run services replace
というコマンドを使う必要があります。もちろん直接 API でデプロイ可能ですが Web のコンソールからはデプロイできません。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
annotations:
run.googleapis.com/launch-stage: BETA
name: SERVICE_NAME
spec:
template:
metadata:
annotations:
run.googleapis.com/execution-environment: gen1 #or gen2
spec:
containers:
- image: INGRESS_IMAGE
ports:
- containerPort: CONTAINER_PORT
- image: SIDECAR_IMAGE
YAML は Kubernetes Resource の形式でリソースの種類は Knative の Service です。ただし、Cloud Run 独自のアノテーションや制限があるため、YAML を書くときはリファレンスやスキーマを参照しながら書くとスムーズです。
本記事のデプロイに使った YAML はこちらです。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: grpc-web
labels:
cloud.googleapis.com/location: us-central1
annotations:
run.googleapis.com/launch-stage: BETA
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/minScale: "1"
autoscaling.knative.dev/maxScale: "1"
run.googleapis.com/execution-environment: gen1
spec:
serviceAccountName: ${SERVICE_ACCOUNT}
containers:
- image: ${ENVOY_IMAGE}
name: envoy
ports:
- containerPort: 8080
resources:
limits:
cpu: ".08"
memory: 128Mi
- image: ${GRPC_IMAGE}
name: grpc
resources:
limits:
cpu: ".08"
memory: 128Mi
livenessProbe:
grpc:
port: 50051
service: bookstore.BookstoreService
スケーリングに関する設定は、今回のサンプルアプリがコンテナ内のメモリにしかデータを保持できないためインスタンス数を 1 に固定しています。
マルチコンテナでは containerPort
が全てのコンテナを通して 1 つしか設定できません。containerPort
を設定したコンテナは Ingress コンテナと呼ばれます。Ingress コンテナ以外のコンテナはサイドカー コンテナと呼ばれます。
containerPort
が設定されていなくても、コンテナのポートがリッスンされていればヘルスチェックは可能です。grpc
コンテナでは 50051
ポートに gRPC ヘルスチェックを設定しています。また、例えば Envoy に admin interface の設定をいれて containerPort
とは別に admin interface のポートにヘルスチェックをするということもできます。
- name: envoy
image: ${ENVOY_IMAGE}
ports:
- containerPort: 8080
startupProbe:
httpGet:
port: 9901
path: /stats
このような YAML を作成し gcloud run services replace service.yaml
を実行することでマルチコンテナのサービスをデプロイします。
おわりに
gRPC-Web のバックエンドとなる Go アプリと Envoy プロキシを Cloud Run のマルチコンテナで 1 つのサービスとしてデプロイしてみました。マルチコンテナでますます Cloud Run が便利に使えるようになりそうです。
Discussion