初学者に優しいスモールスタートで始めるPythonアプリケーションを考えてみた

結果的にFastAPI/APIGateway/Lambdaの構成になった

こんちは、プログラミング初学者に優しいPython製のwebアプリケーションをスモールスタートで始めるためにどのような構成で作ればいいか考えて、ついでにAWSに実行環境を構築してみました。

最近、プログラミングを教える機会がありました。その人はプログラミングで就職することが目的だったので、プログラミング初学者が最速で就職できるようにPythonのアプリケーションの実装をお願いすることになったのですが、具体的にどういうアプリケーションの構成にすれば最短で就職して実務でプログラミングができるようになるのか少し考えてみました。

プログラミング初学者にとって最初に触れる言語やフレームワークはとても重要だと思っています。これらによってプログラミングのメンタルモデルが十分に構築されますし、別の言語・FWを学ぶ時に比較できるベースラインになります。
例えばプログラミングの初学者にHaskellを教えると、その人の頭には純粋な関数型のメンタルモデルが出来上がっていると思います。それもダメではないですが、就職を考えている初学者にとってはもう少しポピュラーでオールラウンドな言語・FWの方が就職への最短経路だと思います。

どんな構成にする?

構成を考える条件はこんな感じです。上から順番に重要だと思ってます

  • Pythonで動作すること
  • 初学者にとって優しいこと
    • 複雑でないこと
  • 基礎的なことを学べること
    • 様々な概念が隠匿されていないこと
  • AWSの上でデプロイされること
  • ランニングコストが小さいこと
  • PDCAが早く回しやすいこと
  • 管理しやすい(再現性が高い)こと
  • DynamoDBに対するSDKが存在していること

悩んだ結果、PythonフレームワークのFastAPIでアプリケーションを構築し、それをLambdaにデプロイする構成になりました。

なぜFastAPIとAPIGateway/Lambda?

上記の条件を大体満たすことができたからです
APIGateway/Lambdaでアプリケーションを構築するので、リクエストがない時間には費用がかかりにくく、ECSやEC2で構築する場合と比較してコストを抑えることができます。また、今回はRDSではなくDynamoDBと連携させるため、OLTPでRDSを扱う際のコネクションの枯渇問題も気にする必要がないです。

その上で動作するコードの構成も重要です。 Lambdaの上でPythonのwebアプリケーションを構築する場合には、FWを導入せずに実装することも可能です。ですが、その際に培われるLambda特有の知見や記述されるソースコードはLambda以外の場面で有効に役立つことは非常に少ないと考えてます。それに対し、何らかのFWの上で実装する場合だと、EC2/ECS環境やローカル環境でも知見を役立てたりコードを再利用することができます。
実際問題、今回作ってもらうアプリケーションでもアクセス数が順調に増えれば、LambdaからECSなどに移行する可能性もありますし、初学者としてFWを使った実装経験は今後の実務でとても有効に働くと考えてます。また、ローカル環境も構築しやすい点も重要なポイントです。

PythonのFWにはDjango/FastAPI/Flaskなど様々なものがありますが、フルスタックなFWか薄いFWかのいずれかに大別できます。
初学者にとってはデバッグのしやすさや個々の概念の理解しやすさが重要だと感じてます。実装すると何かしらの問題にすぐに躓くと思いますが、その問題が解決できずに挫折したり、理解しきれずに「よくわからんが動いている」状況になってしまうことは避けなければなりません。それを考えると、処理のフローが隠蔽される傾向にあるフルスタックなFWよりも、処理のフローを自分でコントロールしやすい薄いFWの方が適しているように感じます。

薄いFWでそこそこポピュラーだとFastAPIとFlaskの2択になりましたが、FastAPIを以下の理由で選びました、

  • バリデーションライブラリの使いやすさはFastAPIの方が良さそう
  • デフォルトでSwagger/Swagger UIの出力をサポートしている、多人数開発ではSwaggerなどを使ったスキーマ表現がほぼ必須に近いので学習しておいた方が良さそうです

