Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

AWS Lambda上でnode-canvasを使ってグラフを描画する

ForSchool事業部でStudyplus for Schoolのサーバーサイドを担当している松田です。

Studyplus for Schoolでは、一部でChart.jsを利用したグラフの表示をしています。
Chart.jsはHTMLのCanvasでグラフを描画するライブラリです。
今回はこのグラフをサーバーで出力したくなったので、どうしたかを書いてみたいと思います。

はじめに

まず最初にサーバーサイドはRailsを使っているのでRubyを利用した出力を考えましたが、フロントと同様の見た目にしたいのでどうにかChart.jsをサーバーサイドで使いたいです。
サーバーサイドJSといえばNode.jsですが、Node.js上にはCanvasのAPIは用意されていません。
が、SeanSobey/ChartjsNodeCanvasを利用することで、Node.jsでChart.jsがレンダリングできます。
このライブラリは内部で Automattic/node-canvas というCanvas APIをcairoで再実装したライブラリを利用しています。
オンデマンドな環境で利用したかったので、Lambda上でこのライブラリを使ったグラフのレンダリングを試してみます。

インフラまわり

今回は外部からHTTPで気軽に扱えるよう、Lambdaプロキシ統合に設定したAPI GatewayとLambdaを併せて利用することを想定しています。
レンダリングした画像をS3にアップロードしたいので、LambdaにS3へのアクセス権限も付与しました。
Node.jsのランタイムは8.10です。

ビルド

Lambdaはその特徴上、依存ライブラリも含めたビルド済みのコード群をzipで圧縮したものをアップロードする必要があります。
先述の通りnode-canvasはcairoを利用しているので、Lambda上で動作するようにcairoをビルドした上で、そのバイナリをzipに含ませなければなりません。
Lambdaの実行環境はLambda 実行環境と利用できるライブラリに記載があるようにAmazon Linuxのようなので、Amazon Linuxのdocker imageを使ってビルドをすればよさそうです。
以下のコードはNot working on AWS Lambda · Issue #1231 · Automattic/node-canvasで紹介されているこちらのgistを参考にしました。

# Dockerfile

FROM amazonlinux:latest

RUN curl --silent --location https://rpm.nodesource.com/setup_8.x | bash -
RUN yum install -y nodejs zip
RUN npm install -g yarn

RUN mkdir /test
COPY ./package.json /test/
COPY ./yarn.lock /test/

WORKDIR /test

ENTRYPOINT ["yarn"]
CMD ["install"]
// package.json

{
  "name": "lambda-chartjs",
  "version": "1.0.0",
  "dependencies": {
    "canvas": "^2.3.1",
    "chart.js": "^2.7.3",
    "chartjs-node-canvas": "^2.0.0"
  },
  "scripts": {
    "prebuild": "docker build -t lambda-build .",
    "build": "docker run -v $(pwd)/node_modules:/test/node_modules lambda-build"
  }
}
// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const renderService = new CanvasRenderService(800, 600);
    const options = {
      type: 'bar',
      data: {
        labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
        datasets: [{
          label: '# of Votes',
          data: [12, 19, 3, 5, 2, 3],
          backgroundColor: [
            'rgba(255, 99, 132, 0.2)',
            'rgba(54, 162, 235, 0.2)',
            'rgba(255, 206, 86, 0.2)',
            'rgba(75, 192, 192, 0.2)',
            'rgba(153, 102, 255, 0.2)',
            'rgba(255, 159, 64, 0.2)'
          ],
          borderColor: [
            'rgba(255,99,132,1)',
            'rgba(54, 162, 235, 1)',
            'rgba(255, 206, 86, 1)',
            'rgba(75, 192, 192, 1)',
            'rgba(153, 102, 255, 1)',
            'rgba(255, 159, 64, 1)'
          ],
          borderWidth: 1
        }]
      },
      options: {
        scales: {
          yAxes: [{
            ticks: {
              beginAtZero: true
            }
          }]
        }
      }
    };
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

ハンドラのコードは、「Usage · Chart.jsと同じグラフを描画してS3に保存し、そのURLを返す」ことをしています。
これら3つのファイルを同じ階層に配置し、 yarn run build を実行するとdockerのAmazon Linuxの環境内でビルドが走り、成果物が node_modules へ出力されます。
あとはその node_modules とハンドラのコードをzipで圧縮してアップロードすれば完了です。

さて、できたAPIを実際に叩いてみると…

{
  "url": "https://******.s3.ap-northeast-1.amazonaws.com/hoge.png"
}

というレスポンスが返ってきて、さらにこのURLにアクセスすると… image.png (51.2 kB) この画像が得られました!
よさそうです。

外側から設定値を渡せるようにしてみる

動作が確認できたので、続いて外部からChart.jsの設定値を指定できるようにしてみます。

// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const options = JSON.parse(event.body);
    const queryParams = event.queryStringParameters || {};
    const width = parseInt(queryParams.width, 10) || 800;
    const height = parseInt(queryParams.heght, 10) || 600;
    const renderService = new CanvasRenderService(width, height);
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

クエリパラメータで画像のサイズを、リクエストボディのJSONでChart.jsに渡す引数を指定するようにしました。

{
  "type": "bar",
  "data": {
    "labels": [
      "6日(火)",
      ...
      "12日(月)"
    ],
    "datasets": [
      {
        "label": "古文",
        "data": [
          0,
          0,
          142,
          0,
          0,
          150,
          0
        ],
        "lineTension": 0,
        "borderColor": "#e30c0c",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(255,75,49,1)"
      },
      ...
      {
        "label": "English",
        "data": [
          0,
          0,
          0,
          0,
          0,
          0,
          0
        ],
        "lineTension": 0,
        "borderColor": "#33b377",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(98,220,156,1)"
      }
    ]
  },
  "options": {
    "scales": {
      "yAxes": [
        {
          "ticks": {
            "beginAtZero": true
          },
          "stacked": true
        }
      ],
      "xAxes": [
        {
          "stacked": true
        }
      ]
    }
  }
}

できたAPIに対して上記のようなJSONでリクエストをして得た画像がこちらです。 image.png (70.2 kB) 日本語が豆腐になっている!!!!

色や値は指定通りですが、テキストに問題がありそうです。
原因はAmazon Linuxに日本語フォントがインストールされていないことなので、さくっとフォントを追加しましょう。
TTF形式の日本語対応フォント(ここではIPAexフォントを例にします)を用意し、同一フォルダ内に置いて以下のコードをハンドラに追加します。1

const path = require("path");

Canvas.registerFont(path.join(__dirname, 'ipaexg.ttf'), { family: 'ipaex' });

これで再度リクエストしてみると、問題なく日本語もレンダリングできるようになりました! image.png (69.6 kB) 完璧ですね。

まとめ

AWS Lambdaは汎用性が高く、想像以上の幅広い要件に対応することができます。
また、node-canvasのようなライブラリを利用することで、画像加工等のリソース配分が難しい処理をオンデマンドな環境で対応することが可能になりました。
うれしいですね。


  1. フォントのライセンスによっては使用できない場合があるので注意してください。