ローカル→S3移行でDBを書き換えなかった理由:パス設計とrawPutFile()の話

「S3に移行したらDBのパスも全部書き換えなきゃいけないよな」と思っていた。

ScanNote(SvelteKit + SQLite + SeaweedFS)で、本番環境のローカルファイルをS3互換ストレージへ一括移行する機能を実装した際の話だ。結論から言うと、DBのパスは一行も書き換えていない。環境変数を STORAGE_TYPE=local から STORAGE_TYPE=s3 に変えて再起動するだけで、既存データがそのまま動いた。

なぜDBを書き換えなくてよかったのか

SQLiteの pages テーブルには image_path カラムがあり、値は nb-abc123/img-xyz789.jpg のような形式になっている。ノートブックID配下にファイル名という構造だ。

このパス形式はローカルストレージの場合もS3の場合も同じだった。ローカルでは /data/files/nb-abc123/img-xyz789.jpg の絶対パス全体ではなく、ベースディレクトリからの相対パスをDBに保存していた。S3のキーも nb-abc123/img-xyz789.jpg で同一形式になる。

つまりアプリケーション層では常に相対パス nb-abc123/img-xyz789.jpg を扱い、実体へのアクセスは FileStorageService インターフェース経由で行う。ローカル実装はこのパスにベースディレクトリを結合してファイルシステムにアクセスし、S3実装はそのままバケットキーとして使う。

DB: image_path = "nb-abc123/img-xyz789.jpg"
                          ↓
LocalFileStorageService: /data/files/nb-abc123/img-xyz789.jpg
S3FileStorageService:    bucket/nb-abc123/img-xyz789.jpg

ファイルをS3にコピーした後は、アプリを再起動して STORAGE_TYPE を切り替えるだけでよい。ストレージ抽象化の恩恵がここで効いた。 FileStorageServiceインターフェースの例:

interface FileStorageService {
  saveImage(notebookId: string, buffer: Buffer): Promise<string>;
  getFile(filePath: string): Promise<Buffer>;
  deleteFile(filePath: string): Promise<void>;
  exists(filePath: string): Promise<boolean>;
}

saveImage()をそのまま使えなかった理由

最初は移行処理でも既存の saveImage() を使えばいいと思っていた。ところが saveImage() の実装を見ると問題があった。

// S3FileStorageService.saveImage() の抜粋
const filename = `${nanoid()}.jpg`;  // ← ここが問題
const key = `${notebookId}/${filename}`;

saveImage() は nanoid でランダムなファイル名を生成する。これは新規アップロード時には意図的な設計だが、移行処理では既存のパスのままS3に配置したい。nanoidが走ると nb-abc123/img-xyz789.jpgnb-abc123/Vy8XrGqP.jpg に変わり、DBのパスと不一致になる。

注意: nanoidによるファイル名生成は、同名ファイルの衝突回避とセキュリティ(ファイル名の推測困難性)のための設計です。

そのため FileStorageService インターフェースには追加せず、S3FileStorageService クラスにだけ移行専用の rawPutFile() を追加した。

// S3FileStorageService に追加したメソッド
/**
 * ファイルパスを変えずにS3にアップロードする(ローカル→S3移行用)
 * saveImage() と異なり、nanoidでパスを変えない
 */
async rawPutFile(filePath: string, buffer: Buffer, contentType: string): Promise<void> {
  this.validatePath(filePath);
  try {
    await this.client.send(new PutObjectCommand({
      Bucket: this.bucket,
      Key: filePath,
      Body: buffer,
      ContentType: contentType,
      ACL: 'bucket-owner-full-control',  // バケット所有者にフルコントロールを付与
    }));
  } catch (error) {
    throw this.createError(
      `Failed to put file: ${filePath}`,
      'IO_ERROR',
      filePath,
      error as Error
    );
  }
}

listFiles() についても同様の判断をした。こちらはローカルストレージでファイル一覧を取得するための機能で、移行専用の特殊処理にすぎない。汎用インターフェースに追加するより、LocalFileStorageService クラスに直接定義する方が意図が明確になる。

移行ユースケースの設計

移行処理の核となる MigrateStorageUseCase は以下の方針で実装した。

1ファイル失敗でも処理を継続する(冪等設計)

ファイルが1000枚あって途中でネットワークエラーが起きたとき、最初からやり直しは辛い。各ファイルを独立して処理し、失敗したものはローカルに残してエラーをレポートする。再実行するとS3に既存のファイルはスキップされるため、安全に再試行できる。

**アップロード成功後にローカル削除する(コピーではなくムーブ)

安全性: S3アップロードが失敗した場合、ローカルファイルは削除されません。これによりデータ損失のリスクを回避しています。**

最初の設計では「コピーのみ(ローカル削除しない)」にしていた。ところがフィードバックで「ローカルに残り続けるとストレージを二重に消費する」という指摘があり、「アップロード成功後にローカル削除する」ムーブ方式に変更した。S3アップロードが失敗した場合はローカルを削除しないため、データ損失のリスクはない。

