SmartHR Tech Blog

SmartHR 開発者ブログ

Solid三兄弟の導入と、フルソリッドな世界

こんにちは、RubyKaigiめっちゃ楽しんでいるエンジニアkinoppydです。これを書いている今日は二日目ですね。ハロー。

おなじみの Schedule.select の今年の大きなトピックは以下の5つです。

  • デザインの刷新
  • PostgreSQLからSQLite3への移行
  • Solid三兄弟の導入(cableはまだ使うかどうかわかりませんが……)
  • Rails 8へのアップグレードとPWA対応
  • Kamalによるデプロイ

過去2回はこちらです。

tech.smarthr.jp

tech.smarthr.jp

今回は第3回目として、Solid三兄弟とフルソリッド構成の話をします。

Solidの世界

RubyKaigi公式スケジュールアプリであるSchedule.selectは、今年はすべてのデータをDBに格納する完全Solid体制をとっています。これはJobのSolidQueue、キャッシュのSolidCache、WebSocketのSolidCableだけではありません。セッションストアとしてActiveRecord::SessionStore、ActiveStorageのバックエンドとしてActiveStorageDBを使用し、SQLite以外の外部ストレージへの依存をゼロにしています。 そこまでSQLiteに依存して何が良いのか、という問いに対しては、半分くらいは「とりあえずやってみたかった、バイブスがぶちあがった」、もう半分くらいは「環境構築のコストがゼロにできるかやってみたかった」ことが挙げられます。 それでは、それぞれのコンポーネントの話を少しずつしていきます。

SolidQueue

github.com

Solid三兄弟の中でも、最も注目度が高いのはSolidQueueだと思います。ActiveJobのバックエンドとして対応しているキューシステムは数が多く、その中であえてDHHが自ら作ったという点だけでも注目度は大きいと思います。Schedule.selectでは、すでに去年の段階からSolidQueueを使用しており、バージョンは当時の最新の0.3でした。今年はバージョン1.1.3を使用しており、アップデートをかけていた2月初頭での最新バージョンです。 SolidQueueの率直な感想としては、特定のGemの機能対してにゴリッゴリに依存していなければ最強の選択肢だと思います。ミニマムで欲しい機能がほとんどそろっており、MissionControlJobという管理用Gemも提供されています。最もユーザーの多いであろうSidekiqは、そもそもActiveJobを経由しない方が良いくらい独自機能が多く、その機能の豊富さと強力さが魅力です。そのため、そもそもActiveJobを使っていない環境でのSolidQueueへの移行はおすすめしません。 SolidQueueの魅力は、その構成の容易さにあります。なので、0->1のプロダクト開発では絶対に採用した方が良いと思います。機能が足りなければ後から乗り換えればよいですし、最初から他のGemの機能が必要になるようなユースケースは希です。

去年から使っていたのでアップデートがありましたが、アップデートガイドもあるのでそこまで困りはしませんでした。

github.com

大きなポイントは0.6への移行です。ここではDBだけではなくデータのマイグレーションがあるので、DBマイグレーションに3ステップが必要になります。ですがそれ外は小さな変更や設定の名称変更程度であり、そこまで大きくつまづくこともないでしょう。

また、0.8からキュー用のDBが分割され独立しますが、これまで通りprimaryのDBで動かすこともできるので、好きな方を選択できます。もしこのタイミングで分割したい場合は、以前に公開したPostgreSQLからSQLiteのマイグレーションの中に出てくるような手順を踏む必要があります。また停止メンテも必須になるはずです。

SolidCache

github.com

