日々のこと

まいにちの暮らしをつれづれ書きます

Kaigi on Rails Day2 レポート / DON'T FORGET TO HAVE FUN!

この記事には、Kaigi on Rails 2024 の Day2 に聞いたセッションの感想と記録を書きたいと思います。

kaigionrails.org

セッション会場は2つあったのですが、2日目は特にどちらの発表も面白そうでどちらを聞くか悩ましかったです。
また、発表の中で、1日目や他の方の発表内容に言及していたりしているのも、おお、繋がっている!!となりました。

イベントで思ったことと、Day1 のセッションについては別記事を書いています)

作って理解する RDBMSのしくみ

Yudai Takada さん

speakerdeck.com

Day2 の朝は少し出遅れてしまったので、1つ目のセッションは結論しか聞けませんでした 。アーカイブが出たら見たいと思います。

Cache to Your Advantage: フラグメントキャッシュの基本と応用

Toru Kawamura さん

drive.google.com

SonicGarden の方の発表でした。Sendagaya.rb を主催されているとのこと。
フラグメントキャッシュの基本についてと、それを応用して作成中の gem(ページ全体にフラグメントキャッシュを広げるもの)についての発表でした。
Rails のキャッシュの機構について分かっていなかったので、基本の説明がありがたかったです。

基本:フラグメントキャッシュとは

  • キャッシュとは、貯蔵所(近くに貯めておく場所)
  • フラグメントとは、断片(ページの一部分)
  • 架空のECサイトを例示しながら説明
  • erbでフラグメントキャッシュを使うには、以下のようにcacheでdo endで囲むだけ
<% cache @product do %>

<% end %>
  • キャッシュデータの保持はキーバリュー
  • キャッシュ事故の不安
    • DBのレコードを更新したが、キャッシュを消すのを忘れて更新されない
    • 特定ユーザーしか見られない情報を誤ってキャッシュしてしまって他のユーザーに見えてしまう
  • 古いキャッシュどうする?
    • キャッシュの失効を手動で行うのは、非常に面倒でミスが発生しやすい、もっと良い方法「キーベースのキャッシュ失効」と呼ばれるものがある from DHH

キーベースのキャッシュ失効

  • ActiveRecordインスタンス単体の場合
    • productsが更新される
    • [モデル名]/[id]-[updated_at] でキーが変わる
  • ActiveRecord::Relaction(複数)の場合
    • [モデル名]/query-[SQLハッシュ値]-[個数]-[最新のupdated_at]
    • where, order, limit, offset が変わると SQL のハッシュが変わる
  • ビューによる追加のキー
    • ビューテンプレートからキーを生成
    • [ビューテンプレート名]:[ハッシュ値]
    • すべてのテンプレートとパーシャルのハッシュ
    • ex. /app/views/products/show.html.erb views/products/show:7a1156131a6928cb0026877f8b749ac/products/5-20240918083000000000
  • ページを見ると以下に分かれる
    • 静的コンテンツ
    • 動的コンテンツ
    • キャッシュNG: 個人ごとに変わる部分があるのでキャッシュしてはいけない部分

応用:フラグメントキャッシュをページ全体に広げる rendering_caching gem

  • 作成中のgem: rendering_caching gem
  • ページ全体をキャッシュさせる
  • Controllerに、cached_rendering と書く
    • 自動的に cache @product を書いたのと同じ
class ProductsController 
  caches_rendering :index, :show
end
  • 他のユーザーのデータを見れないようにする
  • sessionに依存する他のデータを使わない
    • current_user, current_xxx
    • flash
    • csrf_token etc
  • gemで、自動的に session, cookiesが無効になるようにしている
  • CSRFトークンを使わない方法
  • 残ったキャッシュNG部分は、Turbo Frames(洗練されたframeのようなもの)で分離させる

まとめ

  • フラグメントキャッシュは ActiveRecord モデルを渡せばおまかせでスピードアップできるよ
    • rendering_caching gem ならページ全体にもできる
  • ただしキャッシュ内で session, cookies を使わないように注意
    • rendering_caching gem なら自動的に無効

推し活のハイトラフィックに立ち向かうRailsとアーキテクチャ

Hayato OKUMOTO さん

speakerdeck.com

株式会社 TwoGate さんの CTO、奥本さんの発表でした。
ライブ会場などの物販でものすごい量の決済リクエスト(秒間400件超!)を捌くOEM型モバイルオーダーアプリ(Caravan)のアーキテクチャについて語ってくださいました。
TwoGate さん、めちゃくちゃかっこいいと思いました。パフォーマンス関連の話は痺れます。
例として、在庫を更新する処理がロックを取ることでロック競合して進まなくなり、ハイトラフィックな決済リクエストを捌けなくなってしまうという問題をデータの持ち方を変更して解決した話をしてくださったのですが、自分は在庫管理のソリューションに携わっているので、こんなDBテーブル構造で解決したのか!と目から鱗でした。
FOR UPDATE SKIP LOCKED という、行ロックした結果をスキップしてデータを返してくれる構文も初めて知りました。

前提

アプリの特徴

  • サーバサイドはマルチテナント
  • アプリはOEM型、1アーティスト=1アプリ
  • 短期間での提供:内製向けローコード化
    • 1クリックでAppStoreに審査出すところまで作り込んでいる
  • フロントエンドは、Angularとionic、アプリもWebで作っているとのこと

インフラアーキテクチャ

  • Fastly -> ALB - nginx -> Rals App -> Aurora PostgreSQL / ElastiCache Redis(Cache/Queue)
  • Sidekiq
  • Amazon ECS
  • 結構シンプルな構成、モノリシックな Rails

ピークトラフィック

  • CDN 50,000 RPS
  • 決済エンドポイント 660RPS に記録更新(プロポーザルの時は 400 RPSだったとのこと)
  • トラフィックに耐えきれず障害の経験も・・

推し活 × ハイトラフィック

ハイトラフィックに挑む設計

-スロークエリを起こさない実装 - CDNでのキャッシュを活用 - アプリケーションキャッシュ(Redis)の活用 - CDNでのキャッシュが難しい時

