はじめに
前回の記事で Amplify Gen 2 x AgentCore Runtime を使った日本株分析エージェントを作りました。
今回は、作成したツールを 一般公開 するのに必要な機能を検討・実装してみましたので紹介します。
公開したアプリはこちら↓から触れます(Googleアカウントまたはメールアドレスでログイン)。
一般公開で追加した機能
個人利用から一般公開に切り替えるにあたって、以下の機能を追加しました。
いずれも Kiroちゃん と壁打ちしながら決めました。
| 機能 | 概要 |
|---|---|
| Google OAuth 認証 | メール認証に加えて Google ログインを追加 |
| 日次利用回数制限 | 1ユーザー10回/日(JST 0:00リセット) |
| WAF によるレート制限 | AppSync API に IP ベースのレート制限 |
| エラーハンドリング強化 | エラー種別に応じた日本語メッセージ |
| カスタムドメイン(Route53) | Route53 + Amplify Hosting でサブドメインを割り当て |
| UD2(ユニバーサルデザイン)対応 | ライトモード/ダークモード、PC/スマホ対応 |
| セキュリティヘッダーの追加 | X-Frame-Options, X-Content-Type-Options 等 |
構築済みの分析エージェント本体やチャート描画の仕組みはそのままで、その上に「公開するために必要なレイヤー」を載せたイメージです。
アーキテクチャ構成
前回のアーキテクチャ図に対して、赤枠の部分が今回追加した要素です。
主な変更点は次の通り。
- Cognito に Google OAuth を追加(メール認証と併用)
- DynamoDB にテーブルを追加(UsageLogで利用回数を保持)
- usage-check Lambda を追加(利用回数の検証とログ記録)
- WAFv2 WebACL を AppSync API に関連付け(IPレート制限)
- Route53 と Amplify Hosting を関連付け(カスタムドメインでアクセス)
技術的なポイント
1. Google OAuth 認証の追加
Amplify Gen 2 では defineAuth に Google プロバイダーを追加するだけで、Cognito 側の設定が自動で構成されます。
Google Cloud から取得するクライアントIDとシークレットは、Amplify のシークレット管理に保持することで、ハードコードは避けます。
Google OAuth の設定手順やハマりどころは別記事で詳しく書いているので、そちらを参照してください。
2. 日次利用回数制限
一般公開で一番気になるのはコストです。
Bedrock のトークン課金が最大のコスト要因なので、1ユーザー10回/日の利用制限を入れました。
データモデル
利用回数の管理には DynamoDB の UsageLog テーブルを使っています。
UsageLog: a
.model({
userId: a.string().required(),
executedAt: a.datetime().required(),
jstDate: a.string().required(),
query: a.string().required(),
status: a.ref("UsageStatus").required(),
})
.identifier(["userId", "executedAt"])
.secondaryIndexes((index) => [index("userId").sortKeys(["jstDate"])])
.authorization((allow) => [allow.owner()]),
ポイントは jstDate フィールドです。利用回数のリセットは JST 0:00(= UTC 15:00) 基準にしたかったので、Lambda 側で UTC に +9時間のオフセットを適用した日付文字列を保存しています。
export function getJSTDateString(date: Date = new Date()): string {
const jstOffset = 9 * 60 * 60 * 1000; // UTC+9
const jstDate = new Date(date.getTime() + jstOffset);
return jstDate.toISOString().slice(0, 10); // "YYYY-MM-DD"
}
検証フロー
利用回数の検証は2段階で行っています。
【画面表示時】
1. ページ読み込み → getRemainingUsage を呼び出し
2. usage-check Lambda が当日の UsageLog 件数を GSI で集計
3. 残り回数をフロントエンドに返却 → UsageCounter に表示
4. 残り 0 回なら分析ボタンを disabled にする(押せない)
【分析実行時】
1. ユーザーが分析ボタンをクリック
2. checkAndRecordUsage で再度サーバーサイド検証 + UsageLog に記録
3. 許可された場合のみ、チャート取得 + エージェント呼び出しを並行実行
4. 完了後に残り回数を再取得して表示を更新
画面表示時のチェックで UX を守りつつ、分析実行時にもサーバーサイドで二重チェックしています。
DynamoDB の書き込みが失敗した場合は、エラーログを出力しつつ分析実行は許可する方針にしています。利用制限のためにユーザー体験を損なうのは本末転倒なので。
フロントエンドの残り回数表示
残り回数は UsageCounter コンポーネントで表示しています。回数に応じて色が変わります。
export function getUsageDisplayStyle(remaining: number): UsageDisplayStyle {
if (remaining <= 0) {
return { color: "#b91c1c", disabled: true }; // 赤色 + ボタン無効化
}
if (remaining <= 3) {
return { color: "#b45309", disabled: false }; // 警告色(オレンジ)
}
return { color: "var(--color-text, #111)", disabled: false }; // 通常色
}
3. WAF によるレート制限
多層防御の一環として、被害を抑えるための保険としてWAFを入れています。
基本的には認証済みユーザーしか使えないようにしていますが、改修時のミスで認可設定が抜けてしまうようなケースを想定しています。
具体的には、AppSync API に WAFv2 WebACL を関連付けて、IP ベースのレート制限(1000リクエスト/5分/IP) を設定しています。
const webAcl = new CfnWebACL(dataStack, "AppSyncWebACL", {
defaultAction: { allow: {} },
scope: "REGIONAL",
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "AppSyncWebACL",
sampledRequestsEnabled: true,
},
rules: [
{
name: "IPRateLimitRule",
priority: 1,
action: { block: {} },
statement: {
rateBasedStatement: {
limit: 1000,
aggregateKeyType: "IP",
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "IPRateLimitRule",
sampledRequestsEnabled: true,
},
},
],
});
new CfnWebACLAssociation(dataStack, "AppSyncWebACLAssociation", {
resourceArn: backend.data.resources.graphqlApi.arn,
webAclArn: webAcl.attrArn,
});
Amplify Gen 2 の defineBackend から CDK のリソースにアクセスできるので、backend.ts に WAF の設定を直接書いています。Amplify デプロイ時に自動で作成されるので、手動での有効化は不要です。
WAF の適用範囲
WAF は AppSync API にのみ適用しています。
| 対象 | WAF | 理由 |
|---|---|---|
| AppSync API | ✅ 適用 | Lambda + DynamoDB + 外部API呼び出しの入口。コストインパクト大 |
| CloudFront | ❌ 非適用 | Shield Standard で L3/L4 保護済み。静的配信コストは微小 |
| AgentCore Runtime | ❌ 非適用 | WAFv2 の関連付け対象外。JWT認証 + アプリ側利用制限で保護 |
4. エラーハンドリング強化
想定されるエラーパターンを洗い出し、エラー種別を判定して、日本語のメッセージを返すユーティリティを追加してあります。
export function classifyError(error: unknown): ClassifiedError {
if (isAuthExpired(error)) {
return { category: "auth_expired", message: "セッションが期限切れです。再ログインしてください。" };
}
if (isNetworkError(error)) {
return { category: "network", message: "ネットワーク接続を確認してください。" };
}
if (isServerError(error)) {
return { category: "server", message: "サーバーエラーが発生しました。しばらくしてから再度お試しください。" };
}
if (isUsageLimitError(error)) {
return { category: "usage_limit", message: "本日の利用上限(10回)に達しました。明日またご利用ください。" };
}
return { category: "unknown", message: "予期しないエラーが発生しました。" };
}
認証期限切れの場合は自動でサインアウトして再認証フローに誘導しています。
5. カスタムドメイン(Route53)
一般公開するなら、Amplify が自動生成する https://main.xxxxx.amplifyapp.com のような URL ではなく、独自ドメインで公開したいところです。
今回は Route53 で管理しているドメインのサブドメイン jp-stock-agent.azsystems-tools.com を Amplify Hosting に割り当てました。
設定手順
- Amplify コンソール → 対象アプリ → 「ホスティング」→「カスタムドメイン」
- Route53 で管理しているドメインを選択
- サブドメイン(
jp-stock-agent)をmainブランチにマッピング - Amplify が自動で Route53 に CNAME レコードと ACM 証明書を作成
- SSL 証明書の検証完了を待つ(通常数分〜数十分)
Amplifyコンソールだけで完結するので、いつも設定方法に迷う Route53 側を触る必要がなくて簡単でした。
カスタムドメイン設定時の注意点
カスタムドメインを設定したら、以下の箇所も合わせて更新が必要です。
| 設定箇所 | 変更内容 |
|---|---|
amplify/auth/resource.ts |
callbackUrls / logoutUrls に本番ドメインを追加 |
Amplify 環境変数 ALLOWED_ORIGIN
|
CORS 許可オリジンを本番ドメインに設定 |
6. UD2(ユニバーサルデザイン)対応
不特定多数のユーザーに使ってもらうので、ライトモード/ダークモードの両対応と、PC/スマホの両対応を行いました。
ライトモード / ダークモード
チャートを含む全体の配色を CSS カスタムプロパティで管理し、OS(ブラウザ) のテーマ設定に自動で追従するようにしています。
ダークモードのチャートは終値の線が見づらかったかも😂
PC / スマホ対応
スマホではチャートの高さを縮小し、入力フォームのボタンを全幅表示にするなど、画面サイズに応じたレイアウト調整を行っています。
7. セキュリティヘッダー
セキュリティヘッダーに関しては不勉強でしたが、Kiroちゃん推奨の設定を入れました。
next.config.mjs でセキュリティ関連のレスポンスヘッダーを設定しています。
| ヘッダー | 値 | 役割 |
|---|---|---|
X-Frame-Options |
DENY |
悪意あるサイトが iframe でアプリを埋め込むのを防ぐ(クリックジャッキング対策) |
X-Content-Type-Options |
nosniff |
ブラウザがファイルの種類を勝手に推測して実行するのを防ぐ |
Referrer-Policy |
strict-origin-when-cross-origin |
外部サイトへの遷移時に URL のパスやパラメータが漏れるのを制限 |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
株価分析に不要なカメラ・マイク・位置情報を明示的に無効化 |
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
8. コスト配分タグ
複数プロジェクトを運用していると、どのプロジェクトにいくらかかっているか分からなくなります。全リソースにコスト配分タグを一括付与しています。
const TAG_CONFIG = {
Project: "japan-stock-analysis",
Environment: process.env.AWS_BRANCH === "main" ? "production" : "develop",
} as const;
Object.entries(TAG_CONFIG).forEach(([key, value]) => {
Tags.of(backend.stack).add(key, value);
});
AWS Cost Explorer で Project = japan-stock-analysis でフィルタすれば、このプロジェクトのコストだけを追跡できます。
コスト見積もり
小規模(アクティブユーザー100人、月500分析程度)で運用した場合の月額目安も算出しました。
| サービス | 月額目安 |
|---|---|
| Amplify Hosting | ~$1 |
| Cognito | $0(50,000人まで無料) |
| AppSync + DynamoDB | ~$1 |
| Lambda | $0(無料枠内) |
| WAFv2 | $6 |
| AgentCore + Bedrock | ~$10〜30 |
| 合計 | $15〜40 |
最大のコスト要因は Bedrock のトークン課金です。1回の分析で約 $0.02〜0.05 かかります。
モデルは Claude Sonnet を使っているので、Haiku に変更すれば大幅に削減できます。
WAFv2 は固定で$6/月かかるので、外せば削減効果は大きいです。
まとめ
今回は、日本株分析エージェントを一般公開するために追加した機能を紹介しました。
- Google OAuth でログインの選択肢を増やす
- 日次利用回数制限 でコストを制御
- WAF で API への大量アクセスを抑制
- エラーハンドリング でユーザーに適切なメッセージを返す
- UD2 対応 でライトモード/ダークモード、PC/スマホに対応
公開するとなると色々と考慮すべき点が増えますが、今回のアプリの場合はインプット経路が限られていることもあり、必要最小限の対応で十分だったと思います。
Kiroちゃんと壁打ちしながら実装することで、セキュリティヘッダーなどこれまであまり意識してこなかった観点も加えることができ、大きな学びになりました。
再掲ですが公開したアプリはこちらから触れます。
また、今回のソースコードは公開していないので、ベースとなるプログラムは前回の記事で公開しているリポジトリを参照してください。
参考リンク
本記事の内容は投資助言ではありません。実際の投資判断は自己責任でお願いします。