長らく、キャッシュストアと言えばMemcachedかRedisでした。というか、今でもそうだと思います。特にRedisは豊富なコマンドも相まって、ただのキャッシュストア以上にシステムの要件の一部になっていたりします。Sidekiqも、特にProやEnterpriceではRedisの機能に強く依存しており、無くてはならないミドルウェアです。 一方で、0->1での開発においては動かしたいフレームワーク以外のエコシステムは少なければ少ないほど良いです。環境のセットアップや選定に時間がかかることは、本来動かしたい手を少しだけ余分に他のことに使ってしまうからです。決め打ちの環境であればdocker composeで一発という見方もありますが、私はせっかちなのでコンテナが起動するのを待つのも面倒ですし、PCを再起動した後にbin/devしたらPGが立ち上がってない、Redisが立ち上がってないと怒られるのも嫌いです。SolidCache+SQLiteを使えば、少なくともRedisやそれに類するキャッシュストアに対する依存を無くせます。Railsと同じプロセスで動いてくれるキャッシュストアは、余計に考えなくてはいけないことからすべて解放してくれます。 性能に関して言うと、各種ベンチマークで圧倒的にRedisに負けるという現実はあります。とはいえ、それが気になるレベルになるのであればシステムが十分に成長した証で、そのタイミングでキャッシュストアをRedisに移行すれば良いと思います。何か特殊なことでもしていない限りは、移行は難しくないでしょう。

techracho.bpsinc.jp

あと、ローカルでの開発では、動作確認以外の目的でSolidCacheは有効にしない方が良いです。ActiveRecordを経由してキャッシュするので、開発コンソールがとんでもねえことになります。SolidQueueですらうるさいなと思うのに、キャッシュするBlobがバーンとコンソールに表示されるとコンソールが使い物にならなくなります。 そもそもの話として、そんな0->1の段階でキャッシュが必要なのか? というのは検討しても良いと思います。私はただ使いたかったから使いましたが、一般的にキャッシュをうまくコントロールするということは、開発においてRedisがどうのこうの言う手間の100000倍くらい厄介な話です。そもそもまずキャッシュを使わない、という選択肢も忘れずにいてください。

SolidCable

github.com

QueueとCacheに比べると使用頻度という意味でやや地味な存在ですが、個人的に一番驚いたのはSolidCableでした。正確に言うと、turbo-railsのTurboStreamsヘルパとActionCableの組み合わせが非常に強力で、それを手軽に体験できるSolidCableはすごいなという感想です。 ActionCableは前提からしてRedisが必須、さらに本番ではWorker用のインスタンスを別に用意することが推奨されるという環境構築ハードル激高の存在で、キャッシュストアとか目じゃないほど面倒な存在です。要件的にも、Websocketが必須というプロダクトは相対的に少なく、そもそもあんまりやったことが無いという人も多いはずです。 SolidCableはRedisなしで起動しますし、何よりSQLiteを使う以上本番でのWorkerインスタンスの分離も物理的に難しいです。そのためプロセスが分離されるかどうかは選択肢がありますが、必然的にWebサーバーと同一のマシンで動かすことになり、インフラの複雑性が減ります。それが良いことなのかどうかはもちろん議論の余地はあると思いますが、これもSolidCacheと同じで必要になったら切り替えていけば良いと思います。SolidCacheに比べて難易度は桁違いに高いですが。

SolidCacheというよりもTurboStreamsの話になってしまいますが、TurboStreamsは通常のRESTだけではなく、WebSocketを介して <turbo-stream> タグを送ることができます。送られてきたタグは、通常のRESTの時のTurboStreamsと同様にDOMを操作することができます。

ActionCableとTurboStreamsをつなぐためのヘルパは、たったこれだけです。

<%= turbo_stream_from "some_identifier_string" %>

もし特定のユーザーにだけTurboStreamを発行したい場合は、引数にユーザーを特定するオブジェクトを渡します。

<%= turbo_stream_from @user.id, "some_identifier_string" %>

このヘルパは次のようなHTMLを出力します。

<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" connected=""></turbo-cable-stream-source>

このタグは、ブラウザ側でロードされたTurboのJSによって自動的にWebSocketのコネクションを開きます。このコネクションは、Rails側から some_identifier_string 宛てに送られたTurboStreamsの適用を行います。Rails側からは、turbo-railsによってModelにIncludeされたConcernを使い、 some_identifier_string をsubscribeしているすべてのクライアントに変更を通知することができます。次のコードは、Controllerの中でsome_modelを変更したとき、その変更をすべての some_identifier_string でサブスクライブしているクライアントに対してTurboStreamsを送信するものです。