起きてくる問題

  • DBレイヤーでのボトルネックから始まる
    • インデックスの不足・クエリが悪い・テーブル設計が悪い
    • 実行計画が変わって急に遅くなる
  • 各種外部APIのレートリミットに引っかかる
  • ALBのスケーリングに間に合わずエラー発生、事前に暖気したり

対処

  • Performanc Insight / APMによる分析
  • アプリケーションログの分析、特定条件だけ遅い:ある商品だけ在庫が非常に多い
  • 実行計画の解析:ChatGPTに投げると便利!とのこと
  • 負荷試験

本発表で扱うプロダクトの機能

  • 予告した日時に販売開始、在庫限りの販売、受け取り時間枠選択->商品選択
  • ユーザーが一同にアクセスし、在庫の奪い合い!

1. 高スループット & 正確な在庫確保のアーキテクチャ

  • 在庫確保をする簡易な実装

在庫テーブル

商品ID 在庫数 販売数
P1 100 20
P2 150 10
  • 同時に購入者がいると、在庫数を確認したタイミングと更新するまでの間に更新が走れば、在庫数を超過してしまう・・・
  • 解決策:行ロックを導入する
BEGIN;
SELECT * FROM 在庫テーブルWHERE 商品ID = P1 FOR UPDATE;
UPDATE 在庫テーブル SET 販売数 = 販売数 - 10 WHERE 商品ID = P1;
COMMIT;
  • 行ロックを掛けることで正確な数量の販売ができるようになるが、数百RPSでの在庫確保のワークロードでは全然進まない
  • 同じ行を更新するユーザーが多数、ロックの奪い合いでロック待ち・デッドロックが頻発
  • どうやって行を奪い合わないようにすればいい??

  • 1行1在庫のテーブルに変更

  • 順番に排出できれば競合を回避できるはず
在庫ID 商品ID ユーザID
Z1 P1
Z2 P1
Z3 P1
  • FOR UPDATE SKIP LOCKED で実装
    • 行ロックされている行をスキップした結果を応答してくれる
    • PostgreSQL9.5- MySQL8.0- ロックがないものを取得できる
BEGIN;
SELECT * FROM 在庫テーブル WHERE 商品ID = P1 LIMIT 4
FOR UPDATE SKIP LOCKED;
UPDATE 在庫テーブル SET ユーザID = Ua WHERE 商品ID = P1;
COMMIT;
  • 行ロックされていない行を必ず返答する保証がある
    • 競合することがなくなり、数百TPSでの処理が可能に
  • (余談)Solid QueueはあDBをバックエンドにしているが、FOR UPDATE SKKP LOCKEDを使って実現してる!!!

2. 外部決済APIのレートリミットとの向き合い方

  • クレカ決済には外部のペイメントプロバイダを利用
    • 非通過、非保持化のため
  • 決済関連20-30rps程度
  • 決済できず宙に浮く在庫をなくすため、オーソリとってから在庫確保する
    • 売り切れた在庫が復活すると、クレーム発生につながることもあるため
  • 決済プロバイダのレートは上げることはできない
  • 正しく諦める必要がある
  • 決済システムの限界よりもリクエストを受け付けない、レートリミットを導入
  • CDN/WAFで対応する?正確性や条件制御が単純なので不採用
  • Railsのレイヤでレートリミットを Redis で自作した
  • 限界に対して正しく向き合うことは大事
  • 負荷試験で外部APIをモックする時はレートリミット時の挙動も考慮すること

入門「状態」

しんくう さん

speakerdeck.com

「状態」についてのお話でした。状態がどんなときに辛くなるのか、その解決方法についてコード例を交えながら解説してくださいました。
今、自分が開発しているコードで、ユーザーの状態がコードの様々な箇所に散見されているように感じていていて、なにか改善するヒントはないかと思って聞きました。状態の扱い方をわかりやすくまとめてくださっていて参考になりました。

オブジェクトと「状態」

  • 電球 LightBulb クラス
    • @is_on: 電球が点いているか
    • @brightness: 明るさ
  • オブジェクトは、複数のインスタンス変数の組み合わせで「状態」の表現を増やすことができる
attr_accesor :is_on, :brightness

「状態」がどのような時につらくなるのか

    1. 変化を繰り返すと追いづらくなる
    2. 状態の変化が多いと、最終的な状態を把握するのに労力がかかる
    3. インスタンス変数を何度も書き換えている
    1. 変化の幅が大きいと考慮点が増える

つらさ抑制

    1. 「状態」を可能な限り変化させない
    2. 再代入をさせない
    1. 「状態」の変化幅を限定的にする
    2. 値を変更する専用のメソッドを用意してあげて、変更方法をメソッド経由のみにする
    1. 「状態」をシンプルに少なく保つ
    2. 変数を見て状態を読み取らせるのをやめ、状態を読み取るためのメソッドを用意する
    3. 無数のパターンではなく、用意されたメソッドから限られたパターンだけ
  • つらさの抑制まとめ
    • 役目を終えるまで変化をさせないようにする
    • 変化のバリエーションも数が多くならないようシンプルに保つ

Rails実装でのつらい「状態」のいなし方

    1. Controller でのインスタンス変数再代入
    2. 変数に代入するのは1回のみに
    3. 変数の値を決定するためのメソッドを用意する
    4. メソッドを見ればいいので状態を把握しやすくなる
    1. View内でのインスタンス変数を利用した分岐
    2. モデルに状態をわかるようなメソッドを用意してViewから呼ぶ
    3. 「状態」としてメソッドが定義されることで読みやすさが明快になる
def before_open?
  open_at > Time.zone.now
end

約9000個の自動テストの時間を50分から10分に短縮、偽陽性率(Flakyテスト)を1%以下に抑えるまでの道のり

hatsu さん

speakerdeck.com

「状態」のお話と同じ時間帯のこちらの自動テスト改善の発表もめちゃくちゃ参考になりそうで聞きたかったです。
E2Eテスト改善するヒントがたくさん散りばめられていました。
やり遂げているのが素晴らしいです。

