以下AI生成の文章なので、読みにくく申し訳ありません。
自治体向けのオープンデータ管理システムを OSS として公開した。
「なぜ作ったか」という話は note に書いた → https://note.com/easy_deer7322/n/nfc62903bb2b2?app_launch=false
この記事では技術的な話に絞る。
作ったもの
CSVなどのファイルを管理画面からアップロードして公開するシステム。D1に管理データを持ち、Sync Worker が定期的に R2 へ JSON を生成、公開APIはその R2 をカスタムドメインで直接配信する構成。
リポジトリ: https://github.com/pons-llc/opendata_platform
アーキテクチャ
公開API (opendata.example.lg.jp)
└── R2カスタムドメイン直接配信 (Workerなし)
└── datasets/index.json, datasets/{id}.json, etc.
公開ポータル (任意)
└── Public Worker → R2 (JSON 読み取りのみ、D1不使用)
管理画面 (admin.example.lg.jp)
└── Admin Worker → D1 (CRUD) + R2 (ファイルアップロード)
認証: Supabase Auth (Cookie + JWT / ES256)
アップロードAPI (admin.example.lg.jp/api/upload/*)
└── Admin Worker → D1 (APIキー検証) + R2 (ファイル保存)
認証: データセットごとのAPIキー (Bearer)
Sync Worker (Cloudflare Cron)
└── D1 (公開中データセット) → R2 (JSON生成)
日次で R2 孤立ファイルのクリーンアップも実行
公開APIはWorkerを経由しない。R2のカスタムドメインで静的JSONを直接配信するだけなので、アクセスが増えてもWorkerの枠を消費しない。これがコストを下げる最大のポイント。
技術スタック
| レイヤー | 技術 | 用途 |
|---|---|---|
| ランタイム | Cloudflare Workers | 管理API・Sync処理 |
| DB | Cloudflare D1 (SQLite) | データセット・ユーザー・部署管理 |
| ストレージ | Cloudflare R2 | ファイル・公開JSON |
| 認証 | Supabase Auth | JWT発行・セッション管理 |
| フロントエンド | Vanilla JS + MPA | サーバーサイドHTMLレンダリング |
フロントエンドはバンドラーなし。Workers でHTMLを直接レンダリングする MPA 構成。SPAにする理由がないので、シンプルさを優先した。
なぜこのスタックか:コスト試算
Cloudflare 無料枠
| サービス | 無料枠 | このシステムの消費量目安 |
|---|---|---|
| Workers | 10万req/日 | 管理画面で数百req/日 → 余裕 |
| D1 読み取り | 2,500万行/日 | 問題なし |
| D1 書き込み | 5万行/日 | 問題なし |
| R2 ストレージ | 10GB/月 | 数GB程度 |
| R2 Class A (write) | 100万ops/月 | 15分sync × 96回 × 30日 × ~60ファイル ≒ 17万ops |
| R2 Class B (read) | 1,000万ops/月 | 公開API直接配信のためWorker不使用 |
管理画面のリクエストはたかが知れている。Sync はCronで15分おきに動くが、1回あたりのR2書き込みはデータセット数と同程度。60件なら17万ops/月で余裕。
Supabase 無料枠
認証にしか使っていないので、MAU 5万・DB 500MB の Free プランで十分。職員が数十人なら問題ない。
結論: 小〜中規模自治体であれば月額 $0 で動く。
認証設計
Supabase Auth の JWT を Workers 側で検証する構成。
// ES256 (ECC P-256) で検証
// Supabase は HS256 か ES256 を選べる。本番は ES256 推奨
async function verifyJWT(token, secret) {
// ...
}
セッションは HTTP-Only Cookie に保持(XSS対策)。JWT の有効期限は1日に設定し、ページリクエストのたびに Supabase の /auth/v1/user でユーザー情報を取得して有効性を確認している。
ローカル開発時は DEV_MODE=true で /dev-login から任意のユーザーでログインできるが、本番環境では無効化される。
ロール設計
| ロール | 権限 |
|---|---|
| admin | 全データセット編集・ユーザー管理・部署管理・カテゴリー管理 |
| editor | 所属部署のデータセットのみ編集可(複数部署対応) |
editor は user_departments テーブルで複数部署に所属できる。APIは部署チェックを行い、他部署のデータは読み取り専用。
APIキー設計(外部連携用)
kintone など外部システムからCSVを自動アップロードするためのAPIキー機能を実装した。
- データセットごとにAPIキーを発行
- DB には SHA-256 ハッシュのみ保存(平文は発行時のみ表示)
- キーの先頭12文字をプレフィックスとして保存し、管理画面での識別に使う
-
last_used_atを記録して利用状況を確認できる
curl -X POST \
-H "Authorization: Bearer odk_xxxxxxxxxxxxxxxx" \
-F "file=@data.csv" \
https://admin.example.lg.jp/api/upload/{datasetId}
Sync Worker の仕組み
Admin Worker がデータを変更しても、公開APIには即時反映されない。Sync Worker が Cron で定期実行して D1 の内容を R2 に JSON として書き出す構成。
D1 (公開中データセット) → Sync Worker → R2
├── metadata.json
├── datasets/index.json
├── datasets/{id}.json
├── departments/index.json
├── departments/{slug}/datasets.json
├── categories/index.json
└── categories/{slug}/datasets.json
Cron スケジュール:
-
本番:
["*/15 * * * *", "0 3 * * *"]— 15分ごとにsync、深夜3時にsync+cleanup -
開発時:
["0 3 * * *"]— R2のClass A操作($4.50/M ops)を節約するため日次1本
R2 の孤立ファイルクリーンアップ
ファイルをアップロードしてリソースを削除しても、R2上のオブジェクトは残る。日次バッチで D1 に存在しないR2オブジェクトを削除するクリーンアップを実装している。
D1に存在するファイルパスの集合 と R2のオブジェクト一覧を突合して、差分を削除するシンプルな実装。
セキュリティ設計のまとめ
- IP制限: Cloudflare WAF カスタムルールで管理画面ドメインを制限(無料枠5ルール以内)
- 認証: Supabase Auth JWT (ES256) + HTTP-Only Cookie
- ロール: admin/editor の2段階、editorは所属部署のみ
- APIキー: SHA-256ハッシュのみDB保存、平文は発行時のみ
- R2分離: 公開APIはWorkerなしR2直接配信 → D1に一切触れない
- CORS: R2バケットのCORSポリシーで制御
デプロイ
README にステップバイステップのチェックリストを書いた。大まかな流れ:
- Supabase プロジェクト作成
-
wrangler.sample.jsonc→wrangler.jsoncにコピーして設定 -
npx wrangler d1 createとnpx wrangler r2 bucket create -
npm run migrateで D1 マイグレーション -
npm run deployでデプロイ - Cloudflare Dashboard で Secrets 設定(API キーなどはここで設定)
- R2 カスタムドメイン設定
- 管理者ユーザーを Supabase で招待して D1 で admin に昇格
今後やりたいこと
- データプレビュー(CSVをテーブル表示)
- データセットの公開スケジュール設定
- 変更履歴・監査ログ