# in controller
def update
    @some_model.update!(some_model_params)
    @some_model.broadcast_replace_to("some_identifier_string")
end

このコードでは、 some_identifier_string をサブスクライブしているすべてのクライアントに対して、@some_model の表示用パーシャルを最新のもので置き換えるようなTurboStreamsを送信します。broadcast_replace_to などのヘルパはModelのConcernとして定義されており、turbo-railsの中で用意されており、他にもTurboStreamsの基本的な操作に関してヘルパが用意されています。

github.com

特定のユーザーにのみ変更を通知したい場合は、次のように書きます。

# in controller
def update
    @some_model.update!(some_model_params)
    @some_model.broadcast_replace_to(@user.id, "some_identifier_string")
end

実装を見てみるとわかるとおり、identifyに使うオブジェクトは配列で複数渡せるため、特定のユーザーのIDなどを含めることによって指定したユーザーのみに変更を通知することが可能です。

github.com

Schedule.selectでは、トロフィの取得判定ワーカーの中から、トロフィ取得時に通知を送るために使用しています。

<%= turbo_stream_from @user.id, :notification %>

トロフィの取得は特定のモデルを書き換えるわけではなく複数のモデルが影響するので、Modelに取り込まれたConcernが使えません。そのため、使うパーシャルや変数などを直接指定してTurboStreamsをたたく必要があります。

Turbo::StreamsChannel.broadcast_prepend_to(  
  profile.user.id,  
  :notification,  
  target: 'notification',  
  partial: 'components/notification',  
  locals: { title: I18n.t('trophy.notification', name: profile_trophy.trophy.name) }  
)

特定のユーザーの :notification というTurboStream用チャンネルに対して、notificationというID(これは偶然同じですが、同じである必要はありません)を持っているDOMの中にレンダリングしたパーシャルをprependしています。たったこれだけのコードで、ワーカーからTurboStreamブラウザに向けて送信し、非同期で通知用のDOMをレンダリングすることができます。ちょっと感動しました。これまで手でいろいろと書いて頑張ってきたWebSocketの操作が、TurboStreamsでいとも簡単に実現します。今年のSchedule.selectを作成していて、一番の驚きのポイントでした。

話をSolidCableに戻しますが、TurboStreamsを使ったWebSocketの体験の良さを是非味わって欲しいので、そのために難しいことを考えずWebSocketサーバーを用意できるSolidCableを推します。開発環境ではasyncでも動かせるので別にSolidCableなくても良いじゃん感はありますが、本番環境ではそうもいきません。いや、インスタンス1つしかなかったらいくのかな……? もしかしたらそうかも知れませんが、非推奨なのでちょっと怖いですね。

ワーカーを使ってTurboStreamsを送っていて気づいたことがあるのですが、リクエストに応じてワーカーを起動させて通知を送る場合、リクエストが完了するよりも先にWebSocketでTurboStreamsが飛んでしまい、ページ遷移より先に現在のDOMを書き換えてしまい、0.2秒くらい遅れてリクエストの完了がDOMを書き換える、というわりと悲しい現象も発生していました。特にワーカーの場合、レンダリング用のデータ取得はすでに終わっているケースが多いと思うので、最悪ケースではファントムリードに近い現象が発生してしまいます。適切にDOMを更新させるために、ワーカーを使う場合は起動を意図的に遅らせるなどの解決策などは必要そうです。

ActiveRecord::SessionStore

github.com

これはRailsが公式で出している、SessionStoreのActiveRecordアダプタです。セッションストアもRedisが要求されるので、ソリッドのバイブスを得るためにActiveRecordに寄せました。といっても、別に複雑なセッション管理をしているわけでもなく、なんならプロセスもひとつなので、これは完全にソリッドって言いたかっただけです。導入に関して特に難しいこととかはありません。

ActievStorageDB

github.com