Sidekiq vs Solid Queue

Shinichi Maeshima さん

speakerdeck.com

Railsのバックグラウンドワーカーの変遷の歴史を紐解きながら、現在よく使われている Sidekiq と Rails 8.0から標準となる Solid Queue を比較した発表でした。
Solid Queue についてキャッチアップできてなかったのでありがたかったです。
ginza.rb を主催している方とのことでした。地域ruby 主催している方多くていいですね。
バックグラウンドワーカーの話も、今回結構多かった気がします。
そして、TwoGate さんの発表で出てきた SKIP LOCKED がこのセッションでも早速出てきました・・!

バックグラウンドワーカー

  • Sidekiq も Solid Queue もバックグラウンドワーカー
  • Railsにおけるバックグラウンドワーカーは Active Job
    • アダプタ経由で使う(ラッパー)
  • ActiveJobを使わない方が良いという説もある(直接アダプター使いましょう)
  • 圧倒的に Sidekiq が使われている、2024年のデファクトは Sidekiq
  • Rails 8.0 のデフォルトは Solid Queue
  • 何を前提にバックグラウンドワーカーを選定してどのように使うといいのか

Railsのバックグラウンドワーカーの歴史

Active Job以前

  • BackgrounDRb(2006-)
  • Delayed::Job(2008-)
    • RDB使ってる
    • ジョブ取得時のロックの競合、実行可能な列をブロック、並列実行に難あり
  • Resque(2009-)
    • GitHub
    • Redisを使ってる
  • Sidekiq(2012-)
    • Redis
    • OSS版と、商用版 Pro/Exterprise
  • ActiveJob (2014-)
    • Rails4.2から
    • バックグラウンドワーカー処理への共通インタフェースができた
    • deliver_later, Active Storageから使える

「ActiveJob経由ではなくSidekiqを直接使う」説

  • Active Job以前に作られたライブラリは独自のインタフェースと機能を持つ
  • ライブラリの機能を完全に使えないこともある
  • 重複している機能もある(ActiveJobとSidekiqのリトライ機構)

Active Job以降、Rails8.0以前

  • GoodJob(2020-)
    • ActiveJobのアダプタとして動かす前提
    • ストレージはPostgreSQL限定
    • Delayed::Jobと同様のロックの問題は、Advisory Locksを利用して回避している
    • pg_try_advisary_locks: ロックを取得できたらtrue、できなかったらfalseを返す関数

Rails8.0

  • Solid Queue(2023-)
    • Rails 8.0からのデフォルト
    • ストレージはActive Record経由
    • GoodJob 同様 ActiveJob 経由が前提

Solid Queue誕生の秘話

  • HEY(DHHがCTO)ではResqueを長年使っていた
    • resquenの他に6つのプラグインのgemをforkしたり自作したり、辞めたいというモチベーション
  • Solic Trifecta
    • Solid Queue, Solid Cache, Solid Cable に共通する話
    • RedisやめてRDBにしようぜ
    • ディスクの性能が上がっている、ディスクの方がいいじゃん、安い、特にキャッシュ使うときメリット
    • Redisの要素なくなるので開発楽、One Person Framework
  • SKIP LOCKED
    • MySQL8.0.1、PostgreSQL9.5から利用可能
    • すでに取得されているロックを飛ばして残りをロックする
    • これにより、RDBでも大量のジョブを扱えるように
    • FOR UPDATE SKIP LOCKED
  • これまでの知見の積みと2024年の状況を踏まえ Solid Queue が生まれた

バックグラウンドワーカーの選定

Sidekiq の機能の深掘り

  • 仕事で使うにはSidekiq Proを使うのが前提
  • 2種類の信頼性を高める機能がProにはある!
    • エンキュー時にRedisにアクセス失敗したらメモリ上に貯めて次のエンキューに再送してくれる
    • ジョブ実行中に何らかの要因でプロセスが死んでもジョブの情報を失わずに再実行できる
  • 実際には、Enterpriceを推奨
    • cron等の便利な機能を公式サポート付きで利用可能

Sidekiq とSolid Queue を比較

  • SidekiqはEntreprise利用前提での話
  • ストレージ
    • Redis or RDB
    • Solid Queue はジョブ専用の DB を持つ前提、相乗りもできる
    • 大量のジョブを扱うサービスであればSidekiq (Redis)に優位性がある
    • HEYはSolid Queueで2000万ジョブ/日を扱っている実績あり
    • Sidekiqは2万ジョブ/秒
  • インタフェース
    • Solid QueueはActiveJob経由なので迷いにくい
  • 機能

Sidekiq にあって Solid Queue になる機能

  • Batch
    • 複数のジョブをバッチという単位でまとめて管理し、バッチのジョブ全体がおわったことをトリガにできる
  • Rate Limiting
    • 特定の処理の実行数に制限をかける
    • within_limitで、同時実行制御できる
  • Encryption
    • Redisに渡すデータの一部を自動的に暗号化→複合
    • SidekiqのWeb UIで引数の情報を見れるので、個人情報見れないように
    • sidekiq_options encrypt: trueを宣言しておくと、perform_async の最後の引数が暗号化される
SecretJob.perform_async(1, 2, {"mynumber" => "12345678901"})

今使っている Sidekiq から Solid Queue に移行する必要はありますか?

  • 移行のメリットと移行のコストを比較して決めましょう
  • 移行は基本的にメリットが薄い、機能が増えているわけでない、Sidekiqで困っていることが Solid Queue で解決するわけではない
  • 小規模サービスで Sidekiq を Solid Queueに変更して運用コストを減らすのはありかも

rails newするのであればどちらを採用する?

  • ジョブどれくらい使うか次第
  • ジョブをあまり使わないサービスは SolidQueue、ジョブをたくさん使うサービスはSidekiq
  • 中間のサービスは Sidekiq 特有の機能の中で便利なものがあるか探して決める
    • あれば Sidekiq
    • なければ Solid Queue
  • サービス育った結果、Solid Queue からSidekiq への移行するのはできなくはないのでは
  • 個人の意見

