Passenger+Rails3でセッションストアにmemcachedを使うとセッションが取り違えられる件


 Rails3 に限らずセッション情報の格納先に memcached を使うことは多いと思うが、Passenger + Rails3 でセッションストアに memcached を使うとセッションが切れたり他のユーザのセッションにすりかわったりする。実はPassenger のドキュメントにがっつり書かれている。

Because worker processes are created by forking from an ApplicationSpawner server, it will share all file descriptors that are opened by the ApplicationSpawner server. (This is part of the semantics of the Unix fork() system call. You might want to Google it if you’re not familiar with it.) A file descriptor is a handle which can be an opened file, an opened socket connection, a pipe, etc. If different worker processes write to such a file descriptor at the same time, then their write calls will be interleaved, which may potentially cause problems.

 何が問題かというと、memcached との接続を保持しているデスクリプタも fork() のタイミングで共有されてしまうこと。そのため、例えばほぼ同時に Passenger worker A と B から memcached にリクエストを送った場合、A 側のリクエストが若干早ければ memcached 側から送られた A 向けのレスポンスが A と B 双方で受け取ってしまい、本来は B には B 向けのレスポンスが行くはずが A のレスポンスとなってセッションの取り違いが発生してしまう。これの問題点は、エンドユーザからすると全く覚えのない別のユーザになってしまうことにある。

 他サービスに連動していると全く別のユーザで投稿されたりもするこの問題だが、ActiveRecord 接続ではこの問題は発生しない。これもドキュメントに書かれていて、

Note that Phusion Passenger automatically reestablishes the connection to the database upon creating a new worker process, which is why you normally do not encounter any database issues when using smart spawning mode.

Passenger がよしなにしてくれているらしい。

 これについては blog などでも取り上げられてはいるが、大体は Rails 2 向けの記事だった。Rails 3 以降ではセッションの取り扱いが Rails 本体から Rack に切り分けられているので、"On forking application servers and memcached in Rails" に書かれているように

# Reset the memcache-based session store
  ActionController::Base.session_options[:cache].reset if ActionController::Base.session_options[:cache].respond_to?(:reset)

では接続をリセットできない。

 なので、これでいいのかはわからないが、fork() したタイミングで ObjectSpace.each_objectRack::Session::Memcache インスタンスを全て持ってくる。Rack::Session::Memcache#pool で memcache_client インスタンスをいじれるので、reset メソッドを呼び出すようにして対応した。

# Reset memcached connections when
# - running on Phusion Passenger
# - process forked
if defined?(PhusionPassenger)
  PhusionPassenger.on_event(:starting_worker_process) do |forked|
    if forked
      ObjectSpace.each_object(Rack::Session::Memcache) do |obj|
        obj.pool.reset unless obj.pool.nil?
      end
    end
  end
end

 この問題はあまりアクセスがないと露見しないが、5万PV/日を越えたあたりから発生しやすくなってくる。大惨事の引き金になりやすいので、気をつけるべきポイントだと思う。