ITエンジニアによるITエンジニアのためのブログ

Ruby on Rails開発環境でレースコンディションを再現

本稿では、Ruby on Railsの開発環境でレースコンディションを再現する方法を解説します。

  1. レースコンディションとは
  2. レースコンディションの再現方法
    • 複数のRailsサーバーの起動
    • デバッガでブレークポイント設置
    • ブラウザで複数リクエスト送信
  3. まとめ

レースコンディションとは

レースコンディションとは、システムに複数のアクセスがほぼ同時に起こることにより、本来想定していたプログラムロジックとは異なる挙動が起きてしまい、結果としてデータ不整合が起きることを指します。

例えば、会議室を予約できるシステムがあるとします。同じ会議室で同じ時間帯の予約は一人しかできません。つまり、101号室という部屋に9時〜10時の予約をできるユーザーは一人のみということです。

この制限を設けるために、システムで予約を受け付ける前に既に同じ時間帯に予約が入っていないかをチェックする仕組みをコード上で設けます(Railsでのモデル・バリデーションです)。

通常であれば、Aというユーザーが予約した後であれば、Bという別のユーザーが同じ時間帯の同じ部屋を予約することは、それがたったの1秒後でも不可能です。

ただし、Aさんの予約処理が完了する前に、Bさんの予約処理もバリデーションを通過してしまうと、両方の予約がデータベースに登録され、重複予約が発生します。これがレースコンディションです。

レースコンディションの再現方法

Railsに限らずウェブアプリケーションでは、レースコンディションではないか、と疑われるバグが時々起こります。

バグ調査の第一歩はバグの再現ですが、ステージング環境ではレースコンディションを確実に起こすタイミングを取るのが難しいという難点があります。ブラウザーで2つタブを開いてほぼ同時にリクエストを送っても、サーバー側でそれがレースコンディションになるとは限らないからです。

ローカル開発環境ではデバッガを使ってブレークポイントを設置してプログラム実行を止めることができますが、そのままだとデバッガは1プロセスにつき1つしか起動できません。

複数のRailsサーバーの起動

そこで、ローカル開発環境でターミナルウィンドウを2つ立ち上げ、それぞれのウィンドウでRails serverコマンドを実行することで複数のサーバープロセスを起動します。

ただし、コマンドで何もオプションを指定しないと2つ目のサーバー起動時にエラーが表示されてしまいます。エラー内容はrackサーバーに何を使っているかやバージョンによって若干異なりますが、サーバーが既に実行中というエラーか、アドレス(ポート)が既に使われているというエラーのどちらかになります。

# 1つ目のターミナルウィンドウ
$ rails server
=> Booting Puma
...
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
* Listening on tcp://[::1]:3000
Use Ctrl-C to stop

1つ目のエラーは、サーバーが既に実行中というものです。Rails serverを起動するとtmp/pidsディレクトリにserver.pidというプロセスIDが記載されたファイルが作成されます。それが存在するということは、既に1つ目のターミナルによってサーバー実行中なので、もう一つサーバーを起動する必要はありませんよ、というエラーです。

# 2つ目のターミナルウィンドウ
$ rails server
=> Booting Puma
...
A server is already running. Check path_to_app/tmp/pids/server.pid.
Exiting

もう1つのエラーは、アドレス(ポート)が既に使われているエラーとなる場合です。ローカルホスト(127.0.0.1)の3000番ポートが既に1つ目のサーバーに使われているために起こります。

# 2つ目のターミナルウィンドウ
$ rails server
=> Booting Puma
..
Exiting
...
initialize': Address already in use - bind(2) for "127.0.0.1" port 3000 (Errno::EADDRINUSE)

これらのエラーを解決するためには、ポートとpidをそれぞれ1つ目のサーバーとは別のものを指定して2つ目のサーバーを起動する必要があります。

# 2つ目のサーバーを起動。ポートは3001番、プロセスIDファイルはserver2.pidとする。
$ rails server -p 3001 -P tmp/pids/server2.pid

これで、エラーとならずに複数のサーバーが立ち上がります。

httpsでサーバー実行したい場合は、以下の通りバインディングでポートを指定します。

# バインディング(-b)で3001番ポートを指定
rails s -b 'ssl:https://engineerjutsu.com:3001?&key=your_local_domain.key&cert=your_local_domain.crt' -P tmp/pids/server2.pid

デバッガでブレークポイント設置

そして、レースコンディションが疑われるコード箇所にデバッガでブレークポイントを設置します。ここではbyebug gemを使いますが、好きなデバッガで大丈夫です。

上記の会議室予約システムの例だと、バリデーションを通過してレコード保存前の地点が適切なブレークポイントとなります。その場合、ActiveRecordのafter_validationやbefore_saveコールバックを使ってバリデーションを通過した後にブレークポイントを設置します。

class Reservation < ApplicationRecord
  before_save do
    debugger
  end
end

ブラウザで複数リクエスト送信

  1. ブラウザーのタブを2つ開きます。
  2. 1つ目のタブで、1つ目のサーバーのURL(例:localhost:3000)にアクセスし、デバッガが起動するのを待ちます。
  3. 2つ目のタブで、2つ目のサーバーのURL(例:localhost:3001)にアクセスします。
  4. デバッガ内で上記2つのリクエストのプログラム実行を再開し、データベースに本来想定していなかった2つのレコードができることを確認します。

上記の流れを上から下に時系列にまとめると、以下の図のようになります。

リクエスト1リクエスト2
バリデーション通過
バリデーション通過
DBレコード作成
DBレコード作成

こうすることで、ローカル開発環境で確実にレースコンディションを再現することが可能です。

まとめ

複数のRailsサーバーを立ち上げデバッガを使うことで、レースコンディションをローカル環境で再現する方法をまとめてみました。