Cloud Endpointsを使用したgRPCアプリへのリクエスト方法あれこれ
gRPCアプリを作ったとして、以下のような機能が欲しくなってきます。
- REST形式の問い合わせも受ける
- 認証・認可
GCPでアプリを作った場合、「Cloud Endpoints」を使うとこれらをいい感じに実装できます。
どういう風にやっていくのか、サンプルプログラムを通じて理解していきます。
前提
今回作成したプロジェクトは以下の記事のサンプルプログラムを使用させていただいてます。
Google Cloud Endpoints for gRPCの認証まわり
手順
gRPCアプリを作成する
まず動かすためのgRPCアプリが必要になってくので、さくっと作っていきましょう。
今回の完成形のソースコードはこちらです https://github.com/morix1500/cloudendpoint
最終的なディレクトリ・ファイルは以下のような構成になります。
. ├── Gopkg.lock ├── Gopkg.toml ├── api_config.yaml ├── client │ ├── main.go │ └── web │ ├── default.conf │ └── html │ └── index.html ├── docker-compose.yml ├── echo.pb ├── key │ └── serviceaccount.json ├── proto │ ├── echo.pb.go │ └── echo.proto └── server ├── main.go └── web └── nginx.conf
サンプルプログラム概要
以下のようなプログラムを作っていきます。
- APIキー/Firebase認証が不要 のエコーメソッド
- Firebase認証が 必須 のエコーメソッド
- APIキーが 必須 のエコーメソッド
- APIキー/Firebase認証が 必須 のエコーメソッド
protocのインストール
以下の中から自分の環境にあったファイルを落としてくる。 https://github.com/google/protobuf/releases
$ mkdir -p ~/development/lib/protoc $ cd ~/development/lib/protoc $ wget https://github.com/google/protobuf/releases/download/v3.5.1/protoc-3.5.1-osx-x86_64.zip $ unzip protoc-3.5.1-osx-x86_64.zip $ export PATH=${PATH}:~/development/lib/protoc/bin # インストール確認 $ protoc --version libprotoc 3.5.1
protoファイルの作成
インタフェース定義をしていきます。
# proto/echo.proto syntax = "proto3"; package echo; service EchoService { rpc Echo1 (Request) returns (Response) {} rpc Echo2 (Request) returns (Response) {} rpc Echo3 (Request) returns (Response) {} rpc Echo4 (Request) returns (Response) {} } message Msg { string message = 1; } message Request { Msg message = 1; } message Response { Msg message = 1; }
コードなど出力していきます。
$ PROTO_DIR=~/development/lib/protoc/include $ protoc --proto_path=${PROTO_DIR} --proto_path=. --include_imports --include_source_info --go_out=plugins=grpc:. proto/echo.proto --descriptor_set_out echo.pb # echo.pbと、proto/echo.pb.go が生成されているはず
Serverプログラムの実装
// server/main.go package main import ( "fmt" "net" pb "github.com/morix1500/cloudendpoint/proto" "golang.org/x/net/context" "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/grpc/reflection" ) const ( port = ":50051" ) func echo(ctx context.Context, in *pb.Request) (*pb.Response, error) { md, _ := metadata.FromIncomingContext(ctx) fmt.Println(", Metadata:", md) return &pb.Response{Message: in.Message}, nil } type server struct{} func (server) Echo1(ctx context.Context, in *pb.Request) (*pb.Response, error) { fmt.Print("Echo1 Received: ", in.Message) return echo(ctx, in) } func (server) Echo2(ctx context.Context, in *pb.Request) (*pb.Response, error) { fmt.Print("Echo2 Received: ", in.Message) return echo(ctx, in) } func (server) Echo3(ctx context.Context, in *pb.Request) (*pb.Response, error) { fmt.Print("Echo3 Received: ", in.Message) return echo(ctx, in) } func (server) Echo4(ctx context.Context, in *pb.Request) (*pb.Response, error) { fmt.Print("Echo4 Received: ", in.Message) return echo(ctx, in) } func main() { s := grpc.NewServer() pb.RegisterEchoServiceServer(s, server{}) reflection.Register(s) lis, err := net.Listen("tcp", port) if err != nil { panic(err) } if err := s.Serve(lis); err != nil { panic(err) } }
Clientプログラムの実装
// client/main.go package main import ( "flag" "fmt" pb "github.com/morix1500/cloudendpoint2/proto" "golang.org/x/net/context" "google.golang.org/grpc" ) type credential struct { key string referer string jwt string } func (c credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { return map[string]string{ "x-api-key": c.key, "referer": c.referer, "authorization": "Bearer " + c.jwt, }, nil } func (credential) RequireTransportSecurity() bool { return false } func main() { var addr, msg, key, referer, jwt string flag.StringVar(&addr, "addr", "127.0.0.1:50051", "server address") flag.StringVar(&msg, "msg", "Hello", "message") flag.StringVar(&key, "key", "invalid", "API Key") flag.StringVar(&referer, "referer", "invalid", "referer") flag.StringVar(&jwt, "jwt", "invalid", "JSON Web Token") flag.Parse() cred := credential{ key: key, referer: referer, jwt: jwt, } conn, err := grpc.Dial(addr, grpc.WithInsecure(), grpc.WithPerRPCCredentials(cred)) if err != nil { panic(err) } defer conn.Close() c := pb.NewEchoServiceClient(conn) ctx := context.Background() req := &pb.Request{Message: &pb.Msg{Message: msg}} res, err := c.Echo1(ctx, req) if err == nil { fmt.Println("Echo1: succeeded: ", res.Message) } else { fmt.Println("Echo1: failed: ", err) } res, err = c.Echo2(ctx, req) if err == nil { fmt.Println("Echo2: succeeded: ", res.Message) } else { fmt.Println("Echo2: failed: ", err) } res, err = c.Echo3(ctx, req) if err == nil { fmt.Println("Echo3: succeeded: ", res.Message) } else { fmt.Println("Echo3: failed: ", err) } res, err = c.Echo4(ctx, req) if err == nil { fmt.Println("Echo4: succeeded: ", res.Message) } else { fmt.Println("Echo4: failed: ", err) } }
ではClientプログラムを動かしてみましょう
# まずServerプログラムを動かす $ go run server/main.go $ go run client/main.go Echo1: succeeded: message:"Hello" Echo2: succeeded: message:"Hello" Echo3: succeeded: message:"Hello" Echo4: succeeded: message:"Hello"
動きました。ではこのgRPCアプリを使ってあれこれやっていきます。
GCPセットアップ
GCPリソースを操作するための gcoud
をインストールします
# gcloud install curl https://sdk.cloud.google.com | bash # ログインする gcloud auth login # プロジェクトIDを設定 gcloud config set project ${PROJECT_ID}
サービスアカウント作成
Cloud Endpointsを動作させるためのサービスアカウントを作成します。
GCPの「IAM」「サービスアカウント」で、サービスアカウントを作成します。
権限は「Project」「編集」でOKです。
ダウンロードしたJSONファイルは、プロジェクトディレクトリの「/key」配下に入れておきます。
APIキーの取得
APIキーを指定した場合、リクエストを通すようにしたいのでその設定を行います。
以下の画面でAPIキーを発行する
https://console.cloud.google.com/apis/credentials
発行したAPIキーはメモっておく。
Firebase Authenticationの設定
クライアント側でユーザ認証を行ったあと、JWTを発行することができます。
このJWTが正しいものか検証し、問題なければリクエストを通すようなことをCloud Endpointsではできるためそれをやってみます。
そのためにまずFirebase Authenticationの設定を行います。
メールアドレス/パスワードの設定を有効にする。
以下画面の「ウェブアプリにFirebaseアプリを追加」を押し、表示されたコードをコピー。
Firebase認証用のWebアプリを作成します。
<!-- client/web/html/index.html --> <html> <head> <script src="https://www.gstatic.com/firebasejs/4.10.1/firebase.js"></script> <script src="https://unpkg.com/axios/dist/axios.min.js"></script> </head> <body> <script> // ここに上記で発行されたコードを貼り付ける var config = { apiKey: "xxxxxxxxxxxx", authDomain: "xxxxxxxx.firebaseapp.com", databaseURL: "https://xxxxxxxx.firebaseio.com", storageBucket: "xxxxxxx.appspot.com", messagingSenderId: "xxxxxxxx", }; firebase.initializeApp(config); let name = "hoge@example.com"; let pass = "test1234"; // ここに先ほど発行したAPIキーを入力する let apikey = ""; firebase.auth().createUserWithEmailAndPassword(name, pass) .then(user => { console.log("create account: ", user.email); login(name, pass, getEcho); }) .catch(error => { console.log(error.message); login(name, pass, getEcho); }); function login(name, pass, callback) { firebase.auth().signInWithEmailAndPassword(name, pass).then( user => { user.getIdToken(true).then(idToken => { document.getElementById("jwt").innerHTML = idToken; callback(idToken) }) .catch(error => { console.log(error.message); }); }, err => { console.log(err.message); } ) } function getEcho(token) { console.log(token); let instance = axios.create({ baseURL: "http://localhost:8082/", headers: { "x-api-key": apikey, "authorization": "Bearer " + token } }); requestEchoServer(instance, "/v1/echo/1", "echo1"); requestEchoServer(instance, "/v1/echo/2", "echo2"); requestEchoServer(instance, "/v1/echo/3", "echo3"); requestEchoServer(instance, "/v1/echo/4", "echo4"); } function requestEchoServer(instance, path, msg) { instance.post(path, { message: msg }) .then(res => { console.log(res.data); }) .catch(error => { console.log(error); }); } </script> <p>Hello</p> <p>JWT: <span id="jwt"></span></p> </body> </html>
Cloud Endpointsの設定
以下の設定ファイルを作成します。
# api_config.yaml type: google.api.Service config_version: 3 name: echo-api.endpoints.プロジェクトID.cloud.goog title: Echo API apis: - name: echo.EchoService usage: rules: - selector: "echo.EchoService.Echo1" allow_unregistered_calls: true # APIキーの認証をオフにする - selector: "echo.EchoService.Echo2" allow_unregistered_calls: true - selector: "echo.EchoService.Echo3" allow_unregistered_calls: false # APIキーの認証をオンにする - selector: "echo.EchoService.Echo4" allow_unregistered_calls: false authentication: providers: - id: firebase jwks_uri: https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com issuer: https://securetoken.google.com/プロジェクトID audiences: "プロジェクトID" rules: - selector: "echo.EchoService.Echo2" requirements: - provider_id: firebase # Firebaseの認証をオンにする - selector: "echo.EchoService.Echo4" requirements: - provider_id: firebase
以下のコマンドでデプロイを行います。
$ gcloud endpoints services deploy api_config.yaml echo.pb Service Configuration [2018-05-02r0] uploaded for service [echo-api.endpoints.プロジェクトID.cloud.goog] # endpointsのサービス一覧取得 $ gcloud endpoints services list # 上記で出力されたサービス名を使い、デプロイ履歴を参照する # ここで出力された「CONFIG_ID」と「SERVICE_NAME」は後ほど使うのでコピーしておく $ gcloud endpoints configs list --service=サービス名
認証処理がうまくいくか確認
まずCloud Endpointsをローカルで実行できるように、ESP(Extensible Service Proxy)をローカルで実行させる。
ESP をローカルまたは別のプラットフォームで実行する
ESPはクライアントサイドからサーバーサイドへのリクエストをProxyするもので
- 認証
- モニタリング
- ロギング
- RESTからgRPCへのリクエストの変換
を行ってくれます。
このESPをDockerを使用してローカルで実行します。
そのためESPはGCP以外のプラットフォームでも動作させることができます。
Docker Composeの設定
ESPと先ほど作ったhtmlを動作させるWebサーバーがほしいので、Dockerで立ち上げます。
# docker-compose.yaml esp: image: gcr.io/endpoints-release/endpoints-runtime:1 ports: - "8082:8082" volumes: - "./key:/esp" command: > -s 上記で取得した「SERVICE_NAME」 -v 上記で取得した最新の「CONFIG_ID」 -k /esp/serviceaccount.json -a "grpc://docker.for.mac.localhost:50051" -P 8082 nginx: image: nginx:1.13.12-alpine ports: - "80:80" volumes: - ./client/web/default.conf:/etc/nginx/conf.d/default.conf - ./client/web/html:/var/www/html grpcserver: image: golang:1.10.2-alpine3.7 ports: - "50051:50051" volumes: - ".:/go/src/github.com/morix1500/cloudendpoint" working_dir: /go/src/github.com/morix1500/cloudendpoint command: go run server/main.go
$ docker-compose up $ API_KEY=最初の方に作成した認証のためのAPIキーを設定 # 実行 $ go run client/main.go -addr localhost:8082 -msg test! -key ${API_KEY} -referer http://localhost Echo1: succeeded: message:"test!" Echo2: failed: rpc error: code Echo3: succeeded: message:"test!" Echo4: failed: rpc error: code
JWTを指定しない場合、Firebase認証の要らないecho1/echo2のレスポンスが帰ってきました。
JWTも指定してみましょう。
ブラウザで「http://localhost」を閲覧すると、JWTが発行されるはずです。
そのJWTをコピーし…
$ API_KEY=最初の方に作成した認証のためのAPIキーを設定 $ JWT=さっきのJWT # 実行 $ go run client/main.go -addr localhost:8082 -msg test! -key ${API_KEY} -referer http://localhost -jwt ${JWT} Echo1: succeeded: message:"test!" Echo2: succeeded: message:"test!" Echo3: succeeded: message:"test!" Echo4: succeeded: message:"test!"
全部通りました。サーバーロジックに
- APIキー
- Firebase認証
を入れなくてもCloud Endpoints(ESP)がよしなにやってくれました。便利ですね!
RESTの設定
gRPCはブラウザからでは呼び出すことはできません。
そのため、REST APIとして使いたい場面が出てきます。
Cloud Endpointsは、RESTのリクエストをgRPCにプロキシすることができます。
その動作を見てみましょう。
gRPC Serverプログラムの修正
protoファイルでRESTのパスを指定することができます。
詳しくはHTTP/JSON の gRPC へのコード変換 に記載してあります。
// proto/echo.proto syntax = "proto3"; package echo; import "google/api/annotations.proto"; service EchoService { rpc Echo1 (Request) returns (Response) { option (google.api.http) = { post: "/v1/echo/1" body: "message" }; } rpc Echo2 (Request) returns (Response) { option (google.api.http) = { post: "/v1/echo/2" body: "message" }; } rpc Echo3 (Request) returns (Response) { option (google.api.http) = { post: "/v1/echo/3" body: "message" }; } rpc Echo4 (Request) returns (Response) { option (google.api.http) = { post: "/v1/echo/4" body: "message" }; } } message Msg { string message = 1; } message Request { Msg message = 1; } message Response { Msg message = 1; }
上記は以下のようにマッピングされます。
Method | REST API path |
---|---|
Echo1 | /v1/echo/1 |
Echo2 | /v1/echo/2 |
Echo3 | /v1/echo/3 |
Echo4 | /v1/echo/4 |
ではコードなど出力していきます。
git clone https://github.com/googleapis/googleapis.git mv googleapis ~/development/lib/. $ GOOGLEAPIS_DIR=~/development/lib/googleapis $ PROTO_DIR=~/development/lib/protoc/include $ protoc --proto_path=${GOOGLEAPIS_DIR} --proto_path=${PROTO_DIR} --proto_path=. --include_imports --include_source_info --go_out=plugins=grpc:. proto/echo.proto --descriptor_set_out echo.pb # デプロイ $ gcloud endpoints services deploy api_config.yaml echo.pb
Docker Composeの修正(CORS対応)
docker-composeの設定ファイルを修正していきます。
通常、WebからREST APIへ問い合わせる際は非同期(Ajaxなど)で行いますが、
CORS(Cross-Origin Resource Sharing)関係のエラーでひっかかることが多いです。
Cloud Endpoints(ESP)でもこのCORS対応ができるのでそれをやっていきます。
# docker-compose.yaml esp: image: gcr.io/endpoints-release/endpoints-runtime:1 ports: - "8082:8082" - "8083:8083" volumes: - "./key:/esp" command: > -s 上記で取得した「SERVICE_NAME」 -v 上記で取得した最新の「CONFIG_ID」 -k /esp/serviceaccount.json -a "grpc://docker.for.mac.localhost:50051" --http_port 8082 --http2_port 8083 --cors_preset basic --cors_allow_headers * nginx: image: nginx:1.13.12-alpine ports: - "80:80" volumes: - ./client/web/default.conf:/etc/nginx/conf.d/default.conf - ./client/web/html:/var/www/html grpcserver: image: golang:1.10.2-alpine3.7 ports: - "50051:50051" volumes: - ".:/go/src/github.com/morix1500/cloudendpoint" working_dir: /go/src/github.com/morix1500/cloudendpoint command: go run server/main.go
ESPのオプションがどんなものがあるかは、以下で確認してみてください。
https://github.com/cloudendpoints/endpoints-tools/blob/master/start_esp/start_esp.py
RESTの動作確認
ブラウザで「http://localhost」を閲覧し、コンソールを見てみましょう(ChromeのDeveloper Toolなどで)
4件分のメッセージが表示されたはずです。
これはJavaScriptで非同期でREST APIを実行しています。
curlでも同様に実行できます。
curl 'http://localhost:8082/v1/echo/1' -H 'authorization: Bearer ${JWT}' -H 'Referer: http://localhost/' -H 'x-api-key: ${API_KEY}' --data-binary '{"message":"echo1"}'
これでREST => gRPCの動作も確認できました!
最後に
Cloud Endpointsを使うと、gRPCを扱うのがグッと楽になりそうです。
当然ながらGKE(Google Kubernetes Engine)とも連携できるので、GKEと組み合わせて使っていく場面が多そうです。
しかしながら公式ドキュメント以外あまり詳細な資料がなかったため、今回記事にしました。
以下詰まったけどドキュメントがなかったもの
- ESPのDocker Compose化
- Cloud EndpointsのCORS対応
参考文献
ほぼ公式ドキュメントしかなくてつらかった。
クライアントから送られてくるJWTを検証するコード
https://firebase.google.com/docs/auth/admin/verify-id-tokens
cloud endpintsのユーザ認証のやり方
https://cloud.google.com/endpoints/docs/grpc/authenticating-users-grpc?hl=ja
cloud endpoints APIキー認証のやり方
https://cloud.google.com/endpoints/docs/restricting-api-access-with-api-keys-grpc?hl=ja
ローカルでESPを使う方法
https://cloud.google.com/endpoints/docs/openapi/running-esp-localdev?hl=ja
クライアント認証をサポートするESPの設定
https://cloud.google.com/endpoints/docs/authenticating-users?hl=ja