🦔

セッションのレースコンディションの課題

2023/07/02に公開

はじめに

同一のセッションIDのリクエストを複数同時に処理する場合に、レースコンディション(競合状態)が発生する可能性があります。
Rails開発においてセッションのレースコンディションと向き合うことになったので記事にしました。

システムや処理過程の問題であり、処理過程の出力結果がイベントなどの順序やタイミングと予期しない(かつ危険な)依存関係にある場合をいう

出典:競合状態 Wikipedia

セッションのレースコンディションの課題については、既にネット上に同様の事象についての報告がいくつかありました。

Railsは、ストアから読み込んだセッションをリクエストの処理中は保持し、処理が終わるとセッション全体をストアに保存します。そのため、ほぼ同時にリクエストが処理されると、タイミングによっては古い値で上書きされる可能性があります。

出典:Railsアプリを複数ワーカーで動かす時はセッションの先祖返りに注意

I've been investigating problems with flashes and figured out that redis-session-store doesn`t provide any handling of concurrent session changes.

出典:Race condition handling

筆者も動作確認のために下記のようなデモを作成しました。

サインアウトしたつもりが、サインアウトされないという事象が発生しています。

  1. 「slow api」を実行 ※このAPIはsleep 10で10秒要する処理にしている
  2. 「slow api」が実行中に、サインアウトの処理を実行
  3. ログアウトの処理が完了して、サインイン画面に遷移
  4. 「slow api」の処理が完了
  5. サインイン画面をリロードすると、サインイン後の画面に遷移
  6. つまり、サインイン状態のままである(サインアウトされていない)ことが確認できる

検証に使用したリポジトリは下記で公開しています。

https://github.com/kondo97/sample_devise

この記事では、まず何故Railsでセッションレースコンディションの問題が生じるかを説明します。その後、解決策について検討したいと思います。

なおセッションストア[1]には、Redisを用いることにします。加えてこの記事では、上記githubリポジトリのGemfileに記載されているgemを用いることを前提とします。

原因

結論から述べると、以下の2点が主な原因と言えます。

  1. 値の取得は、そのリクエストで最初にセッションを使用するタイミングに実行される
  2. 値の書き込みは、Rackミドルウェアのレスポンス処理の中で全てのリクエストにおいて実行される

これによって、下記の図のような流れでセッションストアのデータが意図しない値で上書きされてしまいます。

セッションデータXとセッションデータYは、下記のような構成になっています。

# セッションデータX
{ 
  warden.user.user.key: [[user_id], "***"], 
  warden.user.user.session: { last_request_at: 111111 } 
  csrf_token: "***"
}
# セッションデータY
# サインアウトの処理により認証データが削除されている
{ csrf_token: "***" }

サインアウトの処理によって削除されるはずのセッションの情報が、リクエストAによる書き込みで上書きされることで、元に戻ってしまうことが分かります。
大枠の流れは以上ですが、よりコードベースで理解を深めたいと思います。

セッションストアからのデータの取得

セッションストアからデータを取得するには、次のようなメソッドを実行します。

session[key] # 例:session[:csrf_token]

sessionメソッドは、ActtionDispatch::Request::Sessionクラスのインスタンスを返却します[2]。よって、session[key]は、ActtionDispatch::Request::Sessionクラスの[]メソッドを実行していることになります。

# actionpack-6.1.7.3/lib/action_dispatch/request/session.rb
# ActtionDispatch::Request::Sessionクラスの一部のメソッドを抜粋
def [](key)
  load_for_read!
  key = key.to_s

  if key == "session_id"
    id&.public_id
  else
    @delegate[key]
  end
end

def load_for_read!
  load! if !loaded? && exists?
end

def load!
  id, session = @by.load_session @req
  # id = sessionのID
  # session = Redisから取得するセッションデータ
  options[:id] = id
  @delegate.replace(session.stringify_keys)
  @loaded = true
end

@delegateにRedisから取得したデータを格納しています。
そして@loadedというインスタンス変数によって、同一リクエストで一度実行されるように管理されていることが分かります。

