Rails アプリケーションに gRPC を導入したときの話
こんにちは、エンジニアの齊藤です。
この記事は Enigmo Advent Calendar 2020 の10日目の記事です。
本日は、Kubernetes にデプロイした複数の Rails アプリケーション間のデータのやり取りに
gRPC を採用した開発について ruby の実装を中心にいくつか共有したいと思います。
ruby を使った gRPC の開発という内容はオフィシャルのスタートガイドで細かく説明されているものがありますので、そちらを踏まえて実際どのように Rails アプリケーションに組み込んだのかという内容でまとめてみました。
まずは Protocol Buffers についてです。
Protocol Buffers によるサービスの定義
gRPC を利用するためには Protocol Buffers という IDL を使ってサービスの内容を定義します。
以下は Seller
という出品者情報のエンティティとなるメッセージを定義した上でそれらを操作するためのプロシージャ名とそれぞれのリクエストパラメーターとレスポンスをメッセージとして定義
したサービスの実装(sellers.proto
)の一部です。
サービスの定義でのポイントはエンティティとなるデータ構造は必ず定義して、これを直接レスポンスのメッセージとしては利用せずにリクエストパラメーターとなるメッセージとレスポンスとなるメッセージを必ずそれぞれ定義するようにしました。 以下の実装でいうと UpsertSellerResponse
では単一の Seller
をレスポンスで返却し、GetSellers
では複数の Seller
を返すような実装になります。
例えば同じ内容のリクエストパラメーターやレスポンスを複数のプロシージャで共有することは可能だとは思いますが、冗長でもプロシージャごとにそれぞれリクエストとレスポンスメッセージを用意しておくほうが API としてわかりやすく定義できるのではと思います。
今のプロジェクトの Protocol Buffers を振り返ると一つのサービスにたくさんのプロシージャを定義してしまっているケースも見受けられ、そういった場合は package
の記述で ruby のネームスペースを利用することができるのである程度階層化することで解消できたのではと思いっています。
syntax = "proto3"; package Buyma.RPC.PersonalShopper; import "google/protobuf/timestamp.proto"; service Sellers { rpc UpsertSeller(UpsertSellerRequest) returns (UpsertSellerResponse) {} // 出品者情報作成・更新 rpc GetSellers(GetSellersRequest) returns (GetSellersResponse) {} // ページング可能な出品者一覧を取得 } message Seller { uint64 seller_id = 1; string nickname = 2; google.protobuf.Timestamp created_at = 4; google.protobuf.Timestamp updated_at = 5; } message UpsertSellerRequest { unit64 seller_id = 1 string nickname = 2; } message UpsertSellerResponse { Seller seller = 1; // 新規に登録または更新された Seller を返す } message GetSellersRequest { int32 page = 1; } message GetSellersResponse { repeated Seller sellers = 1; // 複数の Seller を返す int32 current_page = 2; int32 total_pages = 3 int32 per_page = 4 }
サービス定義からのコード生成
gRPC は Protocol Buffers でサービス内容を定義したファイルからサーバー/クライアント向けのコードを自動生成するツールが提供されています。
それらを利用することでサーバーとクライアントを任意の言語で実装できるようになっています。
今回のケースでは定義(.proto
)ファイルと自動生成された ruby のコードを Gem としてパッケージしそれぞれの Rails アプリケーションで利用することにしました。
ちなみに .proto
自体は開発言語に依存しないので複数の言語で利用されることを想定した場合は別の独立したリポジトリで管理するのが良いと思います。
今回は利用するアプリケーションのスコープと開発言語が ruby に限定されていたので Gem に内包する形で問題ないと判断しました。
ruby で利用するために必要なライブラリは同じく Gem として提供されている grpc と grpc-tools でこれらを gem スペックに記述して導入します。
gruf というライブラリについては後述します。
# 一部省略 Gem::Specification.new do |spec| # 一部省略 spec.add_dependency 'activerecord', '6.0.0' spec.add_dependency 'activesupport', '6.0.0' spec.add_dependency 'google-protobuf', '3.9.0' spec.add_dependency 'grpc', '1.22.0' spec.add_dependency 'gruf', '2.7.0' # 一部省略 spec.add_development_dependency 'bundler', '~> 2.0' spec.add_development_dependency 'grpc-tools', '1.22.0' end
Gem の開発環境は ruby を導入した docker コンテナを利用しました。 以下の grpc_tools_ruby_protoc
というコマンドを実行することで必要なコードを生成できます。
Stub と呼ばれるこれらのコードの生成についてはこちらに詳しく説明がされています。
$ docker-compose exec -w $PWD/lib ash bundle exec grpc_tools_ruby_protoc --ruby_out . --grpc_out . $(cd lib && find . -name '*.proto')
このコマンドを実行することによって lib/sellers_pb.rb
(定義したすべてのメッセージを含む Protocol Buffers の実装), lib/sellers_service.rb
(サービスを実装するための基盤となるクラスとサービスに接続するための Stub と呼ばれるクラス) という2つのファイルが生成されます。
これらを require
することでサーバーとクライアントを実装することが可能になります。
今回は更に Gruf という Gem を使ってこれらのコードを Rails アプリケーションに導入しました。
Gruf を使ったサーバー実装
こちらに説明があるように生成されたコードを使ってサーバーの実装を行い gRPC サーバーのプロセスを起動することは可能ですが、既存の Rails アプリケーションにうまく組み込んでいくにはある程度の設計と共通化するための機能や設定周りの実装が発生します。
今回はその問題を解消してくれる
Gruf というライブラリを使って利用できる実行環境を用意しました。 Gruf についてはこちらの Wiki に詳しい説明があります。
Gruf を使った RPC サーバーの実装は app/rpc/sellers_controller.rb
といった RPC 向けのコントローラーファイルを使い
Rails に馴染んだ設計でサーバーの実装をすることが可能になります。
以下のコントローラークラスの例は先程の Sellers
サービスのコントローラーです。生成された
Buyma::RPC::PersonalShopper::Sellers::Service
を bind することでコントローラーのメソッドとマッピングされます。
upsert_seller
メソッドは先程の定義ファイルの rpc UpsertSeller
の処理を実装したものです。 Buyma::RPC::PersonalShopper::UpsertSellerResponse
を返しています。
各コントローラーメソッドには自動的に request
というオブジェクトが参照できるようになっており、request.message
でリクエストの RPC メッセージが参照できるようになっています。 これは Rails コントローラーでリクエストパラメーターを参照できる params
の仕組みに類似した設計だと思いました。
class SellersController < ::Gruf::Controllers::Base bind Buyma::RPC::PersonalShopper::Sellers::Service def upsert_seller Buyma::RPC::PersonalShopper::UpsertSellerResponse.new( seller: Buyma::RPC::PersonalShopper::Seller.new(seller.to_grpc_hash.slice(:seller_id, :nickname, :created_at, :updated_at)) ) end def get_sellers Buyam::RPC::PersonalShopper::GetSellersResponse.new(sellers: sellers, ... end private def seller params = request.message.to_rails_hash end # 一部省略 end
seller: Buyma::RPC::PersonalShopper::Seller.new(
部分の実装について Seller
メッセージのパラメーターは AcitveRecord のインスタンスである seller から生成していますが seller.to_grpc_hash
という実装は先程の Gem のなかで ActiveRecord::Base に追加しているメソッドです。google.protobuf.Timestamp
型のフィールドには Time
型の値以外をアサインできない制約があるためこのメソッドで to_time
への変換を行うことでその問題を回避しています。
[1] pry(main)>Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current) Google::Protobuf::TypeError: Invalid type ActiveSupport::TimeWithZone to assign to submessage field 'created_at'. from (pry):32:in `initialize` [2] pry(main)> Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current.to_time) <Buyma::RPC::PersonalShopper::Seller: seller_id: 0, nickname: "", created_at: <Google::Protobuf::Timestamp: seconds: 1607499175, nanos: 658255400>, updated_at: nil>
Gruf は Interceptor と呼ばれる gRPC が提供している、メソッドの前後に処理を行うための仕組みもサポートしています。
今回のプロジェクトでは ActiveRecord::RecordInvalid
や ActiveRecord::RecordNotFound
などの例外を各サーバーメソッドで共通してハンドリングするために利用しています。
require_relative '../error/handler' module Buyma module RPC module PersonalShopper module Interceptor class HandleError < Gruf::Interceptors::ServerInterceptor def call yield rescue StandardError => e Buyma::RPC::PersonalShopper::Error::Handler.call(self, e) end end end end end end
インターセプターの登録は config/initializers
で行います。
config/initializers/gruf.rb
Gruf.configure do |c| c.interceptors.use(Buyma::RPC::PersonalShopper::Interceptor::HandleError) end
サーバーの起動は以下のコマンドで、これで bind したすべてのコントローラーのハンドリングが可能になります。
$ bundle exec gruf
Gruf を使ったクライアント実装
クライアント側の実装は Gruf::Client
に実行したい RPC サービス名(ここでは Buyma::RPC::PersonalShopper::Sellers
) を指定してサービスに接続するスタブのインスタンスを生成します。
.call
メソッドでプロシージャ名とパラメーターを指定して実行することができます。
[1] pry(main)> client = Gruf::Client.new(service: Buyma::RPC::PersonalShopper::Sellers, options: { hostname: ENV['GRUF_SERVER'] }) [2] pry(main)> client.call(:UpsertSeller, seller_id: 1, nickname: 'Foo').message.inspect calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller "<Buyma::RPC::PersonalShopper::UpsertSellerResponse: seller: <Buyma::RPC::PersonalShopper::Seller: seller_id: 1, nickname: \"Foo\", created_at: <Google::Protobuf::Timestamp: seconds: 1597815761, nanos: 131317000>, updated_at: <Google::Protobuf::Timestamp: seconds: 1607500796, nanos: 652204000>>>"
レスポンスメッセージについても Gem で Google::Protobuf::MessageExts
に追加した to_rails_hash
メソッドで必要な型変換を行った Hash として取得し以降の処理 (ActiveRecord
のインスタンスの生成等)でデータ型による問題を回避する工夫を行いました。
[1] pry(main)> client.call(:UpsertSeller, seller_id: 490652, nickname: 'Foo').message.to_rails_hash calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller { :seller => { :seller_id => 490652, :nickname => "Foo", :created_at => Wed, 19 Aug 2020 14:42:41 JST +09:00, :updated_at => Wed, 09 Dec 2020 16:59:56 JST +09:00 } }
gRPC サーバーの HealthCheck
Kubernetes にサーバーをデプロイする場合 readinessProbe
と livenessProbe
の設定に必要になるヘルスチェックの実装についても共有します。
ヘルスチェックの実装にはこちら 記事を参考に実装しました。 gPRC にはヘルスチェックのためのプロトコルとそれを実行するための grpc-health-probe というツールがあるのでそちらを導入します。
ツールは gRPC サーバーを起動する Rails アプリケーションコンテナの Dockerfile でインストールしました。
RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 \
&& wget -qO/bin/grpc_health_probe \
https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 \
&& chmod +x /bin/grpc_health_probe
アプリケーションには Grpc::Health::V1::Health::Service
を バインドしたプロシージャを実装をします。
実装内容はアプリケーション上のヘルスチェックを行った上で適切なレスポンスメッセージを返すだけです。
こちらも Gruf のコントローラーとして実装しています。
require 'grpc/health/v1/health_services_pb' class HealthCheckController < ::Gruf::Controllers::Base bind Grpc::Health::V1::Health::Service def check if alive? # アプリケーション上のヘルスチェックを実行する Grpc::Health::V1::HealthCheckResponse .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::SERVING) else Grpc::Health::V1::HealthCheckResponse .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::NOT_SERVING) end end end
動作確認をします。
正常な場合
$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001 status: SERVING
問題がある場合
$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001 service unhealthy (responded with "NOT_SERVING")
あとは Kubernetes の deployment でヘルスチェックコマンドを導入して完了です。
livenessProbe: exec: command: - grpc_health_probe - -addr=:9001 failureThreshold: 3 initialDelaySeconds: 20 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 5
readinessProbe: exec: command: - grpc_health_probe - -addr=:9001 failureThreshold: 3 initialDelaySeconds: 10 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 5
最後に
Rails アプリケーションに gRPC を導入した際のポイントをいくつか共有させていただきました。 今回実際に導入してみて gRPC は IF の仕様が明確にできアプリケーション間で相互にデータをやり取りするようなケースに適している点やそれを仕組み化する基盤としての便利さを体感することができました。 今後の開発でさらに利用する機会を広げて行ければと思いました。
明日はマッドサイエンティストのステェーヴェン・ル・ボエデック氏です。
よろしくお願いします。