はじめに
社内向け管理画面や、ユーザー数・アクセス頻度が少ないWebアプリを開発する際、こんな悩みはありませんか?
- React + FastAPI + ECSは開発・運用コストが高すぎる
- JavaScript bundler設定、状態管理、複雑なデプロイパイプラインが面倒
- ECSは常時起動でコストがかかる(月$15-30程度)
- でも、素のHTMLだけだとインタラクティブ性が足りない
本記事では、FastAPI + htmx + AWS Lambda の組み合わせで、以下を実現する方法を紹介します:
- ✅ htmxで自前のJavaScriptを最小化したインタラクティブUI
- ✅ FastAPIでPythonのみで完結(型安全、高速開発)
- ✅ Lambdaで低コスト運用(アクセスない時は$0、月間10万リクエストで約$0.39 ※試算条件は後述)
なぜこの技術スタックなのか?
従来の課題:React + FastAPI + ECS
開発コスト:
- フロントエンド:React, TypeScript, Vite/Webpack, 状態管理(Redux/Zustand)
- バックエンド:FastAPI, Pydantic, CORS設定
- インフラ:ECS, ECR, ALB, VPC設定
- 学習曲線:フロントエンドエンジニアとバックエンドエンジニアの両方のスキルが必要
運用コスト:
- ECS Fargate(最小構成):月数十ドル規模(構成・リージョンで変動)
- ALB:月十数〜数十ドル規模(構成・リージョンで変動)
- 常時起動が前提(使わなくても課金)
向いていないケース:
- 月間アクセス数が少ない(100-1000リクエスト程度)
- 社内ツール(数人〜数十人のユーザー)
- PoC・MVP開発
解決策:FastAPI + htmx + Lambda
開発のシンプルさ:
- 言語統一:Python中心(自前のJavaScriptを最小化)
- htmx:HTML属性だけでAJAX通信(bundler不要)
- Jinja2:サーバーサイドテンプレート(馴染み深い)
- 型安全:FastAPI + Pydanticで型エラーを防止
圧倒的な低コスト:
- Lambda:使った分だけ課金(月間10万リクエスト: 約$0.29)
- API Gateway:月間10万リクエスト: 約$0.10
- 合計:約$0.39(ECSの約1/50)
向いているケース:
- 社内向け管理画面・ダッシュボード
- 週末プロジェクト・個人開発
- PoC・MVP開発
- アクセス頻度が低いアプリ(日数回〜数百回)
htmxとは?
htmxは、HTMLの属性だけでAJAX、WebSocket、Server-Sent Eventsを扱えるライブラリです。
従来のReactアプローチ
// React + Fetch API
function NoteList() {
const [notes, setNotes] = useState([]);
const addNote = async (title, content) => {
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
});
const newNote = await response.json();
setNotes([...notes, newNote]);
};
return (
<div>
<form onSubmit={(e) => {
e.preventDefault();
addNote(e.target.title.value, e.target.content.value);
}}>
<input name="title" />
<textarea name="content" />
<button>Add</button>
</form>
<ul>
{notes.map(note => (
<li key={note.id}>{note.title}</li>
))}
</ul>
</div>
);
}
htmxアプローチ
<!-- htmx - HTML属性のみ -->
<form hx-post="/notes"
hx-target="#note-list"
hx-swap="innerHTML"
hx-on::after-request="this.reset()">
<input name="title" required>
<textarea name="content" required></textarea>
<button type="submit">Add</button>
</form>
<div id="note-list">
<!-- サーバーから返されるHTMLがここに挿入される -->
</div>
htmxの利点:
- 自前のJavaScriptほぼ不要(CDN 1行で導入可能)
- 状態管理不要(サーバーが真実の源)
- bundler/transpiler不要
- 学習コスト低(HTML属性を覚えるだけ)
実装手順
1. プロジェクト構成
lambda_fastapi_htmx/
├── app/
│ ├── main.py # FastAPI + Mangumハンドラー
│ ├── templates/
│ │ ├── base.html # ベーステンプレート
│ │ ├── index.html # メインページ
│ │ └── components/
│ │ └── note_list.html # ノートリストコンポーネント
│ └── static/
│ └── styles.css
├── template.yaml # SAM IaC定義
├── requirements.txt # ローカル開発用
├── requirements-layer.txt # Lambda Layer用
├── scripts/
│ └── build_layer.sh # Layer作成スクリプト
└── tests/
└── test_main.py # テスト(100%カバレッジ)
2. FastAPIアプリケーション
# app/main.py
from typing import List, Dict, Any
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from mangum import Mangum
# FastAPI初期化
app = FastAPI(title="FastAPI + htmx on Lambda")
# テンプレート設定
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# インメモリDB(実用ではDynamoDB推奨)
notes_db: List[Dict[str, Any]] = []
next_id = 1
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""メインページ表示"""
return templates.TemplateResponse(
request=request,
name="index.html",
context={"notes": notes_db}
)
@app.post("/notes", response_class=HTMLResponse)
async def create_note(
request: Request,
title: str = Form(...),
content: str = Form(...)
) -> HTMLResponse:
"""ノート作成(HTML fragment返却)"""
global next_id
note = {
"id": next_id,
"title": title,
"content": content
}
notes_db.append(note)
next_id += 1
# HTMLフラグメントを返す(ページ全体ではない)
return templates.TemplateResponse(
request=request,
name="components/note_list.html",
context={"notes": notes_db}
)
@app.delete("/notes/{note_id}", response_class=HTMLResponse)
async def delete_note(request: Request, note_id: int) -> HTMLResponse:
"""ノート削除(HTML fragment返却)"""
for i, note in enumerate(notes_db):
if note["id"] == note_id:
notes_db.pop(i)
break
else:
raise HTTPException(status_code=404, detail="Note not found")
return templates.TemplateResponse(
request=request,
name="components/note_list.html",
context={"notes": notes_db}
)
# Lambda handler(Mangum)
handler = Mangum(app, lifespan="off")
ポイント:
-
MangumでFastAPIをLambdaハンドラーに変換 - POST/DELETEはHTMLフラグメントを返す(JSON不要)
- Jinja2テンプレートでHTML生成
3. htmx統合テンプレート
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FastAPI + htmx on Lambda{% endblock %}</title>
<!-- htmx CDN -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
<!-- app/templates/index.html -->
{% extends "base.html" %}
{% block content %}
<h1>Notes App</h1>
<!-- htmx統合フォーム -->
<form hx-post="/notes"
hx-target="#note-list"
hx-swap="innerHTML"
hx-on::after-request="this.reset()">
<div>
<label for="title">Title:</label>
<input type="text" id="title" name="title" required>
</div>
<div>
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea>
</div>
<button type="submit">Add Note</button>
</form>
<div id="note-list">
{% include "components/note_list.html" %}
</div>
{% endblock %}
<!-- app/templates/components/note_list.html -->
{% if notes %}
<ul>
{% for note in notes %}
<li data-note-id="{{ note.id }}">
<h3>{{ note.title }}</h3>
<p>{{ note.content }}</p>
<button hx-delete="/notes/{{ note.id }}"
hx-target="#note-list"
hx-swap="innerHTML"
hx-confirm="本当に削除しますか?">
Delete
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p>ノートがありません。</p>
{% endif %}
htmx属性の説明:
-
hx-post="/notes": POSTリクエスト送信 -
hx-target="#note-list": 結果を挿入する要素 -
hx-swap="innerHTML": 内容を置換 -
hx-on::after-request="this.reset()": 成功後にフォームをリセット -
hx-delete="/notes/{id}": DELETEリクエスト送信 -
hx-confirm="...": 確認ダイアログ表示
4. Lambda Layer作成
#!/bin/bash
# scripts/build_layer.sh
echo "Building Lambda Layer for ARM64 (Graviton2)..."
rm -rf layer layer.zip
mkdir -p layer/python
# ARM64用に依存関係をインストール
pip install \
--platform manylinux2014_aarch64 \
--target layer/python \
--implementation cp \
--python-version 3.12 \
--only-binary=:all: \
--upgrade \
-r requirements-layer.txt
cd layer
zip -r ../layer.zip .
cd ..
echo "✅ Layer built: layer.zip"
# requirements-layer.txt
fastapi==0.115.0
mangum==0.19.0
jinja2==3.1.4
python-multipart==0.0.12
実行:
chmod +x scripts/build_layer.sh
./scripts/build_layer.sh
5. SAM設定
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: FastAPI + htmx on Lambda
Globals:
Function:
Timeout: 30
MemorySize: 512
Runtime: python3.12
Architectures:
- arm64 # Graviton2(コスト削減が見込める・ワークロード依存)
Resources:
# Lambda Layer
FastAPILayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: fastapi-htmx-dependencies
ContentUri: layer.zip
CompatibleRuntimes:
- python3.12
CompatibleArchitectures:
- arm64
# Lambda Function
FastAPIFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: fastapi-htmx-lambda
CodeUri: app/
Handler: main.handler
Layers:
- !Ref FastAPILayer
Events:
RootPath:
Type: Api
Properties:
Path: /
Method: GET
ProxyPath:
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
Outputs:
ApiUrl:
Description: "API Gateway endpoint URL"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
# samconfig.toml
version = 0.1
[default.deploy.parameters]
stack_name = "fastapi-htmx-lambda"
region = "ap-northeast-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = true
resolve_s3 = true
6. デプロイ
# ビルド
sam build
# ローカルテスト
sam local start-api --port 3000
# AWSにデプロイ
sam deploy --guided
動作確認
ローカル開発
# 仮想環境作成
python3.12 -m venv venv
source venv/bin/activate
# 依存関係インストール
pip install -r requirements.txt
# アプリ起動
uvicorn app.main:app --reload
# ブラウザで http://localhost:8000 にアクセス
アプリを起動すると、以下のようなシンプルなインターフェースが表示されます:
動作:
- フォームに「タイトル」と「内容」を入力
- 「Add Note」クリック
- ページリロードなしでリストに追加される ✅
- 「Delete」クリックで削除される ✅
上記のスクリーンショットでは、「本番だけ動かない理由」というノートが作成されており、htmxによる非同期更新が正常に機能していることが確認できます。
Lambda環境でのテスト
# Layer作成
./scripts/build_layer.sh
# SAMビルド
sam build
# ローカルLambda起動
sam local start-api --port 3000
# http://localhost:3000 で動作確認
⚠️ 重要な注意点:インメモリDBの制限
本プロジェクトはデモ目的でインメモリDB(notes_db: List[Dict])を使用していますが、AWS Lambda環境ではデータの永続化は保証されません。
理由: Lambdaはステートレスな実行環境です。各リクエストで異なるLambda実行インスタンスが起動する可能性があり、それぞれが独立したメモリ空間を持つため、インメモリDBの内容は共有されません。なお、同一インスタンスが再利用される場合に一時的に残ることはありますが、保証されません。
- ローカル(uvicorn): ✅ 動作する(単一プロセス)
- Lambda(AWS): ❌ 永続化は保証されない(実行環境が独立)
本番環境での解決策: DynamoDBなどの永続化ストレージを使用してください。DynamoDBをSAMで追加した場合、月間10万リクエストで数セント程度のコスト増加になることが多いです(前提条件で変動)。
パフォーマンス検証
コールドスタート(初回起動)
# Lambda関数を直接呼び出して測定
sam local invoke FastAPIFunction --event events/get_index.json
結果(筆者のローカル環境での sam local 実測例):
- 初回(コールドスタート): 2-3秒程度
- 2回目以降(ウォーム): 200-300ms程度
考察:
- 社内ツールなら許容範囲
- リアルタイム性が必要なアプリには不向き
- オプション:Provisioned Concurrency(有料)でコールドスタート解消
メモリ使用量
REPORT RequestId: xxx
Duration: 392.79 ms
Billed Duration: 393 ms
Memory Size: 512 MB
Max Memory Used: 512 MB # ← ピークメモリ(例)
- 512MB設定で十分
- 128MBに下げることも可能(さらにコスト削減)
コスト試算
試算条件(例):
- リージョン:ap-northeast-1
- API Gateway:HTTP API 想定
- Lambdaアーキテクチャ:arm64
- 平均実行時間:400ms
- メモリ:512MB
Lambda(ARM64 - Graviton2):
- 月間10万リクエスト
- 平均実行時間:400ms
- メモリ:512MB
計算:
リクエスト料金: $0.20 / 100万リクエスト
= $0.02
実行時間料金:
100,000リクエスト × 400ms × (512MB / 1024MB) = 20,000 GB-秒
$0.0000133334 / GB-秒
= 20,000 × 0.0000133334 = $0.27
合計: $0.29
API Gateway:
$1.00 / 100万リクエスト
= 100,000リクエスト × $1.00 / 1,000,000 = $0.10
総コスト: 約$0.39/月
ECSとの比較:
| サービス | 月間コスト | 備考 |
|---|---|---|
| Lambda + API Gateway | $0.39 | 10万リクエスト |
| ECS Fargate(最小) | $15-30 | 常時起動 |
| 差額 | 約1/50 | 使わない時は$0 |
実用性評価
✅ 向いているケース
1. 社内向け管理画面・ダッシュボード
- ユーザー数:数人〜数十人
- アクセス頻度:日数回〜数百回
- 例:社員勤怠管理、在庫管理、レポート閲覧
2. 週末プロジェクト・個人開発
- コストを極限まで抑えたい
- フロントエンドの複雑さを避けたい
- Pythonだけで完結させたい
3. PoC・MVP開発
- 素早く検証したい
- 初期投資を抑えたい
- 後でスケールするかもしれない
4. 定期実行バッチのUI
- Lambdaで定期実行中のバッチ
- 結果確認用のWebUIを追加したい
❌ 向いていないケース
1. リアルタイム性が重要
- チャットアプリ
- リアルタイムダッシュボード
- ゲーム
- 理由:コールドスタートで2-3秒かかる
2. 高頻度アクセス(秒間数十〜数百リクエスト)
- ECサイト
- SNS
- ニュースサイト
- 理由:コスト的にECS/EKSの方が安くなる可能性
3. 複雑なフロントエンドロジック
- リッチなUI/UX
- クライアント側の状態管理が複雑
- オフライン対応
- 理由:htmxの限界(サーバーとの通信前提)
4. ファイルアップロード(大容量)
- Lambda制限:最大6MB(同期)、250MB(非同期)※サービス種別で上限が異なるため要確認
- API Gateway制限:最大10MB ※REST/HTTP APIで上限が異なるため要確認
- 理由:S3直接アップロードの方が適切
まとめ
FastAPI + htmx + Lambda の利点
開発体験:
- ✅ Pythonのみで完結(型安全)
- ✅ JavaScript/bundler不要
- ✅ 学習コスト低(htmxは1日で習得可能)
- ✅ テンプレートエンジンで馴染み深い開発
コスト:
- ✅ 月$0.4前後(10万リクエスト想定)
- ✅ 使わない時は$0
- ✅ ECSの約1/30
運用:
- ✅ サーバー管理不要
- ✅ 自動スケーリング
- ✅ IaCでインフラをコード管理
「ちょうどいい」技術選択
大規模なReact + ECSと、シンプルすぎる静的HTMLの間に、FastAPI + htmx + Lambdaという「ちょうどいい選択肢」があります。
適用シーン:
- 社内ツール、管理画面
- 週末プロジェクト
- PoC・MVP開発
- 低頻度アクセスアプリ
このスタックは、必要十分なインタラクティブ性を最小限のコストで実現できる、現代的な選択肢です。