Importmapを使ったJavaScriptの読み込みとブラウザアドオンの影響

shu_numata さん

speakerdeck.com

Rails7 で標準となった Importmap とブラウザアドオンのAdBlockによる問題のお話でした。AdBlock が JS の読み込みをブロックしていた問題があり、その解決までの道のりを話してくださいました。
Importmapについて知りたくてお聞きしました。

import mapsの概要

  • Rails 6 -> Rails 7 へのアップデート
  • Webpacker がリタイア(Rails 6でデフォルトになって 7でリタイア)
  • 移行先はいずれか
    • js-budling: Webpack や esbuild を使うもの
    • importmap-rails
  • Rails 7でデフォルトとなった importmap-rails ってなに?
    • 今まではJS モジュールを CDN から node_modules へダウンロード、node_modules にあるものを import する
    • Webpack や esbuild などのビルドツールで application.js などにビルドしていた
    • import_map は node_modules を使わず、ブラウザ上でモジュールを読み込める、ビルドしなくていい
    • モダンブラウザで使える -importmap-rails というgem

なぜ Rails 7 からデフォルトになったのか

  • Babelでのトランスパイルが不要になった
  • HTTP/2が当たり前に使われるようになったので、1つのJSファイルにまとめなくていい
    • HTTP/2 は、複数のリクエストを一つのコネクションで同時に送信できるので、ファイルを小分けにしても遅延しなくなった

importmap-rails使う

  • 自分たちのプロジェクトでビルドツールいらない? -> いらない
  • TS使ってなかった、Vue.jsちょっと使ってたけど Stimulus に移行する方針に決まった
    • がっつり TS や Vue.js や React 使ってると、ビルド捨てるの難しい
  • Rails World で NO BUILD という方針が出ていた
  • プロダクトでは、JS を no build にして importmap-rails でやっていくことになった

しばらくして起きたエラー、原因と解決法

  • サーバーサイドに奇妙なエラー
  • ログを調査すると、TURBO_STREAM 形式でリクエストが来るはずが HTML 形式で来てるようなメッセージ
  • 直接リクエストしているわけでもなく、Turbo も動いている、ユーザー環境も最新のブラウザ・・Addonなのか?という疑い
  • よく使われるAddon? -> 翻訳やAdblock
  • Adblock入れるとエラーになる
    • このエラーになると、アプリケーションが使っているJSが全て動かなくなる
  • Adblockが使っているフィルターリスト easylistの中に、EasyPrivacy の中に関係あるものを発見
    • EasyPrivacy を有効化するか無効化するかというオプションのせいだった
  • importmap-railsに移行して起きた問題
  • import-rails で使用するモジュールをダウンロードした
    • importmap-railsで起きた時使用していたのは、v1.2
    • download オプションを使用して、/venderにダウンロードする
    • Sprockets か Propshaht でフィンガープリントがつき、発生しなくなった
  • importmap-rails v2ではvendorへのダウンロードがデフォルトの挙動になった
  • 引き続き外部CDNの使用もしている
  • JSのライブラリの中には1つのエントリポイントに機能がまとまっていない場合がある
  • importmap-railsでは1つのエントリファイルしかダウンロードしない(依存しているものはダウンロードされない)
  • 外部CDNを使う場合は、Adblock にブロックされるかも
    • 対応のPRが出てるがマージされていない
  • 似たような問題が起きた時、気づけるようにしたい
    • 主要な機能での Turbo Stream のリクエストチェックをして気づけるようにした

まとめ

  • ビルドの手間が改善できて良かった
  • Adblockによってブロックされるケースある
  • 最新の import-rails はデフォルトで vendor へ JSモジュールがダウンロードされる
  • JS モジュールが Adblock にブロックされることは今後もあるかも、気づけるようにしたい
  • おまけ:nodelessにできていない
    • ESLintはいらないの?Playwright は使いたい

Tuning GraphQL on Rails

Kazuhiko Yamashita さん

ペパボ在籍時にとてもお世話になっていた、pyama さんの発表でした。
GraphQL APIの中の N+1 を数多く潰してくださっていたことについてでした。GraphQL でN+1を解決する方法をわかっていなかったので、勉強になりました。
爆速で様々な改善を進めてくださっていたことを思い出し、感謝しつつ聴いていました。
サービスのダッシュボードを週次など定期的にみんなで見る活動はいいなあと思っていました。なかなか当時参加できなかったのですが・・・ (自分は全然 GraphQL 周りわかってなかったな・・・と個人的な反省もありつつ、発表内容を振りかえりました)

現場紹介

  • minne ハンドメイドマーケットサービス
  • モノリス構成なRails、ひとつのモノリスだったのをフロントを Next.js で切り出し
    • k8sのpodは分けて運用してる
  • オンプレミスと AWS のマルチクラウド構成
  • AWS EKS(batch)、Elastic Service、RDS etc
  • データセンター間に Direct Connect
  • データセンター持ってるので価格を抑えて調達できる、コスト低いけど運用は大変
  • AWSは運用が楽、コストは・・・

発生していた課題

  • 増え続けるVM、負荷が高い→とりあえずVM増やすの連鎖
    • オンプレ: 120VM、AWS: 20-80 VM(オートスケールで増減)
  • 円安・・・減り続けるお金
  • GraphQL のエンドポイントで N+5000が発生 😨

どのように改善したか

  • GraphQLでよくあるN+1なクエリ
    • 各注文(order)は1つの顧客(customer)に紐付いている
    • ordersを取得して、それぞれのorderに紐付いているcustomerを取得する
    • ordersが5000件だった場合、それぞれのorderに紐づくcustomerを取得しようといてN+5000のSQLが発行される(と理解した)
query { 
  orders { 
    id
    customer {
      name
    }
  }
}
SELECT * FROM orders;
SELECT * FROM customers WHERE customer_id = 1;
SELECT * FROM customers WHERE customer_id = 2;
...
SELECT * FROM customers WHERE customer_id = 5000;

