画像アップロードが動かなくて半日溶かした回の記録。原因が「書いたコードのバグ」じゃなかったときに、どう切り分けたかのメモです。
スマホで綴った写真の画像を、サーバの「同じ写真」に貼り付ける機能を作っていた。仕組みはこう:端末が振った写真のID(client_photo_id)をアップロードに同梱して、サーバはそのIDで既存の写真を探して画像を載せます。前に綴りテキストはこのIDで突合できていたので、画像も同じ手でいけるはずだった。
動かしてみたら、画像は上がる。でも毎回「別の新しい写真」に乗る。綴った写真は画像が無いまま、画像だけ持った写真がどんどん増えていく。
画像が「別の写真」に貼られていた
アップロード口は「IDがあれば既存に貼る/無ければ新規作成」の分岐になっています。同じ client_photo_id が、端末の SQLite、送っているリクエスト、サーバの PostgreSQL に別々にいます。
ここで普通なら「送信側か受信側のコードのバグだ」と探し始める。
コードを直す前に、コードが悪いか確かめた
ここで一回止まった。コードを書き換える前に、本当にコードが悪いのかを先に確かめることにした。送信側(スマホ)も受信側(サーバ)も、読んだ限りでは正しく見える。なら、その「正しく見えるコード」が実際に噛み合うかを直接試せばいい。
やったのは、スマホが送るのと同じ形式のバイト列を手で組んで、サーバのパーサ(届いたリクエストを項目ごとに分解する受付係)にそのまま通すこと。DBには一切書かない、ただのパース確認。
# スマホが送るのと同じ並び(テキスト項目 → ファイル)をバイトで組んで、
# サーバのパーサに通すだけ。DB は触らない。
client_photo_id = drf_request.data.get("client_photo_id")
print(repr(client_photo_id)) # ← ここに ID が出れば、受信コードは正しい出た。client_photo_id はちゃんと取れた。受信側のコードは正しい。送信側もファイル名にIDを載せられている以上、IDの値は持っている。つまり「書いたコードは噛み合う」ことが確定した。
すると残る可能性は一つ。書いたコードは正しいのに、実際に走っているコードがそれと違う。
犯人は「走ってるコードが古い」だった
サーバの起動時刻を見た。開発サーバは手動で起動していて、画像を貼る分岐を足したコミットより9時間も前に起動したままだった。そして起動オプションに --noreload が付いていた。
サーバを再起動して、もう一度スマホから上げ直したら、画像はちゃんと綴った写真に貼られた。コードは最初から一文字も悪くなかった。再起動し忘れていただけ。
教訓は地味だけど効く:--noreload で開発サーバを回しているなら、バックエンドを直すたびに手で再起動します。今回はこれを忘れて、正しいコードを疑う方向に半日使った。--noreload は一例で、本質は「走っているもの」と「編集したもの」がズレうる状況全般——古いキャッシュ、止め忘れた旧コンテナ、別ブランチがデプロイ済みのままの環境——でも同種のバグが出る、という気づきだった。
ついでに、1枚の壊れ画像で全部落ちないように
切り分けの途中で別の問題も出た。アップロード前に画像を縮小するとき、ある写真で例外(範囲外アクセス)が飛んで、そのバッチのアップロードが丸ごと止まっていた。縮小は純Dartのライブラリでやっていて、壊れた画像や苦手な形式だと内部で落ちることがあります。
1枚の事故で全部を巻き込むのは違う。なので、1枚ずつ囲って、ダメな1枚は飛ばして次へ進む形に変えた。
for (final photo in photos) {
try {
final jpeg = await resolveJpegBytes(photo); // ← ここで例外が飛んでも
if (jpeg == null) { skipped++; continue; }
await upload(jpeg);
uploaded++;
} catch (_) {
failed++; // ← その1枚だけ failed にして続行
}
}これで、10枚のうち1枚が壊れていても残りの9枚は上がります。1件の事故で全体を止める(fail-hard)か、1件だけ飛ばして残りを通す(fail-soft)かは、バッチ処理を書くたびに効く判断軸だと分かった。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。