RSS

Google Cloud Platform への移行 - gRPC & grpc-gateway

以前のブログ投稿(概要)では、Amazon Web ServicesからGoogle Cloud Platformへの移行の概要を説明しました。この記事では、その移行においてgRPCgrpc-gatewayが果たした役割を掘り下げ、その過程で得た教訓を共有します。

ほとんどの人がREST APIを持っていますよね?何が問題なのでしょうか?

はい、クライアントが使用するREST APIはまだ残っています。クライアントAPIの移行はスコープ外だったからです。正直に言うと、REST APIは機能させることができ、有用なREST APIはたくさんあります。とは言え、RESTで抱えていた問題は細部にありました。

標準的なREST仕様がない

標準的なREST仕様はありません。ベストプラクティスはありますが、真の規範はありません。そのため、特定のHTTPメソッドやレスポンスコードを使用する時期については、満場一致の合意はありません。さらに、すべての可能なHTTPメソッドやレスポンスコードがすべてのプラットフォームでサポートされているわけではありません…これはREST APIの実装者が、自分たちにとって機能するテクニックでこれらの欠点を補うことを余儀なくされ、REST API全体にさらなるばらつきを生み出します。最良の場合、REST APIは実際にはREST風の方言です。

開発者にとっての負担が大きい

REST APIは、開発者の観点からもあまり優れているとは言えません。まず、RESTはHTTPに紐づいているため、私が選択した言語でAPIを単純にマッピングすることはできません。GoやJavaを使用している場合、コード内でスタブ化できる「インターフェース」はありません。作成することはできますが、REST APIの定義とは余分な言語的要素になります。

第二に、REST APIは、リクエストの意図を解釈するために必要な情報を、リクエストのさまざまなコンポーネントに分散させます。HTTPメソッド、リクエストURI、リクエストペイロードがあり、リクエストヘッダーがセマンティクスに関与する場合はさらに複雑になります。

第三に、コマンドラインからcurlを使用してAPIにアクセスできるのは素晴らしいことですが、それはAPIをそのエコシステムに押し込む必要があるという代償を伴います。通常、そのユースケースは、人々がAPIを迅速に試すことを許可するだけで重要です。もしそれが要件リストの上位にあるなら、RESTを使用することをためらわないでください…ただし、シンプルに保ちましょう。

宣言的なREST API記述がない

REST APIの4番目の問題は、少なくともSwaggerが登場するまでは、REST APIを宣言的に定義し、型情報を含める方法がなかったことです。些細なことに聞こえるかもしれませんが、一般的に適切な定義(型情報を含む)を望む正当な理由があります。その点を強化するために、以下に示すPHPサーバーコードの行をご覧ください。これらはさまざまなファイルから抽出されたもので、「yak」の「hidePin」フィールドを設定し、それがクライアントに返されるものでした。サーバーで実行された実際のコード行は複数のパラメータの関数であったため、実行されたものが基本的にランダムに選択されたと想像してください。

// Code omitted…
$yak->hidePin=false;

// Code omitted…
$yak->hidePin=true;

// Code omitted…
$yak->hidePin=0;

// Code omitted…
$yak->hidePin=1;

フィールドhidePinの型は何でしょうか?確実には言えません。ブール値、整数、あるいはサーバーに実際に記述されているものかもしれません。しかし、いずれにしても、クライアントはこれらの可能性に対処する必要があり、それはクライアントをより複雑にします。

クライアントの型定義がサーバーの期待するものと異なる場合にも問題が発生する可能性があります。クライアントから送信されたJSONオブジェクトのフィールド名が予期しない大文字・小文字を使用していたiOSクライアントを処理した下のサーバーコードをご覧ください。

// Code omitted...
switch ($fieldName) {
  // Code omitted...
  case “recipientID”:
  // This is being added because iOS is passing the recipientID
  // incorrectly and we still want to capture these events
  // … expected fall through …

  case “Recipientid”:
    $this->yakkerEvent->recipientID = $value;
    break;
  // Code omitted...
}
// Code omitted...

