AWS App Runner × FastAPI で作る REST API パターン 3 選

記事タイトルとURLをコピーする

こんにちは。アプリケーションサービス部の河野です。
アプリケーションサービス部では、週に一回、技術発表会という形で、発表者が興味がある技術について自由にプレゼンテーションしています。
その中で「App Runner で REST API 開発するの良いぞ」っていう話をしたので、本ブログでも紹介したいと思います。

導入

AWS で サーバレスで REST API といえば、API Gateway × Lambda をイメージする方が多いと思います。
私もその一人で、大抵の場合は問題ないですが、いくつかの課題もあると考えています。

コールドスタート問題

Lambda は初回起動及び一定時間経過してから実行された場合は、起動に時間がかかることがあります。
これらは、REST APIのレスポンスタイムに直結するため、しばしば問題になることがあります。

最近では、Lambda のProvisioned Concurrency を設定して Lambda を常時起動させることで対策する方法もあります。
しかし、コストの問題だけではなく、Lambda のメリットである自動スケールが効かなくなるため、メリットデメリットを把握した上で設定する必要があります。

docs.aws.amazon.com

デプロイ容量問題

Lambda のデプロイ容量は、解凍後のサイズが 250MB と制限(zip では 50MB)されており、ライブラリの数によっては超えてしまう場合もあります。

docs.aws.amazon.com

当初はデプロイサイズに収まっていたが、機能追加のタイミングでライブラリを追加した場合に超えてしまったという話もよく耳にします。
コンテナ Lamdba にすることによって 10GB まで可能になるため、回避することができます。

serverless.yaml 書くのツラい問題

少し本題とはずれますが、弊社では serverless framework を使ってデプロイすることが多いのであえて取り上げています。
極端な例をあげていますが、Lambda を追加する度に、Lambda と IAM の設定をすることに煩わしい気持ちが正直あります。

スライドの通り Lambda の数が多くなる場合は、Fast API や Flask などのフレームワークを使用して Lambda の数を抑えるアプローチを検討するのもアリだと考えています。

ローカルのテストしづらい問題

Lambda から DynamoDB の処理をローカルで動作確認どうしてますか?という話です。
実行環境にAWS のクレデンシャルを配置して、ハンドラーを直接実行させて AWS 環境のリソース(ここでは DynamoDB)にアクセスさせるパターンが多いのではないでしょうか。
テストコードを書く際は、DynamoDB にアクセスする関数をラップして、その関数が適切な引数で呼び出されているかや、moto を使って boto3 をモックするなどの対応をすることが多いです。

コスト面やクレデンシャルの配置ミスで想定外の環境にアクセスしてしまう事故も起こりかねないため、できればローカルだけで完結したい気持ちもあります。


主に開発者目線での課題が多いですが、以上を踏まえて App Runner で REST API を開発するパターンを考えていきたいと思います。

App Runner とは?

コンテナ化されたウェブアプリケーションや API を開発者が簡単かつ迅速にデプロイできるフルマネージド型サービスです。

aws.amazon.com

デプロイ方法が、ソースコードか ECR か2 通りありますが、今回は ECR の方式を採用しています。
理由は以下の通りです。

  • Dockerfile を開発環境として利用したいため
  • ソースコード(apprunner.yaml) の場合、ローカルでの環境再現が難しく、都度 App Runner にデプロイして動作確認しないといけないのがツラいため

FastAPI とは?

公式ドキュメントが非常にわかりやすく、チュートリアルも豊富なため、初めての方は是非読んでみてください。

fastapi.tiangolo.com

環境

  • MacOS Monterey
  • Docker 20.10.13

パターン①: データストアなし

まずは、一番シンプルなパターンです。 App Runner に FastAPI のコンテナアプリケーションをホスティングしています。
App Runner にアクセスすると、{“message”: “Hello World”} を返す API を実装します。

github.com

パターン①: 実践

ローカルで動作確認

まずは、FastAPI で Hello World を返すプログラムを実装します。

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

fastapi.tiangolo.com

次にDockerfile を作成します。

FROM python:3.10-slim

RUN pip install --upgrade pip

COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt

EXPOSE 8000

COPY ./app /app

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

https://github.com/g-kawano/fast_runner/blob/main/pattern_01/Dockerfile

