らくがきちょう

なんとなく ~所属組織/団体とは無関係であり、個人の見解です~

mangum + FastAPI で ASGI な Python アプリケーションを AWS Lambda + API Gateway 上で動かす

mangum を使うと FastAPIresponder といった ASGI アプリケーションを AWS Lambda + API Gateway 上へ簡単にデプロイすることが出来ます。 今回は FastAPI で書いた ASGI アプリケーションを mangum を使って AWS 上でデプロイする手順をメモしておきます。

mangum init の注意点

mangum を利用する場合、mangum init を使ってプロジェクトファイルを生成します。 ただ、mangum init には注意すべき点があるので先に記載しておきます。

最初に mangum init する

mangum init すると requirements.txt が生成されるのですが、その際必ず中身が下記のように mangum だけで上書きされてしまいます。 ASGI 用の Web フレームワークとして FastAPI 等を利用していても requirements.txt が上書きされてしまい、結果として後の手順で AWS へアップロードするパッケージに mangum しか含まれなくなってしまいます。 その為、最初に mangum init してプロジェクトの設定ファイルを作成し、後から requirements.txt へ必要なパッケージを追記する必要があります。

mangum

ハンドラは app/asgi.py に定義する

mangum-cli を使って mangum のプロジェクト設定を行う際、AWS Lambda から呼び出されるハンドラは code_dir: apphandler: asgi.handler と決め打ちで出力されます。 生成されたファイルである mangum.yml を手動で修正さればハンドラを定義しているファイル名を変更することは可能ですが、予めスクリプトapp/asgi.py にしておけば無用なエラーを避けられて無難だと思われます。

mangum のプロジェクト名は AWS ルールに準拠したものにする

mangum-cli を使って mangum のプロジェクト設定う際にプロジェクト名を指定する必要がありますが、プロジェクト名は AWS へアップロードされる各種設定の名称に利用されます。 ここで例えば _ (アンダースコア) のように AWS の設定名に使えない記号を使ってしまうと、最後の手順で mangum deploy する際に以下のようなエラーになりますので注意が必要です。

# mangum deploy
Deploying your application! This may take some time...

An error occurred (ValidationError) when calling the DescribeStacks operation: 1 validation error detected: Value 'my_first_mangum' at 'stackName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*
There was an error...

mangum cli には削除機能が無い

これは mangum init と関係ありませんが、mangum deployAWS へアプリケーションをデプロイする際、内部的には CloudFormation が実行されます。 CloudFormation テンプレートはあるので「何が作られたのか」は分かりますが、「作成されたもの」を自動的に削除する機能は、現状の mangum-cli にはありません。 不要となった設定は手動で削除する必要があります。

プロジェクトディレクトリを作成する

まず初めにプロジェクトディレクトリを作成します。

my-first-mangum/

プロジェクトディレクトリへ移動しておきます。

cd my-first-mangum/

mangum-cli をインストールする

mangum で作成したプロジェクトを AWS へアップロードするには mangum-cli を使います。 pip でインストールします。

pip install mangum-cli

以下のライブラリがインストールされました。

# pip list
Package         Version
--------------- -------
awscli          1.17.9
boto3           1.11.9
botocore        1.14.9
Click           7.0
colorama        0.4.1
docutils        0.15.2
jmespath        0.9.4
mangum-cli      0.0.1
pip             20.0.2
pyasn1          0.4.8
python-dateutil 2.8.1
PyYAML          5.2
rsa             3.4.2
s3transfer      0.3.2
setuptools      45.1.0
six             1.14.0
urllib3         1.25.8

AWS CLI をセットアップする

mangum-cliAWS へのアップロードを行ってくれますが、前提として AWS CLI のセットアップを完了させておく必要があります。 aws configure を実行し、AWS CLI のセットアップを完了しておきます。

# aws configure
AWS Access Key ID [None]: XXXXXXXXXXXXXXXXXXXX
AWS Secret Access Key [None]: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Default region name [None]: ap-northeast-1
Default output format [None]: json

AWS S3 上に Bucket を作成する

後の手順で mangum-cli を使って作成したパッケージをアップロードする先となる S3 Bucket を作成しておきます。 今回は my-first-mangum-bucket という名前の S3 Bucket を作成したものとします。