2つの改善方法

  • lookahead を利用する
  • バッチロードを利用
    • Shopify/graphql-batch
    • GraphQL::Dataloader
--- IN句で解決
SELECT * FROM orders;
SELECT * FROM customers WHERE customer_id IN(1, 2...5000);

lookaheadを利用した先読み

  • (lookaheadはGraphQLのクエリ内容を解析し、どのフィールドがリクエストされているかをリゾルバの中で事前に確認する手法とのこと)
  • 先読みできる
  • lookaheadがシンプルに書けるが、増えてくると辛い

graphql-batch

require 'promise'

promise = Promise.new
promise.then { |value| puts "Fulfilled: #{value}" }
puts "Pending: #{promise.state}"
promise.fulfill('Hello, World')
# thenに渡されたブロックを遅延実行
% ruby example.rb
Pending: pending
Fulfilled: Hello, World!
  • どこを遅延実行させるか
    • idをまとめて集めて、クエリは後から遅延実行して投げたい

module GraphQL::Batch
  class Loader
    def load(key)
      cache[cache_key(key)] ||= begin
        queue << key
        ::Promise.new.tap { |promise| promise.source = self }
      end
    end
  end
end

class OrderType < Types::BaseObject
  def customer
    Loaders::AssociationLoader.for(Order, :customer).load(object)
  end
end
  • loadに渡された引数(customer_id)を queue に append していく
    • queue = [1, 2, 3, ... 5000]
  • Promise を返却
  • load(object)にどんどん customer_id が追加されていく
  • Promise.sync(Promiseの実行)で、上から順番に浅い順に Promise.sync がコールされる
query { 
  orders { # 1. Orderのプロミスが実行される 
    id
    customer { # 2. Customerのプロミスが実行される
      name
    }
  }
}
# recordsに渡されたActiveRecordモデルのインスタンスに対し、
# 関連データを一括でプリロードし、それぞれのレコードに対応するデータを設定する
def perform(records) # <= recordsには、loadでqueueに溜まっていた値が渡るので、遅延実行対象のレコードをまとめて preload
  preload_association(records)
  records.each do |record|
    fulfill(record, read_association(record)) # <= レコードごとに結果を分割して埋め込む
  end
end

# RailsのActiveRecord::Associations::Preloaderを使って
# recordsに関連するデータをまとめてロード
def preload_association(records)
  ::ActiveRecord::Associations:Preloader.
    new(records: records, associations: @association_name).call
end

def read_association(record)
  record.public_send(@association_name)
end
  • ActiveRecord::Associations::Preloader で IN 句にまとめられる

実践N+1解消

  • インスタンスメソッドは、ARメソッドが個別に実行するので、N+1が発生する
query { 
  orders { 
    id
    customer {
      name
      isLoyal
    }
  }
}
def loyal?
  sales.exists?
end
  • 遅延実行した ↓
module Types
  class CustomerType < Types::BaseObject
    def is_loyal
      Loaders::AssociationLoader.for(Customer, :sales).load(object) do |sales|
        object.loyal?
      end
    end
  end
end
  • Loaders::AssociationLoaderを利用して、関連データ(sales)を一括でロード
  • 各Customerごとに個別のSQLクエリが発行されることを防いでる
  • 太客が過ぎる(1つのCustomerに、5,000,000,000,000件のSalesデータ)
  • Loaderを書く
customers.each do |customer|
  customer_sale = result.find { |row| row["customer_id"] == customer.id }
  fullfill(customer, customer_sale.present?)  # <= クエリの結果を Promiseの戻り値に設定
end
customers.each { |customer| fulfill(customer, nil) unless fulfilled?(customer) }

module Types
  class CustomerType < Types::BaseObject
    def is_loyal
      Loaders::LoyalCustomerLoader.load(object)
    end
  end
end

Loaderのメリット・デメリット

  • Loaderはすっきり書ける
  • Loadler乱立すると、Loaderにビジネスロジックが埋め込まれてモデルと差が生じてしまう、バグの温床に

パフォーマンス問題の探し方

  • APMを使うとわかりやすい
    • Sentryのスパンに名前をつけることができる APMのスパンを分けると探しやすくなる
    • Sentry.with_child_span() でスパンを分けられる

改善の実績

  • オンプレミス 120VM -> 30VMに減少
  • AWSも 20-80VM -> 20VM(スケールアウト不要に)
  • メトリクスも大幅に改善

日々の運用

  • 日々サービスは遅くなる
  • 新機能が増える=新しいクエリが増える
  • SLI指標とSLO目標を決める
  • SLIとSLOは、サービスのKPIからブレイクダウンして決める
    • レスポンスタイムとカートインの相関関係の図
  • モニタリング手法は、APMで可観測性を上げる
    • SaaS(New Relic/ Data Dog/ Sentry)
    • OpenTelemetry(Grafana Stack):規格を標準化することでテレメトリデータの標準化
  • ダッシュボードを作るのがおすすめ、レイテンシ
    • チームで定期的(週次)で見れると良い

今日話したこと

  • N+1の問題は、graphql-batchのAssociationLoader または GraphQL::DataLoaderに準ずる実装で解決できる
  • 安易に先読みすると、レコード数の多いテーブルを大量にロードしてしまう
    • この場合、自作のLoaderを書く
  • Loader乱立すると、モデルとの実装差異が生まれることがあるので、一貫性を持つ実装にすること
  • 日々のモニタリングでパフォーマンスの劣化に対処する

30万人が利用するチャットをFirebase Realtime DatabaseからActionCableへ移行する方法

Ryosuke Uchida さん

speakerdeck.com

pato というエンタメスキルシェアサービスのチャット機能を、Firebase Realtime DatabaseからMySQLに移行し、ActionCableを使って実現するように移行したお話でした。
アーキテクチャを移行作業、すごいです。
それからお話を聴いて、Ruby の Feature Flags で、flipper という gem があることを知りました。どんな感じか使ってみたいなと思いました。
発表した方は、Roppongi.rb のオーガナイザの方でした。

抱えていた問題

