👻

Flaskの1.xと2.xを無停止で入れ替える

2022/12/07に公開

はじめに

この記事はteam DELTA Advent Calendar 2022 7日目の記事です。

自己紹介

山田 尚人(やまだ なおと)といいます。
SEVENRICH GROUP のグループ でCTO Boosterをメインで行っています。
SEVENRICH GROUPとは?
https://costcut.cloud/

個人のZennもあります。
https://zenn.dev/merutin

概要

Pythonのバージョンアップに伴って、古くなっていたFlaskのバージョンを同時に上げることにしました。
バージョンを上げるに当たって、サービスを止めずに実施する必要があったので、その時の修正の内容を改めてまとめて調査しました。

結論

Flask Login周りの挙動を理解することで、Flaskのログイン周りの仕様を明確に出来ました。
2つのバージョンで異なっていたセッションの格納方法を修正することで、無停止で両方のセッションを維持することが出来ました。
外部のライブラリに無理やり修正を入れてしまったので、古いセッション情報を保持しているユーザがいなくなった時に、ライブラリを利用するように戻していく予定です。

環境構築・サンプルを動作させるまで

環境構築手順

Flaskの公式とほとんど同じですが、インストール手順をのせておきます。
Windows11 で検証しています。

https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/installation.html

mkdir flask_sample
cd flask_sample
python.exe -m venv ./venv
.\venv\Scripts\activate

pip install Flask
app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"
flask run

Flaskのセッション管理について

まず、Flaskのセッション管理について見ていきます。

Flaskのセッション保持方法

  • FlaskではセッションをCookieに保持しています。
  • 最低限のサンプルプログラムを書くと、以下のような値が返ってきます。
  • この時点ではCookieには値が入っていません。
curl -i http://localhost:5000
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.9.5
Date: Fri, 02 Dec 2022 00:44:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 20
Connection: close

<p>Hello, World!</p>
  • セッション変数に値をセットすることで、Cookieに値が入ります。
app.py
from flask import Flask, session

app = Flask(__name__)

app.secret_key = "key"

@app.route("/")
def hello_world():
    session["id"] = "session"
    return "<p>Hello, World!</p>"
curl -i http://localhost:5000
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.9.5
Date: Fri, 02 Dec 2022 02:34:07 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 20
Vary: Cookie
Set-Cookie: session=eyJpZCI6InNlc3Npb24ifQ.Y4lkHw.S8gTPxrErPZRcllauD1SfQRB_-E; HttpOnly; Path=/
Connection: close

<p>Hello, World!</p>

つまり、Flaskはサーバー側では値を保持せず、Cookieの値を検証することで、ユーザの判定等ができるようになっています。
追加でなにか値を保持したい場合はsessionの変数に色々な値を入れるだけで大丈夫です。
後述しますが、この値はユーザが読める可能性がある(=base64でエンコードしているだけ)ので、機密情報を入れるのにはお勧めしません。

sessionの中身について深堀していきます。

sessionの中身について

sessionは3つの.区切りで値が入っています。

Set-Cookie: session=eyJpZCI6InNlc3Npb24ifQ.Y4lkHw.S8gTPxrErPZRcllauD1SfQRB_-E; HttpOnly; Path=/

コードとしては、このあたりになります。
https://github.com/pallets/itsdangerous/blob/main/src/itsdangerous/timed.py#L50-L55

session変数の取得

1つ目はFlask側でセットしたsessionの値になります。
base64でデコードすると以下のようになります。

Set-Cookie: session=eyJpZCI6InNlc3Npb24ifQ.Y4lkHw.S8gTPxrErPZRcllauD1SfQRB_-E; HttpOnly; Path=/
↓ 1つ目の区切り
eyJpZCI6InNlc3Npb24ifQ
↓
{"id":"session"}

値が復元出来ました。
このままだと別のサーバで作成したセッションの値をそのまま利用することで、色々偽装できてしまいます。
そのために2つ目以降のデータがあります。

セッションの別パターン

上記で説明したセッションは入っている値が簡単な場合になります。
値が一定以上を超えるとセッションでもつ値が変わります。

Set-Cookie: session=.eJyrVspMUbJSMjQyNjE1M7ewNKAdS6kWAP_OFwo.Y4msjg.jWXy-qwLEmxarANzZMCOyRk58gc; HttpOnly; Path=/