FastAPI からイメージが提供されていますが、ECR の脆弱性スキャンに CRITICAL で一件該当したため、python の公式イメージに変更しています。 python3.6 以降であれば動作するため、適宜ベースイメージは変更してください。

fastapi.tiangolo.com

以下のコマンドを実行し、ローカルで動作を確認します。

$ docker build -t fastrunner-image .

$ docker run -d --name fastrunner -p 8000:8000 fastrunner-image

curl で {"message":"Hello World"} が取得できればOKです。

$ curl http://localhost:8000

ECR を作成

今回は、CloudFormation を使用しました。

AWSTemplateFormatVersion: 2010-09-09
Description: "ecr.template.yml"

Parameters:
  AppName:
    Type: String
    Default: fastrunner

Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub "${AppName}-image"
      ImageTagMutability: MUTABLE
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256

https://github.com/g-kawano/fast_runner/blob/main/pattern_01/ecr.template.yaml

ポイントは以下の通りです。

  • ImageTagMutability を MUTABLE(上書き可能)としています。これは App Runner が一つの イメージタグを参照するためです。
  • ECR のイメージスキャンを ON にしています。ECR にイメージをプッシュ時に脆弱性をスキャンしてくれます。

以下コマンドでデプロイします

$ aws cloudformation deploy --template-file ecr.template.yaml --stack-name fastrunner-ecr

イメージをビルド&プッシュ

ECR のコンソール画面から、「プッシュコマンドの表示」から、プッシュコマンドが表示されるので、これに従って実行していきます。

プッシュが成功すると、ECR の脆弱性スキャンの実行結果も確認できます。

今回、python の公式ベースイメージを使用しましたが、HIGH で一件検出されました。SQLite のバージョンに脆弱性がありますが、今回は検証用かつ使用していないため、スルーします。

App Runner デプロイ

AWSTemplateFormatVersion: 2010-09-09
Description: "app_runner.template.yml"

#----------------------------------------
# Parameters
#----------------------------------------
Parameters:
  AppName:
    Type: String
    Default: fastrunner

Resources:
  AppRunner:
    Type: AWS::AppRunner::Service
    Properties:
      ServiceName: !Sub "${AppName}-service"
      SourceConfiguration:
        AuthenticationConfiguration:
          AccessRoleArn: !GetAtt EcrAccessRole.Arn
        AutoDeploymentsEnabled: true
        ImageRepository:
          ImageIdentifier: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${AppName}-image:latest"
          ImageRepositoryType: ECR
          ImageConfiguration:
            Port: "8000"
      InstanceConfiguration:
        Cpu: 1 vCPU
        Memory: 2 GB
        InstanceRoleArn: !GetAtt AppRunnerRole.Arn

  AppRunnerRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AppName}-app-runner-service-role"
      # アプリケーションに付与する権限
      # ManagedPolicyArns:
      #   - !Ref AppRunnerPolicy
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - tasks.apprunner.amazonaws.com
            Action: sts:AssumeRole

  EcrAccessRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AppName}-ecr-access-role"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - build.apprunner.amazonaws.com
            Action: sts:AssumeRole

https://github.com/g-kawano/fast_runner/blob/main/pattern_01/app_runner.template.yaml

ポイントは以下の通りです。

  • App Runner に二つの IAM ロールを付与しています。
    • ECR にアクセスするためのロール
    • App Runner のアプリケーションで使用するロール
  • ImageIdentifier で参照するイメージを定義しています。もしタグをイミュータブルに設定した場合は、この定義を都度変更する必要があります。

以下コマンドでデプロイします。

$ aws cloudformation deploy --template-file app_runner.template.yaml --stack-name fastrunner-app-runner --capabilities CAPABILITY_NAMED_IAM

ステータスが Runnning になっていることを確認し、デフォルトドメインに対して curl コマンドを実行し Hello Wolrd が返却されれば成功です。

上手くいかない場合は?

App Runner のコンソールのログから、イベントログ、デプロイログ、アプリケーションログが確認できるので、状況に応じて各種ログを確認してみてください。

パターン②: DynamoDB

次は、データストアとして DynamoDB を使用する場合です。

github.com

id を指定して、DynamoDB のテーブル内のヒットした アイテムを取得します。

