redis.jpg

Redis が好きで使いたい人、Redis が要件上必要な人(僕)向けに Rails で Redis とうまく付き合っていく上で抑えておくポイントを書いてみました。Redis サーバそのものをうまく扱う方法はこの記事では紹介しません。あくまで Rails で利用する部分に留めます。

Redis サーバが落ちた時・容量不足になった時、どうする?

Redis だけに限った話ではないですが、Redis サーバが落ちたり、容量が不足してエラーになった時に、アプリケーションをどのように動かすのか、または落とすのかを事前に設計する必要があります。

例えば、キャッシュで利用している場合は、Redis サーバに接続できなかったとしても、アプリケーションは落とさずにそのまま動作させる方が良いでしょう。多少パフォーマンスは落ちますが。ただ、パフォーマンスへの影響が深刻でキャッシュがないことで雪崩式に他のシステムへの悪影響を及ぼすのであれば潔くアプリケーションごと落とした方が良かったりもします。

また、セッションで利用している場合は、セッションを使った機能に関しては落とした方が良かったりしますが、落とし方にもいくつかのパターンがあると思います。例えば、セッションに全く関係のないページの閲覧は最低限できるようにするとか、ログインページなどセッションは必要だけど閲覧だけはできて、ログインしようとしても失敗してエラーメッセージを表示するだけ…など。ここら辺はアプリケーションごとに決めるところです。

キャッシュで使う

Rails では標準でキャッシュストアの Redis をサポートしています( redis or hiredis gem は必要です ) 。使い方も簡単です。

config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }

これだけで Rails.cache.fetchRails.cache.write Rails.cache.read などのメソッドを通じで Redis にキャッシュしたデータを読み書きすることができます。

ActiveSupport::Cache::RedisCacheStore は Redis サーバが落ちても問題ない

ActiveSupport::Cache::RedisCacheStore は Redis サーバに接続した際にエラーが起きても無視するように作られています。つまりページ表示は遅くなるかもしれませんがエラーにはなりません。

def failsafe(method, returning: nil)
  yield
rescue ::Redis::BaseError => e
  handle_exception exception: e, method: method, returning: returning
  returning
end

def handle_exception(exception:, method:, returning:)
  if @error_handler
    @error_handler.(method: method, exception: exception, returning: returning)
  end
rescue => failsafe
  warn "RedisCacheStore ignored exception in handle_exception: #{failsafe.class}: #{failsafe.message}\n  #{failsafe.backtrace.join("\n  ")}"
end

https://github.com/rails/rails/blob/931f95869510b5f80c6aa6c64bdbcfd64a8def49/activesupport/lib/active_support/cache/redis_cache_store.rb#L481-L494

デフォルトではロガーが設定されていると、ロガーを通じて Redis サーバのエラーログを出力します。

ActiveSupport::Cache::Store.logger = Logger.new(STDOUT)

ただ、それだけだと検知できないこともあるので、エラーハンドラーを渡して Sentry などエラー収集サービスに送ることもできます。

cache_servers = %w(redis://cache-01:6379/0 redis://cache-02:6379/0)
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'],
  error_handler: -> (method:, returning:, exception:) {
    # Report errors to Sentry as warnings
    Raven.capture_exception exception, level: 'warning',
      tags: { method: method, returning: returning }
  }
}

話はそれますが、Rails.cache を永続化前提のロジックを組んでいると、アプリケーションの動作自体が壊れる可能性は当然あります…。

セッションで使う

Redis をセッションストアとして利用する場合、おそらく2つの方法があります。

  • redis-rails or redis-actionpack gem を利用する
  • redis-session-store gem を利用する

redis-rails or redis-actionpack の挙動は、落ちる

redis-railsredis-actionpack は Redis サーバが落ちると例外をはいて Rails アプリケーション自体が落ちます。これは、 redis-rails がキャッシュやセッションなど用途が広く、落ちない挙動をデフォルトにするとややこしいことになるから、ということのようです ( 参考 issue ) 。ちなみにキャッシュ側で使う分にはデフォルトで例外ははきません。セッションを利用する場合のみです。

(2つの Gem を書いていますが、もしセッションでしか利用しないなら redis-actionpack を直接使う方が良いようです)

redis-session-store の挙動は、無視する

redis-session-store は Redis サーバが落ちても例外をはかず Rails アプリケーションはそのまま動きます。

def get_session(env, sid)
  sid && (session = load_session_from_redis(sid)) ? [sid, session] : session_default_values
rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e
  on_redis_down.call(e, env, sid) if on_redis_down
  session_default_values
end

https://github.com/roidrage/redis-session-store/blob/37fd1692100016d24cb0a1f45f4c38d315017660/lib/redis-session-store.rb#L94-L99

ActiveSupport::Cache::RedisCacheStore と同様に on_redis_down で例外処理を渡すことが可能です。

この場合、Rails アプリケーションは Redis サーバが反応なくても session オブジェクトにアクセスできますし、何事もないように動きます。ただし、 session は単なるハッシュオブジェクトでしかありません。

def session_default_values
  [generate_sid, USE_INDIFFERENT_ACCESS ? {}.with_indifferent_access : {}]
end

https://github.com/roidrage/redis-session-store/blob/37fd1692100016d24cb0a1f45f4c38d315017660/lib/redis-session-store.rb#L90-L92

そのため、セッションに依存したロジックを書いている場合は、そのままで問題がないのか、もしくは例外処理を入れてエラーページへ飛ばすのかを決める必要があります。

まとめ

個人的にはキャッシュは Rails デフォルトで、セッションは redis-session-store を利用し、適宜例外処理を入れるのが良いかなと思っています。Redis サーバに限らずデータベースサーバなど、アプリケーションが依存しているミドルウェアが障害を起こしたときに、どのような振る舞いをすべきかはちゃんと考えて設計をしていきたいですね。