リージョンがus-central

  • リージョンがus-central、2017年当時はus-centralしか選択できなかった
  • 一斉送信のような大量の書き込みすると顕著に現れる
  • 1,000リクエスト/秒という制約
  • firebase-ruby メンテナンスされていない、PR投げたが反応ない -乗り換えることに

ボトルネックと技術選定

  • Firestore
    • 書き込みスケーラビリティが良い
    • 料金が高い
  • Cloud Pub/Sub
    • フルマネージドでメンテナンスが不要
    • 7日間のストレージ機能、再送機能
    • Pub/Subによる料金がかかるけどデメリットになるほどではない
  • Action Cable
    • npmパッケージあり、フロントの実装も楽
    • インフラレイヤのメンテが必要
  • ベンダーロック嫌だ、ActionCableが良さそう?
  • Action Cableの大規模なチャットサービスでの事例は聞かない、事例作りたい!

リリースまでのロードマップ

  • データ移行 Firebase -> MySQL
    • Firebase Realtime Databaseの全データをBigQueryに吐き出してどんなデータがあるか見てみた
    • よくわからないKeyがめちゃくちゃ生えてた
    • BQで整形することが可能になた
    • talksテーブルへのinsertはやめた(ダウンタウンが発生してしまうから)
  • 本番環境下で検証するために、Feature flags として flipper gemを使用
  • 本番環境で起こった事案
    • GKEを利用していたので、コネクションが30秒で切れてしまう
    • 連続でメッセージを送るとメッセージがロストしてしまう
      • かといってタイムアウトとの時間を伸ばすとコストが上がってしまう・・
    • 再送時に足りないデータを再送するように
  • ギフト機能も、チャットとして送信される
    • 即時決済が走るギフト機能
    • 送信者は Action Cable 経由でなく、Post リクエストで対応
  • 同時接続の安定性
    • Thread または Web concurrency
    • Thread: Pumaのプロセスが処理できる並列リクエストの数
    • Web Concurrency: Pumaのプロセス数(ワーカー数)。各プロセスは独立、それぞれスレッドプールを持っている
    • Threadの方がメモリ効率が良い

残された課題

  • INSERT時にRETURNINGが使えないため、関連テーブルの一括登録ができない
    • LAST_INSERT_ID()というものがあるが、Transaction貼ってないと関連レコードであることを担保できない
    • talksテーブルに関連するカラムを追加 or MariaDBに乗り換え(GCPはサポートしてないんのでAWS?)
  • 運用コスト
    • スケール時のインフラコストが課題

サイロ化した金融システムを、packwerk を利用して無事故でリファクタリングした話

Kota Kusama さん

speakerdeck.com

コインチェックのエンジニアの方が、モノリシックなシステムを packwerk を使って様々な箇所から使われている認可ロジックを切り出してリファクタリングしたお話です。
ちょうど、今自分の会社でも、モノリスからモジュラーモノリスに移行できないかと試行を進めようとしており、この内容は持ち帰りたいと思って聞きました。
packwerk を使うと、安全に機能をパッケージ化できそうですし、試してすぐ戻すということもできそうで良さそうだと思いました。

1. バックエンド部分の現状と課題

  • 暗号資産取引サービス Coincheck
  • 10年経過、プロダクトコードの規模はどんどん膨らんでいる
  • 複雑でモノリシックなコード
    • 適切に統一されたレイヤー化ができていない、サービス横串の仕様整理が難しい、ロジックに対するコードオーナーの境界線が曖昧
  • コインチェックのプロダクトは大きく分けても10を超えている
  • プロダクト1つ1つについて、ユーザーステータスに応じた取引可否の判定が必要だが判定条件がとても複雑になっている
  • 取引可否の判定箇所が、プロダクト毎やModel/Controllerに、40箇所以上

課題:認可ロジックの散在に対する課題

  • この1つの課題を取り上げたお話
  • サービス横串の仕様がわかりにくい
  • 認可ロジックの妥当性を確認するコストが高い
  • ドメインロジックが複雑化する

解決方針

  • IFの統一
  • 認可ロジックの集約

どう集約する?

  • サイロ化した金融システムの難しさ
    • インシデントは金融庁への報告へ繋がる 😭
    • 多くのドメインが存在し影響範囲が広い
  • 最初のステップとして、アーキテクチャをなるべく大きく変更せずに集約したい
  • モジュラーモノリスを選択!
    • マイクロサービス? -> 非現実的、でもある程度のコードの視認性いは維持しておきたい
    • 共通クラス? -> 共通クラス化されている部分はあり、使用方法の不明確さやドメイン毎の仕様により、クラス内にメソッドが乱立
    • モジュラーモノリス!!
      • インフラ部分に影響することなく構成を試せる
      • 関心の分離をしつつ、一定の視認性を維持できる

packwerkを採用

  • 実態は静的解析なので本番に影響を与えない
  • 依存関係の検知ができる
  • コード移動のみでモジュールか実現できて認知負荷が低め

packwerk とは

  • Railsアプリケーションのモジュラーモノリス化けを支援する静的解析ツール
    • bundle exec packwerk check
  • Shopify が作った
  • package.ymlが置かれたディレクトリは以下を1つのパッケージと認識
  • デフォルトでは、packsを切ってディレクトリを置いて、その中にrailsのパッケージをおく
  • package.ymlで依存が認められていなければ、違反として検知する
  • packwerk 自身は、依存関係の違反を検知する

packwerk-extensions

  • メインはプライバシーチェッカー
  • packwerkで検知する違反を拡充させるgem
  • パッケージ内にパブリック領域/パブリッククラスの定義を行い、非パブリック領域への参照を違反として検知
  • インタフェースを統一する上で、認可ロジックへの直接参照を制限するために有効化した

packs-rails

  • パッケージ化したコード類を自動的に、zeitwerk のオートロードパスに乗せてくれる
  • デフォルトで packs 配下のパッケージをパスに追加してくれる

