バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

RubyでgRPCサーバー開発

テクノロジー開発部の村上です。前回の続きで、メインシステムのgRPCサーバー実装について紹介したいと思います。

f:id:nmu0:20200615013217p:plain

前回の記事で紹介させて頂いたようにマイクロサービスを採用していて、その各サービス間の通信をgRPCで行うことにしました。ただ問題なのは、弊社のメイン言語はRubyであり、RubyでのgRPCサーバー開発があまり見受けられないことです。今回私はGrufというフレームワークを採用しました。

その理由として、

  1. ActiveRecordなどのRailsの主要機能が使用できる
  2. graceful shutdownに対応している
  3. 拡張性がある

が挙げられます。これらの背景として、なるべくRailsと同じように開発したいというのがあります。
3の拡張性は、たとえばGrufではinterceptorやhook、エラー処理といったものです。 RubyのgRPCサーバーにもこれらの機能が実装されていますが、wrapして拡張しやすいものになっています。 それに加え、grufのコードが複雑ではなく、何かあったときに自分たちでメンテナンスをすることが可能というのも理由にあります。 GrufはBigCommerce社がMITライセンスで公開しているもので、コミュニティ管理ではないのでメンテナンスが今後も維持される保証はないからです。
また、Grufはリポジトリ上のWikiが充実しているので、基本的にそれに従えば問題なく動作します。 ただ幾つか試行錯誤した部分があるので、以降ではそれらについて紹介させて頂きます。

Protocol Bufferの変換後ファイルのロード

gRPCということで、RPCのインターフェースをProtocol Buffersで定義し、それをRubyのコードに変換する必要があります。 変換自体はgRPCの公式チュートリアル通りにすれば良いです。 ただ、その変換後コードはファイル名と中で定義されているクラス名が一致しないので、Railsの規則に則っていなく自動で読み込まれません。
そこでconfig/initializers/gruf.rbに、

Dir.glob(Rails.root.join('app/<配置ディレクトリ>/**/*_services_pb.rb')).sort.each do |f| require f end

を追加します。ただし、これだけだとRails.envがproductionの場合にRails consoleを起動するとエラーが出ます。 起動の際のeager_load時に再度読み込みが発生することが原因のようです。
その対策として、参考資料の項に記載したStackoverflowの投稿を参考に、

path_rejector = ->(s) { s.include?('app/<配置ディレクトリ>) }
config.eager_load_paths = config.eager_load_paths.reject(&path_rejector)

のようにproduction.rbに記述することでその問題を防ぐことが出来ます。

エラーハンドリング

Grufでは、クライアント側もgrufのライブラリを使えば、gRPCのmetadataを活用してエラー型やエラーメッセージをクライアントに伝えることが可能です。 よってサーバー側のinterceptorで例外をrescueしてその種類に応じてハンドリングすることで、一箇所でエラーを処理して必要な情報をクライアントに伝える事ができます。
クライアントに伝えるエラー形成には、Gruf::Errors::Helpersをincludeして定義されているメソッドを使用します。add_field_errorで必要に応じてフィールドごとのエラーメッセージを定義し、fail!でエラー型とメッセージを指定してエラーをクライアントに返します。

ここで注意点は2つあり、gRPCのmetadataのサイズの制限と、interceptorの順序です。 metadataのサイズには制限があり、grufではそれを超えるのを防ぐために、超えたことを表すエラーに置き換えられてしまいます。 gruf/lib/gruf/error.rbを見るとその部分がわかります。
interceptorの順序については、公式WikiにはFIFOだと書かれているのですが、個人的には少し誤解を招く表現かなと思います。 interceptorはyieldすることでリクエストを伝播するので、オニオンアーキテクチャのような構造となり、 先に足したinterceptorが外側になります。エラーをrescueしてハンドリングするので他のinterceptorのエラーも拾えるように、 エラーハンドリング用のinterceptorは外側、つまり前に足すべきです。

エラーのロギング

もしかしたら私が把握できていないだけかもしれませんが、Railsと違ってgrufでは自動でスタックトレースをログに書き出してくれません。 よってこれもinterceptorを定義して行っています。interceptorの一部を抜き出しますと、以下のような実装となっています。

def call
  yield
rescue GRPC::BadStatus
  raise
rescue StandardError => e
  trace_str = e.backtrace.join($INPUT_RECORD_SEPARATOR)
  logger.error("#{e.message}#{$INPUT_RECORD_SEPARATOR}#{trace_str}")
  raise e
end

GRPC::BadStatusの場合に書き出さないのは、エラーハンドリングの項で述べたfail!によってGRPC::BadStatusのサブクラスがraiseされるのでそれは処理済みの例外ということでログから除外したいからです。interceptorの順序を注意すれば不要かもしれませんが、fail!はエラーハンドリング用のinterceptor以外から呼び出すことが可能なので、念の為に対応しています。

結び

今回は、Grufを用いたRubyでのgRPCサーバー開発をする際のTipsに近いものを紹介致しました。 Wikiとgruf-demoという公式のサンプルを参考にすれば最低限実装するのは簡単なので、あえてHello Worldを終えた先のことを記しました。
少しでも参考になれば幸いです。

参考資料