セッションストアへのデータの書き込み

セッションストアにデータを書き込むには、次のようなメソッドを実行します。

session[key] = "書き込みたいデータ"

これは、ActtionDispatch::Request::Sessionクラスの[]=メソッドを実行していることになります。

# actionpack-6.1.7.3/lib/action_dispatch/request/session.rb
# ActtionDispatch::Request::Sessionクラスの一部のメソッドを抜粋
def []=(key, value)
  load_for_write!
  @delegate[key.to_s] = value
end

def load_for_write!
  load! unless loaded?
end

def load!
  id, session = @by.load_session @req
  options[:id] = id
  @delegate.replace(session.stringify_keys)
  @loaded = true
end

[]=メソッドを実行したタイミングではセッションストア(Redis)への書き込みは実行されません。ActtionDispatch::Request::Sessionクラスの@delegateの値が書き換わるだけです。

実際に書き換わるのは、ActionDispatch::Session::RedisStoreというRackミドルウェアがレスポンス時に実行されるタイミングになります。ActionDispatch::Session::RedisStoreは、redis-actionpackのREADMEにあるとおり、下記の処理でミドルウェアスタックに追加されています。

# redis-actionpackのREADME.mdから記載
# initialize配下にファイルを作成して、初期化時に実行する
ActionController::Base.session_store = :redis_store, # セッションストアにRedisを指定
  servers: %w(redis://localhost:6379/0/session),
  expire_after: 90.minutes,
  key: '_my_application_session',
  threadsafe: false,
  secure: true
# bin/rails middleware
# 略
use ActionDispatch::Session::RedisStore 👈追加
# 略
run SampleDevise::Application.routes

ActionDispatch::Session::RedisStoreクラスは、次のような継承関係にあります。

ActionDispatch::Session::RedisStore < Rack::Session::Redis < Rack::Session::Abstract::ID < Rack::Session::Abstract::Persisted

ここで確認したいのは、Rack::Session::Abstract::Persistedクラスです。このクラスに、Rackミドルウェアのcallメソッドが定義されています。

# rack-2.2.7/lib/rack/session/abstract/id.rb
# ``Rack::Session::Abstract::Persisted``クラスから一部メソッドを抜粋
def call(env)
  context(env)
end

def context(env, app = @app)
  req = make_request env
  prepare_session(req)
  status, headers, body = app.call(req.env)
  res = Rack::Response::Raw.new status, headers
  commit_session(req, res) 👈
  [status, headers, body]
end

def commit_session(req, res)
# 省略
  if not data = write_session(req, session_id, session_data, options) 👈
    req.get_header(RACK_ERRORS).puts("Warning! #{self.class.name} failed to save session. Content dropped.")
  elsif options[:defer] and not options[:renew]
    req.get_header(RACK_ERRORS).puts("Deferring cookie for #{session_id}") if $VERBOSE
  else
# 省略
end

write_sessionメソッドは、Rack::Session::Abstract::Persistedクラスの子クラスであるRack::Session::Redisクラスでオーバーライドされていて、Redisへの書き込みを実行しています。

# redis-rack-2.1.4/lib/rack/session/redis.rb
# Rack::Session::Redisより一部メソッドを抜粋
def write_session(req, sid, new_session, options = {})
  with_lock(req, false) do
    with { |c| c.set(sid.private_id, new_session, options.to_hash) }
    sid
  end
end

あまり処理を追いすぎると本筋から逸れてしまうため、この記事ではここまでにしておきます。
ポイントなのは、セッションストアであるRedisへの書き込みは、Rackミドルウェアのレスポンスのタイミングで、全てのリクエストで実行される[3]ということです。

※いつだったかメモしたRackミドルウェアの図。リクエストとレスポンスのタイミングで処理が挟まれている。

ところで、セッションストアにCookieやデータベースを使用する場合においても、Rack::Session::Abstract::Persistedクラスを継承したクラスを作成することでセッションを扱います。その為、Redis以外のセッションストアでもレースコンディションの問題が発生する可能性があります。しかしながら、筆者においてはそれらの動作検証や詳細な調査は行っていないので、可能性があるとだけ述べておきます。

解決策

解決策について検討したいと思います。同じような事象について報告はあったのですが、明確な解決策を示したものは見つかりませんでした。
この記事では、次のような解決策を検討します。

  1. 楽観的ロック
  2. 上書きではなく更新する
  3. サインアウト処理を待機させる

なお悲観的ロックを用いる場合、リクエストの待機が必要となることから、明らかにユーザーの利便性を損なうためここでは選択肢に入れていません。

ただし、いずれにせよ明確な解決策は筆者においても見つかっていないこと、またそもそもレースコンディションが問題とならないアプリケーションでは、躍起になって向き合う必要のないことを先に述べておきます。

楽観的ロック

他の処理と競合してはならないトランザクションにおいて、開始時には特に排他処理など行なわず、完了する際に他からの更新がされたか否かを確認し、もし他から更新されてしまっていたら自らの更新処理を破棄し、エラーとする。

出典:楽観的並行性制御 Wikipedia

ロックキーとして、セッションデータに更新日時を保持させます。Rackミドルウェアでの書き込みの際に、更新日時にずれがないことを確認します。もしずれがある場合は、他のリクエストで更新が行われたことになります。

セッションデータYが既に書き込まれた状態で、セッションデータXを書き込みが発生した場合、更新日時を確認することで上書きを防ぎます。

# セッションデータX
{ 
  warden.user.user.key: [[user_id], "***"], 
  warden.user.user.session: { last_request_at: 111111 } 
  csrf_token: "***"
  timestamp: 1688287468387 👈古い
}
# セッションデータY
# サインアウトの処理により認証データが削除されている
{ 
  csrf_token: "***",
  timestamp: 1688287468388 👈新しい
}

一方でリクエストAは、"失敗”と定義することは難しいという問題があります。セッションの更新日時にずれがあったからといって、アプリケーションコード上では既にDB操作などは実行されてしまっています。また、セッションデータを"書き込まない"という単純な処理では収まらない可能性があります。例えば、セッションデータがリクエストAの実行中に下記のように変更されている場合はどうでしょうか。

{ 
  warden.user.user.key: [[user_id], "***"], 
  warden.user.user.session: { last_request_at: 111111 } 
  csrf_token: "***"
  new_key: "***"
  timestamp: 1688287468387 👈古い
}

単純に"書き込まない"としてしまうと、new_key: "***"のデータが失われてしまいます。この場合の対応としては、new_key: "***"の書き込みは、アプリケーションコード上で即時にRedisに書き込むといった方法が考えられます。

ところで、ロックキーとして更新日時の例を示しましたが、同一秒で操作が行われた場合は対応できません。楽観的ロックでは、一般的にVersionカラムを利用が推奨されるようです。しかし、Versionカラムは桁数がどれだけ増加するか読めないため、データ量が予想外に増加してしまう可能性があります。ここではミリ秒単位で管理する更新日時を採用しました。

上書きではなく更新する

redis-session-store[4]でレースコンディションに関する解決策の提案するissueが立てられていました。

https://github.com/roidrage/redis-session-store/pull/95

要約すると下記の通りです。

  1. セッションデータの書き込み時に、現在のセッションストアのデータを取得する
  2. リクエストで取得したデータと、1で取得したデータの差分を確認する
  3. 差分が存在した場合は、両者をマージした値をセッションストアに書き込む
if session_current && session_current != session_initial
  session_data = session_current.deep_merge session_data
end

しかし、これらの対応ではこの記事において示したサインアウトの問題は解決されません。最終的にセッションストアには下記のようなデータが格納されるはずです。

{ 
  warden.user.user.key: [[user_id], "***"], 
  warden.user.user.session: { last_request_at: 111111 } 
  csrf_token: "***"
}

一方で認証データのキーについては、マージされないという扱いにすればどうでしょうか。

if session_current && session_current != session_initial
  session_data.delete(:warden.user.user.key) unless session_current.key?(:warden.user.user.key)
  session_data.delete(:warden.user.user.session) unless session_current.key?(:warden.user.user.session)
  session_data = session_current.deep_merge session_data
end

認証データといった特定のキーに関しては上書きを防ぎ、その他のキーは追加・更新される処理に修正してみました。

なお、執筆時点(2023/7/2)では、上記issueはwipのままになっています。

サインアウト処理を待機させる

リクエストAとサインアウトの実行順序は下記の通りでした。

  1. リクエストAの実行
  2. サインアウトの実行
  3. サインアウトのレスポンス
  4. リクエストAのレスポンス 👈ここで上書きされる

実行順序を下記のように制御することで上書きを防ぐ方法を検討します。

  1. リクエストAの実行
  2. リクエストAのレスポンス
  3. サインアウトの実行
  4. サインアウトのレスポンス

サインアウトの処理を、リクエストAの完了まで待機する実装に変更します。

まず、サインアウトを除く全てのリクエストは自らが実行中であることをセッションストアに常に書き込みます。なおここでの書き込みや取得はsession[]=[]を使用するのではなく、即時にセッションストアを操作する処理をイメージしています。

# application_controller
before_action: handle_execute

def handle_set_execute
  # セッションストアにキー(execute)を書き込む
  # json: { warden.user.user.key: ***, warden.user.user.session: ***, execute: true }
end

また処理が完了していたら、初めに追加したキーを削除します。

# application_controller
after_action: handle_execute

def handle_delete_execute
  # セッションストアからキー(execute)を削除する
  # json: { warden.user.user.key: ***, warden.user.user.session: *** }
end

サインアウトは、他のAPIが実行中であれば待機させます。

# devise-4.9.2/lib/devise/controllers/sign_in_out.rbのメソッドをオーバーライドするイメージ
def sign_out
  execute = # セッションストアからexecuteを取得する
  count = 0
  while execute || count > 10 # 最大で10秒待機する
    count += 1
    sleep(1)
  end
  # 既存のサインアウト処理
end

while文の使用は無限ループの危険性があります。また最大で何秒待機させるかという設定も必要になるので、完全には問題を解消できません。そして、サインアウトに何秒もかかってしまうため、ユーザー体験を損なう可能性があります。

おわりに

セッションのレースコンディションについて、原因と解決策の検討を行いました。しかし冒頭で示したような動作が、アプリケーションで重要な問題とならないのであれば向き合う必要のない課題だと思います。他方でセッションがレースコンディションという課題を抱えていることを認識しておくことは重要だと感じます。例えば、セッションストアで新たにキーを管理したくなったときは、レースコンディションの課題を検討するべきです。
筆者は開発経験が浅いので分かりませんが、Rails以外の他のフレームワークでも同様の事象は起きるのではないかとも考えています。セッションのレースコンディションについて、世の開発者がどのように認識して扱っているのか、引き続き調査を進めたいと思います。

参考

排他制御(楽観ロック・悲観ロック)の基礎
Redisと排他制御

脚注
  1. 本文内で後述しますが、Cookieやデータベースを使用する場合でも、レースコンディションは発生する可能性があります。 ↩︎

  2. この記事の範疇を超えるため詳しくは省略しますが、sessionメソッドは、warden-1.2.9/lib/warden/session_serializer.rb及びactionpack-6.1.7.3/lib/action_controller/metal.rbに定義されています。 ↩︎

  3. 実はサインアウトの処理については即時でRedisへの書き込みを行っています。ただし、この場合においても本文中で示した上書きの流れに変更はありません。 ↩︎

  4. redis-session-storeは、セッションの機能を提供するgemです。一方でこの記事ではredis-actionpackを用いています。両者の違いについては、双方のREADMEをご確認ください。 ↩︎

GitHubで編集を提案
株式会社スタメン

Discussion