🐎

Unicorn の「master と worker」を完全に理解する

に公開

Ruby on Rails アプリケーションの Web サーバーとして、多くの現場で利用されてきた Unicorn。あなたも「とりあえず設定ファイルはこう書くものだ」とおまじないのように使ってはいないだろうか?

  • master と worker って、具体的に何をしているのか?
  • シングルスレッドと聞くけど、どうやって並列処理しているのか?
  • worker が全員 I/O 待ちになったら、サーバーは止まらないのか?

この記事では、こうした疑問を解消し、Unicorn の内部構造をスッキリと理解することを目指す。特に、Unicorn の挙動が原因でハマりがちな「fork 後の初期化問題」についても、実例を交えて詳しく解説する。


Unicorn の基本構造:master と worker の分業体制 🏢

Unicorn は Prefork モデル というアーキテクチャを採用している。これは、仕事が来る前に (Pre) 、あらかじめ作業員を複数用意しておく (fork) 方式である。ここで重要なのが worker プロセスであり、全体を管理するのが master プロセスなのだ。

master プロセス:現場監督

  • 役割: クライアントからのリクエストを受け付ける唯一の窓口。
  • 仕事内容:
    1. 起動時に、設定された数の worker プロセスを fork (自身のコピーを作成)して起動する。
    2. 受け付けたリクエストを、待機中の worker に公平に分配する。
    3. worker が異常終了しないか監視し、もし落ちたら新しい worker を再起動させる。
  • 特徴: 自身はリクエストの中身(アプリケーションのコード)を一切処理せず、管理に徹する。

worker プロセス:作業員

  • 役割: master から渡されたリクエストを実際に処理する。
  • 仕事内容: Rails アプリケーションのコードを実行し、HTML などを生成する。
  • 特徴: 一度に1つのリクエストしか処理できない。処理が終わるまでその worker は専有される。

この「監督」と「作業員」による完全な分業体制が、Unicorn の安定性の秘訣なのである。


「シングルスレッド」なのに並列処理できる謎

ここで一つの疑問が生まれる。「Unicorn はシングルスレッドだ」と聞いたことがあるのに、なぜ複数のリクエストを同時に捌けるのだろうか?

答えは、「worker プロセスそれぞれがシングルスレッドで動く」 からである。

  • シングルスレッド: 1つの worker プロセス内では、処理は一つずつ順番にしか実行できない。マルチスレッドのように、worker 内部で複数の処理を同時に行うことはない。
  • 並列処理: しかし、Unicorn は複数の worker プロセスを並行して動かす。worker が 4 つあれば、最大で 4 つのリクエストを同時に処理できる。

つまり、Unicorn の並列処理はマルチスレッドではなくマルチプロセスによって実現されているのだ。これにより、開発者はスレッドセーフティのような複雑な問題を気にすることなく、シンプルなコードを書くことに集中できる。


全 worker が I/O 待ちになったら? キューとトレードオフの考え方 🤔

「もし worker が 2 つしかなくて、2 つとも重いデータベース処理(I/O 待ち)に入ってしまったら、新しいリクエストは捌けなくなるのでは?」

これは非常に鋭い指摘である。その通り、その瞬間、リクエストを即座に処理できる worker は 0 になる。では、その間に来たリクエストはどうなるのだろうか?

答え:キューで待つ

捌ききれないリクエストは、Unicorn の master プロセスが持つ 「バックログキュー」 という待合室に順番に並ぶ。そして、worker のどれか 1 つでも処理が終わり次第、キューの先頭からリクエストが渡される。

スーパーのレジに行列ができるのと同じで、「待ち」が発生すること自体は、システムが正常にリクエストを受け付けている証拠なのだ。問題なのは、その待ち時間が長くなりすぎることである。

worker 数はなぜむやみに増やせないのか?

「じゃあ、待たせるくらいなら worker 数を増やせばいい」と思うかもしれないが、そこには大きなトレードオフが存在する。

  • メモリの限界: worker は独立したプロセスなので、それぞれが Rails アプリケーション全体をメモリに読み込む。worker を増やすと、その数だけメモリ消費量が倍増する。
  • CPU のオーバーヘッド: CPU コア数以上にプロセスを増やすと、OS は CPU 時間を細かく切り替えて各プロセスを少しずつ動かす(コンテキストスイッチ)。この切り替え作業自体が負荷となり、かえって全体のパフォーマンスが低下することがある。

worker 数の設定は、「時々の待ちは許容しつつ、サーバーリソースを最も効率的に使うバランス点」を見つける作業なのである。一般的に、I/O バウンドなアプリケーションでは CPU コア数 × 1.5〜2 程度が目安とされるが、最終的にはメモリ使用量と相談しながら調整する。


