PDFファイルをGCSにアップロードするNode.jsアプリをCloud Runにデプロイしてみた

GCSへのファイルアップロードができるアプリをGoogle Cloud上で動かしたいと思っている方に向けて、参考になるよう備忘録を残します。

なお、作ったアプリの画面はこんな感じです。(実際は検索機能やサムネ作成機能などもありますが、今回のタイトルとは関係ないので純粋にGCSにアップロードする部分のみ、抜粋して載せています)

ホーム画面

ファイル選択

送信後↓

構成は以下の通りです。

GCS:ファイルストレージ

Cloud Run:フロントエンドアプリ(Node.js + Express)

※今回のアプリでは、Node.jsのバージョンは20-slim、Expressは 4.19.2です。

こちらの公式のサンプルコードを元にしております。

変更点としては、

・Bootstrapを使って見た目をいい感じにしたかったのでpugではなくejsを使っている

・他にもページを作るためにRouterでルーティング

・App EngineではなくCloud Runにデプロイ※

※サンプルは App Engine 用のコードですが、途中から必要になって追加したライブラリ(graphicsmagickとimagemagick)がApp Engine上では動かなかったため、Dockerファイルを追加しCloud Buildを使ってCloud Runにデプロイするよう変更しました。

該当箇所のソースは以下の通りです。form.ejsから入力されたファイルを、upload.jsでCloud Storage APIを使ってアップロードします。

app.js

'use strict';

const process = require('process');
const express = require('express');
const app = express();

const ejs = require('ejs');
app.set('view engine', 'ejs');

app.use(express.json());
app.use(express.urlencoded({
    extended: true
}));

app.use(express.json());
app.use("/", require("./route/uploads.js"));


const PORT = parseInt(process.env.PORT) || 8080;
app.listen(PORT, () => {
  console.log(`App listening on port ${PORT}`);
  console.log('Press Ctrl+C to quit.');
});

upload.js

'use strict';

const process = require('process');
const express = require("express");
const router = express.Router();

// [START gae_storage_app]
const {format} = require('util');
const Multer = require('multer');
const {Storage} = require('@google-cloud/storage');

// Instantiate a storage client
const storage = new Storage();

const multer = Multer({
  storage: Multer.memoryStorage(),
  limits: {
    fileSize: 100 * 1024 * 1024, // ファイルサイズを最大100MBに制限
  },
});

const bucket = storage.bucket(process.env.GCLOUD_STORAGE_BUCKET);

router.get('/', (req, res) => {
  res.render('form.ejs');
});


router.post('/upload',  multer.single('file'), uploadFile, replaceEmbeddings);

async function uploadFile(req, res, next) {
    if (!req.file) {
      res.status(400).send('No file uploaded.');
      return;
    }

    var mimetype = req.file.mimetype

    // Create a new blob in the bucket and upload the file data.
    const blob = bucket.file(req.file.originalname);
    const blobStream = blob.createWriteStream({
      resumable: false,
    });
 
    blobStream.on('error', err => {
      next(err);
    });
 
    blobStream.on('finish', () => {
      const publicUrl = format(
        `https://storage.cloud.google.com/${bucket.name}/${blob.name}`
      );
      // res.status(200).send(publicUrl);
      res.render('form.ejs', { getUrl: publicUrl});
    });
    blobStream.end(req.file.buffer);
    next();
}
module.exports = router;  // 外部から読み込むために

form.ejs

<!doctype html>
<html lang="ja" data-bs-theme="dark">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
  </head>
<body class="bg-dark">
  <main class="container-lg pt-5">
    <div class="bg-body-tertiary p-5 rounded">
      <form method="POST" action="/upload" enctype="multipart/form-data">
        <label for="formFile" class="form-label">アップロードするファイルを選択してください</label>
        <input class="form-control" type="file" name="file" required>
        <div class="mt-4">
          <button class="btn btn-primary" type="submit">送信</button>
        </div>
      </form>
    </div>
   
    <% if (locals.getUrl) { %>
      <div class="pt-5"></div>
      <div class="alert alert-info" role="alert">
        アップロードが完了しました。
        <%-getUrl %>
      </div>
    <% } %>
  </main>

</body>
</html>

Dockerfile

FROM node:20-slim

# 必要なライブラリを追加RUN set -ex; \
  apt-get -y update; \
  apt-get -y install imagemagick; \
  apt-get -y install ghostscript; \
  apt-get -y install graphicsmagick; \
  rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install --omit=dev

COPY . .

CMD [ "npm", "start" ]

package.json

{
  "name": "app-name",
  "description": "hogehoge",
  "scripts": {
    "start": "node app.js",
    "build": "NODE_ENV=production webpack",
    "gcp-build":"npm run build",
    "test": "c8 mocha -p -j 2 system-test/*.test.js --exit --timeout=30000"
  },
  "engines": {
    "node": ">=16.0.0"
  },
  "dependencies": {
    "@google-cloud/bigquery": "^7.7.1",
    "@google-cloud/storage": "^7.11.2",
    "body-parser": "^1.20.2",
    "bootstrap": "^5.3.0",
    "buffer": "^6.0.3",
    "ejs": "^3.1.10",
    "express": "^4.19.2",
    "fs": "^0.0.1-security",
    "gm": "^1.25.0",
    "graphicsmagick": "^0.0.1",
    "imagemagick": "^0.1.3",
    "multer": "^1.4.5-lts.1",
    "pdf-image": "^2.0.0",
    "pdf-thumbnail": "^1.0.6",
    "pdf2pic": "^3.1.1",
    "postcss-cli": "^11.0.0",
    "process": "^0.11.10",
    "timers": "^0.1.1",
    "util": "^0.12.5",
    "worker-loader": "^3.0.8"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "@types/multer": "^1.4.7",
    "@types/proxyquire": "^1.3.28",
    "@types/supertest": "^2.0.12",
    "@types/uuid": "^9.0.1",
    "autoprefixer": "^10.4.19",
    "c8": "^8.0.0",
    "mocha": "^10.2.0",
    "pdfjs-dist": "^4.3.136",
    "postcss": "^8.4.38",
    "proxyquire": "^2.1.3",
    "supertest": "^6.3.3",
    "uuid": "^9.0.0"
  },
  "version": "1.0.0",
  "main": "app.js",
  "author": "",
  "license": "ISC",
  "keywords": []
}

ローカルで実行するには以下のコマンドを使います。

$ gcloud init
$ export GOOGLE_CLOUD_PROJECT=プロジェクト名
$ export GCLOUD_STORAGE_BUCKET=GCSバケット名
$ npm install
$ npm start

デプロイは以下の通り

$ gcloud run deploy

GOOGLE_CLOUD_PROJECTとGCLOUD_STORAGE_BUCKETは環境変数なので、デプロイ後にコンソールから設定します。なお、一発目のデプロイはエラーになりますが、編集から環境変数を追加することができます。

これでデプロイ完了です。

なお、ImageMagickはPDFファイルの最初の一枚目をサムネにして検索画面に出したいという要望のために後から追加した機能で使っているライブラリで、今回の実装例に載せたコードからは除外しているので、App Engine(standerd)でも動くと思います。

Error: Could not execute GraphicsMagick/ImageMagick: gm "convert" "-density" "100x100" "-quality" "75" "-[0]" "-resize" "200x200!" "-compress" "jpeg" "./images/000545717.1.png" this most likely means the gm/convert binaries can't be found

https://github.com/GoogleCloudPlatform/nodejs-docs-samples/tree/main/appengine/storage/standard