砲撃に耐える Web サービス

この記事は、そろそろ一般向けにサービスリリースしてみようかなと考えているエンジニア向けに書きました。
説明に Rails を用いていますが、考え方自体は Web アプリ一般に応用可能です。

結論 「cache publicに設計しよう」

出来るだけ多くのページを cache public にしましょう。
砲撃の来るページは cache public に出来るはずです。

この説明だけで意味の分かった方には以下の記事は読む価値はありません。時間を有意義につかってください。ごきげんよう。

砲撃に耐えよう

サービスリリースして、バグもなく順調に事が運び、運が良ければバズったり、どこぞの大手メディアに取り上げられる日が来るでしょう。
その直後、あなたの Web サービスには大量のアクセスが殺到します。
これが俗にいう「砲撃」です。サーバが砲撃に耐えられなければ、やってきたユーザはつまらないエラーページを見て去っていきます。
次の幸運の日まで、彼らが戻ってくることはありません。

砲撃に耐えるために、サーバを増強したりDBを分散したりいろんな方法論がすでに紹介されていますが、それより先にやるべきことはキャッシュの最適化です。
ここで、キャッシュについて整理しておきましょう。

キャッシュにもいろいろありますが、サーバサイドキャッシュとクライアントサイドキャッシュの2つに大きく分けることが出来ます。
(この名称は私が説明のために勝手に命名したので、もっと正しい名称をご存じの方は教えて下さい。)

サーバサイドキャッシュ

DBのクエリ結果や、 render した html をサーバのメモリ上に保存して再利用する技術です。
redis や memcached が使われます。
Rails の機能としては、 Russian Doll Caching というのが有名です。
ググればいくらでも記事が出てくるので詳細は割愛。

クライアントサイドキャッシュ

html や画像をブラウザが URL 単位で保存し、同じ URL へのアクセスが起きた時に通信を省略する機能です。
html ヘッダにいくつかの情報をのせておくことで実現します。
Rails の機能としては、 expires_in , refresh_when , stale? があります。
また、 sprockets が提供する assets は、デフォルトでクライアントサイドキャッシュがばっちり効くように設定されています。

ブラウザとアプリケーションサーバの間にキャッシュサーバが置かれている場合もあります。

このような場合、 html ヘッダが適切に設定されているとキャッシュサーバがアクセスを捌いてくれるので アプリケーションサーバまで砲撃が届かなくなります。 キャッシュサーバはアプリケーションサーバと比べると仕組みが単純なので、一台でもかなりのアクセスに対応可能な上、増設も簡単です。
キャッシュサーバを提供してくれるクラウドサービスはいろいろあります。自前で建てる場合は varnish などがあります。
大きな会社だと社内から社外へのキャッシュサーバを用意していたり、プロバイダが帯域を節約するために用意している場合もあります。

Rails では、先に上げた public 以下や assets を除き、デフォルトではこれらのキャッシュサーバをあまり有効に使ってくれません。
今日の本題は、キャッシュサーバを最大限活用しましょう、というお話です。

キャッシュパブリック cache public とは?

(この名称も私の勝手な命名です。)
「動的なページ」とか「静的なページ」という言い方はよく聞きますね。
html を書き出すために db にアクセスが必要なページが「動的」、不要なページが「静的」です。
Rails では、public/ 以下のファイルおよび assets は「静的」です。
静的なページは全て cache public です。

が、動的なページも cache public に出来る場合があります。
cache public に出来るページとは「ある瞬間に100人からアクセスが有った時、100人全員に同じページを返却できる」ようなページのことです。

例えば twitter のタイムラインは cache public ではありません。
ログインしているユーザそれぞれ、見えるものがばらばらだからです。

では、特定のツイートのページ (例) はどうでしょうか。

リツイート数やお気に入り数は刻一刻変動するのでこれは動的なページですが、ある瞬間に100人からアクセスが有った時、100人全員が同じリツイート数やお気に入り数を見るはずです。

なので、このページは「ほぼ」cache public です。
「ほぼ」と書いたのは、ユーザがログイン済みの場合には一部にユーザ情報が出てるからです。

この、ごく僅かなユーザ別情報の部分を JavaScript で書き出すようにし、かつ、ユーザ名等の個別情報を ajax で別の URL から取るようにすることで、このページを完全な cache public にすることが出来ます。

砲撃の来るページは cache public に出来ます。

マイページのような、ログインユーザ毎に違う内容が表示されるページのURLは紹介する意味が無いので砲撃の対象になりえません。
(拡散するとすればスクリーンショットの形になるでしょう。)