aws s3 mb s3://my-first-mangum-bucket --region ap-northeast-1

ちなみに mangum create-bucket でも同様に、S3 Bucket を作成することが出来ます。

mangum create-bucket my-first-mangum-bucket ap-northeast-1

デプロイ用の設定ファイルを作成する

mangum init [PROJECT] [S3-BUCKET] [REGION] を実行して mangum の設定ファイルを作成します。

mangum init my-first-mangum my-first-mangum-bucket ap-northeast-1

mangum.yml というファイルが以下の内容で作成されました。 timeoutAWS Lambda へアップロードした後、Lambda のタイムアウト値になるようです。

name: my-first-mangum
code_dir: app
handler: asgi.handler
bucket_name: my-first-mangum-bucket
region_name: ap-northeast-1
websockets: false
timeout: 300

この時点でディレクトリ構成は以下のようになりました。

my-first-mangum/
├── mangum.yml
└── requirements.txt

尚、mangum init 実行時に S3 Bucket 名とリージョン名は省略可能ではあるものの、省略すると設定ファイル上 null という値になってしまいます。 結局、これでは AWS へのデプロイ時にエラーとなってしまう為、省略せずに入力しておくことをお勧めします。

requirements.txt を修正する

今回のサンプルは FastAPI も利用するので requirements.txtfastapi も追記します。 この時点で requirements.txt の中身は以下になりました。

fastapi
mangum

app/asgi.py を用意する

app ディレクトリ配下に asgi.py というファイル名でアプリケーションを用意します。 今回は以下の内容にしました。

from mangum import Mangum
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World!"}

handler = Mangum(app, enable_lifespan=False)

この時点でディレクトリ構成は以下になりました。

my-first-mangum/
├── app
│   └── asgi.py
├── mangum.yml
└── requirements.txt

ローカルビルドを作成する

次に mangum build を実行してローカルビルドを作成します。

mangum build

mangum build を実行すると build というディレクトリが作成され、その中に関連するファイルが大量に作成されます。 この時点でディレクトリ構成は以下のようになっていました。 FastAPI も含む為、それなりのサイズになっています。

