ダイビングログをCloudflare D1+R2で作った話——500エラーとの格闘も添えて
Cloudflare D1+R2+Workers APIでダイビングログを実装。500エラー・デプロイのたびにバインディングが消える・画像が切れる、3連続トラブルと格闘した記録
登場人物
- リナ社長 … 高校生なのに会社経営するやり手ギャル。テックにも強くてAI活用が得意。
- タクヤ … 入社3年目の男性社員。真面目で少しだけコードが書ける。
リナ社長「タクヤさ、あたしダイビング好きじゃん」
タクヤ「はい、よく行ってますよね」
リナ社長「ログ帳ずっと紙に書いてるんだけど、せっかくjinbei-labがあるんだから、サイト上で管理したくない?写真も載せたいし」
タクヤ「ダイビングログですか。日付・ポイント・水深・生き物とか記録できるやつ」
リナ社長「そう!あと非公開メモも書きたい。潜ってて思ったこととか、人に見せないやつ。あと管理画面も欲しい」
タクヤ「結構本格的ですね……」
リナ社長「全部Cloudflareで完結させよう。D1がDB、R2が写真、Workers APIがバックエンド」
D1とR2のセットアップ、最初からつまずく
タクヤ「D1のデータベースを作るには wrangler d1 create ですね」
npx wrangler d1 create divelog-db
タクヤ「……エラー出ました。account_id が取れないって」
リナ社長「あー、wrangler.toml に書いてなかったんじゃない?」
タクヤ「あ、本当だ。追記します」
# wrangler.toml に account_id を追加してリトライ
[[d1_databases]]
binding = "DB"
database_name = "divelog-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
タクヤ「今度は通りました。次R2ですね。wrangler r2 bucket create で——」
リナ社長「あ、R2ってサブスクリプション有効化が先に必要じゃなかったっけ」
タクヤ「ほんとだ。ダッシュボードで「R2に登録」を押さないと……無料枠があるので登録だけして」
リナ社長「CLIじゃなくてダッシュボードからバケット作ったほうが早くない?」
タクヤ「そうしましょう。divelog-media という名前で作りました」
テーブル設計とAPI
タクヤ「テーブルはこんな感じにしました。creatures とか tags とか配列になるカラムは全部JSON文字列で保存です。D1がSQLiteベースで配列型がないので」
CREATE TABLE IF NOT EXISTS dive_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
dive_number INTEGER,
point_name TEXT NOT NULL,
area TEXT NOT NULL DEFAULT '',
max_depth REAL,
dive_time INTEGER,
visibility INTEGER,
water_temp REAL,
creatures TEXT NOT NULL DEFAULT '[]',
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
tags TEXT NOT NULL DEFAULT '[]',
thumbnail_key TEXT,
media_keys TEXT NOT NULL DEFAULT '[]'
);
リナ社長「読み取るときは JSON.parse が必要になるやつね」
タクヤ「そうです。API は4本用意しました」
| エンドポイント | 役割 |
|---|---|
GET/POST /api/diving/logs | 一覧取得・新規作成 |
GET/PUT/DELETE /api/diving/[id] | 1件操作 |
POST /api/diving/upload | R2へファイルアップロード |
GET /api/diving/media/[...key] | R2からファイル配信 |
リナ社長「認証は?」
タクヤ「Cloudflare Secretに DIVE_ADMIN_PASSWORD を設定して、Bearer tokenで叩く形にしました。管理者1人なので認証ライブラリとか使わず」
リナ社長「それで十分。複雑にしても仕方ない」
写真のアップロードはファイルを選んだ瞬間にR2へ送って返ってきたキーを積む方式にした。submit時にAPIがでかいファイルを受け取らなくて済む。
const key = `diving/${crypto.randomUUID()}.${ext}`;
await MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
return Response.json({ key });
配信はキャッシュヘッダーをつけて1年間ブラウザキャッシュが効くようにした。
管理画面できた、公開したら500エラー
リナ社長「管理画面動いた!ウチザン礁のログ入れてみる」
タクヤ「いいですね、ステータスも「公開」にして——」
リナ社長「……ねえ /diving/ 開いたら真っ白なんだけど」
タクヤ「HTTP ERROR 500……あ、やばい」
リナ社長「なんで?」
タクヤ「D1にアクセスできてないっぽいです。Astro.locals.runtime?.env でD1を取ろうとしてたんですけど……」
// ❌ 動かなかった書き方
const db = Astro.locals.runtime?.env?.DB;
タクヤ「@astrojs/cloudflare のバージョンによってはSSRページのfrontmatterでこれが undefined になるケースがあるみたいで。APIルートで使ってた書き方と統一します」
// ✅ cloudflare:workers の env を直接使う
import { env } from 'cloudflare:workers';
const db = (env as CloudflareEnv).DB as D1Database | undefined;
タクヤ「JSONカラムの読み取りも JSON.parse を忘れずに入れて……push しました」
logs = logsResult.results.map(row => ({
...row,
tags: JSON.parse(row.tags || '[]'),
creatures: JSON.parse(row.creatures || '[]'),
media_keys: JSON.parse(row.media_keys || '[]'),
}));
リナ社長「直った!ウチザン礁 #292、ちゃんと表示されてる」
タクヤ「よかった……。APIルートで cloudflare:workers を使っているなら、SSRページも同じパターンで統一しないとダメですね」
ホームとヘッダーも更新
リナ社長「あとホームページに「ダイビング写真(近日公開)」って書いてあったじゃん。あれもリンクにして」
タクヤ「「ダイビングログ」に名前変えてアクティブなリンクにしました。ヘッダーも同じ名前で統一です」
リナ社長「完璧。あとは潜るだけだ」
翌日、ログが消えた
リナ社長「ねえタクヤ、今日また /diving/ 開いたら「まだログがありません」になってるんだけど」
タクヤ「え、昨日は表示されてましたよね……。あ」
リナ社長「何?」
タクヤ「セキュリティ対応でリポジトリIDが含まれる wrangler.toml をgitignoreしたんですが、それが原因だと思います」
Cloudflare Pagesはデプロイのたびにリポジトリの wrangler.toml でバインディングを上書きする。gitignoreでリポジトリから消すと、D1もR2も「バインディングなし」の状態に毎回リセットされてしまう。
リナ社長「ダッシュボードで設定すれば残らないの?」
タクヤ「残らないんですよ。ページのデプロイが wrangler.toml の内容を正として上書きするので」
リナ社長「じゃあファイル戻すしかないじゃん」
タクヤ「そうします。リポジトリはprivateですし、このファイルに書くのはリソースIDだけで、パスワードはCloudflare Secretに入ってるので問題ないです」
wrangler.toml をリポジトリに戻してpush。ログが表示された。
画像が切れる
リナ社長「あと写真が切れてるんだけど。魚の頭が見えない」
タクヤ「object-fit: cover で横幅いっぱいに引き伸ばしてるせいですね。コンテナの高さに合わせてトリミングされてます」
object-fit: cover はコンテナをピッタリ埋めるために画像を拡大・クロップする。高さ固定のコンテナだと元の構図が大幅に崩れる。
リナ社長「top にしたら?」
タクヤ「やってみます。……今度は魚の下が切れました」
リナ社長「じゃあ 30% は?」
タクヤ「……まだ切れてますね。そもそも横幅いっぱいにしなくていいなら、トリミング自体やめたほうがよくないですか」
リナ社長「それでいい」
object-fit: cover と固定高さを廃止して、画像を元の比率のまま表示するように変更した。
/* ❌ Before:横幅いっぱいにトリミング */
.hero-image img { width: 100%; height: 480px; object-fit: cover; }
/* ✅ After:元の比率で全体表示 */
.hero-image { display: flex; justify-content: center; background: var(--ocean-deep); }
.hero-image img { max-width: 100%; max-height: 70vh; width: auto; height: auto; }
余白はページ背景のダーク海色が自然に埋めてくれるのでほとんど気にならない。
リナ社長「これでいい。魚がちゃんと見える」
まとめ
リナ社長「今回の構成、まとめておいて」
タクヤ「はい。ハマりポイントも一緒に」
| レイヤー | 使ったもの | 役割 |
|---|---|---|
| データ | Cloudflare D1 | ログ本体(SQLite互換) |
| ファイル | Cloudflare R2 | 写真・動画の保存と配信 |
| API | Astro API Routes | CRUD + アップロード |
| 認証 | Bearer Token(CF Secret) | 管理者操作の保護 |
| フロントエンド | Astro SSR | 公開ページ・管理画面 |
タクヤ「ハマったポイントが3つありました」
Astro.locals.runtimeではD1が取れないことがある →cloudflare:workersのenvで統一wrangler.tomlをgitignoreするとデプロイのたびにD1/R2バインディングが消える → リポジトリに残す(privateリポジトリ前提)object-fit: coverの固定高さは撮影構図によって大きく切れる → 元の比率で全体表示に切り替える
リナ社長「全部デプロイしてから気づくやつじゃん」
タクヤ「そうなんですよ……ローカルだと再現しないやつばかりで」
コメント