パターン②: 実践

ローカルで動作確認

まずは、main.py に以下メソッドを追加します。
パスから id を取得して DynamoDB に対して get_item を実行し、取得したデータを返しています。
今回は DynamoDB 操作用クラスを自作していますが、本題とは関係ないため説明は割愛します。

@app.get("/items/{id}", response_model=ResponseItem)
def get_item(id: str):
    """
    アイテムを取得する
    """
    table = DynamoDBTable("fastrunner-table")
    item = table.get_item({"id": id})

    if not item:
        raise HTTPException(status_code=404, detail="Item not found")

    return item

https://github.com/g-kawano/fast_runner/blob/main/pattern_02/app/main.py

実装したコードをどのようにしてローカルで動作確認を行えばよいでしょうか?

今回は、ローカル環境で完結させたかったので、方法2を試してみました。

以下のような開発環境用の docker-compose.yaml を作成し、FastAPI コンテナから localstack(DynamoDB) コンテナに向けて通信をします。

version: '3'
services:

  fastapi:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: fastapi-container
    environment:
      - LOCAL_STACK_ENDPOINT=http://localstack:4566
      # dummy
      - AWS_DEFAULT_REGION=ap-northeast-1
      - AWS_DEFAULT_OUTPUT=json
      - AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXX
      - AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXX
    restart: always
    ports:
      - 8000:8000

  localstack:
    image: localstack/localstack:latest
    container_name: localstack
    environment:
      - SERVICES=dynamodb
      - DOCKER_HOST=unix:///var/run/docker.sock
      # dummy
      - AWS_DEFAULT_REGION=ap-northeast-1
      - AWS_DEFAULT_OUTPUT=json
      - AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXX
      - AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXX
    volumes:
      - "./scripts:/etc/localstack/init/ready.d"
    ports:
      - "127.0.0.1:4566:4566"
      - "127.0.0.1:4510-4559:4510-4559"

docker compose up を実行すると、fastapi コンテナと localstack コンテナが立ち上がります。
図にすると以下のようなイメージです。

それぞれのコンテナについて解説します。

