Cloud Endpointsを使用したgRPCアプリへのリクエスト方法あれこれ

gRPCアプリを作ったとして、以下のような機能が欲しくなってきます。

  • REST形式の問い合わせも受ける
  • 認証・認可

GCPでアプリを作った場合、「Cloud Endpoints」を使うとこれらをいい感じに実装できます。
どういう風にやっていくのか、サンプルプログラムを通じて理解していきます。

前提

  • OS
    • Mac OSX
  • 言語
    • Golang
  • GCPのアカウントとプロジェクトを作成済み
  • Dockerがインストールされている
  • ローカル環境で動作させる

今回作成したプロジェクトは以下の記事のサンプルプログラムを使用させていただいてます。
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

  • 名前
    • cloudendpoint_sample
  • アプリケーションの制限
    • HTTPリファラー
      • localhost

発行した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