ActiveStorageのDatabase用アダプタで、SQLiteも一応サポートされているようです。とはいえ、メインはPostgreSQLとMySQLみたいですね。ActiveStorageのバックエンド用DBはいくつか選択肢があったのですが、このGemが最も活発にメンテされており、READMEも読みやすかったので採用しました。

これも導入に関しては特に難しいことは無く、唯一あるとすればSchedule.selectはDBのすべてのPKがUUIDなので、通常のmigrationではアタッチがうまくいかないというポイントだけでした。そしてこれは別にActiveStorageDBの問題ではなく、ActiveStorageそのものの問題ですね。っていうか、ActiveStorageってPKの種類が混在している環境で使う場合ってどうするんでしょうね? そんな環境にすんなって話はあるかもしれません。

SolidCacheと同様、ActiveStorageDBもActiveRecord経由でBlobをバンバン突っ込むのでコンソールが爆発します。入れるblobのサイズが桁違いなので、SolidCacheよりヤバいです。動作確認をしたら、開発環境ではdiskなどに置き換えておきましょう。

Solid State Surviver

すべてがSolidになると、見える景色が少しだけ変わりました。環境構築のハードルが激下がった結果、気軽に開発に参加できるというメリットを生み出しました。これまでのRailsアプリでは、開発に参加するためにはまずPostgreSQLを用意して、Redisを用意して……そのためにdocker compose up して……本番環境用のワーカーを用意して……などと考えることが多かったですが、Solidの世界ではすべてが bin/dev で完了します。

開発環境だけではなく、本番環境へのデプロイ、特にDockerfileの準備はさらに容易になりました。SolidQueueはPumaプラグインでサーバーと同じプロセスで動きますし、キャッシュやセッションストアのためにRedisへの接続先をハンドルする必要もありません。ActiveStorageを使うためにストレージサービスへのアクセスキーや権限を取得する必要もありませんし、WebSocket用のサーバーを用意する必要もありません。ほぼそのまま、Railsがデフォルトで出力するDockerfileで本番環境へのデプロイが可能です。

CIの環境構築なども容易になるでしょう。これまでCIの為にアクセサリサービスをたくさん立ち上げる必要がありましたが、Solidの世界では何も必要ありません。デプロイ先の環境も同じです。sshが可能なマシンへのデプロイは標準でkamalが用意されているため複雑な設定なしですぐにデプロイができますし、CloudRunなどのコンテナベースの環境であってもDockerfileそれ一本で動きます。SQLIteのバックアップ用に、LitestreamすらもPumaプラグインで動かすことができます(これはあまりおすすめしませんが)。

もう一つ、フルSolidになったメリットは、DBやRedisへのレイテンシがほぼなくなるという点があります。特にDBは往復の回数が多いので、爆発的に早くなると思います。SQLiteは同じマシン上で動いているため、そりゃ当たり前よなということですね。

フルSolidの大きなデメリットは、開発のコンソールが完全に役に立たなくなることです。画面が大量のキュー実行履歴やキャッシュとストレージやセッションのINSERT文で埋め尽くされます。全く仕事にならなくなると思います。 config/development.rb に設定して、ローカルではdiskやasyncを使うようにしましょう。

また、Solidであり続けることができないこともデメリットです。この構成はいずれ限界を迎えることになります。いつかはSQLiteを脱却し、PostgreSQLやRedis、ストレージサービスも必要になるでしょう。ですが、最初の一歩を踏み出すために、それら「いずれ訪れる限界」に備える必要はありません。とにかく早く踏み出すために、Solidな環境は外すことのできない選択肢です。

今回、Schedule.selectのためにフルSolidな環境を構築して、私はかなりのワクワクを感じました。Solidな世界でサバイブしていきましょう。

We are hiring

残念ながら社内にフルSolidな仕事はありませんが、私はこれから社内のいくつかのプロダクトでSolidなスタートを始められないか試してみたいと思っています。そして私はそれができるチームに居ます。

是非これを読んでSolidなバイブスを感じたあなたも、Solid State Societyにようこそ。

hello-world.smarthr.co.jp