こんにちは。アプリケーションサービス部の河野です。
アプリケーションサービス部では、週に一回、技術発表会という形で、発表者が興味がある技術について自由にプレゼンテーションしています。
その中で「App Runner で REST API 開発するの良いぞ」っていう話をしたので、本ブログでも紹介したいと思います。
導入
AWS で サーバレスで REST API といえば、API Gateway × Lambda をイメージする方が多いと思います。
私もその一人で、大抵の場合は問題ないですが、いくつかの課題もあると考えています。
コールドスタート問題
Lambda は初回起動及び一定時間経過してから実行された場合は、起動に時間がかかることがあります。
これらは、REST APIのレスポンスタイムに直結するため、しばしば問題になることがあります。
最近では、Lambda のProvisioned Concurrency を設定して Lambda を常時起動させることで対策する方法もあります。
しかし、コストの問題だけではなく、Lambda のメリットである自動スケールが効かなくなるため、メリットデメリットを把握した上で設定する必要があります。
デプロイ容量問題
Lambda のデプロイ容量は、解凍後のサイズが 250MB と制限(zip では 50MB)されており、ライブラリの数によっては超えてしまう場合もあります。
当初はデプロイサイズに収まっていたが、機能追加のタイミングでライブラリを追加した場合に超えてしまったという話もよく耳にします。
コンテナ 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 を開発者が簡単かつ迅速にデプロイできるフルマネージド型サービスです。
デプロイ方法が、ソースコードか ECR か2 通りありますが、今回は ECR の方式を採用しています。
理由は以下の通りです。
- Dockerfile を開発環境として利用したいため
- ソースコード(apprunner.yaml) の場合、ローカルでの環境再現が難しく、都度 App Runner にデプロイして動作確認しないといけないのがツラいため
FastAPI とは?
公式ドキュメントが非常にわかりやすく、チュートリアルも豊富なため、初めての方は是非読んでみてください。
環境
- MacOS Monterey
- Docker 20.10.13
パターン①: データストアなし
まずは、一番シンプルなパターンです。
App Runner に FastAPI のコンテナアプリケーションをホスティングしています。
App Runner にアクセスすると、{“message”: “Hello World”} を返す API を実装します。
パターン①: 実践
ローカルで動作確認
まずは、FastAPI で Hello World を返すプログラムを実装します。
from fastapi import FastAPI app = FastAPI() @app.get("/") async def root(): return {"message": "Hello World"}
次に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 以降であれば動作するため、適宜ベースイメージは変更してください。
以下のコマンドを実行し、ローカルで動作を確認します。
$ 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 を使用する場合です。
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 を立ち上げて、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
にスクリプトファイルをマウントすることで、起動時に初期化スクリプトも走らせることも可能です。
どのファイルにマウントするかで実行順序が変わるため、詳細は以下ドキュメントを参考にしてください。
今回は、以下スクリプトを配置して、コンテナ起動時にサンプルテーブルとテストデータが入るようにしました。
# 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 を使用する場合です。
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 を使用しました。
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"
パターン②同様、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 を使用する必要があります。
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 自体をプライベートに置きたい場合は、以下を参照してください。
作成した 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 は必要ありませんが、参考のため配置しています。
ネットワークについて詳細が気になる方は以下をご参照ください。
環境変数 と Secrets Manger を連携する
Aurora の接続情報は、Secrets Manager に保存しています。
最近のアップデートで、Secrets Manager から取得できるようになったので、ついでに試してみました。
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 (執筆記事の一覧)