くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

VercelでOGP画像を生成して、Storageの保存量を節約する

開発しているWebサービスでかかった費用を見ていると、
シェア用のOGP画像の保存量が結構占めていた。。(882円中の492円分で55%)

www.memory-lovers.blog

ランニングコストを減らしたいなと思っていたら、節約術があったので試してみた。
NowのエッジキャッシュでCloud Storage節約サーバー作成 - Crieit

若干ハマったので、その備忘録。

ハマったポイント

いくつかハマったので、

  1. VercelではImageMagickが使えない。。
  2. 背景画像など読み込むファイルはvercel.jsonに設定が必要
  3. Serverless Function内でファイルを書き出すときは、/tmp配下
    • カレントディレクトリは読み込み専用のため、エラーになる
  4. 値が変わる画像の場合は、パスパラメタなどでURLを変える
    • 総額や冊数などを表示しているが、キャッシュのため切り替わらなくなるため。

OPG画像を生成するソースコード

Vercel+node-canvasは以下の記事を参考にした。
【個人開発】フローチャートで診断を作れるWebサービスをリリースしました【全コード公開】 - Qiita

利用するパッケージのインストール

$ npm i canvas sharp axios

# typescriptの場合、以下も追加
$ npm i -D @types/sharp @vercel/node ts-node typescript

ディレクトリ構成

ディレクトリ構成はこんな感じ。

.
├── api
│   └── index.ts
├── assets
│   ├── fonts
│   │   ├── NotoSansJP-Bold.otf
│   │   └── NotoSansJP-Medium.otf
│   └── image
│       └── ogp.png
├── package.json
├── package-lock.json
├── tsconfig.json
└── vercel.json

API部分のソース

// api/index.ts
import { NowRequest, NowResponse } from "@vercel/node";
import axios, { AxiosResponse } from "axios";
import { Canvas, CanvasRenderingContext2D, createCanvas, loadImage, registerFont } from "canvas";
import sharp from "sharp";

const CANVAS_WIDTH = 1200;
const CANVAS_HEIGHT = 630;
const TITLE_COLOR = "#FFFFFF";

export default async (request: NowRequest, response: NowResponse) => {
  // *** Canvas用にフォントファイルの読み込み
  registerFont("./assets/fonts/NotoSansJP-Bold.otf", { family: "NotoSansJP_Bold" });
  registerFont("./assets/fonts/NotoSansJP-Medium.otf", { family: "NotoSansJP_Regular" });

  // *** Canvasの作成
  const canvas: Canvas = createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  const context: CanvasRenderingContext2D = canvas.getContext("2d");


  // *** 背景画像の描画
  const backgroundImage = await loadImage("./assets/image/ogp.png");
  context.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);


  // *** 書影の書き出し: URLから画像を取得して、リサイズ後に、canvasに描画
  const bookImageURL = "http://...";
  const bookWidth = 520;
  const bookHeight = 454;
  // * 外部URLから画像データの取得
  const res: AxiosResponse<Buffer> = await axios.get<Buffer>(bookImageURL, { responseType: "arraybuffer" });
  // * sharpを使って、リサイズ
  const bookImageBuffer: Buffer = await sharp(res.data).resize(bookWidth, bookHeight, { position: "top" }).toBuffer();
  // * リサイズ後のデータをcanvasに描画
  const bookImage = await loadImage(bookImageBuffer);
  context.drawImage(bookImage, (CANVAS_WIDTH - bookWidth) / 2, (CANVAS_HEIGHT - bookHeight) / 2, bookWidth, bookHeight);


  // *** 文字の背景部分の四角い図形の描画
  const rectHeight = 50;
  context.fillStyle = "rgba(0, 0, 0, 0.6)";
  context.fillRect(0, CANVAS_HEIGHT - rectHeight, CANVAS_WIDTH, rectHeight);


  // *** 文字の書き出し
  const title = "タイトル";
  context.font = `60px "NotoSansJP_Bold"`;
  context.fillStyle = TITLE_COLOR;
  context.textAlign = "center";
  context.fillText(title, CANVAS_WIDTH / 2, 500);

  // ** canvasをBufferに変換
  const buf: Buffer = canvas.toBuffer();


  // ** responseの設定: キャッシュなど
  const contentLength = buf.length;
  const cacheAge = 7 * 24 * 60; // 1週間
  response.setHeader("Content-Type", "image/png");
  response.setHeader("Content-Length", contentLength);
  response.setHeader("Cache-Control", `public, max-age=${cacheAge}`);
  response.setHeader("Expires", new Date(Date.now() + cacheAge).toUTCString());
  response.setHeader("Access-Control-Allow-Origin", "*");
  response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
  response.status(200).end(buf);
};

vercel.json

背景画像やフォントなど静的なファイルを読み込めるようにするため、vercel.jsonの設定が必要。
Including Additional Files - Vercel

以下のような感じで、"includeFiles": "assets/**"を記載し、
該当のfunctionが利用することを伝える必要がある。

// vercel.json
{
  "version": 2,
  "routes": [{ "src": "/.*", "dest": "/api/index" }],
  "functions": {
    "api/index.ts": {
      "includeFiles": "assets/**"
    }
  }
}

この設定がないと、function内からファイルを参照できない。
また、以下のような感じで__dirnameを使ってパスを指定すると、自動でincludeしてくれる。

// index.js
const { readFileSync } = require('fs');
const { join } = require('path');
const file = readFileSync(join(__dirname, 'config', 'ci.yml'), 'utf8');

package.json

VercelのServerless Functionでnode-canvasを使うために少し設定がいる。

カスタムビルド(vercel-build)を設定して、不足している.soファイルをコピーする必要がある。

// package.json
{
  // ... 略
  "scripts": {
    "vercel-build": "yum install libuuid-devel libmount-devel && cp /lib64/{libuuid,libmount,libblkid}.so.1 node_modules/canvas/build/Release/"
  },
}

・参考: Vercel Now(旧ZEIT Now)上でnode-canvasを動かす - Blanktar

これでVercel上でOGPが生成できるように!

細かい設定などは、node-canvasのドキュメントを見ながら調整すればOK!

これでユーザ生成画像以外は保存しなくても良くなったので、ストレージ容量を節約できた(´ω`)

VercelではImageMagickが使えないかも?

最初はImageMagickを使って生成することを考えていた。

Build Step - Vercel Documentation」にもImageMagick-develが入っていると書かれているし、 「How do I install something else on the build image?」に書かれているように、yum install ImageMagickでインストールを試みたけど、convertコマンドが見つからない感じに。。

次に、amazon-linux-extrasでGraphicsMagickをインストールしてみたけど、同じくgmコマンドが見つからないよう。。

結果、以下の記事を参考にしつつ、node-canvasに落ち着いた。
【個人開発】フローチャートで診断を作れるWebサービスをリリースしました【全コード公開】 - Qiita

Canvasを使うChart.jsのグラフも画像化できそう?

色々見ていたら、以下のパッケージがあった。
SeanSobey/ChartjsNodeCanvas: A node renderer for Chart.js using canvas.

もしかしたら、Chart.jsのグラフもOGP画像にできそう?今度試してみる。

参考にしたサイトさま