公開日
記事を Git で管理して、デプロイのたびに DB に upsert する
ブログの記事はリポジトリの content/ を正本にして、デプロイのたびにマイグレーション用の Lambda が DB に upsert する。content を API の zip に入れない理由、Lambda Layer 案と S3 アセット案の比較、消した記事を DELETE せず非公開にする理由を書く。
はじめに
devlog #1 で、記事は Git リポジトリを正本にして、デプロイのたびに DB へ upsert すると書いた。管理画面は作らない。記事は PR を通して入る。この記事もそう。
仕組みは 3 本の PR に分けて作った。読み込みと検証、DB への反映、Lambda への渡し方。AI(Claude Code)と壁打ちしながら進めたが、渡し方の方式は自分で決めた。
読み込みと検証
content/blog/<slug>.md と content/works/<slug>.md を読む。slug はファイル名で、小文字と数字とハイフンだけ、200 文字まで、blog と works をまたいで重複しない。frontmatter は title、summary、publishedAt、draft、Marp の指定。publishedAt はオフセット付きの ISO 8601 で、未来の日時はエラーにした。予約投稿はしない。
1 件でも不正なら、全部のエラーをまとめた例外を投げる。1 件直すたびにデプロイし直すのは、流石にしんどい。CI では、実際の content/ を読む検証のテストが走る。記事の PR は、マージする前に frontmatter の間違いで止まる。
DB への反映
マイグレーション用の Lambda が、Flyway と DB ロールの用意の後に、記事を posts に upsert する。SQL は INSERT … ON CONFLICT (slug) DO UPDATE … WHERE … IS DISTINCT FROM …。中身が同じなら更新しない。これで updated_at(OGP の article:modified_time に使う)が、デプロイのたびに動くことはない。
draft: true の記事は対象から外す。DSQL の制約に合わせて 1 記事 1 トランザクションで書き、楽観的同時実行制御の衝突はやり直す。
消した記事は DELETE せず非公開にする
content から消えた記事と、draft に戻した記事は、同じ扱い。published_at = NULL にして非公開にする。行は消さない。
消さない理由は、記事に紐づくいいねとアクセス数を残すため。記事を一度下げて戻したときに、数も戻る。slug を変えると別の記事になって引き継がれないので、一度公開した slug は変えない、というルールも content の README に書いた。
content を API の zip に入れない
ここが設計で一番考えたところ。記事の原稿を、API の Lambda の zip に同梱する案が一番簡単に見える。ただ、API の Lambda は SnapStart で動いている。zip が変わると新しいバージョンが公開され、スナップショットが作り直される。記事を 1 文字直すたびに API のスナップショットが作り直されるのは筋が悪すぎるだろ。
記事を読むのはマイグレーション用の Lambda だけ。だから、content は API の zip から切り離し、マイグレーション用の Lambda に場所だけを渡す形にした。渡し方は 2 つを比べた。
| 観点 | Lambda Layer | S3 アセット+起動時ダウンロード |
|---|---|---|
| 変更の範囲 | infra だけ | infra + backend(S3 SDK、zip の展開) |
| 確実さ | Lambda が /opt に展開済み。失敗点が少ない | 取得と展開を自前で持つ |
| IAM | 追加なし | そのオブジェクトへの GetObject |
| Trigger の再実行 | Layer の ARN が変わる | 環境変数(キーのハッシュ)が変わる |
| 容量 | 関数+Layer の展開後の合計で 250 MB | /tmp の 512 MB |
| 置き場の自由度 | リポジトリの content/ だけ | 環境変数で差し替えられる |
| 残骸 | 置き換え時に旧版が消える | アセットのバケットに旧 zip が溜まる |
Layer 案の方が単純。infra だけで閉じ、backend に手を入れずに済む。それでも S3 アセットにした。決め手は容量。backend の zip はすでに大きく、記事にスクリーンショットなどの画像を入れ始めると、250 MB の上限が先に見えてくる。S3 アセットなら、後から構成を変えずに済む。環境変数で場所を差し替えられるので、リポジトリの外から記事を書く構成にも伸ばせる。
CDK が content/ を zip のアセットにして S3 に置き、バケット名とキーをマイグレーション用の Lambda の環境変数に入れる。キーにはアセットのハッシュが入るので、記事が変わると環境変数が変わり、CDK の Trigger が走り直す。Lambda は起動時に zip を /tmp に展開して読む。展開先の外に出るエントリは拒否する。
読み取り権限は、そのアセットのオブジェクトだけに絞った。最初の実装の Asset.grantRead は、アセット用のバケット全体の読み取り。同じ PR の中で気づいて、バケットにキーを指定する形に直した。API の Lambda には権限を付けない。
公開の流れ
content/blog/<slug>.mdを書き、draft: trueでローカルでプレビューする- PR を出す。CI が frontmatter を検証する
draftを外してマージする。Deploy が走る- マイグレーション用の Lambda が DB に upsert する
- frontend のビルドが API から記事を取り、静的なページにする
デプロイで記事が全部消える事故を防ぐ安全装置も、この Lambda に入っている。これは次の記事で書く。
AI との付き合い方
Layer 案と S3 アセット案の比較表は、AI に観点を出させて自分で埋め直した。表だけ見れば Layer の方が単純で失敗点が少ない。ただ、画像を入れたときの容量は、今の記事の本数からだと見えないんだよな。先に効いてくる制約を優先して、S3 アセットを選んだ。
次回は、デプロイで記事が全部消える事故を、安全装置で止めた話を書く予定。