はじめに
EO PLACE(エオプレイス)は、FF14のプレイヤー店舗やハウジングを写真から探し、保存して、ゲーム内で訪問できるようにする個人開発のWebサービスです。
このサービスをNext.js 16、Docker Compose、VPSで公開した際、ローカルでは動いていた画像アップロードが本番だけ壊れました。アップロードAPI自体は成功し、Docker VolumeにもWebPが存在するのに、編集画面のプレビューには画像が出ません。
最終的には1個の原因ではなく、次の問題を順番に切り分ける必要がありました。
- DBが参照する画像名とVolume内の実ファイル名の不一致
- Alpine上の実行ユーザーのUID/GIDとVolume所有権
- 認可が必要な画像配信と
next/imageのImage Optimization - リバースプロキシを含むアップロードサイズ上限
本記事では、実際に調べた順番と現在の構成を、環境固有情報を除いてまとめます。
症状
ブラウザでは/_next/image?...が400になり、Next.jsのログには次の形式のエラーが出ました。
The requested resource isn't a valid image for /media/shops/<shop-id>/<image-id>.webp received null
最初は壊れたWebPを疑いました。しかし、コンテナ内を確認すると、アップロード直後のファイルは存在していました。
docker exec <web-container> ls -lah /data/uploads/shops/<shop-id>
ここで重要だったのは、「ディレクトリに画像がある」だけでは足りないことです。ログに出たUUIDと、実際に存在するファイルのUUIDが一致するかまで確認します。
docker exec <web-container> sh -c '
test -f /data/uploads/shops/<shop-id>/<image-id>.webp
wc -c /data/uploads/shops/<shop-id>/<image-id>.webp
'
古いDB値を画面が参照していたケースでは、実ファイルが別名で存在していました。これは画像デコードの問題ではなく、メタデータとストレージの整合性問題です。ログ、DBのURL、実ファイルの3つを同じUUIDで比較したことで切り分けられました。
保存先をローカルVolumeにした理由
現在は単一VPSで動かしているため、オブジェクトストレージではなくDockerの名前付きVolumeへ保存しています。
services:
web:
read_only: true
environment:
UPLOAD_STORAGE_PATH: /data/uploads
volumes:
- uploads-data:/data/uploads
volumes:
uploads-data:
コンテナのルートファイルシステムは読み取り専用にし、書き込み可能な場所を/data/uploadsと一時領域に絞っています。単一ホストでは構成が簡単ですが、複数ホストへ水平分割するとそのままでは画像を共有できず、Volumeを含めたバックアップも必要です。将来複数台へ広げるなら、S3互換ストレージ等へ移す前提で、移行境界をストレージ関数の中へ閉じ込めました。
アップロード時に画像を信用しない
拡張子だけでは画像かどうかを判断できません。現在は次の順番で処理します。
- ファイルサイズを検査する
- MIME typeをJPEG、PNG、WebPに限定する
-
sharpで実際にデコードする - 画素数と最大辺を制限する
- WebPへ再エンコードする
- 一時ファイルへ排他的に書き、最後にrenameする
簡略化した実装です。
const MAX_SOURCE_BYTES = 10 * 1024 * 1024;
async function prepareImage(file: File) {
if (file.size === 0 || file.size > MAX_SOURCE_BYTES) throw new Error("invalid_size");
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
throw new Error("invalid_type");
}
const source = Buffer.from(await file.arrayBuffer());
return sharp(source, { failOn: "error", limitInputPixels: 40_000_000 })
.rotate()
.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
.webp({ quality: 85, effort: 4 })
.toBuffer();
}
保存は直接本番ファイルへ上書きせず、同じディレクトリの一時ファイルを使います。
await mkdir(directory, { recursive: true, mode: 0o750 });
try {
await writeFile(temporaryPath, contents, { flag: "wx", mode: 0o640 });
await rename(temporaryPath, destinationPath);
} catch (error) {
await unlink(temporaryPath).catch(() => undefined);
throw error;
}
保存キーも正規表現で許可し、resolve()後のパスがストレージルート配下であることを再確認しています。APIから任意パスを渡せる設計にはしません。
UIDだけでなくGIDも固定する
本番コンテナのプロセスを調べると、UIDは想定どおりでもプライマリGIDがnogroupになっていました。
uid=1001(nextjs) gid=65533(nogroup)
Dockerfileではグループを作っていましたが、Alpineのシステムユーザー作成時にプライマリグループを明示していませんでした。修正後は実行ユーザーとグループを両方指定しています。
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs \
&& mkdir -p /data/uploads \
&& chown -R nextjs:nodejs /data
USER nextjs:nodejs
USER nextjsだけを見て安心せず、実行中コンテナでid、ディレクトリの数値所有者、ファイルモードを確認したのがポイントでした。
Volumeのマウントポイントをone-shotコンテナで整える
既存Volumeのルート所有権は、イメージビルド時のchownだけでは直りません。そこで、Web起動前にマウントポイントを整える一回限りのサービスを置きました。
services:
uploads-init:
image: example-app:local
user: "0:0"
entrypoint: ["/bin/sh", "-c"]
command: ["chown 1001:1001 /data/uploads && chmod 750 /data/uploads"]
restart: "no"
read_only: true
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER]
volumes:
- uploads-data:/data/uploads
web:
depends_on:
uploads-init:
condition: service_completed_successfully
当初は既存ファイルまで再帰的にchownしようとしました。しかし、権限を絞ったコンテナでは子ディレクトリの探索に必要な権限まで広がり、初期化サービスが失敗しました。現在はマウントポイントだけを修復し、Webプロセス自身が作る子ディレクトリとファイルを固定UID/GIDでそろえます。
Composeのservice_completed_successfullyにより、初期化成功後にWebを起動できます(Docker Compose公式ドキュメント)。
認可付き/mediaとnext/imageの落とし穴
EO PLACEには公開、URL限定、フォロワー限定、下書きがあります。そのため、画像ファイルをNginxから無条件に公開せず、Next.jsのRoute Handlerを通しています。
export async function GET(request: NextRequest, context: Context) {
const key = (await context.params).key.join("/");
const user = await authenticateApiRequest(request);
const access = await getShopMediaAccess(shopIdFrom(key), user?.uid);
if (!access.allowed) return new Response("Not found", { status: 404 });
const body = await readStoredImage(key);
return new Response(body, {
headers: {
"Content-Type": "image/webp",
"Content-Length": String(body.byteLength),
"Cache-Control": access.restricted
? "private, no-store"
: "public, max-age=31536000, immutable"
}
});
}
ここで<Image src="/media/...">を通常どおり使うと、ブラウザではなくNext.jsの画像最適化処理が元画像を取得します。ところが、最適化APIは元リクエストの認証ヘッダー等を転送しません。
Next.jsの公式ドキュメントにも、認証が必要なsrcではunoptimizedを検討するよう明記されています(Image Component)。そこで保護対象の画像だけ最適化を無効にしました。
<Image
src={image}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
unoptimized={image.startsWith("/media/")}
/>
これによりブラウザが/media/...へ直接リクエストし、同一オリジンのセッションCookieを送れるようになりました。画像はアップロード時点でサイズ制限とWebP変換を済ませているため、この構成では二重の最適化を避ける意味もあります。
プロキシの上限はアプリより少し大きくする
アプリが10MBまで許可していても、前段のNginxが1MBで拒否すればRoute Handlerへ届きません。逆にプロキシだけ無制限にしても、アプリ側のメモリ消費を守れません。
現在はアプリ側10MBに対し、multipartのオーバーヘッドを見込んで前段を11MBにしています。
client_max_body_size 11m;
障害時には、次の順番で見るとどの層が拒否したか分かりやすくなります。
- ブラウザのNetworkでステータスと要求URLを確認
- TLS終端とNginxのログを確認
- Next.jsのRoute Handlerへ到達したか確認
- DBに保存された画像URLを確認
- Volume内の同名ファイルとバイト数を確認
- コンテナのUID/GIDとモードを確認
-
/_next/image経由か/media直アクセスか確認
今回の学び
- 「アップロード成功」と「表示成功」は別の経路として調べる
- ファイルの有無ではなく、DB、ログ、Volumeのキーを一致させる
- コンテナユーザーは名前だけでなく数値UID/GIDを確認する
- 既存Volumeの所有権はDockerfileの
chownでは変わらない - 初期化コンテナへ与える権限と処理範囲は最小にする
- 認可付き画像ではImage Optimizationが認証情報を引き継ぐか確認する
- 非公開画像へpublic cacheを付けない
画像障害は「画像ライブラリの問題」に見えますが、実際にはHTTP、認証、DB、ファイルシステム、コンテナ境界を横断します。各層で同じ画像キーを追跡できるログと確認手順を用意しておくことが、最も効いた対策でした。