[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

WEBアプリケーションにおいて「あなたはどこにいますか?」(Flask, Bottleでのセッションデータの格納先)

WEBアプリケーションを作ると、ログインしているか?や買い物かごに何が入っているのか?といった状態を管理する必要があります。通常はセッション(session)データに格納しますが、そもそもこのセッションデータがどこにあるのかを調べました。

何故調べたのか

もともとWebLogic等のJavaアプリケーションサーバを触っていました。この時は、クライアントのクッキーにセッションIDを格納し、実際のデータ(状態)はサーバ側のプロセス内メモリーに持たせることが一般的でした。 ※WebLogicにはセッションデータをDBに永続化したりすることもできた

このようにAPサーバのプロセス内にデータが格納されている、APサーバが複数台構成だった場合1回目と2回目で別のサーバにアクセスが振り分けられるとセッションデータが見つからず問題が起きます。これはスケールにも影響します。

で、そもそもPythonのWEBアプリケーションでスケールを考える前に、どこにデータが格納されているかが大事だったわけです。あ、今回もISUCON関連です。

検証の前提

今回は以下のマシン構成です。

  • クライアント(ホストマシン)
  • devntu1(VM:Ubuntu)
    • nginx(port:80)
      • ロードバランサ役
    • nginx(port:8081)
      • uWSGIのフロント役
    • uWSGI(APサーバ)
  • devntu2(VM:Ubuntu)
    • nginx(port:8081)
      • uWSGIのフロント役
    • uWSGI(APサーバ)

クライアントからはdevntu1のポート80にアクセスし、その処理がいずれかのサーバのuWSGIにディスパッチされます。

Flaskの場合

以下のようなアプリケーションでテストしました。

# coding: utf-8
from flask import Flask, session
from socket import gethostname

app = Flask(__name__)
app.secret_key = 'si-kurettoki-'

@app.route('/', defaults={'file_path': ''})
@app.route('/<path:file_path>')
def server_static(file_path):
    # キーがなければ初期化
    if "counter" not in session:
        print("new session")
        session["counter"] = 0
    # このセッションでのアクセス数をカウントアップ
    session["counter"] += 1
    print(session, "at {}".format(session["counter"]))
    return 'i am {} requested at {} and counter {}.\n'.format(gethostname(),file_path, session["counter"])

セッションを記憶し、アクセス回数をカウントしています。レスポンスにはホスト名とカウント数を含めているので処理がいずれのサーバで行われたかがわかります。

では実際にhttp://devntu1/flasktestに4回アクセスするとレスポンスは以下の様になりました、

i am devntu1 requested at flasktest and counter 1.
i am devntu2 requested at flasktest and counter 2.
i am devntu2 requested at flasktest and counter 3.
i am devntu1 requested at flasktest and counter 4.

処理されるサーバは切り替わっていますが、カウントは一貫して上昇しておりセッションデータがサーバをまたがって保たれていることがわかります。

ドキュメントやFlaskのコードをみていると、どうやらセッション識別情報だけでなく、実際のデータも暗号化してクライアントのクッキーに格納しているようです。

クライアントサイドにデータも含めて存在するため、いずれのサーバで処理されても一貫した結果が戻っていたわけですね。

bottleの場合(beaker)

bottleではデフォルトでセッション機構はないため、WSGIアプリケーションでセッションやキャッシュを使えるようにするbeakerを導入することが一般的です。

$ pip install beaker

bottleの場合でFlaskと同じような処理をするには以下のようなコードになります。

# coding: utf-8
import bottle
from bottle import Bottle, route
from beaker.middleware import SessionMiddleware
from socket import gethostname

app = Bottle()

# セッションの設定
session_opts = {
    'session.type': 'file',
    'session.data_dir': '/tmp',
    'session.cookie_expires': True,
    'session.auto': True
}


@app.route('/')
@app.route('/<file_path:path>')
def server_static(file_path=''):
    # セッションの取得
    session = bottle.request.environ.get('beaker.session')
    # キーがなければ初期化
    if "counter" not in session:
        print("new session")
        session["counter"] = 0
    # このセッションでのアクセス数をカウントアップ
    session["counter"] += 1

    print(session, "at {}".format(session["counter"]))
    return 'i am {} requested at {} and counter {}.\n'.format(gethostname(),file_path, session["counter"])


# セッション管理を有効化する
app = SessionMiddleware(app, session_opts)

この状態でhttp://devntu1/bottletestへアクセスしてみます。

i am devntu2 requested at bottletest and counter 1.
i am devntu2 requested at bottletest and counter 2.
i am devntu1 requested at bottletest and counter 1.
i am devntu2 requested at bottletest and counter 3.

3回目のアクセスがdevntu1で処理され、それまでと回数に一貫性が取れなくなっています。また4回目は3が戻っていることから、サーバごとにセッション情報が管理されてしまっていることがわかります。SessionMiddlewaresession.typefileにしており、'session.data_dir': '/tmp'としてセッションデータを各サーバのローカルディスクにしていますので当然ではありますが、この構成では複数台にスケールすることが難しいことがわかりました。

bottleの場合(beaker + redisバックエンド)

'session.type': 'file'の場合は上述のように、サーバをまたがってはくれませんでしたが、逆に、セッションの格納先を各サーバから参照できる共有領域にできれば問題ないともいえます。そこで、格納先をRedisにしてみたいと思います。

SessionMiddlewareのバックエンドをRedisに変更するためにはbeaker_extensionsという追加のパッケージが必要です。PyPiにも登録されていますがバージョンが古いのでGitHubから直接インストールしてみます。

$ pip install git+git://github.com/didip/beaker_extensions.git

さて、Redisは事前に用意しておけばコードはほとんどそのままです。session_optsだけ変更します。

# セッションの設定
session_opts = {
    'session.type': 'redis', # redisに格納する
    'session.url': 'devntu1:6379', # 格納するredis のURL
    'session.auto': True  # 自動でセッションデータを保存する
}

同じようにアクセスしてみます。

i am devntu2 requested at bottletest and counter 1.
i am devntu1 requested at bottletest and counter 2.
i am devntu2 requested at bottletest and counter 3.
i am devntu2 requested at bottletest and counter 4.

今度はサーバをまたがってもカウントが一貫していることが確認できます。セッションデータが期待通り、各サーバから参照できるRedisに格納されたことでセッションデータが共有できています。

まとめ

お手軽さという意味ではFlaskの形式は望ましいでしょう。ISUCONでもこれはメリットになりそうです。一方で、実際のアプリケーションを考えるとカート情報やログイン情報をサーバ側で管理できるという点を踏まえ、Redis等に格納するのが良さそうです。The Twelve-Factor Appにもセッション情報等はデータストアに格納すべきとありますので、実際のシステムではこのあたりを参考にしたいと思います。