3行まとめ
- 前回の記事で、Cloudflare Pagesの「1デプロイ2万ファイル制限」をR2 + Pages Functionsの404フォールバックで越える設計を書いた。今回はその後ろ側、継続運用するためのCI/CD自動化と、そこで実際にハマったトラブルの話
- 公開/未公開の切り替えはDBの
is_published/dynamic_publishedフラグで一元管理し、GitHub ActionsがR2側のビルド・アップロードを自動化している。ここでGit LFSのlfs: trueを指定し忘れてポインタファイルのままビルドが壊れるという事故を実際にやった - 静的側(Cloudflare Pagesの自動デプロイ)とR2側(GitHub Actions)は完全に別経路で動くため、スコア再計算やテンプレート変更が両系統に反映されたか手動で確認する必要があるのが、この構成の運用上の弱点
住所を入れると地震・洪水・土砂災害などのリスクを1ページにまとめて返す、データベース型のサイトを個人開発で運営している。町丁目単位で日本全国をページ化した結果、Cloudflare Pagesの「1デプロイあたり2万ファイル」制限に頭打ちになり、公開分はPages、あふれた分はR2から404フォールバックで配信する二系統構成にした。その設計自体は前回の記事に書いた。
今回は、その二系統構成を毎日・毎月のデータ更新にあわせてどう自動更新し続けているかという運用側の話を書く。
公開/未公開はDBフラグで一元管理
どのページを静的側で公開し、どのページを動的側に置くかは、SQLiteのareasテーブルに持たせたis_published/dynamic_publishedフラグで管理している。
| フラグ | 意味 | 配信先 |
|---|---|---|
is_published = 1 |
公開ページ | Cloudflare Pages(静的SSG) |
is_published = 0 かつ dynamic_published = 1
|
未公開だが先行配信対象 | Cloudflare R2(動的フォールバック) |
Astroのビルドスクリプトは環境変数で公開/未公開の生成対象を切り替える。
{
"scripts": {
"build": "astro build",
"build:dynamic": "BUILD_UNPUBLISHED=true astro build --outDir dist-dynamic"
}
}
build(通常ビルド)はis_published = 1のページだけをdist/に出力してCloudflare Pagesの自動デプロイに乗る。build:dynamicは未公開ページをdist-dynamic/に出力し、これを別パイプラインでR2にアップロードする。DBのフラグを見るだけでどちらのビルドに含まれるかが決まるので、ページの追加・公開切り替えのたびにデプロイ設定を書き換える必要はない。
R2側のビルド・デプロイを自動化する — GitHub Actions
R2側の更新は、Cloudflare Pagesの自動デプロイとは別にGitHub Actionsで自動化している。スコアやテンプレートに関わる変更がmainにマージされたタイミングで走る。
on:
push:
branches: [main]
paths:
- 'etl/data/data.db'
- 'site/src/**'
- 'site/package.json'
- 'etl/scripts/upload_r2_dynamic.py'
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true # data.db が git-lfs 管理のため実体取得に必須
- run: npm ci
working-directory: site
- run: npm run build:dynamic
working-directory: site
- uses: astral-sh/setup-uv@v5
- run: uv run python -m scripts.upload_r2_dynamic
working-directory: etl
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
pathsでトリガー対象を絞っているのは、ドキュメントだけの変更でこの重いジョブ(Astroビルド + アップロード)を毎回走らせたくないため。workflow_dispatchも残してあり、確認漏れに気づいたときにいつでも手動で再実行できるようにしている。
アップロード側のスクリプトは、DBからis_published = 0のエリアのURLパス一覧を取得し、dist-dynamic/配下の該当HTMLだけをR2に送る。
def get_unpublished_url_paths() -> set[str]:
"""DBから未公開エリアのURLパスを取得する。"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
SELECT pref_slug, city_slug, area_slug, parent_city_slug
FROM areas WHERE is_published = 0
""")
...
Cloudflare Pages自体の自動デプロイ(静的ページ側)とは完全に別経路で動いているため、静的側のデプロイをR2側の更新が妨げることはない。逆に言えば、2つの独立したパイプラインが同じDBを見ながらそれぞれ動く構成になっている。
ハマったポイント — Git LFSのポインタ問題
data.dbはサイズの都合でGit LFS管理にしている。ここで一度、actions/checkoutにlfs: trueを指定し忘れたままワークフローを組んでしまい、LFSの実体ではなくポインタファイル(数百バイトのテキスト)をdata.dbとして読み込んでビルドが壊れるという事故を起こした。
エラーメッセージだけを見るとDB破損のように見えて原因調査に時間を使ったが、実態は「checkoutがLFSの実体を取ってきていなかっただけ」というシンプルな話だった。それ以来、data.dbを扱うすべてのワークフロー(R2デプロイ・後述のURL通知系ジョブなど)でlfs: trueを必須にし、ワークフロー冒頭のコメントにも明記して再発防止にしている。
気をつけていること — 二系統への反映漏れ
この構成の弱点は、変更を二系統それぞれに手動で意識して反映する必要があること。スコアの再計算ロジックやページテンプレートを変更したとき、静的側(npm run buildによるCloudflare Pagesの自動デプロイ)は自動的に追随するが、R2側の動的ページはdeploy-r2-dynamic.ymlのトリガー対象パス(etl/data/data.db・site/src/**など)に触れない変更だと再ビルドされずに古いHTMLが残り続ける。
運用上は「テンプレートやスコアロジックを変えたら、R2側も再ビルドされたか確認する」というチェックを月次の運用タスクに組み込んで対応している。自動化してもなお、2つの独立したパイプラインを両方とも意識し続けるという認知コストは残ったままになっている。
まとめ
- 公開/未公開の切り替えはDBフラグ(
is_published/dynamic_published)で一元管理し、ビルドスクリプト側は環境変数でそれを見るだけにした - R2側の更新はGitHub Actionsで自動化しているが、Git LFSの
lfs: true忘れでポインタファイルのままビルドが壊れるという事故を実際にやった。data.dbを扱う全ワークフローで必須設定にして再発防止した - 静的側とR2側は完全に別デプロイ経路なので、両系統への反映漏れを月次の運用チェックで拾う運用にしている。自動化しても人間が意識すべきポイントが消えるわけではない
この記事は Zenn にも同じ内容を投稿しています。