kintoneで画像保存にGoogle Driveを使う方法(Cloud Run経由)

こんにちは。GLASSのエンジニア森です。

今回はkintoneのお話です。商品管理アプリとして長年使ってきたkintoneアプリで、商品画像が多くなりすぎて追加ディスク容量の課金がツラくなってきたので対策を行った話をご紹介します。

背景:kintoneストレージの課題

kintoneではレコードごとに添付ファイルフィールドを使って画像を添付できます。添付ファイルという名前ですが、画像の場合はサムネイルも出るので便利です。この画像ファイルはkintone内のストレージに保存され、ユーザ数x5GBまで追加料金なく利用できます。

それを超えた場合はディスク増設を行うことができ、月額で税抜1,000円/10GBがかかります。

運用初期はよかったのですが、長期運用になってくると「添付ファイルの他アプリへのコピー」や「レコード複製」などで画像ファイルが増え、かなりの量のディスク増設が必要になってしましました。

そこでこれ以上定期的に増設するよりも、画像ファイルを外部に保存する方が効率的と判断し、Google Driveへ保存する方法 に変更しました。

構成:Cloud Run × Google Drive × kintone

処理の流れは以下の通りです。kintoneからGoogle Driveにアップロードする処理にはCloud Runを採用しています。

  • kintone:ユーザーがレコード画面でファイルを添付
  • kintone:添付ファイルを取得し、Cloud RunのエンドポイントへPOST
  • Cloud Run:ファイルを受け取り、Drive APIでGoogle Driveにアップロード
  • Google Drive:指定フォルダにファイルを保存し、URLを返す
  • kintone:返ってきたDriveのURLを文字列フィールドに書き込み

Cloud Run 側のコード(TypeScript)

TypeScript + Express で構築しています。
DriveフォルダやCORS設定などは.envファイルに定義して管理しています。

import express, { type Request, type Response } from "express";
import cors from "cors";
import multer from "multer";
import { google } from "googleapis";
import { Readable } from "stream";

const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
const DRIVE_FOLDER_ID = process.env.DRIVE_FOLDER_ID as string;
const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN || "https://<your_kintone_domain>";
const PERMISSION_MODE = (process.env.PERMISSION_MODE || "none") as "none" | "domain" | "anyone";
const PERMISSION_DOMAIN = process.env.PERMISSION_DOMAIN || "";

if (!DRIVE_FOLDER_ID) {
  console.error("DRIVE_FOLDER_ID is required.");
  process.exit(1);
}

const app = express();
app.use(cors({ origin: ALLOW_ORIGIN }));

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 50 * 1024 * 1024 } // 50MB
});

const auth = new google.auth.GoogleAuth({
  scopes: ["https://www.googleapis.com/auth/drive"]
});
const drive = google.drive({ version: "v3", auth });

app.post("/upload", upload.array("file"), async (req: Request, res: Response) => {
  try {
    const files = (req.files as Express.Multer.File[]) || [];
    if (!files.length) return res.status(400).json({ error: "no file" });

    const results = [];

    for (const f of files) {
      const meta = {
        name: f.originalname,
        parents: [DRIVE_FOLDER_ID]
      };

      const createRes = await drive.files.create({
        requestBody: meta,
        media: { mimeType: f.mimetype, body: Readable.from(f.buffer) },
        fields: "id,name,webViewLink,webContentLink",
        supportsAllDrives: true
      });

      const fileId = createRes.data.id;

      if (PERMISSION_MODE === "domain" && PERMISSION_DOMAIN) {
        await drive.permissions.create({
          fileId,
          requestBody: { role: "reader", type: "domain", domain: PERMISSION_DOMAIN },
          supportsAllDrives: true
        });
      } else if (PERMISSION_MODE === "anyone") {
        await drive.permissions.create({
          fileId,
          requestBody: { role: "reader", type: "anyone" },
          supportsAllDrives: true
        });
      }

      results.push({
        id: fileId,
        name: createRes.data.name,
        webViewLink: createRes.data.webViewLink,
        webContentLink: createRes.data.webContentLink
      });
    }

    return res.status(200).json(results.length === 1 ? results[0] : results);
  } catch (err: any) {
    console.error(err);
    return res.status(500).json({ error: "upload_failed", detail: err?.message });
  }
});

app.listen(PORT, () => console.log(`listening on :${PORT}`));

環境変数例