fastapi コンテナ
  • App Runner にデプロイする Dockerfile を使用することで、デプロイするアプリケーションをローカルで動作確認します
  • LOCAL_STACK_ENDPOINT を localstack に指定することで、DynamoDB のアクセス先を localstack に向けています。
    • デプロイ時は指定しないことで、AWS 環境にデプロイされた DynamoDB にアクセスします(参考
localstack コンテナ

LocalStack を使用することで、簡単にコンテナ上で AWS リソースを動作させることができます。

localstack.cloud

docs.localstack.cloud

LocalStack を立ち上げて、endpoint に "http://localhost:4566" を指定すれば、LocalStack に対して 、実際の AWS 環境と同じような操作を行うことができます。

例えば、テーブルを作成する場合はローカルPCから以下のコマンドを実行すると、localstack コンテナにテーブルを作成することができます。

$ aws --endpoint-url=http://localhost:4566 dynamodb describe-table --table-name fastrunner-table

先に挙げたイメージ図の通り、fastapi コンテナから通信する場合は、コンテナ間通信のため endpoint は "http://localstack:4556" になります。

また、/etc/localstack/init/localstack にスクリプトファイルをマウントすることで、起動時に初期化スクリプトも走らせることも可能です。
どのファイルにマウントするかで実行順序が変わるため、詳細は以下ドキュメントを参考にしてください。

docs.localstack.cloud

今回は、以下スクリプトを配置して、コンテナ起動時にサンプルテーブルとテストデータが入るようにしました。

# wait for dynamodb to be ready
while ! curl -s http://localhost:4566/ > /dev/null; do
    sleep 1
done

# create fastrunner-table if it doesn't exist
aws --endpoint-url=http://localhost:4566 dynamodb describe-table --table-name fastrunner-table 2>/dev/null || \
    aws --endpoint-url=http://localhost:4566 dynamodb create-table --table-name fastrunner-table \
        --attribute-definitions AttributeName=id,AttributeType=S \
        --key-schema AttributeName=id,KeyType=HASH \
        --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

# add sample data to hogeTable
aws --endpoint-url=http://localhost:4566 dynamodb put-item --table-name fastrunner-table \
    --item '{"id": {"S": "1"}, "name": {"S": "Alice"}, "age": {"N": "25"}}'
aws --endpoint-url=http://localhost:4566 dynamodb put-item --table-name fastrunner-table \
    --item '{"id": {"S": "2"}, "name": {"S": "Bob"}, "age": {"N": "30"}}'

以下のコマンドを実行し、ローカルで動作を確認します。

$ docker compose up --build

curl でそれぞれのデータが取得できれば OK です。

$ curl http://localhost:8000/items/1

DynamoDB デプロイ

以下テンプレートを実行し、テーブルを作成します。

https://github.com/g-kawano/fast_runner/blob/main/pattern_02/templates/dynamodb.template.yaml

イメージをビルド&プッシュ

ECR と App Runner は既に存在している場合、ECR にイメージをプッシュすれば App Runner 側で自動的にアプリケーションが更新されます。

ただ、今回 App Runner から DynamoDB にアクセスしているため、イメージを PUSH する前に IAM Policy を付与する必要があります。

デプロイ後、ステータスが Runnning になっていることを確認し、デフォルトドメインに対して curl コマンドを実行し 指定した id のアイテムが返却されれば成功です。

パターン③: Aurora

最後は、データストアとして Aurora を使用する場合です。

github.com

DynamoDB と同様に以下のテーブルを作成し、id を指定してヒットしたレコードを取得します。

create table items(
    id int auto_increment,
    name text,
    age int,
    primary key (id)
) ;

insert into items(name, age)values('Alice', 25);
insert into items(name, age)values('Bob', 30);

パターン③: 実践

ローカルで動作確認

以下の docker-compose.yaml を作成しました。前回の LocalStack のコンテナが mysql のコンテナに変わっただけです。

version: '3'
services:

  fastapi:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: fastapi-container
    environment:
      DB_HOST: mysql
      DB_NAME: ${DB_NAME}
      DB_USER: ${DB_USER}
      DB_PASS: ${DB_PASS}
      IS_CONNECTION_LOCAL: "True"
    restart: always
    ports:
      - 8000:8000

  mysql:
    image: mysql:8.0
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASS}
      TZ: 'Asia/Tokyo'
    volumes:
      - ./scripts:/docker-entrypoint-initdb.d
    restart: always
    ports:
    - "127.0.0.1:3306:3306"

それぞれの接続コマンドを記載していますが、パターン②同様、fastapi コンテナからアクセスする場合は、ホスト名がコンテナ名になっています。
データベースの接続は、SQLAlchemy を使用しました。

www.sqlalchemy.org

IS_CONNECTION_LOCAL の環境変数にローカル接続と AWS 環境での接続の切り替えるフラグを持たせ、それぞれ データベースのアクセス URL を組み立てるようにしています。

# ローカルDB接続かどうかの判断
if settings.IS_CONNECTION_LOCAL:
    DB_URL = f"mysql+pymysql://{settings.DB_USER}:{settings.DB_PASS}@{settings.DB_HOST}:3306/{settings.DB_NAME}"
else:
    db_secrets = json.loads(settings.DB_SECRETS)
    DB_URL = f"mysql+pymysql://{quote(db_secrets['DB_USER'])}:{quote(db_secrets['DB_PASS'])}@{quote(settings.DB_HOST)}:3306/{quote(settings.DB_NAME)}?charset=utf8"

github.com

パターン②同様、docker-entrypoint-initdb.d に sql ファイルをマウントし、コンテナ起動時に初期テーブルとサンプルデータが入るようにしています。

以下のコマンドを実行し、ローカルで動作を確認します。

$ docker build -t fastrunner-image .

$ docker run -d --name fastrunner -p 8000:8000 fastrunner-image

curl でそれぞれのデータが取得できれば OK です。

$ curl http://localhost:8000/items/1

App Runner から Auroa に接続する

App Runner から VPC 内のリソースに接続するためには、VPC Connector を使用する必要があります。

docs.aws.amazon.com