my-first-mangum/
├── app
│   └── asgi.py
├── build
│   ├── LICENSE.md
│   ├── __pycache__
│   │   └── typing_extensions.cpython-38.pyc
│   ├── asgi.py
│   ├── fastapi
│   │   ├── __init__.py
│   │   ├── __pycache__
│   │   │   ├── __init__.cpython-38.pyc
│   │   │   ├── applications.cpython-38.pyc
│   │   │   ├── concurrency.cpython-38.pyc
│   │   │   ├── datastructures.cpython-38.pyc
│   │   │   ├── encoders.cpython-38.pyc
│   │   │   ├── exception_handlers.cpython-38.pyc
│   │   │   ├── exceptions.cpython-38.pyc
│   │   │   ├── logger.cpython-38.pyc
│   │   │   ├── param_functions.cpython-38.pyc
│   │   │   ├── params.cpython-38.pyc
│   │   │   ├── routing.cpython-38.pyc
│   │   │   └── utils.cpython-38.pyc
│   │   ├── applications.py
│   │   ├── concurrency.py
│   │   ├── datastructures.py
│   │   ├── dependencies
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── __init__.cpython-38.pyc
│   │   │   │   ├── models.cpython-38.pyc
│   │   │   │   └── utils.cpython-38.pyc
│   │   │   ├── models.py
│   │   │   └── utils.py
│   │   ├── encoders.py
│   │   ├── exception_handlers.py
│   │   ├── exceptions.py
│   │   ├── logger.py
│   │   ├── openapi
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── __init__.cpython-38.pyc
│   │   │   │   ├── constants.cpython-38.pyc
│   │   │   │   ├── docs.cpython-38.pyc
│   │   │   │   ├── models.cpython-38.pyc
│   │   │   │   └── utils.cpython-38.pyc
│   │   │   ├── constants.py
│   │   │   ├── docs.py
│   │   │   ├── models.py
│   │   │   └── utils.py
│   │   ├── param_functions.py
│   │   ├── params.py
│   │   ├── py.typed
│   │   ├── routing.py
│   │   ├── security
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── __init__.cpython-38.pyc
│   │   │   │   ├── api_key.cpython-38.pyc
│   │   │   │   ├── base.cpython-38.pyc
│   │   │   │   ├── http.cpython-38.pyc
│   │   │   │   ├── oauth2.cpython-38.pyc
│   │   │   │   ├── open_id_connect_url.cpython-38.pyc
│   │   │   │   └── utils.cpython-38.pyc
│   │   │   ├── api_key.py
│   │   │   ├── base.py
│   │   │   ├── http.py
│   │   │   ├── oauth2.py
│   │   │   ├── open_id_connect_url.py
│   │   │   └── utils.py
│   │   └── utils.py
│   ├── fastapi-0.47.1.dist-info
│   │   ├── INSTALLER
│   │   ├── LICENSE
│   │   ├── METADATA
│   │   ├── RECORD
│   │   └── WHEEL
│   ├── mangum
│   │   ├── __init__.py
│   │   ├── __pycache__
│   │   │   ├── __init__.cpython-38.pyc
│   │   │   ├── adapter.cpython-38.pyc
│   │   │   ├── connections.cpython-38.pyc
│   │   │   ├── exceptions.cpython-38.pyc
│   │   │   ├── lifespan.cpython-38.pyc
│   │   │   ├── types.cpython-38.pyc
│   │   │   └── utils.cpython-38.pyc
│   │   ├── adapter.py
│   │   ├── connections.py
│   │   ├── exceptions.py
│   │   ├── lifespan.py
│   │   ├── protocols
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── __init__.cpython-38.pyc
│   │   │   │   ├── http.cpython-38.pyc
│   │   │   │   └── websockets.cpython-38.pyc
│   │   │   ├── http.py
│   │   │   └── websockets.py
│   │   ├── py.typed
│   │   ├── types.py
│   │   └── utils.py
│   ├── mangum-0.7.0-py3.8.egg-info
│   │   ├── PKG-INFO
│   │   ├── SOURCES.txt
│   │   ├── dependency_links.txt
│   │   ├── installed-files.txt
│   │   ├── requires.txt
│   │   └── top_level.txt
│   ├── pydantic
│   │   ├── __init__.cpython-38-x86_64-linux-gnu.so
│   │   ├── __init__.py
│   │   ├── __pycache__
│   │   │   ├── __init__.cpython-38.pyc
│   │   │   ├── class_validators.cpython-38.pyc
│   │   │   ├── color.cpython-38.pyc
│   │   │   ├── dataclasses.cpython-38.pyc
│   │   │   ├── datetime_parse.cpython-38.pyc
│   │   │   ├── env_settings.cpython-38.pyc
│   │   │   ├── error_wrappers.cpython-38.pyc
│   │   │   ├── errors.cpython-38.pyc
│   │   │   ├── fields.cpython-38.pyc
│   │   │   ├── generics.cpython-38.pyc
│   │   │   ├── json.cpython-38.pyc
│   │   │   ├── main.cpython-38.pyc
│   │   │   ├── mypy.cpython-38.pyc
│   │   │   ├── networks.cpython-38.pyc
│   │   │   ├── parse.cpython-38.pyc
│   │   │   ├── schema.cpython-38.pyc
│   │   │   ├── tools.cpython-38.pyc
│   │   │   ├── types.cpython-38.pyc
│   │   │   ├── typing.cpython-38.pyc
│   │   │   ├── utils.cpython-38.pyc
│   │   │   ├── validators.cpython-38.pyc
│   │   │   └── version.cpython-38.pyc
│   │   ├── class_validators.cpython-38-x86_64-linux-gnu.so
│   │   ├── class_validators.py
│   │   ├── color.cpython-38-x86_64-linux-gnu.so
│   │   ├── color.py
│   │   ├── dataclasses.cpython-38-x86_64-linux-gnu.so
│   │   ├── dataclasses.py
│   │   ├── datetime_parse.cpython-38-x86_64-linux-gnu.so
│   │   ├── datetime_parse.py
│   │   ├── env_settings.cpython-38-x86_64-linux-gnu.so
│   │   ├── env_settings.py
│   │   ├── error_wrappers.cpython-38-x86_64-linux-gnu.so
│   │   ├── error_wrappers.py
│   │   ├── errors.cpython-38-x86_64-linux-gnu.so
│   │   ├── errors.py
│   │   ├── fields.cpython-38-x86_64-linux-gnu.so
│   │   ├── fields.py
│   │   ├── generics.py
│   │   ├── json.cpython-38-x86_64-linux-gnu.so
│   │   ├── json.py
│   │   ├── main.cpython-38-x86_64-linux-gnu.so
│   │   ├── main.py
│   │   ├── mypy.cpython-38-x86_64-linux-gnu.so
│   │   ├── mypy.py
│   │   ├── networks.cpython-38-x86_64-linux-gnu.so
│   │   ├── networks.py
│   │   ├── parse.cpython-38-x86_64-linux-gnu.so
│   │   ├── parse.py
│   │   ├── py.typed
│   │   ├── schema.cpython-38-x86_64-linux-gnu.so
│   │   ├── schema.py
│   │   ├── tools.cpython-38-x86_64-linux-gnu.so
│   │   ├── tools.py
│   │   ├── types.cpython-38-x86_64-linux-gnu.so
│   │   ├── types.py
│   │   ├── typing.cpython-38-x86_64-linux-gnu.so
│   │   ├── typing.py
│   │   ├── utils.cpython-38-x86_64-linux-gnu.so
│   │   ├── utils.py
│   │   ├── validators.cpython-38-x86_64-linux-gnu.so
│   │   ├── validators.py
│   │   ├── version.cpython-38-x86_64-linux-gnu.so
│   │   └── version.py
│   ├── pydantic-1.4.dist-info
│   │   ├── INSTALLER
│   │   ├── METADATA
│   │   ├── RECORD
│   │   ├── WHEEL
│   │   └── top_level.txt
│   ├── starlette
│   │   ├── __init__.py
│   │   ├── __pycache__
│   │   │   ├── __init__.cpython-38.pyc
│   │   │   ├── applications.cpython-38.pyc
│   │   │   ├── authentication.cpython-38.pyc
│   │   │   ├── background.cpython-38.pyc
│   │   │   ├── concurrency.cpython-38.pyc
│   │   │   ├── config.cpython-38.pyc
│   │   │   ├── convertors.cpython-38.pyc
│   │   │   ├── datastructures.cpython-38.pyc
│   │   │   ├── endpoints.cpython-38.pyc
│   │   │   ├── exceptions.cpython-38.pyc
│   │   │   ├── formparsers.cpython-38.pyc
│   │   │   ├── graphql.cpython-38.pyc
│   │   │   ├── requests.cpython-38.pyc
│   │   │   ├── responses.cpython-38.pyc
│   │   │   ├── routing.cpython-38.pyc
│   │   │   ├── schemas.cpython-38.pyc
│   │   │   ├── staticfiles.cpython-38.pyc
│   │   │   ├── status.cpython-38.pyc
│   │   │   ├── templating.cpython-38.pyc
│   │   │   ├── testclient.cpython-38.pyc
│   │   │   ├── types.cpython-38.pyc
│   │   │   └── websockets.cpython-38.pyc
│   │   ├── applications.py
│   │   ├── authentication.py
│   │   ├── background.py
│   │   ├── concurrency.py
│   │   ├── config.py
│   │   ├── convertors.py
│   │   ├── datastructures.py
│   │   ├── endpoints.py
│   │   ├── exceptions.py
│   │   ├── formparsers.py
│   │   ├── graphql.py
│   │   ├── middleware
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── __init__.cpython-38.pyc
│   │   │   │   ├── authentication.cpython-38.pyc
│   │   │   │   ├── base.cpython-38.pyc
│   │   │   │   ├── cors.cpython-38.pyc
│   │   │   │   ├── errors.cpython-38.pyc
│   │   │   │   ├── gzip.cpython-38.pyc
│   │   │   │   ├── httpsredirect.cpython-38.pyc
│   │   │   │   ├── sessions.cpython-38.pyc
│   │   │   │   ├── trustedhost.cpython-38.pyc
│   │   │   │   └── wsgi.cpython-38.pyc
│   │   │   ├── authentication.py
│   │   │   ├── base.py
│   │   │   ├── cors.py
│   │   │   ├── errors.py
│   │   │   ├── gzip.py
│   │   │   ├── httpsredirect.py
│   │   │   ├── sessions.py
│   │   │   ├── trustedhost.py
│   │   │   └── wsgi.py
│   │   ├── py.typed
│   │   ├── requests.py
│   │   ├── responses.py
│   │   ├── routing.py
│   │   ├── schemas.py
│   │   ├── staticfiles.py
│   │   ├── status.py
│   │   ├── templating.py
│   │   ├── testclient.py
│   │   ├── types.py
│   │   └── websockets.py
│   ├── starlette-0.12.9-py3.8.egg-info
│   │   ├── PKG-INFO
│   │   ├── SOURCES.txt
│   │   ├── dependency_links.txt
│   │   ├── installed-files.txt
│   │   ├── not-zip-safe
│   │   ├── requires.txt
│   │   └── top_level.txt
│   ├── typing_extensions-3.7.4.1.dist-info
│   │   ├── INSTALLER
│   │   ├── LICENSE
│   │   ├── METADATA
│   │   ├── RECORD
│   │   ├── WHEEL
│   │   └── top_level.txt
│   └── typing_extensions.py
├── mangum.yml
└── requirements.txt