ただし、FastAPIとAPIGateway/Lambdaを組み合わせるにはいくつか問題があります。
FastAPIではエントリーポイントは1つで複数のパスのルーティングを可能にしますが、APIGateway/Lambdaでは基本的に1つのパスに対して1つの関数となっています。また、リクエストがAPIGatewayを経由してLambda関数を呼び出す際にはリクエストの情報はevent変数に含まれますが、これはFastAPIのお作法とギャップがあります。
前者に関してはLambdaプロキシ統合で解決をすることができ、後者に関してはMangumを利用することで自動でリクエストをいい感じにマッピングしてくれます。

AWSに実行環境を構築してみた

初学者にいきなりインフラ構築やらフレームワークの動作環境構築などをお任せすることは難しいため、自分でAPIGateway/Lambdaの環境構築を行い、FastAPIの最低限のコードが動作する状態でデプロイしてみました。
APIGateway/Lambdaについては、TypeScriptでCDKを使って組んでみました。
これだけでAPIGateway/Lambdaが定義できて楽ちんです。ちなみに@aws-cdk/aws-lambda-python-alphaはexperimentalのようなので注意が必要です。

import * as cdk from 'aws-cdk-lib';
import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha';

import { Construct } from 'constructs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { join } from 'path';

export class SubReservationStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const api = new cdk.aws_apigateway.LambdaRestApi(this, "subreservation-apigateway", {
      handler: new PythonFunction(this, "subreservation-lambda", {
        entry: join(__dirname, "../../subapp"),
        index: "subapp/entrypoint/lambda.py",
        runtime: Runtime.PYTHON_3_11,
      })
    })
  }
}

FastAPI側の構成はこんな感じです。

.
├── README.md
├── iac
└── subapp                # Lambdaで指定する`entry`はここ
    ├── README.md
    ├── poetry.lock
    ├── poetry.toml
    ├── pyproject.toml
    ├── subapp
       ├── __init__.py
       ├── entrypoint
          ├── __init__.py
          ├── lambda.py # Lambdaで指定する`index`はここ
          └── local.py  # ローカル環境ではこのファイルを指定して起動
       └── main.py       # メインの処理はここ
    └── tests
        └── __init__.py

初めてPoetryを使ってみたのですが、poetry new hogehogeするとパッケージ名が二重でネストしている構成を出力します(例:hogehoge/hogehoge/__init__.py)。違和感がすごいです。。
また、PythonFunctionで指定するentrypoetry.tomlを含むディレクトリを指定しないとパッケージがLambda関数に含まれません(久々に実装する時に毎回忘れてパッケージのエラーが出てて時間くっちゃう…)

APIGateway/Lambdaが提供するパスのprefixに/prodがついている問題

APIGatewayで構築すると、ホストされるURLがhttps://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/ というように/prodがパスの先頭に付与されます。
FastAPIで@app.get("/hoge")というようにルーティングを定義していても、/hogeではアクセスできません。また、/prod/hogeでアクセスするとAPIGatewayはリクエストをFastAPIに渡してくれますがFastAPIでは/prod/hogeに合致するルーティングが存在せずエラーが出てしまいます。

これはMangumの初期化時にapi_gateway_base_path="/prod"を付与することで解決します。

ちなみにFastAPIでは/docsにアクセスするだけでSwagger UIが表示されるのですが、上記設定後に/prod/docsにアクセスしてもswaggerのjsonファイルの参照先が/openapi.jsonのままなのでSwagger UIが表示され無くなってしまいます。
これはFastAPIのopenapi_url変数をいじって回避することができます。

lambda.py

from ..main import app
from mangum import Mangum

app.openapi_url = "/prod/openapi.json"
handler = Mangum(app, lifespan="off", api_gateway_base_path="/prod")

まとめ

  • プログラミング初学者にはFastAPI/APIGateway/Lambdaの構成が良さそう
  • ググると3,4年前にSAMで同じような構成でデプロイする記事がいっぱい出てくる。昔流行ってたんやろうか?
  • Mangumが有能だった

See also