はじめに
管理アプリをPWAとして作りました。
サーバーを常時起動させずに、ログイン付き・スマホから使える・データはクラウドに残る、という条件で構成を組んでいます。
概要
構成:
ブラウザ(PWA)
├─ CloudFront ─(OAC)─ S3 … HTML/JS/CSS
├─ Cognito Hosted UI … ログイン(PKCE)
└─ API Gateway(HTTP API) … JWTオーソライザー
└─ Lambda(Node.js 20)
├─ DynamoDB … 管理データ
└─ S3(private) … 写真(署名付きURL)
特徴:
常時起動するリソースがない(すべてリクエスト課金)
S3はすべて非公開、CloudFrontと署名付きURLからのみアクセス
ログインはCognito、ユーザーは管理者が作成した1人だけ
技術スタック
- S3 + CloudFront
- Cognito
- API Gateway HTTP API
- Lambda
- DynamoDB
実際の請求
Cost Explorerで直近3か月をサービス別に確認しました。
| サービス | 月額(USD) |
|---|---|
| API Gateway | 0.002 |
| Lambda | 0 |
| DynamoDB | 0 |
| Cognito | 0 |
| CloudFront | 0 |
| S3 | 0.1未満 |
| すべて無料枠の範囲内です。 | |
各サービスの設定
S3 + CloudFront
- S3はパブリックアクセスブロックをすべてON
- CloudFrontからOACで読む。バケットポリシーで自分のディストリビューションだけにs3:GetObjectを許可
- S3の静的ウェブサイトホスティングは使わない
- CloudFrontのカスタムエラーレスポンスで、403と404を/index.html(200)に戻す
OAC経由で存在しないキーを取りに行くと、S3は404ではなく403を返します。403も拾わないと、URL直打ちで真っ白になります。
DynamoDB
- テーブルは1つ
- プロビジョンドモード、1RCU/1WCU
- パーティションキー:userId、ソートキー:itemId
- PITR(ポイントインタイムリカバリ)はON
- GSIなし
Cognito
ユーザープール:
- AllowAdminCreateUserOnlyをONにしてサインアップ画面を封じる
- ユーザー名はメールアドレス
- ユーザーはCLIのadmin-create-userで1人だけ作る
アプリクライアント:
- クライアントシークレットなし
- OAuthフローはcode、スコープはopenid email profile
- コールバックURLに本番のCloudFrontドメインとhttp://localhost:5180/の両方を登録
API Gateway
- HTTP API
- JWTオーソライザーを追加。IssuerはCognitoユーザープール、Audienceはアプリクライアント ID
- ルートANY /{proxy+}にJWTオーソライザーを付けてLambdaへプロキシ
- ルートOPTIONS /{proxy+}は認可なしで別に作る
HTTP APIはREST APIより安くて個人向きだったため利用
Lambdaの実行ロール
- DynamoDB: 対象テーブルに対してQuery / PutItem / DeleteItemのみ
- S3: 画像バケットに対してGetObject / PutObjectのみ
Lambda
ユーザーIDの取得
const userId = event.requestContext?.authorizer?.jwt?.claims?.sub;
if (!userId) return response(401, { error: "Unauthorized" });
写真は署名付きURLで直接S3へ
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
async function createUploadUrl(userId, itemId, contentType) {
const key = `${userId}/${itemId}/photo-${Date.now()}.jpg`;
const command = new PutObjectCommand({ Bucket: IMAGE_BUCKET, Key: key, ContentType: contentType });
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
return { key, url };
}
Lambdaは署名付きURLを返すだけで、ブラウザがそのURLに直接PUT
フロント
Cognitoログイン(PKCE)
async function login() {
const verifier = randomString(64);
const challenge = await sha256Base64Url(verifier);
sessionStorage.setItem("pkceVerifier", verifier);
const url = new URL(`${CONFIG.cognitoDomain}/oauth2/authorize`);
url.searchParams.set("client_id", CONFIG.userPoolClientId);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("redirect_uri", CONFIG.redirectUri);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", challenge);
window.location.href = url.toString();
}
async function exchangeCode(code) {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: CONFIG.userPoolClientId,
code,
redirect_uri: CONFIG.redirectUri,
code_verifier: sessionStorage.getItem("pkceVerifier"),
});
const res = await fetch(`${CONFIG.cognitoDomain}/oauth2/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
return res.json(); // id_token / access_token / refresh_token
}
Service Worker
const CACHE_NAME = "my-app-v60";
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
)
);
});
ハマったところ
- Cognitoの仮パスワードメールがGmailの迷惑メールに入る
- Windows環境でDynamoDBに日本語が入っているとaws dynamodb scanがcp932エラーで落ちる。PYTHONIOENCODING=utf-8を付けると通る
コストを増やさないために
- 固定費のあるサービス(VPCエンドポイント、NAT Gateway、RDS、Secrets Manager)を足さない
- 画像はDynamoDBに入れずS3へ
- 独自ドメインは別にいらないのでつけない