sessionの値の最初に「.」がついています。
後でもう一度出てくるのでここでは簡単な説明にとどめますが、圧縮したほうがサイズが小さくなる場合は圧縮をしています。
該当の部分は以下です。

https://github.com/pallets/itsdangerous/blob/main/src/itsdangerous/url_safe.py#L52-L66

時刻の取得

2つ目の区切りについて見ていきます。

Set-Cookie: session=eyJpZCI6InNlc3Npb24ifQ.Y4lkHw.S8gTPxrErPZRcllauD1SfQRB_-E; HttpOnly; Path=/
↓ 2つ目の区切り
Y4lkHw
↓ base64でデコード
c\x89d\x1F
↓ .rjust(8, b'\x00')
b'\x00\x00\x00\x00c\x89h\x1e'
↓ byte → intに変換 int.from_bytes(b'\x00\x00\x00\x00c\x89h\x1e', 'big')
1669949470
↓ unix timeに変換
2022年12月02日 11:51:10

あんまり詳しいことはわかりませんが、base64で持っているデータをデコードして、intに変換しているようです。
(ビックエンディアンって久しぶりに見ました)

sessionのタイムアウトはここの時刻で判定をしていて、指定した時間が経過していた場合はセッションタイムアウトになります。
セッションの有効時間はデフォルトでは31日になっています。

https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/config.html#PERMANENT_SESSION_LIFETIME

項目の検証

3つ目の項目が前2つの項目の検証になります。
処理的には読み込めてないのですが、おおよその流れとしては以下の用になります。

Set-Cookie: session=eyJpZCI6InNlc3Npb24ifQ.Y4lkHw.S8gTPxrErPZRcllauD1SfQRB_-E; HttpOnly; Path=/
2つ目までの項目(eyJpZCI6InNlc3Npb24ifQ.Y4lkHw)とsecret_key(サンプルではkey)を基にハッシュ値を作成	

この項目はリクエストの時に検証に使われており、作成した方法と同じやり方で、再度ハッシュ値を作成して、異なっていた場合にエラーとしています。
以下の部分に詳細なコードがあるので、気になる場合は参考にしてみてください。
https://github.com/pallets/itsdangerous/blob/2.1.2/src/itsdangerous/timed.py#L78-L83

Flask Loginについて

Flaskに認証の処理を追加するモジュールです。
ログインが必要な処理については

@login_required

をつけるだけで、認証の処理を通してくれます。
今回のプロジェクトで利用していたので、Flask Loginについても見ていきます。

大分誤解されそうな感じですが、以下のようなコードで検証します。
正しい実装方法は公式を参照してください。
https://flask-login.readthedocs.io/en/latest/#login-example

pip install flask-login
app.py
from flask import Flask, session
import flask_login

app = Flask(__name__)

app.secret_key = "key"
login_manager = flask_login.LoginManager()
login_manager.init_app(app)

@app.route("/")
def hello_world():
    flask_login.login_user(User('user1'))
    return "<p>Hello, World!</p>"

@login_manager.user_loader
def load_user(user_id):
    return User(user_id)

class User(flask_login.UserMixin):
    def __init__(self, user_id):
        self.id = user_id

curlでリクエストを送ります。

curl -i http://localhost:5000
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.9.5
Date: Fri, 02 Dec 2022 08:14:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 20
Vary: Cookie
Set-Cookie: session=.eJwljjkOwkAMAP_imsLXHs5n0K7tFbQJqRB_J4hmNJpq3nBfex4P2F77mTe4PwM26KTYSGqZ0qyhZKl9jeJdTZgicnKGMCOLUmQtazChaEzm1gWntq5Yuxin8ZiFyDxiSSUmVxL1VpT84pDOqLqqmzh6FLsKXCPnkfv_5mcEny-Zay6g.Y4mz8g.MqPBr8gf0W2x2PZJcmOf3I7Xa70; HttpOnly; Path=/
Connection: close

<p>Hello, World!</p>

sessionの中身を見ていきます。2つ目以降の.は関係ないので今回は追いません。
また、↑で紹介したように、最初に.がついているものは圧縮されたコードなので、解凍して中身を見ていきます。
今回はPythonのコンソールで実行します。

python
import base64
import zlib