タイムアウトの二層構造:Nginx と Unicorn の役割分担

キューで待たされるということは、ユーザー視点ではレスポンスが遅れていることに他ならない。では、この「待ち時間」は一体どこでタイムアウトするのだろうか?

ここで登場するのが、Unicorn の手前にいる Nginx のようなリバースプロキシである。実は、タイムアウトは役割の違う2種類が存在するのだ。

1. Nginx のタイムアウト:ユーザーのための時間制限

  • 目的: ユーザーを待たせすぎないようにする。
  • 計測範囲: ユーザーからリクエストを受け取ってから、レスポンスを返すまでの合計時間(Unicorn のキューイング時間を含む)。
  • 役割: この時間を超過すると、Nginx は 504 Gateway Timeout などを返し、ユーザーに「サーバーが応答しません」と伝える。これはユーザーが直接体験するタイムアウトである。このとき、あくまでレスポンスを素早く返すだけなので、リクエストされた処理はサーバーで実行されてしまう。

2. Unicorn のタイムアウト:サーバー内部の安全装置

  • 目的: 暴走した worker プロセスを排除する。
  • 計測範囲: worker がリクエストの処理を開始してから完了するまでの時間(キューイング時間は含まない)。
  • 役割: この時間を超過すると、master プロセスがその worker を強制終了させ、新しい worker を起動する。これはサーバーの健全性を保つための内部的な仕組みである。

このように、ユーザーを守る Nginx と、サーバー自身を守る Unicorn という二層のタイムアウトが連携することで、システム全体の堅牢性が保たれているのである。


実践:after_fork フックはなぜ必要か?

Unicorn の仕組みを理解する上で、最も重要なのが fork の挙動である。これが原因で、しばしば予期せぬ問題が発生する。

問題のシナリオ

キャッシュやセッション管理のために、config/initializers/ 以下で Redis への接続をグローバル変数として初期化したとしよう。

# config/initializers/redis.rb
# やってはいけない例
$redis = Redis.new(host: 'localhost', port: 6379)

このコードは、Unicorn の master プロセスが起動する時に一度だけ実行され、Redis サーバーへの TCP 接続が確立される。

しかし、その後 master プロセスが worker を fork すると、問題が起きる。

fork の罠:接続は安全に共有されない

fork は親プロセスのメモリ空間やファイルディスクリプタ(TCP 接続もこれに含まれる)を子プロセスにコピーする。

その結果、全ての worker プロセスが、master プロセスが確立した全く同じ TCP 接続を共有しようと試みてしまう。複数のプロセスが単一の接続を安全な作法なしに使い回すと、データの送受信が衝突し、接続が破損したり、予期せぬエラーが発生する原因となるのだ。

これは、ActiveRecord のデータベース接続など、外部リソースへの永続的な接続全般に当てはまる問題である。

解決策:after_fork

この問題を解決するのが after_fork フックだ。このフックに書かれた処理は、各 worker が fork された後に、それぞれの worker 内部で実行される。

# config/unicorn.rb
after_fork do |_server, _worker|
  # DBコネクションの再確立
  if defined?(ActiveRecord::Base)
    ActiveRecord::Base.establish_connection
  end

  # Redisコネクションを各workerで再確立する
  $redis = Redis.new(host: 'localhost', port: 6379)
end

このように、fork 後に各 worker が自分自身の新しい接続を確立し直すことで、初めて全ての worker が安全かつ意図通りに外部リソースを利用できるのである。


まとめ:Unicorn はシンプルさを選んだアーキテクチャ

最後に、もう一つの鋭い疑問に答えておこう。
「worker が I/O で待っている間に、別のリクエストを処理すればもっと効率的なのでは?」

まさしく、その動きを実現するのが Node.js や Ruby の Falcon のような非同期(ノンブロッキング I/O)サーバーである。それらは I/O の待ち時間を徹底的に活用し、少ないリソースで高いスループットを実現する。

ではなぜ Unicorn はそうしないのか?それは、Unicorn が 「シンプルさ」と「堅牢さ」を優先した同期・ブロッキングモデルだからだ。コードは上から下に素直に実行され、プログラマは複雑な非同期処理を意識する必要がない。一つの worker がクラッシュしても、他の worker には影響を与えない。

どちらが優れているという話ではなく、設計思想の違いなのだ。Unicorn の仕組みを理解することで、なぜ after_fork のような設定が必要なのか、そしてどのようなアーキテクチャの選択肢があるのかが見えてくるはずである。

Discussion