変数名内容
DRIVE_FOLDER_IDアップロード先のフォルダID
ALLOW_ORIGINkintoneドメイン(CORS許可)
PERMISSION_MODEnone / domain / anyone
PERMISSION_DOMAINドメイン共有を使う場合のGoogle Workspaceドメイン

kintone 側のスクリプト

画像ファイルをCloud RunへPOSTし、レスポンスに含まれるファイル情報をサブテーブルに保存します。サブテーブルにはファイル名、ファイルID、画像のリンクを保存します。

// ==== 設定 ====
const API_ENDPOINT = "https://<your-cloud-run-domain>/upload"; // Cloud Run の /upload エンドポイント

// ==== フィールドコード ====
const TABLE = "files"; // サブテーブル
const COL = {
  name: "file_name",       // 文字列(1行)
  id:   "drive_file_id",   // 文字列(1行)
  view: "drive_view_url"   // リンク
};
const SPACE_UPLOAD = "upload_btn"; // スペース(アップロードボタン表示用)

// 空行チェック
function isEmptyRow(row) {
  return !row || !row.value ||
    (!row.value[COL.name]?.value && !row.value[COL.id]?.value);
}

// サブテーブルに1行追加(最初の空行があれば上書き)
function pushRow(record, { id, view, name }) {
  const row = {
    value: {
      [COL.name]: { value: name || "" },
      [COL.id]:   { value: id || "" },
      [COL.view]: { value: view || "" }
    }
  };

  if (record[TABLE].value.length > 0 && isEmptyRow(record[TABLE].value[0])) {
    record[TABLE].value[0] = row;
  } else {
    record[TABLE].value.push(row);
  }
}

// アップロードボタン設置(新規/編集画面)
function setupUploadButton(record) {
  const el = kintone.app.record.getSpaceElement(SPACE_UPLOAD);
  if (!el || el.dataset.inited) return;
  el.dataset.inited = "1";

  const btn = document.createElement("button");
  btn.type = "button";
  btn.textContent = "画像を追加";
  el.appendChild(btn);

  btn.onclick = () => {
    const input = document.createElement("input");
    input.type = "file";
    input.multiple = true;
    input.accept = "image/*";

    input.onchange = async () => {
      const files = Array.from(input.files || []);
      if (!files.length) return;

      for (const f of files) {
        const fd = new FormData();
        fd.append("file", f, f.name);

        const res = await fetch(API_ENDPOINT, { method: "POST", body: fd });
        if (!res.ok) throw new Error("upload failed: " + f.name);

        const body = await res.json(); // { id, webViewLink, webContentLink, ... }

        const data = {
          id: body.id,
          view: body.webViewLink || body.webContentLink || "",
          name: f.name
        };
        pushRow(record, data);
        kintone.app.record.set({ record });
      }

      alert("アップロードが完了しました");
    };

    input.click();
  };
}

(function() {
  kintone.events.on(["app.record.create.show", "app.record.edit.show"], (ev) => {
    setupUploadButton(ev.record);
    return ev;
  });
})();

導入手順

  1. Google Cloud Consoleで「Drive API」を有効化
  2. サービスアカウントを作成し、Driveアップロード用フォルダにコンテンツ管理者として追加
  3. Cloud Runへデプロイ
  4. kintoneへカスタマイズJavaScriptとして上記スクリプトを登録

まとめ

実装時にWebで調べたところGoogle Apps Script(GAS)での実装例がいくつか見つかりました。GASは手軽な反面、実行時間や並列数に制限があり、業務システムの安定稼働には不向きかなと思います。今回はGASよりもCloud Runの方が安定していると判断し採用することにしました。

項目Google Apps Script (GAS)Cloud Run
同時実行数ユーザーごとに制限あり(数十〜数百)自動スケーリングでリクエスト数に応じて拡張
1日の実行回数約90分/日または数千回が上限
※Google Workspaceのエディションによる
制限なし
リクエスト速度・応答API応答が遅延しやすいHTTPリクエストに最適化され、安定レスポンス
認証方式実行ユーザーのGoogleアカウントに依存サービスアカウント単位でIAM制御可能
エラーハンドリングtry-catch程度、再試行や監視は限定的HTTPステータス/ログ出力/モニタリングが標準対応

kintoneは手軽にアプリを作れて便利ですが、添付ファイル(特に画像)を多用するアプリを長年使っているとストレージ問題は避けて通れないですね。
度重なるディスク増設にお悩みの方は今回のGoogle Driveへの保存に切り替える方法を試してみてください。

カテゴリー: 基礎知識
GLASSで一緒に働いてみませんか?」