💬

Lambdaコンテナをローカル実行したら、WebブラウザからのアクセスでCORSで詰んだ話とFastAPIでの回避法

に公開

はじめに

AWS Lambda をコンテナイメージでローカル実行したとき、curl では動作確認できたのに、Webブラウザからアクセスしようとすると CORS エラーでうまくいかない、という経験をしました。

この記事ではその原因と、FastAPI を使って回避する方法について紹介します。

そもそもなぜコンテナイメージでLambdaを作ろうとするのか?

AWS Lambdaはzipとコンテナimageの2つの展開方法があります。今回はコンテナイメージでLambdaを作りますが以下のようなメリットがあるため、Lambdaをコンテナイメージで作ります。

コンテナイメージLambdaのメリット

  • ライブラリ導入が圧倒的に楽
    個人的にこれが一番の理由です。zip形式だと依存ライブラリをLambda LayerやEFSマウントして導入・管理する必要があります。
    コンテナであれば、Dockerfile に RUN pip install と書くだけで済みます。

  • サイズ上限が大きい
    zipアップロード形式では 250MB の制限がありますが、コンテナ形式では 10GBまでOK

  • CI/CD に組み込みやすい
    イメージビルド&ECRプッシュ→Lambda更新 というフローが確立できると、zipをアップロードするより運用がスムーズです。

コンテナイメージLambdaのデメリット

  • 起動がやや遅くなることも
    イメージサイズが大きくなるとコールドスタートが遅くなる可能性があります。ただし、工夫すればある程度は改善できます(軽量ベースイメージ、必要なものだけインストールなど)。

  • 複雑な場合はECRとIAM管理が必要
    権限まわりやECRの管理を含めると、初心者にはややハードルが高いかもしれません。

環境構成

今回は、検証のためDockerを用いて、LambdaのPythonのコンテナとそれにアクセスするクライアントを構築します。

筆者検証環境

  • OS Windows11(Home)
  • WSL2(ubuntu22.04)
  • Docker導入済みであること

ディレクトリ構成

<project_root>
├── backend/
│ ├── app.py # Lambdaハンドラー
│ └── Dockerfile # LambdaまたはFastAPIとして起動
├── client/
│ ├── index.html # ブラウザから呼び出し
│ └── Dockerfile
└── docker-compose.yml # ローカルで統合実行

ファイルの内容

docker-compose.yml

services:
  client:
    container_name: test_client
    build:
      context: .
      dockerfile: client/Dockerfile
    tty: true
    ports:
      - "3100:3100"
    volumes:
      - ./client:/app
      - test_client_node_modules:/app/node_modules
    networks:
      - test_network

  backend:
    container_name: test_backend
    build:
      context: .
      dockerfile: backend/Dockerfile
    tty: true
    ports:
      - "9000:8080"
    volumes:
      - ./backend:/var/task
    networks:
      - test_network

volumes:
  test_client_node_modules:
    driver: local

networks:
  test_network:
    driver: bridge

backend/Dockerfile

FROM public.ecr.aws/lambda/python:3.11
COPY ./backend/app.py ./
CMD ["app.lambda_handler"]

backend/app.py

import json

def lambda_handler(event, context):
    if event.get("httpMethod") == "OPTIONS":
        return {
            "statusCode": 200,
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
                "Access-Control-Allow-Headers": "Content-Type",
            },
            "body": "",
        }

    return {
        "statusCode": 200,
        "headers": {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type",
        },
        "body": json.dumps("Hello from Lambda container!"),
    }

client/Dockerfile

FROM node:20-alpine
WORKDIR /usr/src/app
RUN npm install -g http-server
COPY ./client .
EXPOSE 3100
CMD ["http-server", "-p", "3100"]

