Rails アプリケーションに gRPC を導入したときの話

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 として提供されている grpcgrpc-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::RecordInvalidActiveRecord::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 にサーバーをデプロイする場合 readinessProbelivenessProbe の設定に必要になるヘルスチェックの実装についても共有します。

ヘルスチェックの実装にはこちら 記事を参考に実装しました。 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 の仕様が明確にできアプリケーション間で相互にデータをやり取りするようなケースに適している点やそれを仕組み化する基盤としての便利さを体感することができました。 今後の開発でさらに利用する機会を広げて行ければと思いました。

明日はマッドサイエンティストステェーヴェン・ル・ボエデック氏です。
よろしくお願いします。