← ブログ一覧

ダイビングログを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/uploadR2へファイルアップロード
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写真・動画の保存と配信
APIAstro API RoutesCRUD + アップロード
認証Bearer Token(CF Secret)管理者操作の保護
フロントエンドAstro SSR公開ページ・管理画面

タクヤ「ハマったポイントが3つありました」

  1. Astro.locals.runtime ではD1が取れないことがある → cloudflare:workersenv で統一
  2. wrangler.toml をgitignoreするとデプロイのたびにD1/R2バインディングが消える → リポジトリに残す(privateリポジトリ前提)
  3. object-fit: cover の固定高さは撮影構図によって大きく切れる → 元の比率で全体表示に切り替える

リナ社長「全部デプロイしてから気づくやつじゃん」

タクヤ「そうなんですよ……ローカルだと再現しないやつばかりで」

コメント