STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

新規のRailsアプリケーションにPitchforkを導入しました

こんにちは、エンジニアのima1zumiです。私たちのチームでは、STORES の新規プロダクト開発においてRackアプリケーションサーバとしてPitchforkを選定しました。本記事では、その選定背景、具体的な設定内容や運用上の知見をまとめてご紹介します。

なお、本記事執筆時点ではPitchforkの大きな特徴であるrefork機能は導入していません。

Pitchfork選定の背景と理由

Pitchforkとは

Pitchforkは、Shopifyが開発・メンテナンスを行っているRackアプリケーション用のHTTPサーバです。広く利用されているUnicornをベースとしたフォークであり、prefork型のプロセスモデルを採用しています。

最大の特徴は、メモリ使用量を効率化するための refork 機能です。この機能により、ワーカープロセスを定期的に再生成し、Copy on Write(CoW)の仕組みを最大限に活用することで、特にメモリをlazy loadしがちなRailsアプリケーションにおいて、メモリ消費を抑える効果が期待できます。

github.com

サーバ方式比較と選定理由

開発当初はRailsデフォルトのPumaを利用していましたが、リリース前の検討を経て、より安定した運用を目指すためにアプリケーションサーバの方針を見直しました。

まず、サーバの動作モデルとしてスレッドベース(Pumaなど)とプロセスベース(Unicorn、Pitchforkなど)を比較しました。スレッドベースは高いスループットが期待できる一方、利用する全てのライブラリを含めてアプリケーション全体がスレッドセーフであることを保証する必要があり、潜在的な不具合のリスクを抱えます。この問題を根本的に回避するため、私たちは1つのワーカープロセスが1つのリクエストを処理するプロセスベースモデルの採用を決定しました。

次に、プロセスベースのサーバの中から具体的な選定を行いました。広く使われているUnicornと、そのフォークであるPitchforkが主な選択肢となりましたが、私たちのプロジェクトでは現時点でUnicorn特有の機能を利用する予定はありませんでした。

Pitchforkのrefork機能はCopy on Writeを活かしてメモリ使用量を最適化します。これにより、アプリケーションの起動後に徐々にメモリ使用量が増加する事象を抑制できる可能性があります。現状は新規プロダクトということもありメモリ使用量に課題がないため有効化していませんが、将来的な選択肢として魅力的でした。

PitchforkのベースであるUnicornは長年にわたり多くの現場で利用されており、運用に関する知見が豊富に蓄積されています。PitchforkはUnicornとの互換性が高く、便利なツールも活用しやすいため安定した運用が見込めると考えました。また、社内の他プロダクトでもPitchforkが使われており問題なく運用できている実績もありました。

以上の理由から、将来的な拡張性を考慮してPitchforkを選定しました。

Pitchforkの設定

config/pitchfork.rb の主要な設定は以下の通りです。

# ワーカープロセスの数を環境変数から取得
worker_processes ENV.fetch("WEBSERVER_WORKERS", 2).to_i

# タイムアウトを設定
timeout 10

# masterプロセスがworkerをforkする前に実行される処理
before_fork do |server|
  # ActiveRecordのコネクションを切断
  defined?(ActiveRecord::Base) and ActiveRecord::Base.connection.disconnect!

  # 独自のHTTPクライアントなどをシャットダウン
  CustomStitchingHttpExecutable::HttpExecutable.shutdown
end

# workerプロセスがforkされた後に実行される処理
after_worker_fork do |server, worker|
  # SemanticLoggerは再オープンが必要
  SemanticLogger.reopen

  # ActiveRecordのコネクションを再接続
  defined?(ActiveRecord::Base) and ActiveRecord::Base.establish_connection
end

before_fork ではActiveRecordや外部APIクライアントなど、コネクションプールを持つ処理を切断し、フォーク後に共有されないようにしました。after_worker_fork では明示的に再接続し、意図しない共有状態にならないようにしました。SemanticLoggerは仕組み上再オープンが必須*1なためreopenしています。

github.com

運用上のポイントと注意点

導入後の状況

現在、秒間約20リクエストほどのサービスで安定して稼働しています。Pitchforkへの移行によってレイテンシやスループットに大きな変化はありませんでしたが、これは想定通りです。今回の移行は、パフォーマンス向上よりもスレッドセーフではないことのリスクの解消を主目的としていたため、その目標は達成できたと考えています。

コネクション管理

before_forkでのリソース解放は重要です。ActiveRecordはもちろん、外部APIクライアントなど、コネクションプールを持つライブラリはすべてfork前に切断・シャットダウン処理を実装し、fork後に再接続する対応をしています。これを行わない場合、複数のワーカーが同じソケットを使い回してしまい、通信エラーやメモリリークが起こる可能性があります。

私たちはSolid Cacheも利用していますが、Solid Cacheはデフォルトでは ActiveRecord::Base のconnection poolを使うので ActiveRecord::Base.connection.disconnect! のみ実行しました。

GitHub - rails/solid_cache: A database-backed ActiveSupport::Cache::Store

開発環境ではPumaを利用する

以下の理由から、ローカルではPumaを利用しています。

(1) binding.irb がタイムアウトしてしまう

pitchforkにはソフトタイムアウト、ハードタイムアウトがデフォルトで設定されており、一定時間経過するとリクエストが切られてしまいます。pitchforkにはタイムアウト時間を無限にする設定は見つからず、binding.irbでデバッグするためにはタイムアウトを長時間に設定する必要がありました。

(2) 開発環境用の設定を入れる必要がある

タイムアウト以外にもポート番号、ワーカー数、Pitchfork::MemInfoがmacOSローカルだと動かないなど、開発環境でもpitchforkを使おうとするといろいろと設定する必要があります。

これらのことから、開発環境ではPumaを利用するほうがシンプルで開発しやすいと考えました。開発環境と本番環境でアプリケーションサーバが異なる状態になっていますが、サーバ固有の機能に依存しない限り、互換性の問題は起きにくいと思われます。現在のところ、私たちのプロダクトではこのことによる問題は発生していません。

rack-timeout でタイムアウトを制御する

Pitchforkのタイムアウトによってリクエストがタイムアウトすると、スタックトレースが残らないため調査が困難になります。そのため rack-timeout gemを使ってタイムアウトを制御しています。

まとめ

新規サービスにPitchforkを導入したことで、最大の懸念であったスレッドセーフの問題を根本的に解決し、運用をシンプルにすることができました。refork を使わない状態での導入は非常にシンプルで、問題なく利用することができました。また、将来的にはrefork機能によるメモリ効率化も期待できます。

今後もサービスの成長に合わせて動かしながら最適化を続け、より安全で高効率な運用体制を追求していきたいと考えています。

余談ですが、RubyKaigi 2025でPitchforkの作者のbyroot氏に直接お礼を言えて良かったです。

参考リンク

*1:SemanticLoggerはThreadを使ってログを書きますが、fork後のプロセスはThreadがコピーされないため再オープンしないと書き込みThreadが存在せずログの書き出しができません