この場合、サーバーは、予期しない大文字・小文字を使用するフィールド名を持つJSONオブジェクトを送信したiOSクライアントに対処する必要がありました。これもまた、克服できないものではありませんが、これらの小さな不一致がすべて積み重なり、本当に物事を前進させる問題から時間を奪います。

gRPCはRESTの問題を解決できます

gRPCに慣れていない方のために説明すると、gRPCは「高パフォーマンス、オープンソース、ユニバーサルリモートプロシージャコール(RPC)フレームワーク」であり、Google Protocol Buffersを、サービスインターフェースおよび交換されるメッセージの構造を記述するためのインターフェース記述言語(IDL)として使用します。このIDLは、言語固有のクライアントおよびサーバーのスタブを生成するためにコンパイルできます。それが少し不明瞭に聞こえた場合は、重要な側面に焦点を絞ります。

gRPCは宣言的、型安全、言語非依存です

gRPCの説明は、特定のプログラミング言語に依存しないインターフェース記述言語を使用して記述されますが、その概念はサポートされている言語にマッピングされます。これは、理想的なサービスAPI、サポートするメッセージを記述し、次に「protoc」であるプロトコルコンパイラを使用して、APIのクライアントおよびサーバーのスタブを生成できることを意味します。すぐに、C/C++、C#、Node.js、PHP、Ruby、Python、Go、Javaでクライアントおよびサーバーのスタブを生成できます。Objective-CやSwift用のスタブを作成する追加のprotocプラグインも利用できます。

前述の「hidePin」や「recipientID」と「Recipientid」フィールドに関する問題は、使用される型を確立する単一の標準的な宣言があり、言語固有のコード生成により、実装言語に関係なくクライアントまたはサーバーコードのタイプミスを防ぐことができるため、解消されます。

gRPCはRPCコードを手書きする必要がありません

これはgRPCエコシステムの非常に強力な側面です。開発者は、より直接的であるように見えるため、RPCコードを手で作成することがよくあります。しかし、サポートする必要のあるクライアントの種類の数が増えると、このアプローチの維持コストも非線形に増加します。たとえば、Webブラウザから呼び出されるサービスから開始したとします。しばらくすると、要件が更新され、AndroidおよびiOSクライアントをサポートする必要が出てきます。サーバーはおそらく問題ありませんが、クライアントは同じRPC方言を話すことができる必要があります。多くの場合、違いが生じます。サーバーがクライアント間の違いを補う必要がある場合は、さらに悪化する可能性があります。一方、gRPCを使用すると、プロトコルコンパイラプラグインを追加するだけで、AndroidおよびiOSクライアントのスタブが生成されます。これにより、問題のクラス全体が排除されます。ボーナスとして、生成されたコードを変更しない場合(そして、変更する必要はないはずです)、生成されたコードのパフォーマンス改善はすべて取り込まれます。

gRPCはコンパクトなシリアライゼーション

gRPCはメッセージのシリアライゼーションにGoogle protocol buffersを使用します。このシリアライゼーション形式は、フィールド名がシリアライズされた形式に含まれていないことなどにより、非常にコンパクトです。JSONオブジェクトと比較してみてください。JSONオブジェクトでは、各オブジェクトインスタンスがフィールド名の完全なコピーを運び、追加の波括弧が含まれます。低ボリュームのアプリケーションでは問題にならないかもしれませんが、すぐに積み重なります。

gRPCツールは拡張可能

gRPCフレームワークのもう1つの非常に便利な機能は、拡張可能であることです。現在サポートされていない言語のサポートが必要な場合は、プロトコルコンパイラ用のプラグインを作成して、必要なものを追加できます。

gRPCは契約更新をサポート

サービスAPIでよく見落とされる側面は、それが時間とともにどのように進化する可能性があるかということです。最良の場合、これはしばしば二次的な考慮事項です。gRPCを使用しており、いくつかの基本的なルールに従っていれば、メッセージは前方および後方互換性を持たせることができます。

gRPC-gateway — RESTはしばらくの間私たちと一緒にいるからです…