CloudFormation では以下になります。

 VpcConnector:
    Type: AWS::AppRunner::VpcConnector
    Properties:
      VpcConnectorName: !Sub "${AppName}-vpc-connector"
      Subnets:
        - !ImportValue
          Fn::Sub: "${AppName}-private-subnet-1"
        - !ImportValue
          Fn::Sub: "${AppName}-private-subnet-2"
      SecurityGroups:
        - !ImportValue
          Fn::Sub: "${AppName}-apprunner-security-group-id"
  • サブネット
    • Aurora が所属しているサブネットを指定します
  • セキュリティーグループ
    • アウトバウンド通信を全て許可したセキュリティーグループを指定します(デフォルト)

Inbound traffic – Incoming messages that your application receives are unaffected by an associated VPC. The messages are routed through the public domain name that's associated with your service and don't interact with the VPC. https://docs.aws.amazon.com/apprunner/latest/dg/network-vpc.html#Security%20group

VPC コネクタは、アプリケーションからのアウトバウンド通信にのみ使用されるため、インバウンドの通信を設定しても無視されます。(アプリケーションへのアクセスはデフォルトドメインを介して受信します)

App Runner 自体をプライベートに置きたい場合は、以下を参照してください。

aws.amazon.com

作成した VPC Connector を App Runner に適用します。(一部抜粋)

      NetworkConfiguration:
        EgressConfiguration:
          EgressType: VPC
          VpcConnectorArn: !GetAtt VpcConnector.VpcConnectorArn

これにより、App Runner のアウトバウンド通信は、VPC Connector で指定した Subnet に送信され、同 Subnet 内にある Aurora に通信が可能になります。
VPC Connector を使用した場合、アプリケーションのアウトバウンド通信は、VPC Connector で指定したサブネットのルーティングに従うことになります。
そのため、例えばアプリケーション内でインターネットに通信が必要な場合は、NAT Gateway 経由でインターネットに出る経路を設定する必要があります。
今回のパターン③の場合は、App Runner ⇄ Aurora のみの通信のため NatGatway は必要ありませんが、参考のため配置しています。

ネットワークについて詳細が気になる方は以下をご参照ください。

aws.amazon.com

環境変数 と Secrets Manger を連携する

Aurora の接続情報は、Secrets Manager に保存しています。
最近のアップデートで、Secrets Manager から取得できるようになったので、ついでに試してみました。

aws.amazon.com

Cloudformation では以下のように定義します。 Name に Secrets Manager の Key 名を指定し、Value に 取得したい Secrets Manager の ARN を指定すればOKです。

ImageConfiguration:
            Port: "8000"
            RuntimeEnvironmentSecrets:
              - Name: DB_SECRETS
                Value: !ImportValue
                  Fn::Sub: "${AppName}-db-secrets-arn"

https://github.com/g-kawano/fast_runner/blob/main/pattern_03/templates/app_runner.template.yaml#L28

アプリケーション側では、DB_SECRETS に Secrets Manager の JSON 文字列が設定されているので、パースしてデータを取得します。

db_secrets = json.loads(settings.DB_SECRETS)

https://github.com/g-kawano/fast_runner/blob/main/pattern_03/app/lib/db/session.py#L13

また、Secrets Manager から取得する場合は、パターン②同様、App Runner の IAM ロールに IAM Policy を付与する必要があります。

 - Effect: Allow
            Action:
              - "secretsmanager:GetSecretValue"
              - "secretsmanager:DescribeSecret"
              - "kms:Decrypt*"
            Resource:
              - !ImportValue
                Fn::Sub: "${AppName}-db-secrets-arn"
              - !Sub "arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/*"

https://github.com/g-kawano/fast_runner/blob/main/pattern_03/templates/app_runner.template.yaml#L88

VPC や Aurora 自体の説明は省きましたが、App Runner の設定を更新後、イメージを ECR にプッシュして、デフォルトドメインに対して curl コマンドを実行し 指定した id のレコードが返却されれば成功です。

さいごに

長くなってしまいましたが、 App Runner で REST API を実装するパターンを 3 つ紹介させていただきました。
本当はプライベートサービスも試したかったところですが、次の機会にします。

さて、導入で示した Lambda の課題は割と解決できているのではないでしょうか?

本番ワークロードを考えた場合には、まだまだ考慮する部分は出てくると思いますが、本記事がAPI Gateway × Lambda だけではなく、App Runner の選択肢もあるということを知っていただくきっかけになれば幸いです。

swx-go-kawano (執筆記事の一覧)