前の記事 で、月数百円で動く広告なし個人ブログの思想を書きました。
今回はその技術編です。「なぜこの構成を選んだか」を中心に、設計上の判断ポイントを残しておきます。コードは載せないので、同じようなものを作りたい人向けのアーキテクチャ参考資料として読んでください。
全体構成
ざっくり言うと、こうなっています。
[運用者] --aws s3 cp--> [S3 source/]
|
v
[Lambda: schedule] ← EventBridge 日次 09:20 JST
| + Bedrock 呼び出し
v
[S3 staging/]
|
v
[Lambda: publish] ← EventBridge 月曜 06:00 JST
|
┌───────────┼─────────────┐
v v v
[S3 blog/] [S3 assets/] [S3 archive/]
| |
└─────┬─────┘
v
[CloudFront] ──> [閲覧者]
全部 S3 で完結します。 データベースなし、コンテナなし、常駐サーバなし。
判断1: なぜ DynamoDB を使わなかったか
ブログシステムを設計するとき、最初に考えたのは「記事メタ情報をどこに持つか」でした。普通なら DynamoDB か RDS を入れたくなります。
でも、よく考えたら 記事数は多くても数百本。S3 の ListObjectsV2 で全件取れる規模です。しかも 1 秒もかからない。
それなら、各記事のメタ情報を manifest.json として S3 に同居させる ほうがシンプル。
archive/20260518-claude-code-basics/
├── claude-code-basics.html
├── manifest.json ← この記事に関する全情報
├── _attachments/
│ └── screenshot.png
└── _source/
└── claude-code-basics.md ← 元ファイル
manifest.json にはこんな情報が入っています。
{
"title_kebab": "claude-code-basics",
"title_display": "Claude Code 基礎",
"publish_date_jst": "2026-05-18",
"category": "tech",
"ai": {
"calls": [
{ "prompt": "convertToArticle", "model_id": "claude-sonnet-4-6", "input_tokens": 5586, "output_tokens": 2282 },
{ "prompt": "generateSlug", "model_id": "claude-haiku-4-5", "input_tokens": 1762, "output_tokens": 24 }
]
},
"stages": {
"staged_at": "2026-05-09T07:53:50+00:00",
"published_at":"2026-05-11T06:00:13+00:00"
}
}
トップページや category ページを再生成するとき、archive/*/manifest.json を全部読んで集約するだけ。これで十分でした。
DynamoDB を入れると、必ず「データが S3 と DB の間でズレる」問題と戦うことになります。manifest.json を S3 に同居させれば、真実は常にひとつ です。
判断2: なぜ CloudFront の Behavior でセキュリティ境界を作ったか
このブログのバケットには「公開フォルダ」と「非公開フォルダ」が混在しています。
s3://da-leca-blog/
├── source/ ← 非公開(ネタ置き場)
├── staging/ ← 非公開(公開待ち)
├── archive/ ← 非公開(履歴)
├── blog/ ← 公開
└── assets/ ← 公開
普通なら source/staging/archive 用にバケットを分けるところです。でも、それだと記事生成 Lambda が複数バケットをまたぐことになって、IAM ポリシーが複雑になる。
代わりに、1 バケットに全部置いて、CloudFront の Behavior 列挙で公開範囲を絞る という設計にしました。
CloudFront の Behavior はこれだけです。
| Path Pattern | Origin |
|---|---|
/blog |
blog バケット |
/blog/* |
blog バケット |
/assets/blog/* |
blog バケット |
/sitemap.xml |
blog バケット |
/robots.txt |
blog バケット |
/source/* や /staging/* に対応する Behavior がないので、CloudFront 経由で叩いても 404 が返ります。バケットへの直接アクセスは S3 Block Public Access + OAC で塞いでいるので、結果として 「Behavior に書いた path しか到達できない」 という単純なルールで境界が引けます。
VPC もセキュリティグループもいりません。「許可リストとしての Behavior」 という発想、けっこう気に入っています。
判断3: なぜ Bedrock を選んだか(Anthropic API 直叩きじゃなく)
Claude を使うなら Anthropic API を直接叩くのが普通です。値段も Bedrock とほぼ同じ。
それでも Bedrock 経由にしたのは、IAM ロールだけで認証が完結する からです。
API キーをどこかに置く必要がない。Secrets Manager にも、環境変数にも、Parameter Store にも置かない。Lambda 実行ロールに bedrock:InvokeModel を付けるだけで Claude が呼べる。
# こんなのが書ける(認証情報はどこにもない)
import boto3
client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
response = client.invoke_model(
modelId="global.anthropic.claude-sonnet-4-6",
body=json.dumps({...})
)
API キーを管理するコストって、地味に積み重なります。
- どこに置く?
- ローテーションは?
- 漏れたらどうする?
- Lambda 起動時のオーバーヘッドは?
これが全部なくなるのは大きい。「鍵を持たない」が一番安全 という原則そのままです。
判断4: なぜ EventBridge を 2 つに分けたか
「ネタを記事化する処理」と「記事を公開する処理」を、別の Lambda + 別のスケジュールにしました。
| Lambda | スケジュール | 仕事 |
|---|---|---|
schedule_lambda |
毎日 09:20 JST | source のネタからランダムで staging に記事を作る |
publish_lambda |
月曜 06:00 JST | staging の記事を本番に公開 |
これを 1 つにしなかった理由は、「人間がレビューできる時間」を入れたかった から。
毎朝 9:20 に schedule_lambda が動いて、その日に staging が増えると通知が来ます。週末までに違和感のある下書きがあれば、月曜の公開前に差し替えられます。
実際にはほとんど差し替えませんが、「いつでも介入できる」状態を保てているのが安心感につながります。完全自動化は気持ちよく見えるけど、運用者の判断を挟む隙がなくなると、たぶん事故ったときに泣きます。
判断5: idempotent を最初から設計に入れた
Lambda は何があっても再実行される前提で組みました。EventBridge のリトライ、手動 invoke、CloudWatch の手動再実行……どれが来ても壊れないようにする。
具体的にはこんな工夫をしています。
staging はディレクトリごと一意
staging/20260518-claude-code-basics/
YYYYMMDD-slug でユニーク。同じものを再実行しても、同じパスに上書きされるだけ。
publish は「同じファイルを同じパスにコピーする」だけ
S3 の CopyObject は冪等です。何度やっても同じ結果になる。
manifest の published_at も上書きされるだけ。
archive への移動は「コピー + 残置」
publish 時に staging を archive に「コピー」しますが、staging 側を削除するのは最後の最後。途中で失敗しても、staging が残っているので再実行できます。
idempotent を後から足すのは地獄なので、最初から「再実行で壊れないか?」を全関数で問うのがおすすめです。
判断6: AI プロンプトを AppConfig に外出しした
Claude に投げる system prompt は、コードに埋めずに AWS AppConfig に置いています。
prompts:
convertToArticle:
revision: v1.1
system: |
あなたはブログ編集者です...
generateSlug:
revision: v1.1
system: |
Generate a URL-friendly kebab-case slug...
pickCategory:
revision: v1.0
system: |
以下の記事をカテゴリ分けします...
Lambda は起動時に AppConfig から取得して、5 分 TTL でキャッシュ。
これの何がいいかというと、プロンプトを直したいとき CDK deploy が要らない こと。
プロンプトのチューニングはトライアル&エラーが多くて、毎回 deploy していたら回らない。AppConfig なら数秒で反映されます。
しかも revision を manifest に記録しているので、「v1.0 のプロンプトで生成した記事」と「v1.1 で生成した記事」が後から判別できます。AI 出力の品質変化を追えるのは地味に大事です。
やらなかったこと(意図的に)
- Markdown を DB に保存 — S3 ファイルとして置く方が grep もコピーも楽
- CMS UI —
aws s3 cpでいい - 記事の差分管理 — archive にスナップショットを残せば十分。Git は使わない
- 複数言語対応 — 必要になったらテンプレートを拡張する ←あとから英語とスペイン語にも対応しました
- OGP 画像の自動生成 — 必要になったら別 Lambda で足す ←あとから足しました
- prev / next リンク — manifest 拡張で後付け可能、今は無し ←あとから足しました
「今いらないものは作らない」を徹底しました。あとから足せる設計にだけしておく。
このアーキテクチャが向いている人
- 個人 or 1〜2 名で運用するブログ
- 月の記事数が 1〜10 本程度
- 広告を出したくない
- インフラ費を 1,000 円以下に抑えたい
- ある程度 AWS と Python が触れる
逆に向いていないのは:
- 複数人で同時編集するチームブログ → ヘッドレス CMS を使うべき
- リアルタイム性が必要なメディア → Lambda + S3 では非同期になる
- 大量アクセスで動的コンテンツを返したい → CloudFront だけでは足りない
おわりに
このブログを作るときに何度も自問したのは、「これ、本当に必要?」でした。
機能を足すのは簡単で、引くのが難しい。引いた結果として残ったのが、いま動いているこの仕組みです。
S3 + CloudFront + Lambda + Bedrock。たった 4 つのマネージドサービス。これだけで、AI がメモを記事にして、毎週月曜の朝に勝手に公開してくれる個人ブログが回っています。
似たような仕組みを作りたい人の参考になれば。