Drive APIとResumable Uploadで実現!Googleドライブ→GCS大容量ファイル転送

Drive2GCS-resumable

こんにちは!以前、Google Apps Script (GAS) を使って Google Drive のファイルを Google Cloud Storage (GCS) にコピーする方法を紹介しました。

最初の記事: Google Drive から Google Cloud Storage にファイルを簡単にアップロードできるライブラリを探していたら結局自作していた件

しかし、このシンプルな方法には「ファイルサイズの壁」がありました。具体的には、以下の2つの大きな壁です。

  1. UrlFetchApp の壁: 一度に送信できるデータ量が 50MB までという制限 (公式割り当て参照)。

  2. DriveApp.getBlob() の壁: GAS が扱える Blob (ファイルデータ) のサイズも 50MB までという制限 。

「1」の UrlFetchApp の壁を回避するために GCS の Resumable Upload を利用する方法が考えられますが、ファイルを DriveApp.getBlob() で読み込む限り「2」の壁は依然として存在し、結局 50MB を超えるファイルを GAS 単体で扱うことはできませんでした。

「GAS でなんとか 50MB 超のファイルも扱いたい…!」

そんな要望に応えるべく、今回はさらに一歩進んだテクニック、Google Drive API を直接利用してファイルをチャンク(部分)ごとに読み込み、GCS の Resumable Upload で転送する方法を解説します。これにより、上記「1」「2」両方の 50MB の壁を回避することを目指します!

ただし、結論から言うと、この方法にも限界はあります。 それは GAS の実行時間制限です。この記事では、その実装方法と、残された課題について詳しく見ていきましょう。

GAS を取り巻く「2つの 50MB の壁」

まず、GAS で Drive から GCS へファイルを転送する際に立ちはだかる制限を再確認します。

  1. UrlFetchApp のペイロードサイズ制限 (50MB):

    UrlFetchApp.fetch() で GCS にデータを送信する際、一度に送れるデータ本体(ペイロード)は 50MB までです。最初のシンプルなコードではファイル全体を一度に送ろうとしたため、ここでエラーが発生しました。GCS の Resumable Upload は、ファイルを分割して送信することでこの制限を回避します。

  2. DriveApp.getBlob() / Blob サイズ制限 (50MB):

    DriveApp.getFileById(id).getBlob() や blob.getBytes() など、GAS がスクリプト内でファイルデータを Blob オブジェクトとして直接扱う場合、その Blob のサイズは 50MB までという制限があります。そのため、50MB を超えるファイルを Drive から読み込もうとすると、getBlob() の時点で「ファイルが最大サイズを超えています」といったエラーが発生してしまいます。Resumable Upload を使っても、読み込み段階でこの制限に引っかかっては意味がありません。

つまり、50MB 超のファイルを扱うには、読み込みと書き込みの両方で 50MB の壁を回避する必要があるのです。

解決策: Drive API で読み込み + GCS Resumable Upload で書き込み

そこで採用するのが、以下の合わせ技です。

  1. 読み込み: DriveApp.getBlob() を使わず、UrlFetchApp を使って Google Drive API を直接呼び出し、Range ヘッダーでバイト範囲を指定してファイルの一部(チャンク)だけを読み取る。

  2. 書き込み: 読み取ったチャンクを、GCS Resumable Upload の仕組みを使って UrlFetchApp で GCS に送信する。

ここで利用する GCS Resumable Upload とは、Google Cloud Storage が提供する、大きなファイルをチャンク(塊)に分割してアップロードするための仕組みです。通信が中断された場合に途中から再開できるため、信頼性の高いアップロードが可能です。GAS の文脈では、ファイルをチャンクで送信することで UrlFetchApp の 50MB ペイロードサイズ制限を回避できる点が重要です。

より詳しくは公式ドキュメントを参照してください。

参考: 再開可能なアップロードの実行する (Google Cloud ドキュメント)

これにより、GAS のスクリプト内では常に比較的小さなチャンクデータしか扱わないため、50MB の Blob サイズ制限の影響を受けずに済みます。また、GCS への送信もチャンク単位なので、UrlFetchApp のペイロードサイズ制限も回避できます。