export class MigrateStorageUseCase {
  constructor(
    private readonly localStorage: LocalFileStorageService,
    private readonly s3Storage: S3FileStorageService
  ) {}

  async execute(input: MigrateStorageInput): Promise<MigrateStorageOutput> {
    const files = await this.localStorage.listFiles();

    // dryRun: サイズ集計のみ
    if (input.dryRun) {
      let totalBytes = 0;
      for (const filePath of files) {
        try {
          const info = await this.localStorage.getFileInfo(filePath);
          totalBytes += info.size;
        } catch { /* サイズ取得失敗は無視 */ }
      }
      return { dryRun: true, totalFiles: files.length, totalBytes, /* ... */ };
    }

    // 実際の移行処理
    const results: MigrationFileResult[] = [];
    for (const filePath of files) {
      try {
        const info = await this.localStorage.getFileInfo(filePath);
        const alreadyExists = await this.s3Storage.exists(filePath);

        if (alreadyExists) {
          // S3に既存 → アップロードせずローカル削除(冪等)
          await this.localStorage.deleteFile(filePath);
          results.push({ path: filePath, status: 'skipped' });
          continue;
        }

        const buffer = await this.localStorage.getFile(filePath);
        await this.s3Storage.rawPutFile(filePath, buffer, info.mimeType);
        await this.localStorage.deleteFile(filePath);  // アップロード成功後に削除
        results.push({ path: filePath, status: 'moved' });
      } catch (error) {
        // 失敗してもローカルは削除しない
        results.push({
          path: filePath,
          status: 'failed',
          error: error instanceof Error ? error.message : String(error),
        });
      }
    }
    // ...
  }
}

DIコンテナを使わない判断

移行APIのエンドポイントでは、アプリケーション全体のDIコンテナを経由せず、両ストレージサービスを直接インスタンス化している。

// POST /api/v1/settings/storage/migrate
export const POST: RequestHandler = async ({ request }) => {
  // STORAGE_TYPE=s3 の前提条件チェック
  const storageType = loadStorageTypeConfig();
  if (storageType !== 's3') {
    return json({ success: false, error: 'STORAGE_TYPE=s3 に設定してください。' }, { status: 400 });
  }

  const s3Config = loadS3StorageConfig();
  const storagePath = process.env['STORAGE_PATH'] || './data/files';

  // DIコンテナを使わず直接インスタンス化
  const localStorage = new LocalFileStorageService(storagePath);
  const s3Storage = new S3FileStorageService(s3Config);
  const useCase = new MigrateStorageUseCase(localStorage, s3Storage);

  const result = await useCase.execute({ dryRun: body.dryRun });
  return json({ success: true, result });
};

この判断の背景は、移行処理の特殊性だ。通常フローでは STORAGE_TYPE で選択された一方のサービスのみが使われる。一方、移行では「ローカルから読み出してS3に書き込む」という両方を同時に使う操作になる。DIコンテナに「移行用の特殊構成」を追加するより、エンドポイントでシンプルに直接生成する方がわかりやすいと判断した。 一般的なDIコンテナの使用パターンでは、アプリケーション起動時に環境変数に基づいて単一のストレージサービスを注入します。移行処理のように複数のストレージサービスを同時に必要とする場面は例外的なケースです。

2ステップのUI設計

移行UIは「確認 → 実行」の2フローにした。

  1. 「コピー対象を確認」ボタンdryRun: true でAPIを呼ぶ → 対象ファイル数・合計サイズを表示
  2. 「移動実行」ボタンdryRun: false でAPIを呼ぶ → moved / skipped / failed 件数を表示

dryRunがいる理由は、本番データが何ファイル・何GBあるか事前に見たいからだ。サイズを確認してから「では実行する」と判断できる。dryRunの実装はシンプルで、ファイル一覧を取得してサイズを集計するだけでファイルには触れない。

失敗ファイルがある場合はUIに「失敗したファイルはローカルに残っています。再実行することで再試行できます」と表示する。ユーザーに何が起きたかを明示し、次のアクション(再実行)を示している。

環境・バージョン

  • SvelteKit: 2.5.4
  • Svelte: 5.0.x
  • @aws-sdk/client-s3: 3.980.x
  • nanoid: 5.0.x
  • S3互換: SeaweedFS(MinIO互換API)
  • Node.js: 22系

やってみてわかったこと

ストレージ抽象化が正しく設計されていれば、移行はDBを触らずに済む。逆に言うと、DBに絶対パス(/data/files/nb-abc123/img-xyz789.jpg)を保存していたら、パスの書き換えが必要になっていた。相対パスだけをDBに持つことで、ストレージバックエンドの切り替えとデータ移行が独立する。 この設計パターンは「パス正規化」と呼ばれ、ストレージ層の変更がアプリケーション層に影響しないようにする重要な原則です。

移行専用メソッドをインターフェースに追加しなかった判断も重要で、汎用インターフェースに「特定の移行でしか使わないメソッド」を追加すると実装クラスが全て対応する必要が生じる。移行用のユースケースが具象クラスに依存するのはアーキテクチャ的に許容範囲だった。