# sessionの値のうち、最初の.を削除して、次の.までの値で実行する
comp = base64.urlsafe_b64decode(b'eJwljjkOwkAMAP_imsLXHs5n0K7tFbQJqRB_J4hmNJpq3nBfex4P2F77mTe4PwM26KTYSGqZ0qyhZKl9jeJdTZgicnKGMCOLUmQtazChaEzm1gWntq5Yuxin8ZiFyDxiSSUmVxL1VpT84pDOqLqqmzh6FLsKXCPnkfv_5mcEny-Zay6g')
zlib.decompress(comp)

結果は以下のようになりました。

{
	"_fresh": true,
	"_id": "814071365b379703e568fa5c849321ddeb2ed32202341de65fa21034db227830b4784068392e92ab5119cddf36121c4134c7541cc75a382044f6c93c0cd59a38",
	"_user_id": "user1"
}

Flask Loginではsessionの値に独自のデータを入れることで、ユーザを識別しているようです。
_idはユーザエージェントの情報とIPアドレスを使って生成しているみたいです。
https://github.com/maxcountryman/flask-login/blob/main/src/flask_login/utils.py#L394-L403

_user_idはサンプルコードで作成している、Userクラスのidです。クラスの値がここで保持される様子です。

flask_login.login_userの2つ目の引数にremember=Trueを設定すると、remember_tokenというものが発行されます。
Flask Loginの0.2.1ではこの値が入っていると、Flaskのsessionよりも優先されます。
0.6.1ではFlaskのsessionに格納した値が優先されていました。

Set-Cookie: remember_token=user1|24f9fe593134d9f88cb3fd360ff763c96b522b0603d8bbcc054dfebe4625aa7b21fea8bc102402f7e19aaace4ffb2858c45411ade519a23bedd1882cefe4d0d4; Expires=Sat, 02 Dec 2023 09:54:10 GMT; HttpOnly; Path=/

ただし、このCookieは有効期限が設定されているので、一定期間を経過すると消えてしまいます。
※ デフォルトでは1年の設定になっています。

セッションのまとめ

  • Flaskの場合
    Cookieのsessionの値にセットする
    3つのセクションがあって、sessionの値、日時、ハッシュ値となっている

  • Flask Loginの場合
    session自体はFlaskの場合と変わらない。_id_user_idが追加でセットされている。
    _user_idはサンプルで作成したクラスの値
    _idはUser-AgentとリクエストのIPアドレスを組み合わせてハッシュにしたもの

  • remember_tokenがTrueの場合
    Cookieにremember_tokenの値が入る
    有効期限があり、それ以降は自動的にはセットされない

Flask Loginの0.6.1での挙動をチャートにすると以下のようになります。

graph TB
  I[リクエスト] --> S{sessionに_user_idがある}
  S -->|Yes| F[後続処理へ]
  S -->|No| E[sessionエラー]
  S -->|セットされていない| R{remember_tokenでの検証}
  R -->|保持している| F
  R -->|保持していない | E

厳密にはremember_tokenに値が入っていない場合にcallbackでの処理(独自のセッション取得処理)やHeaderの値での処理が入ります。
https://github.com/maxcountryman/flask-login/blob/0.6.2/src/flask_login/login_manager.py#L343

実際の修正

Flaskのバージョンによるセッションの保持方法の違いや、修正の方針について見ていきます。

現在の構成など

EC2 + RDSの標準的な構成です。Flask自体は状態を持たないので、気軽にオートスケールができます。
主にネイティブアプリからのリクエストを受け付けるサーバとして利用しています。
ログイン処理は1回実施したらそれ以降は意識させたくないため、セッションを基本的に保持したままの方針にしています。

バージョンアップに際して、アプリ側では意識することなく変わっていたいので、セッションを維持したまま動作できるような仕組みの構築を目指しました。

インフラ(AWS)

  • RDS
  • EC2(Elastic Beanstalk)
  • ALB

利用しているモジュール

  • Flask 1.0.0 → 2.2.1
  • Flask Login 0.2.0 → 0.6.1

修正方針

数年運用されているサービスであるため、1年で期限が切れてしまうremember_tokenには値が入っていないユーザが多くいました。
そのため、sessionを修正してログインを維持できるような仕組みの構築を行いました。

2つのバージョンの違い

Flaskのsessionで異なる点は以下の2つでした。
ただし、Flask LoginとFlaskは密接に関わっているので、修正箇所自体はFlask Loginのみでした。

hmac.newのdigestmodの値が違う

Flask Loginの0.2.xではsha1、0.6.xではsha512を引数として渡しています。
https://github.com/maxcountryman/flask-login/blob/0.6.2/src/flask_login/utils.py#L382

