データストレージ移行計画
背景と問題
現状の課題
現在のアーキテクチャでは data/members.json をプロジェクトリポジトリ内で管理し、Next.js API Route (app/api/members/route.ts) から fs.writeFileSync で書き込んでいる。
Vercel のサーバーレス関数は /var/task(プロジェクトルート)が 読み取り専用 となっており、fs.writeFileSync は EROFS: read-only file system エラーで失敗する。
その結果、ローカル開発環境では動作するが、本番(Vercel デプロイ済み環境)では編集内容を保存できない。
現在の暫定対応として、書き込みエラー時に HTTP 503 を返してユーザーに通知する処理を実装しているが、根本的な解決には外部ストレージへの移行が必要である。
関連情報
- 開発ノート 実装 #7「Vercel 本番環境でのファイル書き込み制限」
- GitHub Issue #69「編集保存機能改善」
現在のアーキテクチャ
┌─────────────────────────────────────────────┐
│ GitHub リポジトリ │
│ data/members.json ←── git commit │
│ │ ▲ │
│ │ GitHub Actions │
│ │ sync-members.yml │
│ │ (毎日 JST 09:00) │
└─────────┼────────────────────┼──────────────┘
│ (静的ファイル) │ (コミット)
▼ │
┌─────────────────────┐ scripts/sync-status.ts
│ Vercel デプロイ │
│ Next.js App Router │
│ /api/members (GET) │ ← JSON 読み取り ✅
│ /api/members (PUT) │ ← JSON 書き込み ❌ (EROFS)
└─────────────────────┘
移行先オプションの比較
オプション A: Vercel Blob(推奨)
@vercel/blob パッケージを使い、members.json の内容を Blob ストレージに保存する。
移行後のデータフロー:
Vercel Blob
members.json (blob)
│
├── GET /api/members → blob.downloadUrl から JSON 取得
├── PUT /api/members → JSON を上書き保存
└── sync-status.ts → Blob を上書き更新
| 観点 | 評価 |
|---|---|
| 移行コスト | 低い(JSON 形式を維持、API 変更が最小) |
| 既存データ形式の互換性 | ◎ そのまま利用可能 |
| 同期スクリプトへの影響 | 小(@vercel/blob SDK でアップロードに変更) |
| インフラ追加 | Vercel Blob のみ(同一プラットフォーム) |
| コスト | 無料枠: 5 GB ストレージ・1 GB 転送/月 |
| バージョン管理 | なし(git での日次コミット履歴は失われる) |
バージョン管理喪失のリスクと代替手段
現在の
data/members.jsonは GitHub Actions による毎日の自動コミットで変更履歴が git に蓄積されており、特定日時へのロールバックや変更追跡が可能である。Blob 移行後はこれらが失われる。失われるもの:
- git での日次コミット履歴(ロールバック手段・変更追跡)
代替手段:
- Vercel Blob は上書き保存のみで、標準的なバージョニング機能は持たない(
@vercel/blobのlist()で同名ファイルの過去バージョンは取得できない)- 重要な変更前には手動で JSON のスナップショットを別ファイル名として Blob に保存する運用を補完策として採用する
「重要な変更」の例:
- バグ修正による一括データ更新(statusHistory の整形など)
- メンバーデータ構造の変更(新フィールド追加など)
- 移行フェーズ実施前(Blob 移行の各フェーズ開始前)
スナップショット管理方針:
- ファイル名に ISO 日時を含める(例:
members-backup-YYYYMMDDTHHmm.json)- 直近 3 件のスナップショットを保持する。新たなスナップショット作成後、4件目以降の古いものを
del()で削除する- 変更追跡が必要になった場合は、将来的にオプション C(Vercel Postgres)への再移行を検討する
必要な環境変数:
| 変数名 | 用途 | 取得タイミング |
|---|---|---|
BLOB_READ_WRITE_TOKEN |
Blob への読み書き権限 | Vercel ダッシュボードで Blob ストア作成時に自動生成 |
MEMBERS_BLOB_URL |
members.json の Blob URL | フェーズ 4 の初期データ移行スクリプト実行後に取得 |
注意:
MEMBERS_BLOB_URLはフェーズ 4 完了後でないと確定しない。フェーズ 2・3 の実装時はプレースホルダーとして設定し、フェーズ 4 実行後に正式な URL に差し替えること。
オプション B: Vercel KV(Redis)
@vercel/kv を使い、メンバーを member:{id} というキーで個別に管理する。
| 観点 | 評価 |
|---|---|
| 移行コスト | 中(データ構造の変更が必要) |
| 既存データ形式の互換性 | △ 全件取得に SCAN が必要で複雑 |
| 同期スクリプトへの影響 | 中(各メンバーを個別に SET) |
| インフラ追加 | Vercel KV のみ |
| コスト | 無料枠: 256 MB |
「全メンバー一覧取得」のパターンが多いこのアプリには不向き。
オプション C: Vercel Postgres(Neon)
PostgreSQL を使ってメンバーデータをリレーショナルに管理する。
| 観点 | 評価 |
|---|---|
| 移行コスト | 高い(スキーマ設計・Prisma 導入が必要) |
| 既存データ形式の互換性 | △ JSON → テーブル変換が必要 |
| 同期スクリプトへの影響 | 大(SQL INSERT/UPDATE に変更) |
| 拡張性 | ◎ 将来的な機能拡張に対応しやすい |
| コスト | 無料枠: 0.5 GB |
現在の規模(47 名)には過剰。将来的にディスコグラフィー等(Issue #56)を追加する場合は有力候補。
オプション D: GitHub API 経由でのコミット
編集 API が GitHub Contents API を呼び出してリポジトリに直接コミットする方式。
| 観点 | 評価 |
|---|---|
| 移行コスト | 中(GitHub Personal Access Token が必要) |
| 既存データ形式の互換性 | ◎ |
| リアルタイム性 | × 保存後に再デプロイが必要 |
| レート制限 | あり(GitHub API) |
保存後すぐに画面に反映されないため UX が悪く、このアプリには不向き。
推奨案: オプション A(Vercel Blob)
選定理由
- 移行コストが最小: JSON ファイルをそのまま Blob に置き換えるだけで、データ構造・型定義の変更が不要。
- 同期スクリプトとの親和性が高い:
sync-status.tsは現在fs.writeFileSyncでファイルを更新しているが、put()関数に変更するだけで対応できる。 - 同一プラットフォーム: Vercel プロジェクトと統合されており、追加インフラ管理が不要。
- 無料枠で十分: 現在のデータ量(
members.jsonは約 200 KB 程度)は無料枠の範囲内。
実装計画
フェーズ 1: Vercel Blob の設定
- Vercel ダッシュボードで Blob ストア(
morning-status-app-store等)を作成する。 BLOB_READ_WRITE_TOKEN環境変数を Vercel プロジェクトと GitHub Actions Secrets に登録する。- パッケージを追加する:
bun add @vercel/blob
フェーズ 2: API Route の移行
app/api/members/route.ts の fs.readFileSync / fs.writeFileSync を @vercel/blob の API に置き換える。
GET(読み取り):
export async function GET() {
const res = await fetch(process.env.MEMBERS_BLOB_URL!);
const data = await res.json();
return NextResponse.json(data);
}
PUT(書き込み):
import { put } from "@vercel/blob";
export async function PUT(request: Request) {
const updatedMember: Member = await request.json();
// 既存データを取得して更新し、Blob に PUT
const res = await fetch(process.env.MEMBERS_BLOB_URL!);
const data = await res.json();
// ... 更新処理 ...
await put("members.json", JSON.stringify(data, null, 2), {
access: "public",
contentType: "application/json",
addRandomSuffix: false,
});
return NextResponse.json({ message: "Member updated successfully", member: updatedMember });
}
注意:
put()はデフォルトで新しい URL を発行する。固定 URL を使いたい場合はaddRandomSuffix: falseを指定するか、URL を環境変数で管理する。Vercel Blob のcopy()とdel()を組み合わせる方式も検討すること。
フェーズ 3: 同期スクリプトの移行
scripts/sync-status.ts の fs.writeFileSync を Blob の put() に置き換える。
import { put } from "@vercel/blob";
// ...
await put("members.json", JSON.stringify(data, null, 2), {
access: "public",
contentType: "application/json",
addRandomSuffix: false, // 固定ファイル名
token: process.env.BLOB_READ_WRITE_TOKEN,
});
GitHub Actions の sync-members.yml では git add / commit の代わりに Blob への書き込みのみで完結するため、contents: write パーミッションが不要になる。
data/members.json の凍結方針
移行後、git 上の data/members.json は移行時点の内容で凍結する。
- 同期スクリプトは Blob のみを更新し、git へのコミットは行わない
data/members.jsonはビルド時の SSG 初期データとしてのみ使用する(フェーズ 5 でrevalidatePath/ ISR を導入することで、本番データは Blob を正として扱う)- 凍結後は
data/members.jsonへの手動コミットは行わないこと(Blob との乖離が拡大し、次回ビルド時に古いデータが SSG ページを上書きするリスクがある)
補足: フェーズ 5(SSG 対応)を完了させることで、一覧・詳細ページともに Blob のデータが正となり、git の JSON との乖離が問題にならなくなる。フェーズ 5 未実施のまま運用する場合は、詳細ページのデータが徐々に古くなる点に注意すること。
フェーズ 4: 初期データ移行
// 一度だけ実行するスクリプト(scripts/migrate-to-blob.ts)
import { put } from "@vercel/blob";
import fs from "fs";
import path from "path";
const data = fs.readFileSync(path.join(process.cwd(), "data", "members.json"), "utf-8");
const result = await put("members.json", data, {
access: "public",
contentType: "application/json",
addRandomSuffix: false,
token: process.env.BLOB_READ_WRITE_TOKEN,
});
console.log("Blob URL:", result.url);
// → この URL を MEMBERS_BLOB_URL 環境変数に設定する
フェーズ 5: 静的ビルドの対応(推奨)
現在 generateStaticParams で SSG しているメンバー詳細ページについて、Blob からデータを取得するように app/members/[id]/page.tsx のデータ取得部分を変更する。
推奨理由: Blob 移行後、同期スクリプトは Blob のみを更新するため、SSG で生成された詳細ページは古いデータのまま固定される。一覧ページ(
/api/members経由でリアルタイムデータ)と詳細ページのデータが食い違う状態を防ぐため、フェーズ 1〜4 と合わせて対応することを推奨する。
ビルド時は data/members.json を引き続き使用し、ランタイム(API)のみ Blob を参照する ハイブリッド方式 も可能。この場合、Blob 更新後に Vercel の revalidatePath でキャッシュを無効化する。
推奨実装:
- 全ページ再検証(対象IDが不明な場合):
PUT /api/membersの処理完了後にrevalidatePath('/members/[id]', 'page')を呼び出す(Next.js 14+、該当するすべての/members/*ページを再検証) - 単一ページ再検証(対象IDが判明している場合):
revalidatePath(`/members/${updatedMember.id}`)を呼び出す(更新対象のページのみを再検証するため効率的) - または
app/members/[id]/page.tsxにexport const revalidate = 3600(ISR) を設定し、1 時間ごとに自動再生成する
移行後のアーキテクチャ
┌─────────────────────────────────────────────┐
│ GitHub リポジトリ │
│ data/members.json (ビルド用・参照のみ) │
│ │
│ GitHub Actions sync-members.yml │
│ → scripts/sync-status.ts │
│ → @vercel/blob put() │
└────────────────────────┬────────────────────┘
│ (Blob 書き込み)
▼
┌─────────────────┐
│ Vercel Blob │
│ members.json │
└────────┬────────┘
│ (読み書き)
┌────────▼────────────┐
│ Vercel デプロイ │
│ /api/members (GET) │ ✅
│ /api/members (PUT) │ ✅
└─────────────────────┘
想定作業量
| フェーズ | 作業内容 | 難易度 | 優先度 |
|---|---|---|---|
| 1 | Vercel Blob 設定・パッケージ追加 | 低 | 必須 |
| 2 | API Route 移行 | 中 | 必須 |
| 3 | 同期スクリプト移行 | 低 | 必須 |
| 4 | 初期データ移行スクリプト実行 | 低 | 必須 |
| 5 | 静的ビルド対応(SSG + revalidate) | 高 | 推奨 |
フェーズ 1〜4 の実装は 1 日以内での完了を見込む。