App ServiceでFlaskアプリのパフォーマンス改善(スタートアップコマンドにworker・thread指定で高速化)
はじめに
現場でAzureのAppServiceにデプロイしているflask(python)アプリが重いという事象がありました。最終的に解決したのは自分ではないのですが、他の人でも陥りそうな事象だと思ったので、どうやって解決したかを備忘録的に残しておこうと思います。AppServiceを使っていたり、それ以外のサービス上にflaskをデプロイしている人にも、自分のアプリが重いなと思った人の参考になれば幸いです。
結論
flaskのデフォルトでは複数リクエストを同時に処理することができないので、AppServiceのスタートアップコマンドを以下のように修正することで複数リクエストを処理することができ、処理が高速化しました(オプションの値はあくまで参考なので、マシンスペックや要件に合わせて修正が必要です)。
gunicorn --bind=0.0.0.0 --timeout 600 --threads 6 --workers 3 app:app
flaskのデフォルトとローカルアプリの並列化
前述したようにflaskのデフォルトでは複数リクエストを同時に処理することができないようです。つまり並列処理ができず、シングルスレッドで動いているということになります。
なので、デフォルトのままだと、複数人からアプリに向けてリクエストを送った際に同時に処理ができず、アプリが重くなってしまいます。1人で開発をしている時は、複数人でアプリにリクエストを送るということをしないので、気付きにくい内容となっています。
ローカルでflaskアプリを並列処理可能にする方法
今回はAppServiceの話ですが、ローカルの場合は以下記事の手順で並列処理ができるようです。
AppServiceにデプロイしたflask
まずpythonアプリをAppService上にデプロイする際には以下のクイックスタートのページが参考になります。手順通りにコマンドを実行することで、簡単にアプリをデプロイし、起動するところまで勝手にやってくれると思います。
ただ、このページではどのような処理が実行されてアプリが起動されるかがわからなすぎるので、以下のページの「コンテナの特性」も見てみましょう。ポイントとなる部分の画像(リンク先の内容が変わる可能性もあるので)
この中でアプリ起動の上で特に知っておいた方が良いかなと思うのは以下の2点かと思いました。
- アプリはGunicorn WSGI HTTPサーバーを使用して実行され、スタートアップコマンドでカスタマイズできる
- requirements.txtがアプリのルートに存在すれば、勝手にpip installしてライブラリをコンテナ内にインストールしてくれる
その上で、どのようにアプリが起動されるかは以下の「コンテナーのスタートアップ プロセス」に記載されています。
ポイントとなる部分の画像(リンク先の内容が変わる可能性もあるので)
カスタムのスタートアアップコマンドがあればそれを使用してアプリを起動し、存在しない場合はDjangoやflaskアプリであれば、既定のコマンドでGunicornを起動するようです。
ポイントとなる部分の画像(リンク先の内容が変わる可能性もあるので)
flaskアプリのメインモジュールは既定では、application.pyやapp.pyと想定されるので、そのようなファイルをルートに配置している場合は、問題ないですが、既定ではない場合は、スタートアップコマンドも下図のように修正する必要があるので、そこも注意です。
ポイントとなる部分の画像(リンク先の内容が変わる可能性もあるので)
また、複数のコマンドを記載した、スタートアップコマンドファイルを用意して、そちらを実行するような設定もできます。
workersとthreadsについて
では、スタートアップコマンドにどのような値を設定すべきかを考えるために、workersとthreadsについて、学びましょう。AIもわかりやすく教えてくれたので、そちらの回答も参考に解説します。また、以下の記事のように参考になる記事も調べればいろいろあります。
workers(ワーカー)とは
- 定義と特徴
ワーカーとは、OSレベルのプロセスのことです。各ワーカーはFlaskアプリケーションの独立したインスタンスを実行します。- プロセスごとにメモリが独立
そのため、あるワーカーがクラッシュしても他のワーカーには影響が及ばず、アプリケーション全体の安定性が向上します。 - マルチコア活用
複数のCPUコアを持つサーバーでは、ワーカー数を増やすことで並行処理能力が向上します(一般に「2×CPUコア数+1」などの目安が使われることもあります)
https://docs.gunicorn.org/en/stable/design.html#how-many-workers
- プロセスごとにメモリが独立
threads(スレッド)とは
- 定義と特徴
スレッドは、1つのプロセス内で複数の実行経路を持つ軽量な単位です。- 同一プロセス内での実行
各スレッドは同じメモリ空間を共有するため、データの共有が容易ですが、同時に意図しない競合(スレッドセーフの問題)が起こる可能性があります。 - I/Oバウンド処理に有効
外部API呼び出しやファイル操作など、待ち時間が発生する処理では、スレッドによる並行実行で効率が上がります。
- 同一プロセス内での実行
workers×threads
要は、計算などの処理を行うところがワーカー(プロセス)で、1ワーカーごとに複数のスレッドを使用できます。複数スレッドを使用可能にすることで、外部API呼び出しなどの待ちが発生する処理では、待っている間に他の処理を他のスレッドで実行することができます。
gunicorn --bind=0.0.0.0 --timeout 600 --threads 6 --workers 3 app:app
最初に示した上のコマンドを設定する場合、wokers×threadsの計算で3×6=18の同時リクエストが可能になります。
デフォルトのflaskとスタートアップコマンドではwokers×threadsが1×1=1となり、1つのリクエストを処理するのに時間がかかった場合、他のユーザーからのリクエストの処理を開始するまで待ちが発生し、アプリが重いように感じるというのが今回私が当たった事象でした。
どのような値でスタートアップコマンドを設定すればいいか
上ではwokers×threadsを3×6に設定しましたが、ではこの値はどのような値が良いのでしょうか?
上記の通りworkersの値に関しては、gunicornのサイトにも推奨の記載があるので、「2×CPUコア数+1」でいいのかなと思いました。コア数が1の場合は「workers=2×1+1=3」ということになりますね。ただし、複雑な計算処理などを1つのプロセスで実行する場合は、コア数=workersにするという話も聞いたことがあるので、上はあくまでgunicornの推奨ということで、ケースバイケースとなります。
AppServiceを利用している場合は、AppServiceプランのvCPUの値がコア数になるので、こちらを参考にすると良いと思います(この画面でメモリも確認できます)。
https://learn.microsoft.com/ja-jp/azure/app-service/app-service-configure-premium-tier
threadsの値に関しては、アプリ内にI/Oバウンド処理がどれだけ多いかとアプリのメモリ容量を考えて値を決めるのが良いかと思います。リクエストが多く、スレッドの設定値も多いとメモリ使用率が高くなり、アプリが落ちる可能性があります。
アプリが重い原因が見つけづらかった件
workersとthreadsを設定していなかったことがアプリが重かった要因でしたが、その原因に気付くまで時間がかかりました。アプリが重い場合は、アプリの操作全般が重かったので、アプリの基盤側に問題があると思いましたが、CPU使用率やメモリ使用率に変動がほとんどなく、問題がなさそうだったからです。結果として、1プロセス、1スレッドでしか処理ができないので、CPU使用率もメモリ使用率も上がりようがなかったということでした。複数人で同時にリクエストを送ることで並列処理されてないんじゃないかと気付けたメンバーがいたのですが、1人で開発している時には気付きづらいですし、知らないと原因を掴みづらいなとも感じたので、今回備忘録として残そうとも思いました。workersやthreadsを設定するということは、flaskをよく利用している人には当たり前のことかもしれませんが、flaskアプリで陥りがちなあるあるとして、そこまで多くの記事はネット上で見かけないような気がします。
最後に
まとめとして、結論は初めに書いてあるので、そちらを見返してください!
workersやthreadsをどのように設定すればいいかは、今回初めて調べたので、違う考えを持つ方もいるかもしれません。何かあれば、コメントで教えていただけると助かります!
Discussion