Azure Functions で Flask/WSGI のAPIを動かす

## はじめに

仕事でバックエンドに [AWS API Gateway](https://aws.amazon.com/jp/api-gateway/) + [AWS Lambda](https://aws.amazon.com/jp/lambda/) + [Mangum](https://mangum.io/) + [FastAPI](https://fastapi.tiangolo.com/ja/) で構築したサーバーレスなアプリケーションをプロダクションに持っていくことができた。ひと息したところで、別途 Azure を使うプロジェクトから参加要請が来た。ここはAzureなら[Azure API Management](https://azure.microsoft.com/ja-jp/services/api-management/) + [Azure Functions](https://azure.microsoft.com/ja-jp/services/functions/) だよなぁと思ってたけど、チュートリアル的にはひとつひとつの関数を追加する方法はあるが、のなんらかのフレームワークを利用する方法が探せず。まあ仕事の中で考える・勉強しつつやるかなと思ってたけど。コロナ禍の３連休、マンボーで外にも出たくないし、オリンピックもつまらないし、で Azure の勉強がてら試したメモ。

## Azure Functions での WSGI/ASGI 状況 (2021/08)

結局、[Azure Functions Python Library](https://github.com/Azure/azure-functions-python-library) を使い、[WsgiMiddleware](https://github.com/Azure/azure-functions-python-library/blob/dev/azure/functions/_http_wsgi.py) や [AsgiMiddleware](https://github.com/Azure/azure-functions-python-library/blob/dev/azure/functions/_http_asgi.py) からアプリケーションを繋いであげれば良い。

過去にテスト的に？

- WSGI: [azf-wsgi](https://github.com/vtbassmatt/azf-wsgi)
- ASGI: [Bonnette](https://github.com/jordaneremieff/bonnette) 

と組み合わせる必要があったが現在は上記不要。

例えば、日本語の記事、Qiita の記事だと[こちら](https://qiita.com/kniwase/items/bc763493b1f12e8d7119)が見つかりましたが、結局 ASGI の方は、 Mangum の作者が Azure 向けに Bonnette を開発したが、もうメンテしないんでってことで、そこらへんのアイデアが Azure Functions Python Library の方に取り込まれていったので、特に追加のライブラリーなしで `azure-functions` だけで使える(v1.7.1以降)。同様に　WSGI の方は、 azf-wsgi があったがこちらの方も先行して `azure-functions` 自体に取り込まれていた(v1.1.0以降)。

https://github.com/Azure/azure-functions-python-library/pull/45

https://github.com/Azure/azure-functions-python-library/issues/75

## 動かしてみる

FastAPI で行きたいと思っていたが、すでにアプリケーションの方は [Flask](https://palletsprojects.com/p/flask/) で開発が進んでいたので、 Flask と WSGI で。

Azure のリソース(Resource Group, Functions, Blob Storage, etc) の作成方法等には触れません。

### 雛形を作る

まず、[クイックスタート: コマンド ラインから Azure に Python 関数を作成する](https://docs.microsoft.com/ja-jp/azure/azure-functions/create-first-function-cli-python)を参考に進める。

どうも、この手順通りに進めると、プロジェクト名や関数名が UpperCamelCase で.NET的だったり、Python仮想環境の位置がどうなのか？とかPython的な違和感があるので、そこら辺を読み替える。

#### Functions App プロジェクトと仮想環境の作成

```sh
$ mkdir myproject
$ cd myproject
```

2020/08/08 現在Azure側は私の環境では3.8.6だったので合わせておきました。

```sh
$ pyenv local 3.8.6
$ python -m venv .venv
$ source .venv/bin/activate
```

[Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools) - funcコマンドで雛形作成

```sh
$ func init .
Select a number for worker runtime:
1. dotnet
2. dotnet (isolated process)
3. node
4. python
5. powershell
6. custom
Choose option: 4
python
Found Python version 3.8.6 (python3).
Writing requirements.txt
Writing .funcignore
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /Users/username/Workspaces/myproject/.vscode/extensions.json
```

#### 関数追加

Http Triggerで名前を`functions_wsgi`として追加します。普通作ったままだと、`/api/functions_wsgi` というパスで呼び出す事になりますが、後述するように全部のパスをWSGI/ASGIでフレームワークに渡すので実際にはこの名前はなんでも良いです。

```sh
$ func new --template "Http Trigger" --name functions_wsgi
```

#### ディレクトリー構成

これで、これまでの作業で、こんな構成になっているかと。

```
.
├── functions_wsgi
│   ├── __init__.py
│   └── function.json
├── host.json
└── requirements.txt
```

ここに、普通の？Flask/WSGIのアプリを置きます。

```
.
├── flask_app           # Flask/WSGI WebApp
│   ├── __init__.py
│   └── app.py
├── functions_wsgi      # Functions entry -> WSGI
│   ├── __init__.py
│   └── function.json
├── host.json
└── requirements.txt
```

こんな感じで、 デフォルトの [Werkzeug](https://werkzeug.palletsprojects.com/) なり、[Gunicorn](https://gunicorn.org/) なりでちゃんと動くもの。(私は Azure Functions Core Tools の雛形で作成される Azure Functions 環境依存の関数を Flask化しました)

```sh
$ python flask_app/app.py
 * Serving Flask app 'app' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
```

#### Azure Functions と WSGI/ASGIを統合

`functions_wsgi/function.json` と `host.json` ファイルを次の様に変更し、デフォルトのパス `/api` をやめて、全てのパスをルーティングされるように変更。

```diff
   "scriptFile": "__init__.py",
   "bindings": [
     {
+      "route": "{*route}",
       "authLevel": "function",
       "type": "httpTrigger",
       "direction": "in",
```

```diff
   "extensionBundle": {
     "id": "Microsoft.Azure.Functions.ExtensionBundle",
     "version": "[2.*, 3.0.0)"
+  },
+  "extensions": {
+    "http": {
+      "routePrefix": ""
+    }
   }
 }
```

で、 `scriptFile:` に設定されている、`functions_wsgi` の `__init__.py` の中身を次の様にする。多分FastAPI/ASGIなどなら `azure.functions.AsgiMiddleware`で良いはず。

```python
import azure.functions as func

from flask_app.app import app


def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse:

    return func.WsgiMiddleware(app.wsgi_app).handle(req, context)
```

これで、 全てのリクエストがWSGI/ASGIアプリの方に流れていく。

## できたもの

https://github.com/hkato/sample-azure-functions-wsgi-flask

## ローカルで動かす

Azure Functions Core Tools 上で実行。

```sh
$ func start
Found Python version 3.8.6 (python3).

Azure Functions Core Tools
Core Tools Version:       3.0.3477 Commit hash: 5fbb9a76fc00e4168f2cc90d6ff0afe5373afc6d  (64-bit)
Function Runtime Version: 3.0.15584.0
.
.
.
	functions_wsgi: [GET,POST] http://localhost:7071/{*route}
```

`http://localhost:7071/{*route}` となっている通り、全てのルートが `functions_wsgi` に流され、 Flask なりに渡されるので、そっちで指定したパスで動ける。

`/pet/{pet_id}` みたいなURLは試してないけど、

- GET `/hello?name={user_name}`
- POST `/hello`
- GET `/foo`
- POST `/bar` 

を用意して、動作してるんで、あとはFlaskの話なのでまあいけるんじゃないかと。

```sh
$ curl -X GET 'http://localhost:7071/hello'
This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.
```
```sh
$ curl -X GET 'http://localhost:7071/hello?name=hoge'
Hello, hoge. This HTTP triggered function executed successfully.
```
```sh
$ curl -X POST 'http://localhost:7071/hello' -H "Content-Type: application/json" -d '{"name": "hoge"}'
Hello, hoge. This HTTP triggered function executed successfully.
```
```sh
$ curl -X GET 'http://localhost:7071/foo'  
test
```
```sh
$ curl -X GET 'http://localhost:7071/bar'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
```
```sh
$ curl -X POST 'http://localhost:7071/bar' -H "Content-Type: application/json" -d '{"name": "hoge"}'
{"name":"hoge"}
```

## デプロイ

Azure 自体にデプロイというか発行し、ローカル同様に動作してるんですが…

```sh
$ func azure functionapp publish xxxxxxxx
Getting site publishing info...
Creating archive for current directory...
Performing remote build for functions project.
Deleting the old .python_packages directory
Uploading 4.83 KB [###############################################################################]
Remote build in progress, please wait...
Updating submodules.
.
.
.
Detecting platforms...
Detected following platforms:
  python: 3.8.6
Version '3.8.6' of platform 'python' is not installed. Generating script to install it...
.
.
.
Resetting all workers for xxxxxxxx.azurewebsites.net
Deployment successful.
Remote build succeeded!
Syncing triggers...
Functions in xxxxxxxx:
    functions_wsgi - [httpTrigger]
        Invoke url: https://xxxxxxxx.azurewebsites.net/{*route}?code=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

## ~~あとで調べる~~

デプロイ時に、

`Invoke url: https://xxxxxxxx.azurewebsites.net/{*route}?code=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`

なんだか知らないけど、codeが発行されるが[^1]、これをパラメーターとして追加しないと404になる。これをどうしたら良いのか不明。チュートリアあるにある素の Functions では発行されなかったが。

[^1]: コメント欄参照 [Azure Functions の HTTP トリガー](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=csharp#configuration)

あとは、API Management、ここはインフラとしてかな。JWT検証とか不要になりそうなんで便利そう。