おそらく考えているでしょう:gRPCは素晴らしいですが、対処すべきRESTクライアントがたくさんあります。このエコシステムには、grpc-gatewayと呼ばれる別のツールがあります。Grpc-gatewayは「RESTful JSON APIをgRPCに変換するリバースプロキシサーバーを生成します」。したがって、RESTクライアントをサポートしたい場合は、サポートでき、実質的な追加労力はかかりません。既存のRESTクライアントが通常のREST APIからかけ離れている場合は、grpc-gatewayでカスタムマーシャラーを使用して補うことができます。

移行とgRPC + grpc-gateway

前述のように、移行の一部として、PHPコードとRESTエンドポイントの多くを再作業したいと考えていました。gRPCとgrpc-gatewayの組み合わせを使用することで、レガシーREST APIのgRPCバージョンを定義し、grpc-gatewayを使用してクライアントが慣れているRESTエンドポイントを正確に公開できました。これらの代替実装が導入されたことで、DNS更新と実験および構成システムの組み合わせを使用して、既存のクライアントに混乱を与えることなく、古いシステムと新しいシステム間でトラフィックを移行できました。既存のテストスイートを活用して機能を確認し、古いシステムと新しいシステム間のパリティを確立することもできました。各コンポーネントがどのように連携するかを見てみましょう。

「/api/getMessages」のgRPC IDL

以下は、GCPでレガシーYik Yak APIを模倣するために定義したgRPC IDLです。例は、クライアントが現在の場所を中心としたメッセージセットを取得するために使用する「/api/getMessages」エンドポイントのみを含むように簡略化しています。

// APIRequest Message — sent by clients
message APIRequest {
  // userID is the ID of the user making the request
  string userID = 1;
  // Other fields omitted for clarity…
}

// APIFeedResponse contains the set of messages that clients should
// display.
message APIFeedResponse {
  repeated APIPost messages = 1;
  // Other fields omitted for clarity…
}

// APIPost defines the set of post fields returned to the clients.
message APIPost {
  string messageID = 1;
  string message = 2;
  // Other fields omitted for clarity…
}

// YYAPI service accessed by Android, iOS and Web clients.
service YYAPI {
  // Other endpoints omitted…

  // APIGetMessages returns the list of messages within a radius of
  // the user’s current location.
  rpc APIGetMessages (APIRequest) returns (APIFeedResponse) {
    option (google.api.http) = {
      get: /api/getMessages // Option tells grpc-gateway that an HTTP
                              // GET to /api/getMessages should be
                              // routed to the APIGetMessages gRPC
                              // endpoint.
    };
  }

  // Other endpoints omitted…
}

YYAPIサービス用のProtoc生成Goインターフェース

上記のIDLは、プロトコルコンパイラによってGoファイルにコンパイルされ、以下のようなクライアントプロキシとサーバーのスタブが生成されます。

// Client API for YYAPI service
type YYAPIClient interface {
  APIGetMessages(ctx context.Context, in *APIRequest, opts ...grpc.CallOption) (*APIFeedResponse, error)
}

// NewYYAPIClient returns an implementation of the YYAPIClient interface  which
// clients can use to call the gRPC service.
func NewYYAPIClient(cc *grpc.ClientConn) YYAPIClient {
  // Code omitted for clarity..
}

// Server API for YYAPI service
type YYAPIServer interface {
  APIGetMessages(context.Context, *APIRequest) (*APIFeedResponse, error)
}

// RegisterYYAPIServer registers an implementation of the YYAPIServer with an
// existing gRPC server instance.
func RegisterYYAPIServer(s *grpc.Server, srv YYAPIServer) {
  // Code omitted for clarity..
}

YYAPIサービスのRESTリバースプロキシ用のGrpc-gateway生成Goコード

上記のIDLでgoogle.api.httpオプションを使用することで、grpc-gatewayシステムに「/api/getMessages」のHTTP GETリクエストをAPIGetMessages gRPCエンドポイントにルーティングするように指示します。次に、HTTPからgRPCへのリバースプロキシを作成し、以下の生成された関数を呼び出すことでセットアップできます。