Drive API でのチャンク読み込みは、具体的には Drive API v3 の files.get メソッドのエンドポイント (https://www.googleapis.com/drive/v3/files/FILE_ID?alt=media) に対して、Range: bytes=開始バイト-終了バイト というヘッダーを付けてリクエストを送ることで実現します。

GAS での実装: Drive API チャンク読み込み + GCS Resumable Upload

それでは、具体的な実装コードを見ていきましょう。

【最重要】この方法でも残る「実行時間」という壁

実装コードを見る前に、非常に重要な注意点です。この Drive API を利用した方法でも、GAS の実行時間制限 (通常6分 / Workspace 30分) という根本的な制限は依然として存在します。

ファイルサイズが大きくなると、Drive からの読み込みと GCS への書き込みの両方で UrlFetchApp.fetch() の呼び出し回数が非常に多くなります。数百MB程度のファイルなら完了する可能性もありますが、GB単位のファイルになってくると、処理が終わる前にタイムアウトしてしまう可能性が非常に高いです。

この方法は GAS で実現できる限界に近いアプローチですが、万能ではないことをご理解ください。

必要な準備

スクリプトを実行する前に、以下を確認・準備してください。

  • OAuthスコープ: スクリプトが Google Drive と Google Cloud Storage にアクセスするための権限が必要です。スクリプトエディタの プロジェクトの設定 > OAuth 同意画面 などで以下のスコープが有効になっているか確認します。

    • https://www.googleapis.com/auth/drive.readonly (または https://www.googleapis.com/auth/drive

    • https://www.googleapis.com/auth/devstorage.read_write

    • https://www.googleapis.com/auth/script.external_request

    不足している場合は、スクリプトエディタの 表示 > マニフェストファイルを表示 を選択し、appsscript.json ファイルを直接編集します。以下のように oauthScopes プロパティに必要なスコープが含まれていることを確認、または追加してください。

    {
      "timeZone": "Asia/Tokyo", // あなたのタイムゾーンに合わせてください
      "dependencies": {
      },
      "exceptionLogging": "STACKDRIVER",
      "runtimeVersion": "V8",
      "oauthScopes": [
        "https://www.googleapis.com/auth/drive.readonly",
        "https://www.googleapis.com/auth/devstorage.read_write",
        "https://www.googleapis.com/auth/script.external_request"
      ]
    }
    
    
  • GCSバケット: アップロード先のGCSバケットを作成しておきます。

実装コード

/**
 * Drive APIでファイルをチャンクごとに読み込み、GCSにResumable Uploadする関数
 * 注意: 50MBのBlobサイズ制限は回避できるが、実行時間制限により超巨大ファイルでは失敗する可能性が高い。
 *
 * @param {string} fileId Google DriveのファイルID
 * @param {string} bucketName アップロード先のGCSバケット名
 * @param {string} gcsPath GCS上でのオブジェクトパス(ファイル名含む) 例: "folder/my_large_file.zip"
 * @return {boolean} アップロードが成功したかどうか
 */
function uploadDriveToGcsChunkedRead(fileId, bucketName, gcsPath) {
  // --- 定数設定 ---
  // Driveからの読み込み、GCSへの書き込みチャンクサイズ。256 KiBの倍数。
  // メモリや実行時間制限を考慮して調整。小さすぎるとAPI呼び出し回数が増える。
  // 例: 8MB = 8 * 1024 * 1024 = 8,388,608
  // 例: 1MB = 1 * 1024 * 1024 = 1,048,576
  const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB

  // --- ファイル情報取得 (DriveAppを使用) ---
  let file;
  let totalSize;
  let mimeType;
  try {
    file = DriveApp.getFileById(fileId);
    totalSize = file.getSize(); // ファイルサイズ取得 (メタデータなのでサイズ制限なし)
    mimeType = file.getMimeType(); // MIMEタイプ取得
    Logger.log(`ファイル名: ${file.getName()}, サイズ: ${totalSize} bytes, MIMEタイプ: ${mimeType}`);
    if (totalSize === 0) {
      Logger.log("ファイルサイズが0バイトのため、アップロードをスキップします。");
      // GCSに0バイトファイルを作成する場合は別途処理が必要
      return true;
    }
  } catch (e) {
    Logger.log(`DriveAppでのファイル情報取得エラー: ${e}`);
    return false;
  }

  // --- 認証トークン取得 ---
  const token = ScriptApp.getOAuthToken();

  // --- 1. GCS Resumable Uploadセッションの開始 ---
  const initiationUrl = `https://storage.googleapis.com/upload/storage/v1/b/${bucketName}/o?uploadType=resumable&name=${encodeURIComponent(gcsPath)}`;
  const initiationHeaders = {
    "Authorization": "Bearer " + token,
    "X-Upload-Content-Type": mimeType, // DriveAppから取得したMIMEタイプを使用
    "X-Upload-Content-Length": totalSize.toString(),
    "Content-Type": "application/json; charset=UTF-8"
  };
  const initiationPayload = JSON.stringify({
    "name": gcsPath
  });

  let sessionUri; // GCSアップロード用セッションURI
  try {
    const initiationOptions = {
      method: "POST",
      headers: initiationHeaders,
      payload: initiationPayload,
      muteHttpExceptions: true
    };
    const initiationResponse = UrlFetchApp.fetch(initiationUrl, initiationOptions);
    const initiationResponseCode = initiationResponse.getResponseCode();

    if (initiationResponseCode === 200) {
      sessionUri = initiationResponse.getHeaders()["Location"];
      if (!sessionUri) {
         Logger.log("GCSセッションURIがレスポンスヘッダーに含まれていません。");
         Logger.log(`レスポンスコード: ${initiationResponseCode}`);
         Logger.log(`レスポンス内容: ${initiationResponse.getContentText()}`);
         return false;
      }
      Logger.log(`GCS Resumable Uploadセッション開始成功。Session URI: ${sessionUri}`);
    } else {
      Logger.log(`GCS Resumable Uploadセッション開始失敗。レスポンスコード: ${initiationResponseCode}`);
      Logger.log(`レスポンス内容: ${initiationResponse.getContentText()}`);
      return false;
    }
  } catch (e) {
    Logger.log(`GCS Resumable Uploadセッション開始中にエラー: ${e}`);
    return false;
  }

  // --- 2. Driveからチャンクを読み込み、GCSへアップロード ---
  let byteStart = 0;
  let lastGcsResponseCode = 0;

  // Drive APIのファイルダウンロード用URL
  const driveDownloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`;

  while (byteStart < totalSize) {
    // 実行時間制限のチェック (簡易的) - 必要ならより厳密に実装
    // if (/* 制限時間に近づいたら */) { Logger.log("実行時間制限"); return false; }

    const byteEnd = Math.min(byteStart + CHUNK_SIZE - 1, totalSize - 1);
    const currentChunkSize = byteEnd - byteStart + 1;

    let chunkBytes; // このチャンクのバイトデータ

    // --- Drive APIからチャンクを読み込み ---
    try {
      const driveHeaders = {
        "Authorization": "Bearer " + token,
        "Range": `bytes=${byteStart}-${byteEnd}` // 読み込む範囲を指定
      };
      const driveOptions = {
        method: "GET",
        headers: driveHeaders,
        muteHttpExceptions: true
      };

      Logger.log(`Driveからチャンク読み込み中: bytes=${byteStart}-${byteEnd}`);
      const driveResponse = UrlFetchApp.fetch(driveDownloadUrl, driveOptions);
      const driveResponseCode = driveResponse.getResponseCode();

      if (driveResponseCode === 200 || driveResponseCode === 206) { // 200 (全範囲) or 206 (部分範囲)
        chunkBytes = driveResponse.getContent(); // バイト配列を取得
        // 取得したバイト数が要求したサイズと一致するか確認 (念のため)
        if (chunkBytes.length !== currentChunkSize) {
           Logger.log(`Driveから取得したチャンクサイズが予期しない値です。要求: ${currentChunkSize}, 取得: ${chunkBytes.length}`);
           // ここでリトライするかエラーにするか判断
           return false;
        }
        Logger.log(`Driveチャンク読み込み成功 (${chunkBytes.length} bytes)`);
      } else {
        Logger.log(`Driveからのチャンク読み込み失敗。レスポンスコード: ${driveResponseCode}`);
        Logger.log(`レスポンス内容: ${driveResponse.getContentText()}`);
        return false;
      }
    } catch (e) {
      Logger.log(`Driveからのチャンク読み込み中に例外エラー: bytes ${byteStart}-${byteEnd}`);
      Logger.log(e);
      return false;
    }

    // --- GCSへチャンクをアップロード ---
    if (!chunkBytes) {
       Logger.log("読み込んだチャンクデータがありません。アップロードをスキップします。");
       // 通常ここには来ないはず
       return false;
    }

    try {
      // Content-Length は UrlFetchApp が自動計算するため指定しない
      const gcsUploadHeaders = {
        "Authorization": "Bearer " + token,
        // Content-Range は Driveから読み込んだ範囲と同じものを指定
        "Content-Range": `bytes ${byteStart}-${byteEnd}/${totalSize}`
      };
      const gcsUploadOptions = {
        method: "PUT",
        headers: gcsUploadHeaders,
        payload: chunkBytes, // Driveから読み込んだバイト配列
        contentType: null,
        muteHttpExceptions: true
      };

      Logger.log(`GCSへチャンクアップロード中: bytes ${byteStart}-${byteEnd}/${totalSize} (${chunkBytes.length} bytes)`);
      const gcsUploadResponse = UrlFetchApp.fetch(sessionUri, gcsUploadOptions);
      lastGcsResponseCode = gcsUploadResponse.getResponseCode();

      if (lastGcsResponseCode === 308) { // Resume Incomplete - 成功、次のチャンクへ
        const rangeHeader = gcsUploadResponse.getHeaders()["Range"];
        Logger.log(`GCSチャンクアップロード成功 (308 Resume Incomplete)。サーバー受信済み: ${rangeHeader || '不明'}`);
        byteStart = byteEnd + 1; // 次の開始位置へ
      } else if (lastGcsResponseCode === 200 || lastGcsResponseCode === 201) { // OK or Created - 完了!
        Logger.log(`GCS最終チャンクアップロード成功。ファイル作成完了!レスポンスコード: ${lastGcsResponseCode}`);
        byteStart = totalSize; // ループを終了させる
        return true; // アップロード成功
      } else {
        Logger.log(`GCSへのチャンクアップロード中に予期せぬエラー。レスポンスコード: ${lastGcsResponseCode}`);
        Logger.log(`レスポンス内容: ${gcsUploadResponse.getContentText()}`);
        // 必要に応じてGCSアップロードキャンセル処理 (DELETEリクエスト) を追加
        return false; // アップロード失敗
      }
    } catch (e) {
      Logger.log(`GCSへのチャンクアップロード中に例外エラー: bytes ${byteStart}-${byteEnd}`);
      Logger.log(e);
       // 必要に応じてGCSアップロードキャンセル処理 (DELETEリクエスト) を追加
      return false; // アップロード失敗
    }
  } // end while loop

  // ループが正常に完了したが、最終レスポンスが200/201でなかった場合のフォールバック
  if (lastGcsResponseCode !== 200 && lastGcsResponseCode !== 201) {
     Logger.log(`アップロードは完了したように見えますが、最終GCSステータスコードが予期しない値でした: ${lastGcsResponseCode}`);
     return false;
  }

  // 通常ここには到達しないはず
  return false;
}

// --- 実行例 ---
function testChunkedUpload() {
  const targetFileId = "YOUR_DRIVE_FILE_ID"; // ★テストしたいDrive上のファイルIDに置き換える
  const targetBucket = "YOUR_GCS_BUCKET_NAME"; // ★あなたのGCSバケット名に置き換える
  const targetPath = "test/large_chunked_upload_test.dat"; // ★GCS上でのパス(ファイル名含む)

  if (targetFileId === "YOUR_DRIVE_FILE_ID" || targetBucket === "YOUR_GCS_BUCKET_NAME") {
    Logger.log("テスト実行前に、testChunkedUpload()内の targetFileId と targetBucket を設定してください。");
    return;
  }

  Logger.log(`チャンク読み込みアップロードを開始します: FileID=${targetFileId}, Bucket=${targetBucket}, Path=${targetPath}`);
  const startTime = new Date();

  const success = uploadDriveToGcsChunkedRead(targetFileId, targetBucket, targetPath);

  const endTime = new Date();
  const executionTime = (endTime.getTime() - startTime.getTime()) / 1000; // 秒単位

  if (success) {
    Logger.log(`アップロード処理が成功しました。所要時間: ${executionTime} 秒`);
  } else {
    Logger.log(`アップロード処理中にエラーが発生しました。ログを確認してください。所要時間: ${executionTime} 秒`);
  }
}

コードのポイント解説

  • CHUNK_SIZE: Drive からの読み込みと GCS への書き込みを一度に行うサイズです。256KiB の倍数で、メモリ使用量や API 呼び出し回数のバランスを見て調整します (例: 8MB)。

  • ファイル情報取得: DriveApp を使ってファイルサイズ (getSize()) と MIME タイプ (getMimeType()) を取得します。getBlob() は呼び出さないため、50MB の Blob サイズ制限は関係ありません。

  • GCS セッション開始: GCS Resumable Upload のセッションを開始します。ここは以前の方法とほぼ同じです。

  • Drive API チャンク読み込み:

    • while ループ内で、ファイルの byteStart から byteEnd までの範囲を計算します。

    • UrlFetchApp.fetch を使って Drive API v3 (files.getalt=media) を呼び出します。

    • Range ヘッダーで読み込むバイト範囲を指定するのが重要です。

    • レスポンス (driveResponse.getContent()) から、その範囲のバイトデータ (chunkBytes) を取得します。

  • GCS チャンクアップロード:

    • Drive から読み込んだ chunkBytes をペイロードとして、GCS のセッション URI に PUT リクエストを送信します。

    • Content-Range ヘッダーには、Drive から読み込んだのと同じバイト範囲を指定します。

    • contentType: null を指定し、Content-LengthUrlFetchApp の自動計算に任せます。

  • レスポンス処理: GCS からのレスポンスコード (308, 200, 201) を確認し、次のチャンクに進むか、完了/エラー処理を行います。

パフォーマンスと残された限界: 実行時間

この Drive API を利用した方法で、GAS の 50MB の壁 (Blob サイズとペイロードサイズ) は回避できました。しかし、実行時間という最後の壁が残っています。

ファイルサイズが大きくなるほど、ループ内の Drive API 呼び出しと GCS への UrlFetchApp 呼び出しの回数が比例して増加します。例えば 1GB のファイルを 8MB チャンクで処理する場合、約 128 回のループ (Drive API 呼び出し 128 回 + GCS 呼び出し 128 回) が必要になります。ネットワークの状況や Google 側のサーバーの応答速度にもよりますが、これだけの API 呼び出しを GAS の実行時間制限 (通常 6 分 / Workspace 30 分) 内に完了させるのは、ファイルサイズが大きくなると現実的に困難になります。

体感的には、数百MB程度であれば Workspace アカウント (30分) で完了する可能性はありますが、GB を超えるようなファイルを安定して処理するのは難しいでしょう。

解決策: Cloud Run functions / Cloud Run

GAS の実行時間制限という壁に突き当たった場合、やはり最も推奨されるのは Google Cloud Run functionsCloud Run を利用することです。

これらのサーバーレス環境では、

  • より長い実行時間、より多くのメモリが利用可能。

  • Google Cloud Client Library を利用でき、Drive から GCS へのファイル転送(特にストリーミング処理など)をより効率的かつ簡単に実装可能。

  • GAS のような実行環境固有の制限に縛られにくい。

GAS はあくまで処理の「起動トリガー」として利用し、実際の重い処理は Cloud Functions / Run に任せるのが、大容量ファイルを扱う際のベストプラクティスと言えます。

まとめ: どの方法を選ぶべきか?

Drive から GCS へのファイル転送について、いくつかの方法とその限界を見てきました。

  1. シンプルな一括コピー (最初の記事):

    • メリット: 実装が最も簡単。

    • デメリット: 50MB の壁 (ペイロード & Blob) にすぐ当たる。

    • 適している場合: 確実に 50MB 未満のファイルしか扱わない場合。

  2. getBlob + GCS Resumable Upload (※考え方として):

    • メリット: ペイロード制限は回避。

    • デメリット: 50MB の Blob サイズ制限は回避できず、結局 50MB 超は扱えない。

    • 適している場合: (あまりないが) ペイロード制限だけが問題で、ファイルは 50MB 未満の場合。

  3. Drive API チャンク読み込み + GCS Resumable Upload (今回の記事):

    • メリット: 50MB の壁 (ペイロード & Blob) を両方回避できる。GAS 単体で実現できる最も高度な方法。

    • デメリット: 実行時間制限の壁が残る。コードが複雑。

    • 適している場合: 数百MB程度のファイルで、Workspace アカウント (30分) を利用でき、多少不安定でも GAS で完結させたい場合。

  4. Cloud Run functions / Cloud Run 活用:

    • メリット: GAS の各種制限 (サイズ、時間) をほぼ気にしなくて良い。専用ライブラリで効率的。

    • デメリット: GAS 以外の知識・環境設定が必要。

    • 適している場合: 50MB を超えるファイル (特に GB 単位) を扱う場合、安定性・確実性が求められる場合。

最終的にどの方法を選ぶかは、扱うファイルのサイズ、処理の頻度、求められる安定性、そして利用できる環境やスキルによって判断してください。

今回の記事が、GAS で大容量ファイルを扱おうと奮闘するあなたの助けになれば幸いです!

Previous Post