0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

S3時代のファントムファイル

0
Posted at

はじめに

ファイル削除機能を実装するとき、DBの行とS3のオブジェクトのどちらを先に消すか、考えた経験は過去にありますか?

この2つは、絶対に同時には消えず、DBのトランザクションはS3まで面倒を見てくれないので、どちらかが先に消え、間で処理が落ちれば必ず不整合が生まれます。

最近『SQLアンチパターン 第2版』を読んでおり、その12章「ファントムファイル(幻のファイル)」でこの話が出てきます。
ただし書籍の議論は、画像の実体をWebサーバーのファイルシステムに置いていた時代が前提です。
実体をS3のようなオブジェクトストレージに置くのが当たり前になった今、6つあるとされる問題のうち、どれが解決済みで、どれが今も残っているのでしょうか。

この記事は、書籍の勉強のまとめとして、12章が挙げる6つの問題を「オブジェクトストレージが前提の2026年」で仕分けし直したものです。
クラウドが解決してくれた問題と、自分のコードで引き受けるしかない問題を、AIと壁打ちしながら自分なりに整理しました。

この記事で話すこと・話さないこと

話すことは次の2つです。

  • 12章の「6つの問題」は、S3などのオブジェクトストレージでどこまで解決したか
  • 今も残る問題への実務的な落とし所(削除の順序、孤児ファイルの回収)

逆に、次の内容には踏み込みません。

  • BLOBカラムにファイルを入れた場合の性能検証
  • CDNを使った配信設計
  • 各ストレージサービスの網羅的な機能比較

12章「ファントムファイル」が指摘する6つの問題

画像などのバイナリを保存するとき、多くの設計では「DBにはパスだけをVARCHARで保存し、実体はファイルシステムに置く」形を選びます。
書籍はこれを無条件の正解だと思い込むことをアンチパターンと呼び、次の6つの問題を挙げています。

image

そして解決策は「必要に応じてBLOB型を採用する」、つまりファイルをDBの中に入れる選択肢も常に検討せよ、と結ばれています。

「いや、今どきファイルはS3では」と思った方も多いのではないでしょうか。
実は第2版の監訳注にも、次のような一文があります。

現代ではウェブアプリケーション上のファイルシステムに画像ファイルを格納する場面は大幅に減りましたが、Amazon S3 に代表されるクラウド上のオブジェクトストレージにファイルをアップロードし、その URL のみをデータベースに格納する設計の場合も、本章がある程度参考になると考えてください。

(『SQLアンチパターン 第2版』12章 監訳注より)

「ある程度参考になる」とは、具体的にどの問題が参考になるのでしょうか。
ここから6つを仕分けしていきます。

クラウドストレージが解決した問題

調査した結果、6つのうち2つ、
4番(バックアップ)と5番(権限)は概ね解決していることがわかりました。

バックアップは耐久性とバージョニングで概ね解消 (4番目)

書籍が書かれた時代は「DBはバックアップしたが /var 配下の画像はバックアップ対象外だった」という事故が現実にありました。

S3は99.999999999%(イレブンナイン)の耐久性を持ち、標準のストレージクラスでは複数のアベイラビリティゾーンに自動で冗長化されます。
さらにバージョニングを有効にすれば、削除はdelete markerの挿入として扱われ、誤削除や誤上書きからも復旧できます。
「ファイルの実体が失われる」リスクについては、自前のファイルサーバー時代より格段に安全になりました。

ただし「DBとファイルのバックアップが別物である」という構造自体は残っています。
たとえばSupabaseの自動バックアップ(Daily Backups / PITR)の対象はPostgreSQLだけで、Storageに置いたファイルの実体は含まれません。
また、Cloudflare R2にはS3のようなバージョニング機能がありません(2026年8月時点)。
「DBを復元したのにファイルは戻らない」は、サービスの組み合わせ次第で今でも起こります。

権限管理はストレージ側の権限体系に置き換わった (5番目)