それ以外の、ブログの記事ページや商品の紹介ページが、砲撃のターゲットです。これらのページから、ログインユーザの個別情報を切り分けてやることで、 cache public なページにすることが出来ます。
そうすることで、砲撃の殆どはキャッシュサーバが打ち返し、アプリケーションサーバが応答すべきはログインユーザの個別情報を含む小さな json だけになります。
これだけでもかなり砲撃に強くなりますが、万が一アプリケーションサーバが砲撃に耐え切れずエラーになっても、コンテンツの主要な部分はユーザに届くのです。
砲撃でアクセスしてくるユーザの殆どはログインアカウントなんて持ってませんし。

cache public な設計の実際

routes.rb を書いてURL設計をする際に、どのURLが砲撃の対象になりうるかを吟味しましょう。
controller 別に切り分けられれば実装がしやすくなります。
path で切り分けられるとさらに理想的です。

cache public にするつもりのページでは session と flash を使わないように実装します。
ApplicationController に current_user を定義するのをやめると良いでしょう。

cache public なページが current_user にアクセスしてユーザ固有情報を含む html を返却すると、発見困難かつ悲惨な事故に繋がります。事故を未然に防ぐため、cache public にするページでは current_user にそもそもアクセス出来ないようにすることを強くお薦めします。

cache public にするつもりの controller には、以下の宣言を追加します。

  before_action do
    expires_in(1.second, public: true)
  end

これで、この controller の全てのレスポンスには Cache-Control: public という html ヘッダが付与され、あとはキャッシュサーバがおおむねうまいことやってくれます。
「え、1秒でいいの?」殆どの場合はOKのはずです。1秒の寿命を指定すれば、秒間何千アクセスの砲撃が来ようとも、アプリケーションサーバには「1秒に1アクセス」しか届かなくなるわけですから。
ここで欲張って寿命を1分とか1時間にしても、dbを書き換えた時の応答が悪くなるだけです。

勘違いしがちなのですが、 current_user を使っても

  before_action do
    expires_in(1.second, public: true) unless current_user
  end

として、未ログイン時だけ cache public にすればいいのでは、というのはうまくいきません。
こうすると、ログイン中のユーザのページが別のユーザに配信されてしまうことは防げますが、ログイン中のユーザに未ログインのページが配信されることになってしまいます。
cache public にするかどうかは必ずURL毎に決定する必要があります。

flash が使えないことにも注意します。ログアウト時に

redirect_to root_path, notice: "ログアウトしました"

とかやりがちですが、トップページが cache public なら、誰かのトップページだけに「ログアウトしました」を表示することは出来ません。
このようなメッセージも、 javascript で描画するようにするのが良いでしょう。
flash は cache public なページヘのアクセスでも容赦なく消されるのであてになりません。

(session[:messages] ||= []) << 'ログアウトしました'

のようにして、 session に積んでおきましょう。

Rails のよくあるパターンでは、ユーザのログインを sessions#new と #create に、ログアウトを #destory に置くことが多いでしょうから、 sessions#show でユーザ情報の json を取れるようにすると良いでしょう。

# routes.rb
resource :session, only: [:new, :create, :show, :destroy]

単数形の resource なので、 :id 無しの GET /session が #show に対応します。
ログインユーザのユーザ名、アイコンのURL等を json で返却し、Javascript で描画します。
session[:messages] は返却と同時にクリアしておくようにすると、 flash と同じような使い勝手になります。

SessionsController の他、マイページ等、 cache private なページだけが current_user にアクセスできるようにしましょう。

varnish と cookie

メジャなキャッシュサーバとして varnish がありますが、 varnish のデフォルト動作として、「リクエストに cookie がついてきた時はキャッシュを返さない」というルールがあります。
このデフォルト動作を活かしたまま、期待通りキャッシュを効かせるためには、以下のように path を設定します。

# config/initializers/session_store.rb
Myapp::Application.config.session_store :cookie_store, key: '_myapp_session', path: '/users'

そしてもちろん、ログイン・ログアウト他、ユーザ別のレスポンスをする全てのページを /users および /users/ 以下に配置する必要があります。
URL をこのように綺麗に切り分けられない場合は、 cache public なページヘのリクエストで予め cookie を削除するように varnish に設定が必要となります。
詳細は VCLExampleCacheCookies – Varnish を参照してください。

まとめ

  • 砲撃を受けうるページは cache public にしよう
  • その為に、ログイン情報や flash message は分離し、 javascript で描画しよう
  • 事故を未然に防ぐため、 cache public にしたアクションからは current_user 等へアクセスできないようにしよう