ローカルビルドをパッケージ化する

前の項目でローカルビルドの作成に成功したら、今度は mangum package を実行してローカルビルドをパッケージ化します。

mangum package

正常に終了すると packaged.ymltemplate.yml の、ふたつのファイルが作成されているはずです。 このファイルはほぼ同じ内容で、CodeUri だけが異なります。 以下に packaged.yml のサンプルを引用しておきます。

packaged.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ASGI application updated @ 2020-02-04 02:13:06.968651
Globals:
  Function:
    Timeout: 300
Parameters: {}
Resources:
  HTTPFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://my-first-mangum-bucket/1234e4b5b40235469223c27031801234
      Handler: asgi.handler
      Runtime: python3.7
      Environment:
        Variables: {}
      Events:
        ProxyApiRoot:
          Type: Api
          Properties:
            Path: /
            Method: ANY
        ProxyApiGreedy:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: ANY
  HTTPFunctionIAMPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: root
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Action:
          - s3:*
          Resource:
          - arn:aws:s3:::*
          - arn:aws:s3:::*/*
        - Effect: Allow
          Action: dynamodb:*
          Resource:
          - arn:aws:dynamodb:::*
          - arn:aws:dynamodb:::*/*
        - Effect: Allow
          Action:
          - execute-api:ManageConnections
          Resource:
          - arn:aws:execute-api:::*
          - arn:aws:execute-api:::*/*
      Roles:
      - Ref: HTTPFunctionRole
Outputs:
  HTTPFunctionAPI:
    Description: API Gateway endpoint URL for HTTPFunction
    Value:
      Fn::Sub: https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/
  HTTPFunction:
    Description: HTTPFunction ARN
    Value:
      Fn::GetAtt: HTTPFunction.Arn
  HTTPFunctionRole:
    Description: Implicit IAM Role created for HTTPFunction
    Value:
      Fn::GetAtt: HTTPFunctionRole.Arn

テンプレートの妥当性を評価する

必須ではありませんが、mangum validate を実行すると CloudFormation 用テンプレートの妥当性を確認することが出来ます。

# mangum validate
[04-Feb-20 01:38:09] Found credentials in shared credentials file: ~/.aws/credentials
Template is valid!

アプリケーションを AWS へデプロイする

ここまでの用意が完了したら、いよいよアプリケーションを AWS へデプロイします。 mangum deploy を実行します。 内部的には CloudFormation で必要な設定を作成していくのですが、若干時間がかかります。 今回は最小のアプリケーションを作成したつもりですが、私の環境では mangum deploy が完了するまでに 1 分かかりました。

mangum deploy

AWS の設定を確認する

S3 や Lambda、API Gateway を確認すると CloudFormation によってデプロイされた各種設定を確認出来ます。 S3 上にはアプリケーションのパッケージがアップロードされていました。

f:id:sig9:20200204021746p:plain

Lambda 関数も定義されています。

f:id:sig9:20200204021750p:plain

Lambda 関数のトリガーに API Gateway が設定されています。

f:id:sig9:20200204021753p:plain

ブラウザでアクセスしてみる

Lambda 関数のトリガーの API Gateway に設定された URL へブラウザでアクセスすると無事、ASGI アプリケーションが実行されました。

f:id:sig9:20200204021756p:plain