書籍が問題にしたのは「SQLのGRANT / REVOKEが外部ファイルに効かない」ことでした。

現在のオブジェクトストレージには、SQLとは別レイヤーの権限体系が最初から用意されています。
S3ならIAMポリシーとバケットポリシーでアクセスを制御し、エンドユーザーには有効期限付きの署名付きURL(presigned URL)を発行します。
署名付きURLの有効期限は最長7日まで指定でき、「この人にこのファイルだけ、この時間だけ」という制御は、むしろSQLの権限より細かくできます。
なお、LambdaやECSのIAMロールのような一時クレデンシャルで署名した場合は、指定した期限より先にクレデンシャルの失効でURLが切れます。
「7日を指定したのに1時間で切れる」は踏みやすい落とし穴です。

象徴的なのはSupabase Storageです。
Supabase Storageはファイルのメタデータを storage.objects というPostgreSQLのテーブルで持ち、そのテーブルへのRow Level Security(行レベルセキュリティ)ポリシーがそのままファイルのアクセス制御になります。
「SQLの権限がファイルに及ばない」という2010年当時の問題意識が、逆にSQLの仕組みでファイルを守る形で解消されている例です。

2026年になっても残る問題

残りの4つ、1番(削除の不整合)、2番(トランザクション分離)、3番(ロールバック)、6番(パスはただの文字列)は、S3を使っても解決しません。

理由は単純で、これらはファイルの置き場所の問題ではなく、「DBとストレージという2つのシステムをまたぐトランザクションが存在しない」ことの問題だからです。

ここで誤解しやすいのがS3の整合性モデルです。
S3は2020年12月以降、書き込み後の読み取りについて強い整合性(strong read-after-write consistency)を提供しています。
ただしこれは「PUTが完了したら、直後のGETやLISTで必ず最新が見える」という話です。
DBのトランザクションのように「コミットするまで他者から見えない」「失敗したらまとめて巻き戻る」を保証するものではありません。

つまり、アプリケーションが「DBの行を消す」と「S3のオブジェクトを消す」を順番に実行する限り、次のどちらかは必ず起こり得ます。

image

どちらの順序を選んでも、片方の失敗をゼロにはできません。
選べるのは「失敗したときに、どちらの壊れ方をするか」だけです。

6番の「パスはただの文字列」も変わっていません。
DBの s3_key カラムに入っているのはただのテキストで、そのキーのオブジェクトが実在するかをDBは保証してくれません。

落とし所は「孤児オブジェクトを許容して回収する」

方針は一言でいうと「不整合は避けられないので、被害の小さい側に倒す」です。
先ほどの図の2つの壊れ方を比べると、孤児オブジェクト(ユーザーから見えない、ストレージ代がかかるだけ)のほうが、壊れたリンク(ユーザーに見える、機能が壊れる)よりずっとマシです。

削除はDBが先、ストレージが後

deleteAttachment.ts
import { DeleteObjectCommand } from '@aws-sdk/client-s3';

async function deleteAttachment(attachmentId: string) {
  const attachment = await attachmentRepo.findById(attachmentId);
  if (!attachment) throw new NotFoundError('attachment', attachmentId);

  // DB削除後にS3を削除する。S3削除が失敗しても孤児オブジェクトが残るだけで
  // DBは整合している。逆順だと、DB削除の失敗時に「実体だけ消えたレコード」が残る
  await db.transaction(async (tx) => {
    // 関連テーブルの削除もこのトランザクションにまとめる。S3の削除は含めない
    await attachmentRepo.deleteById(attachmentId, tx);
  });

  try {
    await s3.send(
      new DeleteObjectCommand({ Bucket: BUCKET, Key: attachment.s3Key }),
    );
  } catch (error) {
    // 削除処理としては成功扱いにする。孤児は掃除ジョブで回収する
    logger.warn('S3オブジェクトの削除に失敗しました', {
      s3Key: attachment.s3Key,
      error,
    });
  }
}

