Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

PitchforkというWebサーバーを作るまでの長い道のり(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

  • 英語記事: The Pitchfork Story | byroot’s blog
  • 原文公開日: 2025/03/04
  • 原著者: byroot -- Railsコアコミッター、Rubyコミッターであり、ShopifyのRuby/Railsインフラチームのシニアスタッフエンジニアです

日本語タイトルは内容に即したものにしました。
記事末尾の関連記事を先に読んでおくことをおすすめします。

PitchforkというWebサーバーを作るまでの長い道のり(翻訳)

2年と少し前、私はShopifyのRuby on Railsインフラストラクチャチームの一員として、PitchforkというRuby製Webサーバーを新たにリリースしました。

Shopify/pitchfork - GitHub

Pitchforkの設計は少し風変わりで、厳しいトレードオフを要求されるので、本記事ではこうした設計が決定されるまでの経緯や、本プロジェクトの将来について私がどう考えているかについて説明したいと思います。

🔗 Unicornの設計は良い

私が11年前にShopifyに入社して以来、同社のメインとなるモノリスアプリケーションでは一貫してUnicornがproduction環境のアプリケーションサーバーとして使われています。
Unicornは、多くのRubyistから(ほとんどの場合)レガシーソフトウェアとみなされてきました(Unicornのメンテナー自身ですらそうです)。しかし私はこの意見に強く反対するものであります。

Unicornに対する主な議論は、「RailsアプリはほぼIO-boundであり、しかもGVLも存在するので、スレッド化サーバーを使えばスループットが向上する」というものです。
RailsアプリケーションのほとんどはIO-boundではないと私が信じる理由については以下の過去記事で解説しましたが、これが一般的に真であるかどうかにかかわらず、Shopifyのモノリスには該当しないため、Shopifyにおいてスレッド化サーバーという選択肢はありませんでした。

「RailsアプリはIO-boundである」という神話について考える(翻訳)

さらに、2014年の私はレジリエンスを担当するチームで働いていました(当時のShopifyには、RubyとRailsのインフラストラクチャチームはありませんでした)が、そのチームでは、サービス停止の可能性を減らす取り組みや、停止を防げなかった場合の影響範囲を縮小する取り組みを行っていました。そのチームでは、ToxiproxySemianといったツールを開発してきました。

Shopify/toxiproxy - GitHub
Shopify/semian - GitHub

私がレジリエンスチームに在籍している間に、かなり壊滅的な障害に何度か居合わせました。一部のC拡張でセグメンテーションフォールトが発生したり(#11968)、ひどい場合はRuby VMがデッドロックしたり(#16332)、データストアが応答しなくなったなどです。

この経験から私が学んだのは、できるだけ多くのバグをCIで事前にキャッチできるよう頑張るべきであると同時に、どうやってもキャッチできないバグが存在することも受け入れるべきであるという教訓でした。つまり最終的には数字のゲームになります。アプリ開発者が6人なら、めったにそういう事態にはなりませんが、(数千人とまでいかなくても)数百人の開発者が毎日積極的にモノリスを変更していれば、バグの発生は避けられません。

そういうわけで、多層防御(defence-in-depth)戦略を採用することが重要です。バグをゼロにするのは無理だとしても、少なくともさまざまな技法を用いて影響範囲を限定することは可能です。
以下の過去記事にも書いたように、Unicornのプロセスベースの実行モデルは、システムのレジリエンス(回復力)に大きく貢献するのです。

Rails: Unicorn::WorkerKillerのメモリ管理とレジリエンスを再考する(翻訳)

🔗 「Unicornで万事OK」ではない

しかし私はUnicornの設計を擁護するつもりはありません。Unicornに欠点があることも重々承知しています。

Unicornの欠点の1つは、スローロリス(slowloris)のような一般的な攻撃から保護できないことです。そのため、サーバーをNginxなどのバッファリングリバースプロキシの背後に配置する対策が不可欠です。

これを「複雑さを増やしている」と捉える人もいるかもしれませんが、私に言わせれば逆です。たしかに「可動部品」は1つ増えますが、私の視点では、古典的なセキュリティ懸念事項については、世界中で実際に使われていてドキュメントも豊富で、場のバトルテストを生き延びたソフトウェアにお任せする方が、アプリケーションサーバーをインターネット上で直接公開しても大丈夫なレベルだと信用するよりも複雑にはなりません。

先週生まれたばかりの新しい攻撃方法に対応するなら、自分が選んだアプリケーションサーバーを利用している一部のRubyコミュニティに頼るよりも、Nginxコミュニティの方が信頼できるでしょう(Rubyコミュニティを信頼しないのではなく、コミュニティの規模が大きい方がその分セキュリティ修正が早まることが期待できるという話です)。

いずれにしろリバースプロキシがどこかで使われるのであれば、SSLの終端やHTTP新バージョンの許可、静的アセットの配信といった多くの標準的な懸念事項はリバースプロキシに任せるのがよいでしょう。多くの技術スタックで使われている標準的な部品として、チェインされる後続の部品から多くの複雑さを取り除いてくれるのであれば、リバースプロキシは複雑な要素を増やす余計な可動部品にはならないと思います。

しかしそう思うのは私ぐらいでしょう。特に、最近私が公開した過去記事に対する反応を見ていると、何が複雑で何がシンプルかという見解が誰でも一致するとは限らないと思えてきます。

マルチプロセス設計でよく取沙汰されるもう1つの欠点は、コネクションプールを効率よく扱えないというものです。コネクションを複数のプロセス間で共有するのは簡単ではないため、Unicornの個別のワーカーが独自にコネクションのプールを維持することになり、ほとんどの時間がアイドル状態になります。

しかしこれについても対案はあまりありません。
スレッド化サーバーを採用するというトレードオフを受け入れたとしても、引き続き1コアあたり少なくとも1プロセスを実行しなければならないため、アイドルコネクションの個数をUnicornより大きく減らすことはできません。それによって多少時間を稼ぐことはできても、いずれそれでは間に合わなくなるでしょう。

結局のところ、ある程度以上スケールしてくれば、何らかの形でのコネクションプールの外部化は避けられないことを受け入れるしかありません。私が思いつく唯一の別案は、ファイルディスクリプタをIPC(プロセス間通信)経由で渡す形でプロセス間コネクションプールを実装するというものです。技術的には一応可能であるものの、ProxySQLmcroutertwemproxyをセットアップするよりも複雑にならずに済むとは思えません。

マルチプロセス設計でもう1つよく耳にする不満は、データをメモリ上にキャッシュできないというものです。
またしても昔のアナログレコードの針飛びのようなお説教の繰り返しと思われそうですが、インプロセスの並列処理(parallelism)を実用的に実行する方法がRubyに備わっていない限り、1コアあたり1個以上のプロセスを実行するしかないので、インプロセスでデータをキャッシュしようとしてもうまくいきません。

しかし仮にそうした制約がなかったとしても、私としてはヒープメモリをキャッシュとして使わない方がよいと主張します。ヒープメモリをキャッシュにするとガベージコレクタで余分な作業が発生しますし、どっちみちデプロイのたびにキャッシュが全消去されてしまいます(しかもデプロイは頻繁に行われる可能性があります)。それなら、Webノードごとに小さなローカルMemcachedインスタンスを実行するか、SQLiteか何かでキャッシュする方がずっとましです。シリアライズが必要なのでインメモリキャッシュより少しばかり遅くはなるものの、デプロイでも失われないうえにサーバー上の全プロセス間で共有されるので、ヒット率はずっと高くなります。

Unicornモデルに対する最後の不満点としてよく槍玉に上がるのは、プロセス数が多いせいでメモリ使用量が余分にかかるという点です。Pitchforkは、まさにその点を解決するために設計されたのです。

🔗 ヒープ片付け屋という業務

「普段どんな仕事をしていますか」と聞かれるたびに、説明に困ってしまいます。私のやっている作業は、小さなものをたくさん寄せ集めたアマルガム(合金)のようなもので、必ずしも論理的につながっているとは限りません。納得のいく答えを出すのはほぼ無理な話で、毎回答えが違っていたでしょうし、何度か恥ずかしい回答もあったでしょう。

私もいろんな役回りを担当していますが、その中に私が「ヒープ片付け屋(Heap Janitor)」と呼んでいる役割があります。モノリスに機能を追加するよう(数千ではないにしても)数百人の開発者たちが指示を受けると、メモリ使用量が増大し続けます。どんなコード行もメモリ上のどこかにVMバイトコードとして配置されるので、そのようなメモリ増加は正当ですが、中にはデータ構造を改善したり一部のデータ重複を解消することで削減・排除可能なものもあります。

Shopifyのモノリスでメモリリークが発生したり、メモリ使用量が問題になるレベルで増加したりすると、ほとんどの場合私も調査に参加しました。そうするうちに、Rubyアプリケーションのヒープ分析方法やメモリリークの発見方法、メモリ使用量の削減機会を発見する方法に関する専門性を身につけていきました。

作業を支援するため、それ専用のツールもいくつか開発しました。それらのツールをCIに統合して、Shopifyモノリスのヒープが何で構成されているかという夜間レポートを毎朝受け取ることで、過去の傾向を的確に把握して、新たに発生した問題を積極的に修正できるようにしました。

Shopify/heap-profiler - GitHub

Active Recordが保持するスキーマ情報の重複を解消したこともありますし(#35860)、プロセスごとのメモリ使用量をやっとのことで114MBまで削減したこともあります。また、これまで数百ものgemにメモリ使用量を削減するためのパッチを投げてきましたが、パッチの多くは一部の文字列のインターン(interning)に関連していました(#399)。

しかし、メモリ上の一部のデータ表現をコンパクト化する方法は探せばいろいろ見つかりますが、絶えず追加され続ける新機能のすべてをカバーするのは不可能です。

🔗 奇跡のCopy-on-Write

これまでのところ、アプリケーションのメモリ使用量を最も効果的に削減する手法は、Copy-on-Write(CoW)で共有可能なメモリを増やすことです。PumaやUnicornでは、起動時に一度読み込んだものをその後一切変更しないようにすることが重要です。

Shopifyのモノリスは36個のワーカーを擁するかなり大きなコンテナで動いているので、メモリ上に1GiBのデータが追加されると、そのデータが起動時に読み込まれてから決して変更されなければ、Copy-on-Writeのおかげでワーカーあたりのメモリ使用量はたった28MiB(1024 / 36)の増加で済みます。これは十分納得できる結果です。

残念なことに、遅延読み込み(lazy loading)パターンはRubyコードできわめて多用されています。以下のような||=を使ったコードを山ほど目にしたことがあるでしょう。

module SomeNamespace
  class << self
    def config
      @config ||= YAML.load_file("path/to/config.yml")
    end
  end
end

ここでは例としてYAML設定ファイルを使っていますが、別の場所からデータを取得したり計算したりする場合もあります。重要な点は、この@ivar ||=がクラスやモジュールのメソッドで実行されていることです。

このパターンは、そのデータが不要な場合は無駄な計算を行わないという意味ではdevelopment環境には適していますが、production環境では良くありません。共有メモリに存在しないだけでなく、このデータを必要とする最初のリクエストで余分な読み込み作業が発生し、デプロイ時にレイテンシが急増するからです。

このコードを改善する非常に簡単な方法は、||=をやめて定数に置き換えることです。

module SomeNamespace
  CONFIG = YAML.load_file("path/to/config.yml")
end

しかし、何らかの理由で開発中にこれをどうしても遅延読み込みしたい場合は、Railsのあまり知られていないAPIであるeager_load_namespacesを以下のように利用できます。

module SomeNamespace
  class << self
    def eager_load!
      config
    end

    def config
      @config ||= YAML.load_file("path/to/config.yml")
    end
  end
end

# config/application.rbに以下を書く
config.eager_load_namespaces << SomeNamespace

上の例では、Railsをproductionモードで起動するとconfig.eager_load_namespacesに追加したすべてのオブジェクトに対してeager_load!を呼び出します。これにより、development環境では遅延読み込みを維持しつつ、production環境ではeager loading(事前一括読み込み)が可能になります。

私は、Shopifyのモノリスとそのオープンソース依存関係を改良して、eager loadingを増やすことに多くの時間を費やしました。問題のある呼び出し側をトラッキングできるようにapp_profilerというプロファイリングミドルウェアを設定して、ワーカーで処理される最初のリクエストのプロファイリングを自動的にトリガーするようにしました。同様に、いくつかのワーカーが最初のリクエストの直前と直後に ObjectSpace.dump_allでヒープをダンプするようにUnicornを設定しました。

Shopify/app_profiler - GitHub

理論上は、Railsリクエストの一部としてアロケーションされたすべてのオブジェクトは、リクエストが完了すればその後は参照されなくなるはずです。リクエストの直前と直後にヒープスナップショットを取得して両者の差分を作成すれば、起動時にeager loadされるべきオブジェクトを見つけられます。

時間の経過とともに、このデータのおかげで共有メモリの使用量を全体の約45%から約60%まで増やすことに成功し、個別のワーカーのメモリ使用量も大幅に削減されましたが、やがて頭打ちになってきました。

60%は良い数字ですが、もっと増やせるだろうと期待していました。理論上は、リクエストサイクルの一部としてアロケーションされたメモリだけは共有できないものの、残りのオブジェクトの圧倒的多数は共有可能なはずであり、共有メモリの比率は80%に近くなると予想していたので、まだ共有されていないのはどのメモリなのかという疑問が生じました。

🔗 インラインキャッシュ

それからしばらくの間、eBPFプローブでこの疑問を解消しようとして数日間manページを読み込んだものの、私には解決できそうになかったため1、諦めました。

しかしある日突然ひらめいたのです。「インラインキャッシュが原因に違いない」と。

Shopifyモノリスのヒープは、その大半がVMバイトコードで構成されており、前述したように、開発者によって書かれたすべてのコードはその中のどこかに配置されることになります。そのバイトコードはほぼイミュータブルですが、そのごく近くにインラインキャッシュ2が配置されていて、少なくとも初期段階ではミュータブルです。

開発者の書いたコードとインラインキャッシュがヒープ内で互いに接近していると、インラインキャッシュが改変されたときに4kiBのページが無効化され、同一ページ内にある多数のイミュータブルオブジェクトも一緒に無効化されてしまいます。

私の仮説を検証するために、以下のテストアプリケーションを作成しました。

module App
  CONST_NUM = Integer(ENV.fetch("NUM", 100_000))

  CONST_NUM.times do |i|
    class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
      Const#{i} = Module.new

      def self.lookup_#{i}
        Const#{i}
      end
    RUBY
  end

  class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
    def self.warmup
      #{CONST_NUM.times.map { |i| "lookup_#{i}"}.join("\n")}
    end
  RUBY
end

メタプログラミングを使ってはいますが、かなりシンプルな内容です。10万個のメソッドを定義して、それぞれが一意の定数を参照します。メタプログラミングを取り払ってみると以下のようなコードになります。

module App
  Const0 = Module.new
  def self.lookup_0
    Const0
  end

  Const1 = Module.new
  def self.lookup_1
    Const1
  end

  def self.warmup
    lookup_0
    lookup_1
    # (以下略...)
  end
end

なぜこのパターンにしたのかというと、大量のインラインキャッシュ(ここでは定数キャッシュ)を生成して、それらを効率よくウォームアップできる方法だからです。

>> puts RubyVM::InstructionSequence.compile('Const0').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,6)>
0000 opt_getconstant_path                   <ic:0 Const0>             (   1)[Li]
0002 leave

上の<ic:0>は、この命令(instruction)には関連するインラインキャッシュが含まれていることを示しています。これらの定数キャッシュは最初は初期化されず、このコードパスが最初に実行されたときに、Ruby VMはその定数が指すオブジェクトを検索する低速な処理を行い、結果をキャッシュに保存します。
その後の実行では、キャッシュが無効化されていないことを確認するだけで済みますが、定数のキャッシュはめったに無効化されません(実行時によほど込み入ったメタプログラミングを実行していれば別ですが)。

このアプリを使って、インラインキャッシュがCopy-on-Writeの有効性に与える影響を実証できます。

def show_pss(title)
  # LinuxのPSS(プロセスが利用する物理メモリのサイズ)を手軽に取得する方法
  print title.ljust(30, " ")
  puts File.read("/proc/self/smaps_rollup").scan(/^Pss: (.*)$/)
end

show_pss("initial")

pid = fork do
  show_pss("after fork")

  App.warmup
  show_pss("after fork after warmup")
end
Process.wait(pid)

上のスクリプトをLinuxで実行すると以下のような結果が得られます。

initial                                    246380 kB
after fork                                 121590 kB
after fork after warmup                    205688 kB

つまり、テスト用にこしらえたこのAppでは、初期段階のRubyプロセスは246MBに達しますが、子プロセスをforkすると、その比例メモリ使用量(proportionate memory usage)は予想どおりすぐに半減します。
ただし、子プロセスでウォームアップ用のApp.warmupが呼び出されると、これらすべてのインラインキャッシュが初期化され、ほとんどのCopy-on-Writeページが無効化されてしまうため、比例メモリ使用量は205MBまで増加します。

次に何をするかは薄々お気づきでしょう。以下のようにApp.warmupをforkの直前に呼び出しておけば、大量のメモリを節約できるようになります。

def show_pss(title)
  # LinuxのPSS(プロセスが利用する物理メモリのサイズ)を手軽に取得する方法
  print title.ljust(30, " ")
  puts File.read("/proc/self/smaps_rollup").scan(/^Pss: (.*)$/)
end

show_pss("initial")
App.warmup
show_pss("after warmup")

pid = fork do
  show_pss("after fork")

  App.warmup
  show_pss("after fork after warmup")
end
Process.wait(pid)
initial                                    246404 kB
after warmup                               251140 kB
after fork                                 123944 kB
after fork after warmup                    124240 kB

これで私の理論をある程度検証できました。forkする前にインラインキャッシュを埋めておく方法を見つけられれば、膨大なメモリ節約を実現できます。ポリモーフィックコードパスのインラインメソッドキャッシュなど、一部についてはキャッシュの更新が発生することは確実ですが、その大部分は基本的に静的なメモリになります。

ただし、言うは易く行うは難し、です

私がこの問題を指摘したとき、これらのコードパスを起動時に実行することにしてはどうかという提案をよく寄せられましたが、test環境であっても十分な範囲に対して行うのは難しく、ましてやproduction環境の起動中に行うのはなお大変です。
さらに困ったことに、コードパスの多くは副作用を伴うため、コンテキストの外で実行すればおしまい、という手が使えません。
とにかく、こういうものがアプリケーションに存在すると起動に時間もかかり、メンテナンスも困難になる可能性があります。

別のアイデアは、これらのキャッシュを事前に生成することで静的にするというものです。定数キャッシュなら比較的簡単に済みますが、これは全体像の一部に過ぎません。メソッドのキャッシュやインスタンス変数のキャッシュを静的に予測するのは(不可能ではないにしても)ずっと困難であるため、多少は効くかもしれませんが、問題を完全に解決するには至りません。

この種のキャッシュが隣り合って保存されていると、やがて一方が変更されたときにサイズ4kiBのメモリページ全体が無効化されてしまいます。

さらに、一定の期間中はトラフィックをUnicornのマスタープロセスで処理するという別案もありました。しかし、そのマスタープロセスはすべてのワーカーの監視と調整を担当していてタイムアウトできないうえに、リクエストをレンダリングする余力もないので、このアイデアも気に入りませんでした。

🔗 Pumaのfork_worker

このアイデアは、かなり長い間(はっきり覚えていませんが確実に数か月間)私の頭の片隅に引っかかっていたのですが、ある日Pumaでfork_workerという実験的機能を発見しました。(少なくとも私と非常によく似た)問題に直面した人が、興味深いアイデアを思いついたのです。

Pumaは、最初は通常通り起動し、クラスタプロセスがワーカーたちを監視します。
しかし間もなく、最初のワーカーを除いたすべてのワーカーを一斉にシャットダウンするメカニズムをトリガーできます。このとき、クラスタプロセスをforkするのではなく、残っているワーカーをforkすることになります。

すなわち、プロセス階層の観点では以下のようになります。

10000   \_ puma 4.3.3 (tcp://0.0.0.0:9292) [puma]
10001       \_ puma: cluster worker 0: 10000 [puma]
10002       \_ puma: cluster worker 1: 10000 [puma]
10003       \_ puma: cluster worker 2: 10000 [puma]
10004       \_ puma: cluster worker 3: 10000 [puma]

上が以下のようになります。

10000   \_ puma 4.3.3 (tcp://0.0.0.0:9292) [puma]
10001       \_ puma: cluster worker 0: 10000 [puma]
10005           \_ puma: cluster worker 1: 10000 [puma]
10006           \_ puma: cluster worker 2: 10000 [puma]
10007           \_ puma: cluster worker 3: 10000 [puma]

このソリューションはかなり優秀だと思えました。コードパスを別の何らかの方法で自動実行するのではなく、それをライブトラフィックに行わせておいてから他のワーカーとステートを共有するだけで済みます。シンプルですね。

しかしこの機能には大きな懸念点がありました。すなわち、プロセス階層が3レベルに増えてしまうということです。以下の過去記事でも説明したように、何か問題が生じたときにどのワーカーも安全に終了できるようにしておきたいのです。

Rails: Unicorn::WorkerKillerのメモリ管理とレジリエンスを再考する(翻訳)

ここでworker 0が終了またはそれ自体がクラッシュすると、他のワーカーが孤立したまま残ってしまいます。POSIXでは、他のワーカーはPID 1(つまりinitプロセス)に引き継がれますが、Pumaはさまざまな理由でワーカーを直接の子として必要とするため、レジリエンス(回復力)において大きな問題となります。レジリエンスを維持するには、これらのワーカーを子ではなく兄弟としてforkする必要がありますが、これは不可能です。

Shopifyのモノリスをこの方法でデプロイするのは、どうやっても正当化できませんでした。いずれ大きな問題が発生するのは目に見えています。しかし効果がどのぐらいあるかについて非常に興味を惹かれたので、カナリア環境にコンテナを1つ用意して、このfork_worker機能を有効にしたPumaをしばらく運用するという実験を行いました。その結果、素晴らしいパフォーマンスと無惨な結果の両方が得られました。

素晴らしかった点は、メモリの節約効果が絶対的に大きかったことです。
無惨な結果は、新たに生成されたワーカーでgrpc gemがエラーをraiseするようになったことです。このエラーは、同僚の1人がgrpc gemに安全性チェックを追加したことによるものなので(#16332)、私もそこそこ把握していました。この安全チェックは、forkが存在するときにgrpcがデッドロックするのを防ぐためのものです。

プロセスの親子関係における私の懸念に加えて、grpc gemを「fork安全」にするのがほぼ不可能であることも明らかでした。
そういうわけで私は、このアイデアを「決して日の目を見ないグッドアイデア集」という名前の引き出しにお蔵入りさせて先に進むことにしました。

🔗 child subreaper属性

それからどのぐらい経ったのか定かではありませんが、ある日私は別の問題についての解決方法をprctl(2)のmanページで探していたところ、PR_SET_CHILD_SUBREAPERという定数をたまたま見つけました。

設定がゼロ以外の場合、呼び出し側プロセスの"child subreaper"属性を設定します。設定がゼロの場合、属性を設定解除します。

subreaperは、子孫プロセスに対するinit(1)の役割を果たします。プロセスが孤立すると(つまりその直接の親が終了すると)、そのプロセスは、まだ生きている最も直近の祖先subreaperを再び親として紐づけられます(=reparented)。

これはまさしくPumaの実験的機能をより堅牢にする機能でした。私はこの機能のことを知らなかったどころか、自分が欲しかったのはこの機能だったことすら気づいてなかったのです。

PumaのクラスタプロセスでPR_SET_CHILD_SUBREAPERを有効にすると、worker 0は「孫プロセスをforkしてそれを孤立させる」という古典的なデーモン化手順を実行する形で兄弟プロセスを生成可能になります。これにより、新しいワーカーがPumaクラスタプロセスの親になって兄弟プロセスをforkできるようになります。

さらに当時の私たちは、YJITをproduction環境で実行していたことでメモリ使用量の状況が著しく悪化していただめ、YJITを一部のワーカーでのみ有効にするトリックを使う必要がありました。

定義上、JITコンパイラは実行時にコードを生成するため、共有ページに保存できないメモリを大量に必要とします。このアイデアをproduction環境で使えるようにすれば、JITコンパイルされたコードも共有可能になることでメモリをさらに節約できる可能性があります。

そこで、次の数週間をプロトタイプ作成に費やしました。

🔗 最初期のプロトタイプ

私はPumaの機能を改善するとともに、Unicornにも同じ機能を追加してどちらが最もシンプルになるかを確認できるようにしました。

おそらく私がPumaよりもUnicornに精通していたこともあって、Unicornでやる方が簡単であることがわかり、メーリングリストにパッチを送付しました。

パッチの最初のバージョンでは、PR_SET_CHILD_SUBREAPERは実際には使われていませんでした(これはLinuxのみの機能で、UnicornはすべてのPOSIXシステムをサポートしているため)。代わりに、Unicornのゼロダウンタイム再起動機能に基づいて、新しいマスタープロセスをforkし、古いプロセスをシャットダウンして、pidfile を置き換えました。

わかりやすいように、従来のUnicornプロセスツリーを最初に説明します。

PID     Proctitle

1000   \_ unicorn master
1001       \_ unicorn worker 0
1002       \_ unicorn worker 1
1003       \_ unicorn worker 2
1004       \_ unicorn worker 3

reforkをトリガーすると、ワーカーは新しいマスタープロセスのように振る舞い始めます。

PID     Proctitle

1000   \_ unicorn master
1001       \_ unicorn master, generation 2
1002       \_ unicorn worker 1
1003       \_ unicorn worker 2
1004       \_ unicorn worker 3

続いて、古いマスタープロセスと新しいマスタープロセスがそれぞれ段階的にシャットダウンしてワーカーを生成します。

PID     Proctitle

1000   \_ unicorn master
1001       \_ unicorn master, generation 2
1005         \_ unicorn worker 0, generation 2
1006         \_ unicorn worker 1, generation 2
1003       \_ unicorn worker 2
1004       \_ unicorn worker 3

古いマスタープロセスのワーカーがなくなると、その時点で終了します。

この方法のメリットは、すべてのPOSIXシステムで動作することでしたが、非常に壊れやすく、Unicornをデーモン化モードで起動する必要がありました。これは、コンテナや最新のデプロイシステムでは望ましくありません。

また、マスタープロセスとワーカーで通信パイプを利用可能にするために、名前付きパイプをファイルシステムに作成することに依存していましたが、これはまったくエレガントではありませんでした。

とは言うものの、修正パッチを送信したり、そうした機能が上位層で求められているかどうかについてのフィードバックや、実装に関するフィードバックを得るにはこれで十分でした。

🔗 プロセス間通信

Unicornのマスタープロセスは、たとえばシャットダウンを依頼するなどの操作を実行するためにワーカーと通信する必要があります。

プロセス間通信を最も手軽に行う方法は、シグナルを送信することです。しかし定義済みのシグナルはごくわずかしかなく、その多くは既に意味づけされています。また、シグナルは非同期的に処理されるため、システムコールを中断する傾向があり、実行中のアプリケーションと競合する可能性があります。

そこでUnicornでは「ソフトシグナル(soft signals)」を実装しています。本物のシグナルを送信する代わりに、各ワーカーを生成する前にパイプを作成しておいて、子プロセスはリクエスト処理の合間にマスタープロセスからメッセージが届いているかどうかを探索します。

以下は、この振る舞いをシンプルにしたサンプルコードです。

def spawn_worker
  read_pipe, write_pipe = IO.pipe
  child_pip = fork do
    write_pipe.close
    loop do
      ready_ios = IO.select([read_pipe, @server_socket])
      ready_ios.each do |io|
        if io == read_pipe
          # 親プロセスがパイプに送信したコマンドを処理する
        else
          # HTTPリクエストを処理する
        end
      end
    end
  end
  read_pipe.close
  [child_pid, write_pipe]
end

マスタープロセスはパイプの書き込み側を保持し、ワーカーは読み取り側を保持します。アイドル状態のワーカーは常に、コマンドパイプやHTTP ソケットにepollkqueueselectで読み取るものが現れるまで待機します。この例では、機能的に同等のRuby提供のIO.selectを使っています。

これにより、Unicornのマスタープロセスは、常にPIDとすべてのワーカーへの通信パイプの両方を保持するようになります。

しかし私の場合は、マスター自身が生成していないワーカーについてもマスターから認識可能にしたかったのです。PIDについてはそれほど難しくはなく、第2のパイプを逆方向に作成するだけで済みます。それによって、ワーカーはマスターにメッセージを送信して、新しいワーカーPIDを知らせることが可能になります。しかし親との通信パイプを確立するにはどうすればよいでしょうか。

そういうわけで、私の最初のプロトタイプでは名前付きパイプ(FIFOとも呼ばれます)を使ったのです。
名前付きパイプは通常のパイプとまったく同じですが、ファイルシステムツリー上のファイルとして公開されます。これによりマスタープロセスは、合意された場所で名前付きパイプを検索して孫プロセスにメッセージを送信する方法が手に入ります。
これはこれで動きましたが、Unicornのメンテナーがフィードバックで指摘したように、それよりもずっとクリーンなソリューションであるsocketpair(2)UNIXSocket#send_ioがあったのです。

まずsocketpair(2)は、その名の通り互いに接続された2つのソケットを作成します。これはパイプに非常に似ていますが、双方向である点が異なります。プロセス間で双方向通信が必要だったため、毎回2つのパイプを作成するよりもシンプルでクリーンでした。

しかし、UNIXドメインソケットには「ファイルディスクリプタを別のプロセスに渡せる」というあまり知られていない機能が存在します(少なくとも私は知りませんでした)。以下はRubyによるこの機能の簡単なデモです。

require 'socket'
require 'tempfile'

parent_socket, child_socket = UNIXSocket.socketpair

child_pid = fork do
  parent_socket.close

  # ファイルシステム上に存在しないファイルを作成する
  file = Tempfile.create(anonymous: true)
  file.write("Hello")
  file.rewind

  child_socket.send_io(file)
  file.close
end
child_socket.close

child_io = parent_socket.recv_io
puts child_io.read
Process.wait(child_pid)

上の例では、子プロセスが作成した無名ファイルを、UNIXドメインソケット経由で親と共有しています。

この新しい機能のおかげで、設計の壊れやすさを大幅に軽減できました。これで、新しいワーカーが生成されたときに、必要なすべてのメタデータと、新しいワーカーとの直接通信用に接続されたソケットを含むメッセージをマスタープロセスに送信可能になりました。

🔗 Unicornプロジェクトをforkするという決定

Eric Wongからの提案のおかげで、PR_SET_CHILD_SUBREAPERをベースにしつつも、それよりはるかにすっきりした設計からスタートできました。
しかしいくつかの理由から、Unicornの新機能を本家に反映するのではなく、その時点でプロジェクトを別名でforkすることに決めました。

最初の理由は、Unicornのいくつかの機能はreforkとの連携が難しいことが明らかになってきたことです。不可能ではないもののかなりの手間が必要になり、最終的に一部のユーザーのUnicornが壊れるリスクが生じます。

Unicornプロジェクトに貢献するのは簡単ではありません。Unicornには非常に古いRubyをサポートするというポリシーがあるのですが、古いRubyには私が使いたい多くの機能が欠けているうえに、現代のシステムではインストールが難しいため、特にデバッグが困難です。
また、Unicornはbundlerをはじめとする現代のRuby用ツールの大半を使っていないため、多くの人にとって貢献するのが難しく、テストフレームワークも独自のbashベースの単体テストで、修正パッチもforgeなどではなくメーリングリストで受け付ける形になっています。

私はUnicornが外部からの貢献に冷淡だと言いたいのではありません。これはUnicornが意図したものではありませんが、現実にはそれに近いものになっています。

そこで私は、新機能をサポートするために大規模な変更を加えなければならなくなったときに、万一ミスしても既存のユーザーに迷惑をかけず、productionでの経験に基づいて私の管理の元でリリースを繰り返せる別プロジェクトにするのが望ましいと思えたのです。

以上の理由でUnicornをforkすることに決めました。

最初に、現代のコンテナベースの世界では無用と思われる多くの機能をUnicornから削除し、kgioへの依存も削除して、新しいRubyに導入された非ロッキングIO APIに置き換えました。

このシンプルなUnicornをベースとすることで、使いもしない機能を壊してはならないという制約から解放されて、必要な機能だけをシンプルかつクリーンかつ堅牢に実装できるようになりました。

新しいプロジェクトは、名前を好きに決められるのが嬉しい点です。
当初は、Ruby製Webサーバーには動物の名前をつけるというトレンドを引き継いで、別の神話上の動物にちなんだ命名にする形でUnicornの系譜をたどりたいと考えました。そこで、この新プロジェクトにDahuという名前を付けようかとしばらく検討していましたが、最終的に名前のどこかにfork という言葉がある方がキャッチーになるだろうと考えました。
Rubygemsで使われていない名前を探すのはとても大変ですが、長年放置されていたpitchforkというgemを見つけたので、gemのオーナーにメールで問い合わせたところ、とても丁重にそのgemを私に譲ってくれました。
pitchforkはこのようにして誕生したのです。

🔗 「モールド」プロセス

サーバーを大幅に変更可能になったので、新しいワーカーを生成する役割をマスタープロセスから移すことに決め、そのために従来のマスタープロセスの名前を「モニタープロセス(monitor process)」に変更しました。

Unicorn では、Copy-on-Writeの強みを最大限に活用できるpreload_appオプションを使うと、新しいワーカーはマスタープロセスからforkされますが、マスタープロセスはリクエストに応答しないため、マスタープロセスに読み込まれたアプリケーションコードはまったく呼び出されません。また、Unicornをコンテナ内で実行している場合は、初期プロセスを合法的に置き換える方法がありません。

そこで私は、Pitchforkのモニタープロセスではアプリケーションコードを一切読み込まないようにし、代わりに、そこから生成される最初の子である「モールド(mold: 鋳型)」プロセスがアプリケーションコードの読み込みを担当するようにしました。モールドプロセスの役割は、アプリケーションを読み込んで、「モニター」プロセスから指示を受けたら新しいワーカーを生成することです。

最初のプロセスツリーは以下のようになります。

PID     Proctitle

1000   \_ pitchfork monitor
1001       \_ pitchfork mold

次に、モールドプロセスが完全に起動したら、モニタープロセスはワーカーを生成するためのリクエストをモールドプロセスに送信します。モールドプロセスは、これを古典的なダブルforkで行います。

PID     Proctitle

1000   \_ pitchfork monitor
1001       \_ pitchfork mold
1002          \_ pitchfork init-worker
1003             \_ pitchfork worker 0

init-workerプロセスが終了すると、worker 0は孤立し、モニタープロセスによって自動的に再び親に紐づけられます。

PID     Proctitle

1000   \_ pitchfork monitor
1001       \_ pitchfork mold
1003       \_ pitchfork worker 0

すべてのワーカーとモールドが同じレベルに並ぶため、その気になればいつでも、ワーカーが新しいモールドになったことを宣言して他のすべてのワーカーをそこから再生成できます。

PID     Proctitle

1000   \_ pitchfork monitor
1001       \_ pitchfork mold <exiting>
1003       \_ pitchfork mold, generation 2
1005       \_ pitchfork worker 0, generation 2
1007       \_ pitchfork worker 1, generation 2

もちろん、サーバーの能力が大幅に減少しないように、これらすべては一度に1ワーカーずつ段階的に実行されます。

🔗 ベンチマーク

その後、私は定数キャッシュのデモをRackサーバーのメモリ使用量ベンチマークに書き換えました。初期バージョンのPitchforkはそこで期待どおりのパフォーマンスを発揮しました。

2つのワーカーと2つのスレッドを持つPumaと比較すると、4つのプロセスで構成されたPitchforkはメモリ使用量が半分になります。

$ PORT=9292 bundle exec benchmark/cow_benchmark.rb puma -w 2 -t 2 --preload
Booting server...
Warming the app with ab...
Memory Usage:
Single Worker Memory Usage: 207.5 MiB
Total Cluster Memory Usage: 601.6 MiB
$ PORT=8080 bundle exec benchmark/cow_benchmark.rb pitchfork -c examples/pitchfork.conf.minimal.rb
Booting server...
Warming the app with ab...
Memory Usage:
Single Worker Memory Usage: 62.6 MiB
Total Cluster Memory Usage: 320.3 MiB

もちろん、これはあくまでもデモンストレーションを目的とした極端なマイクロベンチマークであり、現実のproduction環境におけるアプリケーションへの影響を示すものではありませんが、非常に励みになりました。

🔗 productionに至る困難な道のり

新しいサーバーを作成してベンチマークする作業は簡単で楽しいので、その気になれば形になるまで何か月でも没頭できるでしょう。

しかし、実際にproduction環境に乗せてみると、自分が犯したミスや考えの至らなかった問題もことごとく白日のもとにさらされます。

ただしPitchforkの場合は、私が以前から知っている大きな障害物が1つ潜んでいて、Pitchforkをproductionで運用する前に解決しなければならないこともわかっていました。その障害物とは、私にとって因縁の宿敵であるgrpc gem です。

grpc/grpc - GitHub

私はgrpc gemのコンパイル問題を修正したり、リークその他の問題解決のために机に頭をぶつけてきた経験が長いので、このgemをfork安全にするのが簡単でないことは覚悟していました。

どれほど手ごわいかを皆さんに知っていただくために、grpc gemのソースパッケージからのsloccountレポートを以下に貼っておきます(テストなどは除外してあります)。

$ cloc --include-lang='C,C++,C/C++ Header' .
-----------------------------------------------------------------
Language       files          blank        comment           code
-----------------------------------------------------------------
C/C++ Header    1797          43802          96161         309150
C++              983          35199          53621         261047
C                463           9020           8835          81831
-----------------------------------------------------------------
SUM:            3243          88021         158617         652028
-----------------------------------------------------------------

ヘッダーもコードの一部と見なすかどうかにもよりますが、Ruby自身のソースコード量よりずっと多いか、同程度となっています。

比較のために、ruby/rubyからテストコードやデフォルトのgemを除いた同じsloccountを以下に示します。

$ cloc --include-lang='C,C++,C/C++ Header' --exclude-dir=test,spec,-test-,gems,trans,build .
------------------------------------------------------------------
Language        files          blank        comment           code
------------------------------------------------------------------
C                 304          51562          83404         315614
C/C++ Header      406           8588          32604          84751
------------------------------------------------------------------
SUM:              710          60150         116008         400365
------------------------------------------------------------------

さらに、grpcと連携して動作するgoogle-protobuf gem も追加する必要があります。これはかなり大きなサイズです。

ruby-protobuf/protobuf - GitHub

そのため、grpcをfork安全にする代わりに、まずは問題のある依存関係を排除できないかを検討しました。結局、たった1つのサービス呼び出しを除けば、Shopifyのモノリスではほとんど使われていなかったのですが、残念ながら、このgrpc gemを使っているチームを説得して別のものに移行してもらうことは叶いませんでした。

その後、このライブラリをどうにかしてfork安全にする方法を模索したのですが、私には無理だと認めざるを得ませんでした。私にできたのは、Pythonバインディングでは環境変数の背後にあるfork安全オプションが利用できることを理解したことだけで、これによって理論的には可能であることが確認できたものの、それでも私の能力を超えていました。

そのため、悔しかったのですがPitchforkプロジェクトを断念せざるを得ませんでした。grpcが依存関係に残っているうちは実現は不可能でした。

それから数か月後、ある同僚が大西洋の向こうから私の恨み節を聞きつけて、何か手伝えることはないかと声をかけてくれました。Python版のgrpcはfork安全であること、ShopfyがGoogle Cloudの大口顧客として非常に高いレベルのサポートを受けられることから、彼はGoogleの背中を押して実装させることは可能だと考えました。それから随分(おそらく半年ほど)かかったものの、彼の期待通りgrpc gemはとうとうforkをサポートするようになったのです(#33430)。

こうして半年間の停滞期を乗り越え、Pitchforkプロジェクトはめでたく復活を遂げました。grpcを改良してくれたAlexander Polcynに心から感謝申し上げます。

🔗 その他のfork安全問題を修正する

grpc以外にも問題があることはその時点で明らかでしたが、そちらについてはある程度対処できる自信はありました。production環境のUnicornをPitchforkに置き換えることは、(たとえreforkを有効にしなくても)メリットがありました。そうすることで、HTTPおよびIOレイヤにバグが導入されていないことを確認できるうえに、kgioへの依存を解消してrack 3との互換性を確保できるようになり、他にも細かなことを実現できたからです。これが最初のステップでした。

その後、grpc以外のfork安全性の問題を修正するのにさらに1か月ほどかかりました。

最初に手掛けたのは、reforkをCIでシミュレートすることでした。CIワーカーでテスト100件ごとにPitchforkと同じ方法でreforkを行いました。
これによって、他のgem(特にruby-vips)におけるfork安全性の問題も明らかになりました。幸いこのgemはWebワーカーではあまり使われていなかったので、これに対処する新しい戦略を考えました。

Pitchforkにおいてfork安全でなければならないのは、次のモールドプロセスに昇格されるワーカーだけであり、すべてのワーカーがfork安全である必要はありません。つまり、ライブラリを利用するとfork安全でなくなる代わりに、めったに呼び出されないのであれば、「このワーカーはモールドに昇格させない」とマーキングする方法が使えます(#55)。

この機能が乱用されると、すべてのワーカーがfork安全でなくなってしまい、二度とreforkできなくなってしまいます。しかしPitchforkをproductionでリリースした後、ワーカーがfork安全でないとマーキングされる頻度を監視したのですが、そのような事態はきわめて稀だったので問題はありませんでした。

reforkを有効にしてCIがグリーンでパスするようになった後も、アプリケーションが本当にfork安全なのかどうかが少し心配でした。CIでreforkをシミュレートすることは、死んだスレッドの問題をキャッチするには効果的でしたが、ファイルディスクリプタが継承される問題をキャッチするにはあまり役立たなかったからです。

production環境のファイルディスクリプタの継承に関する問題は、主に複数のプロセスが同じファイルディスクリプタを同時に使うときに発生します。しかしCIでは、reforkをシミュレーションしても常に1つのプロセスしか実行されません。

そのため、ファイルディスクリプタが漏洩しないようにするための戦略を別途考えなければなりませんでした。

そういうわけで、Pitchforkで別のヘルパーclose_all_ios!を開発することにしました。アイデアは比較的シンプルで、reforkが発生したら、ObjectSpace.each_objectですべてのIOインスタンスを探索し、Pitchfork::Info.keep_ioで明示的にfork安全としてマーキングされていない限り、それらをクローズするというものです。

このヘルパーは、今のところRubyレベルのIOしかキャッチできず、C拡張が保持しているファイルデスクリプタはキャッチできないため、万全ではありませんが、それでもgemやプライベートコードなどの問題を多数発見するうえで役立ちました。

以下はmini_mime gemの問題を発見した例です(#50)。

このmini_mime gem は、MIMEタイプに関する情報を含むフラットなファイルをクエリできる小さなラッパーです。これを行うには、読み取り専用ファイルを保持しておいて、そのファイルに対して以下のようにseekを実行します。

def resolve(row)
  @file.seek(row * @row_length)
  Info.new(@file.readline)
end

seekreadlineはスレッド安全ではないため、gemはこれらすべてをグローバルミューテックスでラップします。

ここでの問題は、fork時にファイルディスクリプタが継承されることと、ファイルディスクリプタがファイルやソケットへの単なるポインタではないことです。ファイルディスクリプタには、seekreadを呼び出すとインクリメントするカーソルも含まれます。

このforkを安全にするには、forkが発生したことを検出してファイルを再度オープンする方法がありますが、実はそれよりずっと良いソリューションがあります。

それは、seek + readではなくpread(2)に依存する方法です。これは、RubyのIOクラスで公開されていてすぐ使えます。readのようにカーソルを進めるのではなく、preadはファイル冒頭からの絶対オフセットを取得するため、マルチスレッドやマルチプロセスのシナリオに最適です。

def resolve(row)
  Info.new(@file.pread(@row_length, row * @row_length))
end

preadを使うことで、そのgemのfork安全性を修正できるだけでなく、グローバルミューテックスを削除してgemを高速化できるので、gemにとってもユーザーにとってもメリットがあります。

🔗 production環境での最初のrefork

コードベースとその依存関係をgrepして問題になりそうなパターンを探す作業を何度か繰り返した後、単一のカナリアコンテナでならreforkを手動でトリガーできる程度に自信がつき始めました。

念のため申し上げておくと、この時点ではまだ問題がいくつか残っているだろうと予想はしていましたが、問題をそれ以上キャッチする方法が思いつかず、データ破損のような最も重大な問題については解決したと確信していました。

手動によるreforkでは、ワーカーがひとたびfork非安全とマーキングされたら手動reforkを禁止しておくのを忘れていた(#60)ことを除けば、問題は発生していませんでした。🤦

それ以外はうまく動いたので、数日かけて徐々に(最初は1%、次に10%...)reforkを自動化しましたが、これも問題なさそうでした。
その合間に、メモリ使用量の削減とレイテンシへの悪影響が折り合う適切なトレードオフを見つけるために、reforkの頻度をあれこれ変えて試していました。

しかしShopifyモノリスの大きな特徴の1つは、デプロイ頻度が極めて高いことです。 30分に1回デプロイが発生し、世界中のどの開発チームでも、夜間の数時間と週末の数日を除けば、デプロイが止むことはありません。

「問題が発生したらコンピュータを再起動すればたいてい問題が解決する」のと同じ理由で、Webアプリケーションを再起動すると、発現までに時間のかかるような多くのバグが発生しないまま覆い隠されてしまうものです。私は長年にわたってこのようなインフラストラクチャの変更を手掛けてきた経験から、たとえうまくいったと思っても翌週末になって問題が発覚する可能性があることを学んできました。

そして、まさにそれが起きたのです。
金曜の夜から土曜にかけて一部のアプリケーションサーバーが応答を停止してCPU使用率が極めて高くなったため、SRE(Site Reliability Engineering)担当者たちに招集がかけられました。

運良く私の手元にはrefork微調整用のツールがずらりと揃っていたので、土曜の朝に調査を開始して、ただちに決定的な証拠をいくつか押えることに成功しました。

最初に気づいたのは、Railsのafter_forkコールバックが完了するまで平均1分近くかかっていたことです。通常なら1秒もかかりません。
このafter_forkコールバックで主に行われる処理は、Pitchfork::Info.close_all_ios!の呼び出しと、データストアへのeagerな再接続の2つでした。したがって、これらのスパイクはIOの「リーク」が原因であるとすれば説明がつきます。

私は自分の疑問を確認するために、早速手元のカナリアコンテナに飛びつきました。ワーカープロセスはどれも正常でしたが、モールドプロセスでは確かにファイルディスクリプタが「リーク」していました。そのときのログは今も残してあります。

appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:46 UTC 2023
155
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:47 UTC 2023
156
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:47 UTC 2023
157
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:48 UTC 2023
157
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:49 UTC 2023
158
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:49 UTC 2023
158
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:50 UTC 2023
159
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:51 UTC 2023
160
appuser@web-59bccbbd79-sgfph:~$ date; ls  /proc/135229/fd | wc -l
Sat Sep 23 07:52:51 UTC 2023
160

モールドプロセスが1秒あたりおよそ1個のペースでファイルディスクリプタを生成していることがわかりました。

そこでls -lh /proc/<pid>/fdコマンドを2回実行して結果をスナップショットしてから、diffでどのファイルディスクリプタが新しいかを調べました。

$ diff tmp/fds-1.txt tmp/fds-2.txt
130a131,135
> lrwx------ 1 64 Sep 23 07:54 215 -> 'socket:[10443548]'
> lrwx------ 1 64 Sep 23 07:54 216 -> 'socket:[10443561]'
> lrwx------ 1 64 Sep 23 07:54 217 -> 'socket:[10443568]'
> lrwx------ 1 64 Sep 23 07:54 218 -> 'socket:[10443577]'
> lrwx------ 1 64 Sep 23 07:54 219 -> 'socket:[10443605]'
> lrwx------ 1 64 Sep 23 07:54 220 -> 'socket:[10465514]'
> lrwx------ 1 64 Sep 23 07:54 221 -> 'socket:[10443625]'
> lrwx------ 1 64 Sep 23 07:54 222 -> 'socket:[10443637]'
> lrwx------ 1 64 Sep 23 07:54 223 -> 'socket:[10477738]'
> lrwx------ 1 64 Sep 23 07:54 224 -> 'socket:[10477759]'
> lrwx------ 1 64 Sep 23 07:54 225 -> 'socket:[10477764]'
> lrwx------ 1 64 Sep 23 07:54 226 -> 'socket:[10445634]'
...

これらのファイルディスクリプタは、どれもソケットでした。
次に、rbtraceでヒープダンプを取得して、このリークがRubyではどのように見えるかを調べました。

...
5130070:{"address":"0x7f5d11bfff48", "type":"FILE", "class":"0x7f5d8bc9eec0", "fd":11, "memsize":248}
7857847:{"address":"0x7f5cd9950668", "type":"FILE", "class":"0x7f5d8bc9eec0", "fd":-1, "memsize":8440}
7857868:{"address":"0x7f5cd99511d0", "type":"FILE", "class":"0x7f5d81597280", "fd":4855, "memsize":248}
7857933:{"address":"0x7f5cd9951fb8", "type":"FILE", "class":"0x7f5d8bc9eec0", "fd":-1, "memsize":8440}
7857953:{"address":"0x7f5cd99523c8", "type":"FILE", "class":"0x7f5d81597280", "fd":4854, "memsize":248}
7858016:{"address":"0x7f5cd9952fd0", "type":"FILE", "class":"0x7f5d8bc9eec0", "fd":-1, "memsize":8440}
7858036:{"address":"0x7f5cd9953390", "type":"FILE", "class":"0x7f5d81597280", "fd":4853, "memsize":248}
...

上の"type":"FILE"はRubyのT_FILE基本型に対応し、この型はどんなIOオブジェクトにもなりえます。それらのIOオブジェクトのコンテキストを得るためにharb3というツールで調べたところ、ただちに答えを得られました。

harb> print 0x7f5cd9950668
    0x7f5cd9950668: "FILE"
           memsize: 8,440
  retained memsize: 8,440
     references to: [
                      0x7f5cc9c59158 (FILE: (null))
                      0x7f5cd71d8540 (STRING: "/tmp/raindrop_monitor_84")
                      0x7f5cc9c590e0 (DATA: mutex)
                    ]
   referenced from: [
                      0x7f5cc9c59158 (FILE: (null))
                    ]

/tmp/raindrop_monitorというパスは、以前ならUnicornのマスタープロセス(Pitchforkではモールドプロセスに移行しました)で実行されていたユーティリティスレッドの1つのようです。

ここではraindrops gemを用いてサーバーポートに接続し、TCP統計を抽出してキューに入れられているリクエスト件数を推定して、アプリケーションサーバー使用率のメトリクスを生成していました。

基本的には、ループ内で以下のコードを実行して、結果をすべてのワーカーからアクセス可能にしています。

Raindrops::Linux.tcp_listener_stats("localhost:$PORT")

ここで問題なのは、tcp_listener_statsがTCP統計取得のためにソケットをオープンしたのに、ソケットをクローズしないため制御が返ってこないことです。ファイルディスクリプタをクローズするのはRubyのGC(ガベージコレクタ)の責務です。

通常ならGCが頻繁にトリガーされるため大きな問題にはなりません。しかしPitchforkのモールドプロセスは(Unicornのマスタープロセスもそうですが)大した処理を行っていないため、アロケーションがめったに発生しません。そのためGCがトリガーされる可能性も非常に低くなり、たとえトリガーされたとしても、これらのオブジェクト(つまりファイルディスクリプタ)は時間とともに大量に積み重なってしまいます。

次に新しいワーカーが生成されると、そのワーカーが継承したこれらのファイルディスクリプタをすべてクローズする必要があるため、カーネルで大量の作業が発生します。これで、観察された問題について完璧に説明がつき、時間とともに問題が悪化する理由についても説明がつきました。reforkの頻度は固定されておらず、当初は比較的頻度を高めに設定していたのですが、その後だんだん頻度が下がり、時間が経過すればするほどファイルディスクリプタが蓄積される時間もどんどん長くなっていたのです。

この問題を解決するため、これらのソケットをeagerにクローズするパッチをRaindropsに送信し、そのパッチを直ちにシステムに適用したところ、問題は解消しました。

ここで私にとって興味深かったのは、このバグはPitchforkに移行する前から存在していたことでした。Unicornでも既にマスタープロセスでソケットが蓄積されていたのですが、私たちが気づくほどの影響は生じていなかったのです。

production環境で見つかった問題はこれだけではありませんが、reforkがうまくいかない可能性のある最も影響が大きい問題の良い例です。

🔗 reforkの頻度をチューニングする

reforkのバグ修正と並行して、バランスを取るためにreforkのさまざまな設定の導入にも多くの時間を費やしました。

reforkもCopy-on-Writeも無料ではありません。説明を聞いただけでは魔法のように思えるかもしれませんが、カーネルにとっては重たい作業です。

メモリを共有するプロセスをforkする分にはさほどコストはかかりませんが、その後で子か親のどちらかが改変されたために共有ページを無効化しなければならなくなると、カーネルはプロセスを一時停止してページをコピーする必要があります。
つまり、reforkがトリガーされると、少なくともその後しばらくはプロセスのレイテンシにある程度の悪影響が生じることが予想されます。

そのため、最適な落とし所を見つけるのが難しくなることがあります。reforkの頻度を増やしすぎるとサービスのレイテンシが悪化し、かといってreforkの頻度を減らしすぎるとメモリがあまり節約されません。

設定にこのような変動要素が増えてきたら、私は複数の設定を同時にデプロイして結果をグラフ化し、それを元に最適な落とし所を探ることがよくあります。今回もまさにそれを行いました。

最終的に、以下のようにおおよそ直線的に増加する設定に落ち着きました。

PITCHFORK_REFORK_AFTER="500,750,1000,1200,1400,1800,2000,2200,2400,2600,2800,...

これは、「若いコンテナはさまざまな遅延初期化を比較的早いペースでトリガーする可能性が高いが、コンテナがウォームアップするに連れて無効化の頻度は下がる」という考え方です。

Shopifyのモノリスでreforkした結果については、2023年に書いた以下の記事にかなり詳しく書いてありますので、詳しく知りたい方はそちらをご覧ください。手短に言うと、メモリ使用量は30%削減され、レイテンシは9%削減されました。

参考: Effects of Pitchfork reforking on Shopify’s Monolith | Rails at Scale

メモリ使用量を削減できたのはおおむね予想通りでしたが、レイテンシについてはそれほど悪化しなければよしとする程度に考えていたので、レイテンシまで削減できたのは当初嬉しい驚きでした。

レイテンシについても削減できた理由を理解するには、さらなる調査が必要でした。

🔗 Unicornのバイアス

Linux上でUnicornやPitchforkが動作する仕組みについて知っておくべき点の1つは、どちらもリクエストをepollシステムコールで受信していることです。リクエストが着信するとカーネルがワーカーを起こし、ワーカーはただちにacceptを呼び出してリクエストを受け取ります。

これは極めて古典的なパターンであり、多くのサーバーで使われていますが、"thundering herd"という問題に苦しんできた歴史があります。

完全にアイドル状態のワーカーが32個あり、どのワーカーもepollを待っているとします。ここでリクエストがやってくると、32個のワーカーが一斉に目を覚まし、先を争ってacceptを呼び出そうとしますが、成功するのは常に1個のワーカーだけです。これはリソースの無駄遣いが甚だしいため、2016年にLinux 4.5がリリースされたときにepollEPOLLEXCLUSIVEというフラグが追加されました。

このフラグが設定済みの場合、Linuxカーネルはリクエスト着信時に1個のワーカーだけを起こします。ただしこの機能では公平性などは一切考慮されないため、最初に目についたものを起動する以外のことを行いません。また、この機能の実装方法は、いわゆる後入れ先出しキュー(LIFO)、つまりスタックのように振る舞います。

その結果、ほとんどのワーカーがほとんどの時間ビジー状態である場合を除くと、一部のワーカーに多数のリクエスト処理が押し付けられるという不公平が生じます。私が目撃した例では、worker 0が数千件のリクエストを処理している一方、worker 47は10件かそこらのリクエストしか処理していなかったことがあります。

この問題の影響を受けるのはUnicornだけではありません。Cloudflareのエンジニアが書いた以下の記事には、Nginxが同様に振る舞う様子がもっと詳しく説明されています。

参考: Why does one NGINX worker take all the load?

Rubyでこのような不均衡が生じると、「VMのインラインキャッシュ」「アプリケーション内のあらゆる遅延初期化コード」「YJIT」が、あるワーカーでは激しくウォームアップされるのに、他のワーカーではさっぱりウォームアップされない、という状況が発生します。

🔗 reforkでレイテンシを削減できるしくみ

こうしたキャッシュやJITなどのせいで、ウォームアップされない「コールド」ワーカーは、ウォームアップされたワーカーよりも目に見えて速度が落ちます。バランスが偏ることで、ワーカーごとのウォームアップ状況が甚だしくばらついてしまいます。

ただし、ワーカーを新しいモールドプロセスに昇格させるときの基準は、そのワーカーが処理したリクエスト数に基づくので、ほとんどの場合、最もウォームアップされたワーカーが最終的に次世代のワーカーのテンプレートとして使われることになります。

その結果、reforkを有効にすると、平均的なワーカーのウォームアップはずっと良くなるので、実行も高速になります。私がPitchforkについて書いた最初の記事では、reforkを有効にしたコンテナと無効にしたコンテナを比較したときにワーカーのJITコードがどれだけ増加するかを示すことでこの点を説明しました。

JITコードが増えるに連れて実行も速くなり、ホットメソッド(実行頻度の高いメソッド)のコンパイルに要する時間も短縮されます。

🔗 Pitchforkの本当の強みとは

既に述べたように、私がPitchforkに取り組んだ動機はメモリ使用量を削減することでした。特にYJITの登場でいくつかの限界に直面していたため、そのあたりを一気に解決したいと思っていました。
しかし、お金をかけてでもサーバーのメモリを増設してもらうように依頼する方が、実際はよほど手間がかからずに済んだでしょう。今どきのメモリーは随分安くなっていて、たいていのホスティングサービスでは1コアあたり4GiBのメモリが提供されています。これはRubyにとっても十分な容量です。

極めて大規模なモノリスを扱う場合はさすがに少し厳しいかもしれませんが、その場合でもコアあたりのメモリ容量を増やすのは比較的やりやすいので、その分コストはかかるものの全体としてはさほど悪くないでしょう。

私がreforkの本当のメリットを初めて理解できたのは、production環境で完全なreforkをリリースしたときでした。
reforkはメモリを節約できるだけではありません。最もよく「温まった」ワーカーがチェックポイントとなって実質的にワーカーのテンプレートとして使われるようになるということは、トラフィックの小さなスパイクが発生したときに、普段は暇そうにしているアイドル状態のワーカーたちもそのトラフィックに応答するようになり、従来よりも応答がずっと高速になることを意味します。

さらに、私たちがUnicornを使っていたときは、リクエストのタイムアウトやメモリ不足によってワーカーが終了する様子を注意しながら詳しく監視していました。Unicornのワーカーが終了すると、せっかく温まったワーカーが冷めたコールドワーカーに置き換えられてしまい、パフォーマンスが目に見えて悪化するためです。

しかしreforkを有効にして以来、メモリ不足がめったに発生しなくなったため、そのような事態の発生頻度が減っただけでなく、ワーカーが強制終了したときにも、既にJITコードをたっぷり含んで十分温まったワーカーに置き換えられるようになったのです。

私は、Pitchforkの真に優れた機能は、メモリ使用量の削減よりも、まさにこれだと信じています。

私はreforkによるチェックポイント化がいかに強力であるかを実感したことで、Shopifyモノリスの最適化をさらに推し進めました。

🔗 さらなる高みへ

YJITはかなり短時間でウォームアップされ、コストも比較的小さくて済むという嬉しい特性があります。つまり、短時間でパフォーマンスがピークに達し、しかもその間のRuby実行はさほど遅くならないということです。

しかし昨年の夏、私がRuby 3.4.0-preview1をproduction環境でテストし始めたところ、YJITのコンパイル時間が大きく伸びてしまっていることに気づきました。コンパイルの終わったコードの実行は引き続き同程度に高速でしたが、YJITが突然コンパイルで4倍のCPUを要求してサーバーのCPU使用率が大きく上昇し、全体的なレイテンシが悪化したのです。

何があったかというと、YJITチームが最近レジスタアロケータをより賢い形に書き直した結果、目に見えて遅くなっていたのです。これはJITの設計でよくあるトレードオフで、JITコンパイラが複雑になると生成コードの高速化が見込める一方で、コンパイル中のパフォーマンスが悪化するというものです。

この問題はもちろんYJITチームに報告しましたが、このパフォーマンス低下はすぐには回復しそうにないことは明らかでした。production環境でこのようなリグレッションが発生するとなると、Rubyプレビュー版を使い続けるのは難しくなります。

ここでやっと「コンパイル、やりすぎでは?」と気づいたのでした。

考えてみれば、Pitchforkを36ワーカーでデプロイしたとして、YJITが36個のワーカーすべてで有効になっていたとしたら、ホットメソッドにさしかかるたびにすべてのワーカーが新しいコードをコンパイルすることになります。つまりほとんどのメソッド(特に最もホットなメソッド)は36回もコンパイルされてしまいます。

しかし、あるワーカーがモールドプロセスに昇格するまでに500件ものリクエストを処理すると、他のワーカーが手元でコンパイルしておいたコードをすべて窓から投げ捨ててしまうことになり、大変な無駄です。

そこから、YJITをworker 0でのみ有効にしてみたらどうだろうと思いつきました。EPOLLEXCLUSIVEによるバランス調整バイアスのおかげで、worker 0がモールドプロセスに昇格される可能性が最も高いことは既にわかっているので、それ以外のワーカーについてfork安全ではないとマーキングするだけで済みます。

これは、Pitchforkのコンフィグで実に簡単に設定できます。

after_worker_fork do |server, worker|
  if worker.nr == 0
    RubyVM::YJIT.enable
  else
    ::Pitchfork::Info.no_longer_fork_safe!
  end
end

もちろん、最初の世代が昇格すれば、すべてのワーカーでYJITが有効になります。これは、デプロイ直後のYJITオーバーヘットを劇的に削減するうえで有用でした。

デプロイ前後のシステム時間の分布を以下のグラフで示します。YJITはウォームアップ中にシステム時間が急上昇する傾向があります(ページを実行可能または書き込み可能にする目的でmprotectを頻繁に呼び出すため)。このときカーネルにはかなりの負荷が発生します。

グラフで最初に発生しているスパイクは、このコンフィグを有効にする前にデプロイしたときのもので、2番目のスパイクは黄色の線がコンフィグが有効の場合、緑の線がコンフィグが無効のままの場合です。

現時点では、一度有効にしたYJITをオフにする方法はありませんが、数年前に別の理由でそうした機能を実験したことがあります。この機能を使えば、1個を除いたすべてのワーカーでYJITコンパイルを無効にでき、YJITのウォームアップによるオーバーヘッドをさらに削減できるので、この機能を復活させる必要があるかもしれません。

この他にも、以下の記事で書いた帯域外ガベージコレクションのような、Pitchforkで推進しているがPitchforkに限定されない機能がいくつかありますが、ここですべてを紹介する余地はありません。

Ruby 3.4に導入される次世代の帯域外ガベージコレクション(翻訳)

🔗 Shopify以外でのPitchfork利用について

Pitchforkのニーズは極めて特殊なので、私はPitchforkを「Unicornの独断的なfork」以上のものとして扱うつもりは毛頭ありませんでした。
実際、私が書いた以下の長いドキュメントで、普通ならPitchforkに移行しないであろう本質的な理由についても解説したほどです。

参考: pitchfork/docs/WHY_MIGRATE.md at b70ee3c8700a997ee9513c81709b91062cc79ca1 · Shopify/pitchfork

しかし、このリポジトリでオープンされているissueや、いくつかのカンファレンスで見聞きした内容や、いくつかのDMで受け取った内容によると、Pitchforkに移行完了したか移行作業中の企業がいくつかあるようです。

当然ながら、比較的大規模なモノリスをUnicornで動かしている企業がほとんどです。

しかしPitchforkへの移行完了について一般公開されている情報は、私の知る中では以下の日本語記事だけです。

参考: スタディサプリ最大のRailsアプリケーションにYJIT+pitchforkを導入してメモリ使用量を劇的に削減するまで - スタディサプリ Product Team Blog

しかし、おそらくその方がよいのでしょう。本記事で示したように、reforkは極めて強力な機能ですが、fork安全性の問題によって極めて壊滅的なバグが引き起こされる可能性があり、しかもデバッグが恐ろしく困難になる可能性もあるため、そうした問題に対処できるリソースや専門知識を持ち合わせている専門チームに任せる方がよいでしょう。

そういうわけで、私はPitchforkを誇大宣伝したくありません。

とはいうものの、reforkを有効にするつもりはなくてもUnicornを現代化することについては純粋な興味を抱いている人たちがいることもわかってきました。これはPitchforkに移行する理由としておそらく十分でしょう。

🔗 Pitchforkの将来について

ここまで私が紹介してきたパフォーマンス強化についてひととおり読んできた方は、Shopifyはさぞかしこの新型アプリケーションサーバーに満足しているに違いないとお考えになるかもしれません。

そうでもありません。

私の直属のチームやマネージャやディレクター、そして多くの同僚の間では好評だったものの、上層部のPitchforkに対する評価はそこまで肯定的ではありませんでした。

reforkはエンジニアリングの責任放棄すれすれのハックであり、よろしくない

言い回しのきつさはさておき、この指摘には確かにうなずける点もあると私が認めていると知ったら、皆さんは驚くかもしれません。

だからこそ、私は本記事を書く前に、IO-boundなRailsアプリケーションの実態や、Rubyのパラレリズムの現状といった話題について本シリーズ記事を通して書いてきたのです。RubyでWebサーバーを設計するうえで現時点ではどのようなトレードオフがあるのかについて、よりよい形で説明するのが目的でした。

「RailsアプリはIO-boundである」という神話について考える(翻訳)

RubyのRactorとは一体何なのか(翻訳)

「今日の」私は、Pitchforkの設計はRailsの大規模モノリスのニーズに最もよく応えていると心から信じています。でなければPitchforkを開発することもなかったでしょう。Pitchforkは真のパラレリズムとJITウォームアップの高速化を提供するとともに、GCに要する時間が極めて短く済み、メモリ使用量を低く抑えると同時に、十分なレジリエンス(回復力)も備えています。

しかし私はそれと同時に、Pitchforkの設計が「明日にでも」時代遅れになってくれればいいと本気で願っています。

将来のRubyが、単一プロセス内で真のパラレリズムを実現可能になることを心から願っています。「Ractorの改良」または「GVLの段階的削除」のどちらの形で実現しても構いません。

しかしこれは仮定の未来です。これが本当に実現されたら、私はその瞬間に喜んでPitchforkに非推奨のお知らせを表示するとともに、Pitchforkの後継サーバーに取り組むつもりです。

と申し上げたものの、自分がそこまで楽観的な性格ではないことは承知しています。そうした未来は2〜3年後にやってくるかもしれませんが、率直に言うとそれより早まることは絶対にありえません。

これはRubyだけの問題ではなく、エコシステムの問題でもあります。仮にRactorが明日から使えるレベルで完璧に仕上がったとしても、おびただしいgemをRactor世界に適合させるための作業が必要になります。これがyak-shaving(=本質的でないが避けられない作業)として最もつらく大変な作業となるでしょう。

信じていただきたいのですが、私もこれまでかなりのyak-shavingを手がけてきました。Ruby 2.7でキーワードの非推奨警告が表示されるようになったとき、Shopifyのモノリスとその依存関係に含まれる問題をすべて自分の手でつぶしました。メンテナーに連絡を取るために、オープンソースのさまざまなgemに100件を超えるプルリクを投げました。最近も、Ruby 3.4リリースにさきがけてfrozen string literalに対応するため、大量のgemに山のような修正プルリクを送信しました。

要するに、私はyak-shavingを恐れたりしませんが、Shopifyのモノリスのようなアプリケーションを、その依存関係も含めてRactor互換にするには、想像を絶するほど膨大な量の作業が必要です。そしてその作業以上に、Rubyのようなエコシステムは、Ractorに適応するための猶予となる時間が必要です。これは、投入するエンジニアの頭数を増やせば済むような問題ではありません。

それまでの間、reforkがハックであるかどうかは私にとってまったくどうでもよいことです。私にとって重要なのは、reforkが何らかの現実の問題を解決するということ、そして現在それを実現していることです。

もちろん、reforkによる解決は完璧ではありません。インプロセスの並列処理で利用可能なデータベースコネクション数よりも多くのデータベースコネクションが必要になるなど、reforkでは解決できない一般的な不満点もいろいろあります。しかし私には、この問題をforkにほとんど依存していない別のサーバーで合理的に解決できるとは思えませんし、そういうサーバーで問題解決を試みるのは本末転倒でしょう。

エンジニアの責務は、現実が課してくる制約に配慮しながら問題を解決することです。

そういうわけで、Pitchforkは私の見立てでは少なくとも今後数年は通用するでしょう。

🔗 関連記事

「RailsアプリはIO-boundである」という神話について考える(翻訳)

Rubyアプリケーションでスレッドが停止する様子を計測する(翻訳)

RubyのGVLを消し去りたいあなたへ(翻訳)

Ruby: fork(2)がみんなに嫌われる理由(翻訳)

Rails: Unicorn::WorkerKillerのメモリ管理とレジリエンスを再考する(翻訳)

RubyのRactorとは一体何なのか(翻訳)


  1. 原注: 数年後、John Hawthornがperfでこれを効果的に実行する方法を発見してくれました(#10899)。 
  2. 原注: インラインキャッシュについては過去記事で何度か説明しているので、JSONの最適化パート2を参照してください。 
  3. 原注: 今なら、同じユースケースにはsheapの方がおそらくおすすめです。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。