client/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Lambda APIテスト</title>
</head>
<body>
  <h1>APIテスト</h1>
  <button id="apiButton">Lambdaを呼び出す</button>
  <pre id="result"></pre>

  <script>
    async function fetchApiData() {
      try {
        const response = await fetch("http://127.0.0.1:9000/2015-03-31/functions/function/invocations", {
          method: "POST",
          headers: {
            "Content-Type": "application/json"
          },
          body: JSON.stringify({})
        });
        const data = await response.json();
        return data;
      } catch (error) {
        console.error("APIリクエストエラー:", error);
        throw error;
      }
    }

    document.getElementById("apiButton").addEventListener("click", async () => {
      const resultElem = document.getElementById("result");
      resultElem.textContent = "リクエスト中...";
      try {
        const data = await fetchApiData();
        resultElem.textContent = JSON.stringify(data, null, 2);
      } catch (e) {
        resultElem.textContent = "エラー: " + e;
      }
    });
  </script>
</body>
</html>

検証環境立ち上げ

Docker導入済みであれば以下のコマンドで環境を立ち上げられる

docker compose build
docker compose up -d

実行

ローカル実行する(curlでアクセス)

Lambdaをローカルでテストするには、次のようなエンドポイントを使います。
今回はdocker-compose.ymlでbackendのサービスのportを9000:8080にしているので以下のようになります。

curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
# 以下レスポンス
{"statusCode": 200, "headers": {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"}, "body": "\"Hello from Lambda container!\""}

このように問題なく動作し、レスポンスも得られます。

ローカル実行する(Webブラウザからアクセス、失敗!)

任意のWebブラウザ(Google Chrome等)からhttp://localhost:3100/にアクセスして、「Labbdaを呼び出す」ボタンを押すと「エラー: TypeError: Failed to fetch」というメッセージが出てきます。
この時、ブラウザの開発者コンソールにはCORSエラーが表示されます。
開発者コンソール

原因の詳細は、正直言うとよくわかっていません。ブラウザでは事前に OPTIONS リクエスト(プリフライト)が送られるのですが、このURLではOPTIONS に対応していないためエラーとなるというこ都があるらしくその可能性はあります。

回避策:FastAPIをかませる

回避策として、FastAPIを使ってLambdaをHTTPエンドポイント化することで回避します。

修正ファイル

backend/Dockerflie

FROM python:3.11-slim
WORKDIR /var/task
COPY ./backend/app.py ./
RUN pip install fastapi uvicorn
CMD ["python", "-m", "uvicorn", "dev_main:app", "--host", "0.0.0.0", "--port", "8080", "--reload", "--reload-dir", "."]

ベースイメージの変更:public.ecr.aws/lambda/python:3.11→python:3.11-slim
fastapiとuvicornのインストール、fastapiの起動をするようにしています。

また、backend/dev_main.py では以下のように Lambda ハンドラーを FastAPI 経由でラップしています。

backend/dev_main.py

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app import lambda_handler

app = FastAPI()

# CORS対応
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.post("/api")
async def lambda_compatible_endpoint(request: Request):
    event = {"body": ""}
    result = lambda_handler(event, context={})
    return result
if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8080)

また、クライアントのアクセス先URLも変更が必要です。
http://127.0.0.1:9000/2015-03-31/functions/function/invocations

http://127.0.0.1:9000/api

修正後の動作

curlコマンドはアクセス先URLが変わります

curl localhost:9000/api -X POST
# レスポンスは先ほどと同じ

先ほどと同じようにブラウザからのアクセスし「Lambdaを呼び出す」をクリックすると

{
  "statusCode": 200,
  "headers": {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type"
  },
  "body": "\"Hello from Lambda container!\""
}

のようなメッセージが画面上に表示されます。

まとめ

  • Lambdaコンテナは curl では動くが、ブラウザでは CORS でブロックされる
  • OPTIONS リクエストに対応していないことが原因と思われる
  • FastAPI を挟むことで CORS にも対応可能
  • ローカル検証であってもブラウザアクセスを考えるなら、FastAPIなどをかませる必要がある

サーバレスの開発って難しいです…

Discussion