ポイントは、S3の削除をDBのトランザクションの外に出していることです。
トランザクションの中でS3を呼ぶと、S3の失敗でDBまで巻き戻ってしまい、「どちらを正とするか」が曖昧になります。
DBを常に正としておけば、S3側の失敗は「DBに存在しないキーのオブジェクトが残っている」という機械的に検出できる状態に落ちます。

アップロードは逆に、実体が先でDBコミットが後

uploadAttachment.ts
import { randomUUID } from 'node:crypto';
import { PutObjectCommand } from '@aws-sdk/client-s3';

async function uploadAttachment(userId: string, body: Buffer) {
  const fileId = randomUUID();
  // キーをDBのIDから導出する。6番の「パスはただの文字列」は消せない問題だが、
  // 生成規則を1箇所に固定すれば、手入力のパスよりずっと壊れにくい
  const s3Key = buildObjectKey(userId, fileId);

  // 実体を先に置き、DBへの登録を最後にする。途中で失敗して残るのは
  // 「どこからも参照されない孤児オブジェクト」で、実体のないレコードよりも被害が小さい
  await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: s3Key, Body: body }));

  await attachmentRepo.create({ id: fileId, uploadedBy: userId, s3Key });
}

削除とアップロードで順序が逆になるのは、どちらも「失敗したら孤児オブジェクト側に倒れる」ように揃えているからです。
「DBに実体のない参照を持たせない」という1つのルールに集約できます。

署名付きURLの直アップロードではDBが先になる

ただし「実体が先」は、サーバーがファイル本体を受け取る構成の話です。
署名付きURLを発行してクライアントからS3へ直接アップロードさせる構成では、サーバーは実体が置かれる瞬間に立ち会えません。
このため「未確定のDBレコードを作る → クライアントがS3へPUT → 確定APIでレコードを完了にする」という3段階になり、DBが先になるのが自然です。
IDとキーの採番、認可、同名チェックを実体より先にDB側で済ませられる利点もあります。

この構成で大事なのは、順序ではなく確定APIがS3の実体を検証することです。
確定APIがクライアントの「アップロードしました」という申告だけを信じてレコードを完了にすると、S3へのPUTが失敗していても完了扱いになります。
つまり、孤児側に倒したつもりが、壊れたリンク側の経路を作ってしまいます。
確定APIで HeadObject(オブジェクトの存在とメタデータを確認するAPI)を打って実体を確認し、ファイルサイズも申告値ではなくS3の実測値を保存すれば、この経路を塞げます。
先ほどのルールは、この構成では「未確定レコードは参照とみなさない。確定は実体を確認してから」と読み替えられます。

残った孤児は掃除ジョブとライフサイクルで回収する

孤児を許容する以上、回収の仕組みまでがセットです。

  • 定期バッチで「DBに存在しないキーのオブジェクト」を突き合わせて削除する
  • クライアント直アップロードの一時領域には、S3のライフサイクルルールで有効期限を設定し、放置されたオブジェクトを自動削除する
  • DB側にも期限切れ処理を入れ、確定APIが来ないまま残った未確定レコードを一定時間で失敗扱いにする

書籍の言葉を借りれば、1番の問題(削除の不整合)は「ガベージコレクションの問題」です。
アプリケーションの削除処理を完璧にするのではなく、GCのように後から回収する発想に切り替えると、設計がシンプルになります。

このあたりの運用は現場ごとに工夫があると思います。
「自分たちのプロジェクトは削除イベントをoutboxテーブルに積んで非同期で消している」など、別のやり方があればコメントで教えてもらえると嬉しいです。

最後に

12章の主張は「ファイルを外に置く設計には6つの代償がある。知ったうえで選べ」で、BLOBに戻れない今こそ「自分のコードはこの問題を意図して引き受けているか」と自問することが大切だと感じました。

次は他の章でも同じ仕分けをやってみようと思います。

参考文献

株式会社シンシア

株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

弊社には年間100人以上の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?