Serverless Framework+mangum+FastAPIで、より快適なPython API開発環境を作る

はじめに

最近ハイボールにハマっているSREのたっち(@TatchNicolas)です。

昨日オンライン開催されたJAWS DAYS 2020にて、JX通信社もサーバレスをテーマとして発表をしました。(by 植本さん

発表でもありましたように、上記プロジェクトにおいて開発当時はスピードを優先してプロジェクトメンバーの手に馴染んでいて分担もしやすいフレームワークとしてFlaskを採用しました。

一方で、JX通信社としてはFlaskよりもFastAPIを使うプロジェクトが増えてきており、今後もその傾向は続く見込みです。

そこで、特設ページ作成やAPI提供など初動としての開発が一段落したのを機に、JAWS DAYSで発表した仕組みを今後のために発展させる検証をしたので紹介します。

TL; DR;

  • JAWSでは Serverless Framework+awsgi+Flaskな構成でスピーディにコロナ特設ページ向けAPIを作った話をした
    • 緊急性の高いプロジェクトであったが、非常にスピード感のある開発ができた
  • Flaskに代わるフレームワークとしてJX通信社ではFastAPIが流行中
    • OpenAPIドキュメント自動生成、Request/Responseをクラスとして定義するなどカッチリと開発ができる
    • しかしFlaskライクな軽量フレームワークで、シンプルで書きやすい
  • そこで、FastAPIを使うパターンでも前述の良い開発者体験を提供する仕組みを作った

サンプルコードはこちらにおいてあります。

github.com

前提

awsgiはAPIGatewayからLambda Proxy Integrationに渡されるイベントをWSGI*1アプリケーションが理解できる形式に変換し、またWSGIアプリケーションが返すレスポンスをLambda Proxy Integrationに従った形式に変換してくれる便利なツールです。

github.com

WSGIの精神的後継として*2、asyncに対応したASGIが登場しました。そのASGIに対応したフレームワーク(FastAPIなど)とLambda Proxy Integrationの間でアダプタとして動いてくれるのが、mangumです。

github.com

ざくっとまとめると以下のようになります。

WSGI ASGI
フレームワーク例 Flask, Django*3 FastAPI,Sanic,Responder
Lambda Proxyアダプタ awsgi mangum
(HTTP Server)*4*5 gunicorn, uWSGIほか uvicorn

それでは早速やってみましょう。

やってみる

今回用意したサンプルコードはdocker-composeで動くようにしてあるので、下記のリポジトリをクローンしたら下記の要領で起動してみて下さい。

$ docker-compose up -d
$ docker-compose exec exam_results python -c 'from main import init_ddb_local; init_ddb_local()'

実際のコードを見ていきましょう。

# exam_results/main.py

(省略)

from fastapi import FastAPI, HTTPException
from mangum import Mangum


app = FastAPI()  # FastAPIのインスタンス

(省略)

handler = Mangum(app, False)  # FastAPIのインスタンスをMangumのコンストラクタに渡して、handlerとして外から読めるようにしておく

(省略)

exam_results/main.py のに app という変数名でASGI準拠のアプリケーション(=FastAPIのインスタンス)を作り、それを引数として作ったMangumのインスタンスを handler として定義します。

これを serverless.yml の中でhandlerとして指定することで、APIGatewayから発生したイベントをMangumが受け取り、FastAPIに渡してくれるというわけです。

# serverless.yml
(省略)
functions:
  exam_results:
    events:
    - http:
        path: /{path+}
        method: GET
        private: false
        cors: true
    handler: exam_results.main.handler  # ←ココ
    environment:
      PYTHONPATH: exam_results
      DDB_TABLE: ${self:custom.envMapping.${self:provider.stage}.DDB_TABLE}
    role: ManageStudentsRole
(省略)

細かなimportや環境変数設定は実際のサンプルコードを参照してください。

起動時の二行目のコマンドで、下記の関数を呼び出しています。

def init_ddb_local():
    if ExamResultsTable.Meta.host and not ExamResultsTable.exists():
        print('creating a table...')
        ExamResultsTable.create_table(
            read_capacity_units=1,
            write_capacity_units=1,
            wait=True
        )
        print('Done.')

最初のif文でテーブルのモデル(ExamResultsTable)の内部クラスMetaのなかで、「環境変数から DDB_HOST が指定されている」かつ「テーブルがまだ存在しない」場合のみPynamoDBを使って create_table しています。

実際にリクエストをしてみましょう。

$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","subject":"math","score":50}' localhost:8001/scores
{"name":"Alice","subject":"math","score":50}
$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Bob","subject":"history","score":49}' localhost:8001/scores
{"name":"Bob","subject":"history","score":49}

$ curl localhost:8001/scores?student=Alice
{"exam_results":[{"name":"Alice","subject":"math","score":50}]}
$ curl localhost:8001/scores?subject=history
{"exam_results":[{"name":"Bob","subject":"history","score":49}]}

次に、このアプリケーションをAWSにデプロイしてみます。(sls コマンドのインストールやAWS操作のための権限は先に済ませておいてください。)

$ sls deploy --stage=dev
Serverless: Generating requirements.txt from pyproject.toml...
(いっぱいメッセージが出る)
Service Information
service: fantastic-service-with-fastapi
stage: dev
region: ap-northeast-1
stack: fantastic-service-with-fastapi-dev
resources: 13
api keys:
  None
endpoints:
  GET - https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/{path+}
  POST - https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/{path+}
functions:
  exam_results: fantastic-service-with-fastapi-dev-exam_results
layers:
  None
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

endpoints にあるURLに対して、localhost:8001 に対して行ったのと同じ操作ができると思います。

あとは sls deploy --stage=devdevstg prd に書き換えて実行すれば、それぞれ独立した環境を作成することができます。 コマンドがシンプルなのでCI/CDも簡単に書くことができるでしょう。

また、/docs にアクセスするとOpenAPIのドキュメントが自動生成されています。

f:id:TatchNicolas:20200323212131p:plain
自動生成されたdocs

何が嬉しいのか

開発スピードを高く保てる

早く上手に使えば運用やデプロイの負担が大幅に軽減されるLambdaと、ローカルでもガンガン開発しやすいWebアプリケーションフレームワークのいいとこ取りをすることができます。また、FastAPIの提供する「Web APIを作る」ために便利な機能を活用することができます。

Lambdaを手元で動かしたいという点で、本記事と似たことを実現しようとしているSAM Localは「Lambdaっぽいなにかを手元で動かす」発想です。一方で今回のやり方は「ローカルで動いているアプリケーションをASGI(FlaskであればWSGI)をインターフェイスとしてLambdaに載せる」という方法です。そのため、serverless.yaml 内ではhttpイベントのパスを{path+} 、 メソッドをANYにしておくことでL7の仕事をAPIGatewayではなくLambda内のアプリケーションに任せています。

awsgiもmangumも(gunicornやuvicornといった)HTTP Serverを起動するわけではなく、単にイベントを変換しているだけなのでオーバヘッドもほとんどゼロなため、Lambdaがキチンとスケールしてくれればパフォーマンスの心配はありません。

いつでも実行環境を載せ替えられる

たとえば運用しつつ何らかの事情で「Lambdaじゃキツイな」となってきたらECSやk8sなどコンテナベースの実行環境に載せかえることも容易にできます。その際にアプリケーションは変更する必要がありません。ローカルで動かしていたDockerイメージをそのまま、またはDistrolessなどにちょっと書き換えるだけで引っ越すことができます。

今回は最小限の構成にするために、Pythonのコードはすべてexam_results/main.pyに詰め込みました。しかし実際のアプリケーションではもっと真面目にファイル・ディレクトリの構成を切り分けると思います。その場合はLambda用(exam_results/lambda.py)とDocker用(exam_results/docker.py)に入口をそもそも分けてしまったり、Poetryのextrasを使って依存ライブラリ管理を分けたりしても良いでしょう。

ハマったところ/改善したいところ

serverless-python-requirementsのモジュールとPoetryがうまく連携しない

パッケージ管理およびvenvの管理にはPoetryを使っていたのですが、Serverless Framework側でモジュール機能を使うと、プラグインが期待通りに動作せず*6にpackageに依存ライブラリを含めてくれません。

PoetryだけではなくPipenvでも同じ問題があり、こちらはIssue報告されています。(執筆時点で未解決)

github.com

Pipfileやpyproject.tomlでなくrequirements.txtであれば individually: true のオプションがちゃんと効いてLambda関数単位で依存関係を解決してくれるようです。なので、CI/CDのタイミングでコマンドを一行足してモジュールごとにPoetryにrequirements.txtを生成させば解決します。

ただし今回のサンプルコードでは、1スタック1関数として作ることで回避しました。*7

DynamoDB LocalとPynamoDBでスキーマ定義が二重管理になってしまう

宣言的に書くためにDynamoDBのスキーマ定義はserverless.ymlに書きたいところですが、それをローカル開発時のDynamoDBにそのまま適用することはできません。

今回アプリケーションはPynamoDBを使ってDynamoDBとやりとりをしており、PynamoDBのモデルはserverless.ymlで定義したスキーマと一致するように書いています。そこで、簡単に初期化関数を定義してPynamodDBの機能を使ってローカルのテーブルを作りました。

しかしこれは実際のアプリケーションには必要ないある種「余計な」コードがソースの中に紛れ込んでしまっています。serverless.ymlに書いた定義をそのままローカル開発環境にも適用できればよいのですが、スマートな方法があれば改善したいです。*8

さいごに

サーバレスはその恩恵と引き換えに、どうしてもプラットフォームの制約を受け入れる必要が生じます。そんな制約の一つが「イベントという形で入力を渡されるので、Lambdaとしての書き方を強制されて慣れ親しんだフレームワークとは勝手が異なる」ことだと思います。

しかしWSGI/ASGIというインターフェイス(とそれらを繋いでくれるmangumのようなツール)を活用することで、ローカルで作業のしやすいコンテナベースの開発とサーバレスな開発をシームレスに切り替えられます。JX通信社のプロダクトではECS+RDSで一部補助的にLambdaを使う構成が定番でしたが、最近はサーバレスを積極的に活用する場面も増えてきました。

今回はそんな社内の技術スタックの移り変わりにあわせた開発ができるように工夫してみたことの記録でした。少しでも皆さんの参考になれば幸いです。

*1:Pythonの標準仕様として用意されているWebアプリケーションのインターフェース定義で、Ruby屋さんにとってのRackにあたります

*2:公式Docより: ASGI (Asynchronous Server Gateway Interface) is a spiritual successor to WSGI, intended to provide a standard interface between async-capable Python web servers, frameworks, and applications.

*3:Django 3.0からASGIに対応していく方針ですが、現時点で限定的です。詳しくは https://docs.djangoproject.com/ja/3.0/releases/3.0/#asgi-support

*4:Ruby屋さんにとってのunicorn

*5:今回はHTTP Serverは使わずに素のWSGI/ASGIアプリケーションとして動かして、mangumにブリッジしてもらいます

*6:プラグイン側のコードをみたところservicePathにプロジェクトルートが渡されてしまいpyproject.tomlを見つけられないことが原因のようでした。lib/pip.jsではmodulePathという変数にモジュールのディレクトリ名が入ってくるのでうまく連結できれば動かせたかもしれません

*7:厳密にマイクロサービスするなら永続化層も共有すべきではないという考え方もあるので、1つのスタックに1つのLambda関数を良しとしました

*8:たとえばserverless.ymlをパースして、テーブルの有無をチェックして必要に応じて作成するなどできると思います