// RegisterYYAPIHandler registers the http handlers for service YYAPI to “mux”.
// The handlers forward requests to the grpc endpoint over “conn”.
func RegisterYYAPIHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
  // Code omitted for clarity
}

したがって、単一のgRPC IDL記述から、選択した言語のクライアントおよびサーバーインターフェースと実装スタブ、さらにRESTリバースプロキシを無料で取得できます。

gRPC — いくつかの問題点があったと聞きましたが?

2016年第1四半期後半にGoのgRPCを使い始めましたが、当時は確かにいくつかの問題点がありました。

早期導入者の問題

Issue 674(リソースリーク)に遭遇しました。これはGo gRPCクライアントコード内のリソースリークで、高負荷時にはgRPCトランスポートがハングする可能性がありました。gRPCチームは非常に迅速に対応し、修正は数日以内にマスターブランチにマージされました。

grpc-gatewayの生成コードでリソースリークに遭遇しました。しかし、その問題を見つけたときには、すでにそのチームによって修正され、マスターにマージされていました。

早期導入者としての最後の問題は、GoのgRPCクライアントがgRPCプロトコル仕様の一部であったGOAWAYパケットをサポートしていなかったことでした。幸い、これは本番環境には影響しませんでした。Issue 674のために作成したリポジトリケースでのみ発生しました。

全体として、私たちがどれほど初期段階であったかを考えると、これはかなり合理的でした。

ロードバランシング

gRPCを使用する場合、これは間違いなく慎重に検討する必要がある分野です。デフォルトでは、gRPCはHTTP1ではなくHTTP2を使用します。HTTP2は、サーバーへの接続を開き、複数のリクエストに再利用できます。このモードを使用すると、リクエストがロードバランシングプール内のすべてのサーバーに分散されなくなります。移行を実行していた当時、既存のロードバランサーはHTTP2トラフィックをうまく処理できなかったか、まったく処理できませんでした。

当時、gRPCチームにはロードバランシング提案がなかったため、クライアントサイドのロードバランシングを強制しようと多くの時間を費やしました。最終的に、生のgRPC通信のほとんどはデータセンター内で行われ、すべてKubernetesを使用してデプロイされていたため、リモートサーバーに毎回接続する方が簡単でした。これにより、システムがKubernetes Service内のサーバーに負荷を分散することができました。私たちのセットアップでは、全体的な応答時間に約1ミリ秒しか追加されなかったので、簡単な回避策でした。

ロードバランシングの問題はこれで終わりでしょうか?正確にはそうではありません。基本的なgRPCベースのシステムが稼働した後、ロードテストを開始し、興味深い動作に気づきました。以下は、gRPCサーバーごとのCPU負荷グラフの時間経過を示しています。何か奇妙な点に気づきますか?

最も負荷の高いサーバーはCPU使用率約50%で実行されていましたが、最も負荷の軽いサーバーは、ウォームアップに数分かかっても約20%で実行されていました。判明したのは、毎回接続していたにもかかわらず、ネットワークトポロジの一部としてnghttp2のインバウンドプロキシがあり、これが既に接続していたサーバーにインバウンドリクエストを送信する傾向があり、不均一な分散を引き起こしていたことです。nghttp2インバウンドプロキシを削除した後、CPUグラフの負荷分散のばらつきは大幅に減少しました。

結論

REST APIには問題がありますが、すぐに消えるものではありません。よりクリーンなものを試したい場合は、gRPC(REST APIを公開する必要がある場合はgrpc-gatewayと組み合わせて)を検討することを強くお勧めします。早期にいくつかの問題に遭遇しましたが、gRPCは私たちにとって純粋な利益でした。より厳密に定義されたAPIへの道を開いてくれました。また、GCPでレガシーREST APIの新しい実装を構築することもでき、AWSの実装から新しいGCP実装へのトラフィックをシームレスに移行する準備が整いました。

Go、gRPC、Google Cloud Platformの使用について議論したところで、Google BigtableとGoogle S2ライブラリの上に新しいジオストアを構築した方法について議論する準備ができました。これは次の投稿のトピックです。