前の記事 で、月数百円で動く広告なし個人ブログの思想を書きました。

今回はその技術編です。「なぜこの構成を選んだか」を中心に、設計上の判断ポイントを残しておきます。コードは載せないので、同じようなものを作りたい人向けのアーキテクチャ参考資料として読んでください。


全体構成

ざっくり言うと、こうなっています。

[運用者] --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 UIaws 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 がメモを記事にして、毎週月曜の朝に勝手に公開してくれる個人ブログが回っています。

似たような仕組みを作りたい人の参考になれば。


前の記事: 月数百円・広告ゼロで回す「ファイルを置くだけブログ」を作った話