やったこと

  • 認可ロジックを集約した認可パッケージの作成を行うことで課題を解決
  • ユーザーが特定の取引を実施できるか否かを返却するクラスを作成、IFを1つに統一
  • 引数にユーザーIDと取引種別を受け取る
  • 認可ロジックは、非パブリックなクラスに集約
    • 非パブリック領域への参照を違反として検知する設定
    • CIで違反を検知、違反があるとマージできない

導入後の評価

  • 既存動作に極力影響を与えない構造
  • 静的解析のツールなので本番環境に影響を与えない
  • パッケージするにあたってディレクトリ構造を変える必要はある
  • packs-rails のコードの中身を見ると、Railtieクラスが定義されている
    • パッケージ単位で、Rails::Applicationのpaths に、Packs::Rails.config.pathsの個々のパスとパッケージのルートパスを結合して追加している
  • これにより、zeitwerk によるオートロード対象になり、パッケージ配下のファイルが読み込まれる
  • packs-rails を併用することで、ディレクトリ構造を変更するだけでモジュラーモノリス構造、パッケージ化けを安全にできる

防ぎ切れない観点

  • モノリス側で再び同じようなロジックが作成されることを機械的に防ぐことはできない
    • 各チームへの説明、README.mdの整備
    • カスタムcopを使って検知

今後の展望

  • 機能のパッケージ化をいくつか並行で進めている
  • 目先は昨日のパッケージ化を進めつつ、ゆくゆくはドメイン分割など次の一手の兆しが見えると良い

omakaseしないためのrubocop.yml のつくりかた

Shu Oogawara さん

speakerdeck.com

packwerkの話と同じ時間に、rubocop のお話もありました。
私は今の会社に入って最初に rubocop の導入をして rubocop.todo.yml に残る既存の違反を無視するための設定を減らしていきたいと考えていたので、このお話はとても聞きたいと思っていました。この時間もどちらを聞くかとても迷った・・・ スライド見たら、チームで毎週、コードルールをどうするか定期的に議題にあげて話しつつ進めててとてもいい取り組みだと思いました。後でアーカイブ見ないとです。

Identifying User Identity

MOROHASHI Kyosuke さん

speakerdeck.com

多くの Rails アプリケーションに存在する、利用者を表す User モデルについての考察でした。お話を聞きながら、User の設計についてどんどん整理されていって、聴くうちにどんどん頭がすっきり整理されていって気持ちよかったです。おおお!となりました。整理された設計・考え方は気持ち良いのだなと思いました。
また、スライド下部に書いてあるちょっととした Tips のような話がおもしろくて、見たい!と思いつつ、発表中は前の人の頭とかで見れなかったので、手元で資料見てたりしました。
発表会場のホールは、前の方に moro さんのお知り合い(ファン?)の方がいて、わいわいたのしそうな雰囲気になっていました。

はじめに: ユーザーとはなにか

  • 同じサービスでもいろいろなユーザーがいる
  • そのサービス上で「一人」の主体として識別したい = identify したい単位がユーザーである

架空のユーザー管理機能やることリスト

  • ユーザーとして、サービスに登録したい
  • ユーザーとして、サービスを利用したい
  • ユーザーとして、ログインしたい
  • ユーザーとして、サービスを退会したい

ユーザーとして、サービスを利用したい

  • ActiveRecord モデル User を定義する
  • 基本的には主キー id だけで良い!!!!
  • 人間が読み出す代理キーの human_id、created_at

id だけのテーブルが表現するもの

  • いること = identityがあること、そのものを表現
  • identity があると、その持ち物であるデータを作れる(サービスを使ってもらえる)
  • 名前やメールアドレスはbelongs_to :userな別テーブルに保存する
    • user_profiles
    • user_credentials: 第3者にばらされたくないようなデータ

本当に大丈夫…?

  • サービスの主要機能を使っているときに、メアドやプロフイールを参照するシーンは実は少ない
  • ActiveRecordがやってくれる
  • 今までの経験から大丈夫でした

分けても大丈夫としてメリットは?

  • サービス利用開始時点で、個人情報取得の離脱を減らせる
  • 秘匿性の高い情報を分析環境から見えなくするのとか楽にできる 🙌
  • Rails 8, generate authentication 🤔
    • email_address, :password_digestついてきちゃう・・

ログインした状態でサービスの機能を使える

  • ログインしているユーザーを判別できる
  • リクエストの操作主体が、このアイデンティティを持った存在である、ことがわかればOK
  • current_userのよくある実装
    • session[:user_id]にusers.idが入っている
  • APIでも考え方は同じ、JWT や BearerトークンにユーザーIDを判別できる情報が載っている
  • current_userができること
    • 自分の所有物にアクセスできる
    • ユーザーとして、サービスを利用できている
  • この設計だと、ログイン機能を作り込まなくても「ユーザーIDを指定してsessionに入れる」仮実装ができていれば、機能開発ができる!

ユーザーとして、ログインしたい

ユーザーとして、サービスに登録したい

  • サービスにおけるユーザーのアイデンティティが生まれるのはいつなのか
  • 登録フローを完了したときに、アイデンティティが生まれるという考え方
  • 登録している段階は、usersレコードはできない
  • 登録しようとしていることを表す UserRegistration モデル
    • belngs_to :user, optional: true
    • メールアドレスや事前聴取のアンケートなども保存できる

登録完了したらユーザーができる

  • UserRegistration から User以下一連のデータグラフを作成する
  • ここではじめて、そのサービスにおけるユーザーとしてのアイデンティティが生まれる
  • Userテーブルは今いる人だけ、登録完了したユーザのみ
  • 登録中テーブルも複雑に育ちうる、それらをUserから分離できてうれしい

ユーザーとして、退会したい

  • 退会した場合も「いたこと」の事実は残す必要がある
  • 存在した、というアイデンティティは消えない
  • 実装上は、usersを参照する外部キー制約として現れる
    • 持ち物を全部消さない限り、存在した事実は消せない
  • usersレコードは消さないが、user_credeintialsは消せる、個人情報は消すことができる
  • credentialsを消せばログインもできなくなる
  • 副次的に「有効なメアド/電話番号」にユニーク制約をかけられる
  • 「論理削除するなの会」の会員としては、論理削除だとユニーク制約かけられなくて辛い