https://github.com/maxcountryman/flask-login/blob/0.2.1/flask_login.py#L680

生成するロジックが異なるので、作成したdigestを比較しても、エラーになってしまいます。
そのため、そのままでは1.xと2.xでセッションを共有することが出来ませんでした。

sessionの2つ目の要素であるタイムスタンプが違う

※ Flaskで利用しているitsdengerousのロジックです。
1.xでは何故か独自のタイムスタンプを作成しています(2011/01/01が0)。
https://github.com/pallets/itsdangerous/blob/ccc9c1e43030da167bffbc35ee059dede1b30b60/itsdangerous.py#L367-L370

2.xではunix timeになっています。
https://github.com/pallets/itsdangerous/blob/2.1.2/src/itsdangerous/timed.py#L34-L37

時刻の生成方法が異なるため、1.xのサーバで2.xのCookieを受け取ると、未来の時刻になるのでタイムアウトとみなされます。
つまり、2.xで保持しているタイムスタンプを利用して、1.xにリクエストを送るとエラーになってしまいます。
移行後はいいのですが、移行中にリクエストの割合を徐々に変更していく場合にセッション切れが多発することになります。

Flask Loginでsessionに格納する値が異なる

取得の部分をよくよく見てみると、user_idの取得をしている部分が異なっています。
0.6.2では_user_idから取得しているのに対して、0.2.1ではuser_idから取得しています。
最終的には修正しなくても大丈夫だったのですが、調査していて見つけた差分なので、共有しておきます。

https://github.com/maxcountryman/flask-login/blob/0.2.1/flask_login.py#L572

https://github.com/maxcountryman/flask-login/blob/0.6.1/src/flask_login/login_manager.py#L407

※ 修正の必要がなんで不要だったかは定かではないのですが、_user_idの検証をする前の部分で判定をしているので問題にならなかったと記憶しています。

修正

違いを単純に修正していきます。

decode_cookieの処理を2つのロジックで実施する

cookieのデコード処理を以下の部分でやっています。
_cookie_digestの処理でdecodeを行っており、出来なかった場合はif文を通らないだけなので、下に_cookie_digest_sha1の関数を作成して、sha1での処理を行います。

修正のイメージとしては以下のようになります。

login_manager.py
def decode_cookie(cookie, key=None):
    """
    省略
    """
    try:
        payload, digest = cookie.rsplit("|", 1)
        if hasattr(digest, "decode"):
            digest = digest.decode("ascii")  # pragma: no cover
    except ValueError:
        return

    if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
        return payload
+
+    if hmac.compare_digest(_cookie_digest_sha1(payload, key=key), digest):
+        return payload

"""
省略
"""
+def _cookie_digest_sha1(payload, key=None):
+    key = _secret_key(key)
+
+    return hmac.new(key, payload.encode("utf-8"), sha1).hexdigest()

https://github.com/maxcountryman/flask-login/blob/0.6.2/src/flask_login/utils.py#L62-L63

この修正をすることで、sessionの値をまずは2.x側で検証する、駄目だった場合に1.xで検証することができます。

sessionのタイムスタンプの違いを許容する

Flask 1.xの場合と比較して、Flask 2.xの値が大きすぎるので、Flask 1.xのセッション情報をFlask 2.x側で受け取ると必ずログアウトしてしまいます。
セッションタイムアウトまでの時間をめっちゃ長くすることで、暫定的に回避をしました。

セッションタイムアウトはPERMANENT_SESSION_LIFETIMEで保持していて、極端に長くする場合は以下のようにすることでごまかせます。

config.py
PERMANENT_SESSION_LIFETIME = datetime.timedelta.max

セッションタイムアウトを事実上無効にしたことで、2つのバージョンを共存できるようになります。

結果

ここまでの修正は色々試行錯誤をしながら行ったこともあり、何度かリリースをして不具合を見つけることがありました。
テスト環境の構築や検証を手厚く実施するなど、もっとうまくできた点は色々あるとは思いますが、最終的には無停止で言語とフレームワークのアップデートが出来ました。

共存は大変だなと改めて思い知らされました。

Team DELTA

DELTAでは、Pythonの仕事は大分レアですが、CTO Boosterでのインフラやアプリケーションのレイヤーの改善の他にも、言語のバージョンアップやミドルウェアのコアまで調べて改善を行っています。
技術的に深い領域の修正や改善に興味があれば、 こちらまでお問い合わせください!

Discussion