公開日

記事を 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 には権限を付けない。

公開の流れ

  1. content/blog/<slug>.md を書き、draft: true でローカルでプレビューする
  2. PR を出す。CI が frontmatter を検証する
  3. draft を外してマージする。Deploy が走る
  4. マイグレーション用の Lambda が DB に upsert する
  5. frontend のビルドが API から記事を取り、静的なページにする

デプロイで記事が全部消える事故を防ぐ安全装置も、この Lambda に入っている。これは次の記事で書く。

AI との付き合い方

Layer 案と S3 アセット案の比較表は、AI に観点を出させて自分で埋め直した。表だけ見れば Layer の方が単純で失敗点が少ない。ただ、画像を入れたときの容量は、今の記事の本数からだと見えないんだよな。先に効いてくる制約を優先して、S3 アセットを選んだ。

次回は、デプロイで記事が全部消える事故を、安全装置で止めた話を書く予定。