管理スタッフが特別な権限でログインしたい

まとめ

  • ユーザーが「いること」それ自体を表現するのが最も大事
  • ログイン状態とは、リクエストの主体たるユーザーが識別可能であること。ログインは識別可能にすること
  • 登録フローの途中では、まだユーザーとして存在していない
  • 退会しても「いたこと」の事実は残る
  • 管理スタッフは「ユーザーとして」いるのではない、アイデンティティプールを分けるべき

そうするとなにが嬉しいのか

  • ユーザー管理周りは、いろんな複雑さが存在する
  • 原則に沿って整理できる。不合理でないし、かっこいいコードにできて面白い
  • いい感じに保てると、サービスの目玉機能を伸ばす役にも立てる 🥳
  • ER図を自動生成すると太陽が2つある図になりがち
    • 機能の主たるテーブルと、usersテーブル
    • 大事なエンティティなので、よく考えていきたい
  • かっこいいコード、ユーザー状態をstatus列なしで表できる
UserRegistration.scope :succeeded, -> { joins(:user) }
UserRegistration.scope :expired, -> {
  where.missing(:user).where(created_at: ..N.days.ago)
}
User.scope :active, -> { joins(:credentials) }
User.scope :withdrawn, -> { where.missing(:credential) }

ちなみに発表の中で触れられていた、OmniAuthの発表スライドはこちらでした。 speakerdeck.com

基調講演: WHOLENESS, REPAIRING, AND TO HAVE FUN

島田浩二さん

speakerdeck.com

札幌のえにしテック代表で、様々な本を翻訳されている島田さんのお話でした。
このお話を現地で聞くことができて良かったです。胸に響くものがありました。
「修復しながら変化に適応し続ける」「DON'T FORGET TO HAVE FUN!」というメッセージを受け取りました。
Kaigi on Rails の締めくくりに最高の基調講演でした。ありがとうございました。

書籍「RailsによるアジャイルWebアプリケーション開発

今日、伝えたいこと

  • ソフトウェアを設計する際に大事だと考えていること
      1. 全体が機能するように設計する
      1. 変化に向けて設計する

1. 全体が機能するように設計する

  • Web開発していく中で学んだこと
    • システムでユーザーに価値を提供する意識
    • システム全体が自分たちの問題を解く形にうまく構成されていくことが大事

①システムコンポーネントを知る

  • 良い設計には物事の正確な観察が必要
  • 適切な場所で適切な問題を解けるように
  • アプリケーションだけでなんでも解こうとしてない?

②設計のパターンを知る

  • Webシステムのよくある問題と、それに対するよくある解決策を知る
  • ex. リクエストを複数のサーバーで処理したい -> サーバーの手前にロードバランサーを置く などなど
  • 設計とは、現実世界の問題を1つずつ解いていくことの積み重ね
  • その結果現れるのがシステムの構造、最初に全体構造があるわけでなく、そうなるもの

どこから始めると良いか

  • Web技術、データベース技術、計算機システムなど、自分のアプリケーションを取り巻く要素につい勉強していく

2. 変化に向けて設計する

  • 現実の難しさ
  • 設計の開始時点はもっとも何も分かっていないタイミング、不確実性
  • システムの前提が分かりきっていない中でも設計を進めていかなくてはならない

オプションを手にいれる

  • オプションとは、将来的な選択肢を買う権利
  • Tidy First? というケントベックの書籍(英語だけ->近々日本語でも出版されるよう)

ソフトウェア設計は、振る舞いの変更への準備だ。今日行う設計は、将来振る舞いを変更する「オプション」に支払うプレミアムのようなものだ。

  • 将来変更するためのオプションを買っている

オプションのための設計

  • 何にでも対応できるような構図やしくみを作る? → ×
  • 選択肢を広げたかったのに、実際には狭めて待ち構えていることになってしまう
  • どの方向にも進めるようにシンプルに作る、選択肢を狭めないことが大事
  • そのためのアプローチ: できるだけ標準の道具、標準の方法で、必要十分に作る

オプションのためのWebアプリケーション設計

  • Railsはオプションのための設計をサポートしてくれるフレームワーク
  • Ruby on Rails scales from HELLO WORLD to IPO
  • 標準的な道具で、最小の最善構成を用意してくれている
    • The menu is omakase
  • 本当に必要になるまで複雑な仕組みを避けられる
  • オプションのための設計のためには、Railsを使いこなす!
  • Railsの思想に沿って標準の道具を十分に活用する

本当の難しさ

  • シンプルさを維持し続けること
  • 機能変更や追加が続いていく中でうまくやらないと将来の変更に向けた設計が破られてしまう
  • 「時を超えた建設の道」 24章 修復のプロセスを拠り所にしている
  • 「元の状態に戻す」のではなく、「変わった後の全体と再度調和させる」
  • とてもクリエイティブな活動
  • 修復し続けることで変化に適応し続ける
  • 手本は、Ruby on Rails
  • ずっと Ruby on Rails
  • 手触りがずっとRails、でも変わっていっている
  • Web標準が変わっていくから、Railsも変わっていく(DHH)
  • 修復が必要な箇所に気づくには?

Process Feel

  • Process Feel という考え方がヒントになるのでは?
    • SRE conference
    • 複雑なシステムの状態を直感的に理解する
  • ケントベック先輩「感じる」といっている
  • Process Feelを養うには?
    • 複雑性によりオペレーターへの認知負荷をいかに減らせるか
  • 概念圧縮
  • The One Person Framework
  • Rails Way は導きの星
    • One Person Framework で修復箇所を完治する能力を高めシステムを修復し続ける

どこから始めるといいか

  • Railsの上手な使い方を学んでいく
    • 書籍で
    • コードで(once
    • コミュニティで
  • Kaigi on Rails!!
  • Railsの上手な使い方を持ち寄ってみんなで学んでく
  • DHHは、たのしそう
  • 楽しさにはビジネス価値があります by Martin Fowler
  • たのしいが大事
  • DON'T FORGET TO HAVE FUN!