仕事やプライベートで調べたことのメモ書きなど(@札幌)

仕事やプライベートで調べたこと、興味ある事のメモ書きです。2016年4月から札幌で働いてます。※このブログは個人によるもので、団体を代表するものではありません。

Azure FunctionsでPythonを使うときのグローバル変数は要注意 (従量課金)

Azure FunctionsでPythonがGAになって久しいですが、実際に使う上で注意しなければならないことがいくつかありそうです。なお、これは従量課金プランでの挙動を確認しています。

グローバル変数やクラス変数は使わない方がいい

一つは実行のされ方に由来するのですがグローバル変数の取り扱い。「Azure Functions の Python 開発者向けガイド」には、以下のような記載があります。

main() 関数が同期 (async 修飾子が付いていない) の場合、関数は asyncio スレッド プール内で自動的に実行されます。

github.com

このことが意味するところは、そもそもAzure Functions作成する"アプリ"は、独立した"アプリ"のようにはふるまわず、どちらかというといわゆる"関数"のように実行されます。(だからAzure Functionsという?) 関数をきちんと"べき等"に書いていれば問題にはなりませんが、グローバル変数やクラス変数などを使ってコードを書くと問題になります。

Functions側コード例)

import logging

import azure.functions as func

X = None

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    global X
    if X is None:
        X = 0
    X = X + 1

    return func.HttpResponse(f"X is {X}.\n")

これをデプロイしたい後に実行すると以下のように表示されます。

$ curl https://xxxxxxxxx.azurewebsites.net/api/testhttptrigger?code=xxxxxxxxx
X is 1.
$ curl https://xxxxxxxxx.azurewebsites.net/api/testhttptrigger?code=xxxxxxxxx
X is 2.
$ curl https://xxxxxxxxx.azurewebsites.net/api/testhttptrigger?code=xxxxxxxxx
X is 3.
$ 

また、これを非同期呼び出しでたくさん呼び出すとまた面白いことが起こります。(function側のコードは戻り値から改行コードをなくすように修正)

呼び出し側コード例)

import requests
import urllib
import asyncio

END_POINT_URL='https://xxxxxxxxx.azurewebsites.net/api/testhttptrigger'
API_KEY='xxxxxxxxx'
HEADERS = {
    'x-functions-key': API_KEY
}

def req(i):
    r = requests.post(url=END_POINT_URL, headers=HEADERS)
    print(r.text)

async def run(loop):
    async def run_req(i):
        return await loop.run_in_executor(None, req, i)

    tasks = [run_req(i) for i in range(20)]
    return await asyncio.gather(*tasks)

loop = asyncio.get_event_loop()
print(loop.run_until_complete(run(loop)))

出力例)

$ python test_api_call.py 
X is 1.
X is 1.
X is 2.
X is 2.
X is 3.
X is 3.
X is 4.
X is 4.
X is 5.
X is 5.
X is 6.
X is 6.
X is 7.
X is 7.
X is 8.
X is 9.
X is 10.
X is 8.
X is 9.
X is 1.
[None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None]
$ 

面白いですね。"X is 1."と複数回表示されています。

動的に新しいVMに割り当てられると挙動が異なる。

以下のドキュメントにあるように、従量課金プランではVMが動的に生成されます。

従量課金プランを使用する場合、Azure Functions ホストのインスタンスは、受信イベントの数に基づいて動的に追加および削除されます。 このサーバーレス プランではスケーリングが自動的に行われ、関数の実行中にのみコンピューティング リソースに対して料金が発生します。

docs.microsoft.com

つまり、グローバル変数やクラス変数は、同じVM内に割り当てられる関数では同じものを参照し、異なるVMに割り当てられた場合は異なるものを参照するという挙動のようです。

同じVMではデフォルトでは同時に1つしか実行されない

また、前述の「Azure Functions の Python 開発者向けガイド」には以下のような記載もあります。

既定では、Functions Python ランタイムで一度に処理できる関数の呼び出しは 1 つだけです。

つまり、基本的には、1VM内では同時に1ファンクションしか実行されません。したがって、先の例にあるような同じVM内で同じグローバル変数を参照するケースは、直列に実行されたことで発生したケースになります。ちなみに、従量課金プランだと最大200VMまでスケールすることができるようです。

では、先ほどの例では、処理時間が短い関数での実験でしたが、少し処理時間が長い関数を想定してsleepを入れて実行してみます。

Functions側コード例)

import logging
import datetime
import time

import azure.functions as func

X = None

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')
    dt_start = datetime.datetime.now()


    global X
    if X is None:
        X = 0
    X = X + 1

    time.sleep(30)

    dt_finish = datetime.datetime.now()
    return func.HttpResponse(f"X is {X}. start:{dt_start} end:{dt_finish}")

先ほどの呼び出し側コードで実行してみます。

$ python test_api_call.py 
X is 1. start:2019-10-20 06:50:13.226000 end:2019-10-20 06:50:43.264680
X is 1. start:2019-10-20 06:50:19.663393 end:2019-10-20 06:50:49.673589
X is 2. start:2019-10-20 06:50:43.265475 end:2019-10-20 06:51:13.274649
X is 2. start:2019-10-20 06:50:49.674135 end:2019-10-20 06:51:19.707538
X is 3. start:2019-10-20 06:51:13.278349 end:2019-10-20 06:51:43.294640
X is 4. start:2019-10-20 06:51:43.296729 end:2019-10-20 06:52:13.324173
X is 5. start:2019-10-20 06:52:13.324622 end:2019-10-20 06:52:43.354648
X is 6. start:2019-10-20 06:52:43.355110 end:2019-10-20 06:53:13.384191
X is 7. start:2019-10-20 06:53:13.384767 end:2019-10-20 06:53:43.414497
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"/>
<title>502 - Web server received an invalid response while acting as a gateway or proxy server.</title>
<style type="text/css">
<!--
body{margin:0;font-size:.7em;font-family:Verdana, Arial, Helvetica, sans-serif;background:#EEEEEE;}
fieldset{padding:0 15px 10px 15px;} 
h1{font-size:2.4em;margin:0;color:#FFF;}
h2{font-size:1.7em;margin:0;color:#CC0000;} 
h3{font-size:1.2em;margin:10px 0 0 0;color:#000000;} 
#header{width:96%;margin:0 0 0 0;padding:6px 2% 6px 2%;font-family:"trebuchet MS", Verdana, sans-serif;color:#FFF;
.....

VMの立ち上がり具合により異なった結果となりますが、実行途中でエラーとなりました。これは、実行に時間がかかりすぎていることに起因するエラーです。

まとめ

つまるところ、httpTriggerで、同時に大量のリクエストをされる可能性のある時間の長い処理をするのは、問題がありそうですので気を付けましょう。