🖼️

AWS lambdaを使ってD3.jsで描画したグラフをpng出力する

2022/12/02に公開

はじめに

この記事は、FOLIO Advent Calendar 2022の2日目の記事です。

直近D3.jsのグラフをpng出力する処理を実装する必要があり、プロジェクト上の制約なども鑑みlambda関数でグラフを描画、レンダリングすることを選択しました。

なお、実装ならびにこの記事を書くにあたってはスタディプラスさんのAWS Lambda上でnode-canvasを使ってグラフを描画するという近いコンセプトの記事を大いに参考にさせていただきました。

ただし、参考記事とは以下のような差分がありまた自分の知見の整理のために改めてこの記事を書いてみることにしました。

参考記事ではAWS lambdaをnodejsランタイムで実行しているが、本記事ではコンテナイメージを利用し、その上でnodejsを実行している

参考記事ではchart.jsを使っているが、本記事ではD3.jsを使っており、またグラフのレンダリングについてはD3.jsが生成したsvgファイルをcanvascanvgを使ってpngにレンダリングしている

そもそもなぜlambdaのコンテナイメージを使ったか

今回、AWS lambdaのコンテナイメージを選択した理由は、lambda関数のソースコードの容量上限の問題にぶち当たったからです。

当初、PoCとして実装する際には、参考記事と同じく、nodejsランタイムを使うことを想定していました。
そこで問題になったのがlambda関数のソースコードの容量上限です。
今回レンダリング処理で肝要になるcanvasは容量が大きく、lambda関数本体のソースコードに含めてZip圧縮すると容量上限の50MBを簡単に超えます。そうすると次なる自然な選択肢だとnode_modules/ に含まれるライブラリをlambda layer化するなどが考えられると思います。

ただし私の例だと、グラフのインプットデータの取得や成果物をアップロードするなどでS3とのやり取りにaws-sdkも使いたい(aws-sdkもなかなかのサイズです…)など、他のライブラリの容量もバカにできなかったため、50MBの容量上限に収まるようにLayerを複数に分けるなど細かい工夫が必要になってしまい、アプリケーションリポジトリからの環境構築も検証段階にしてすでにかなり複雑になってしまいました。

そこで、2020年に発表されたコンテナイメージを利用することにしました。結果的にライブラリの容量上限問題も気にせずに環境構築を行うことができました。ちなみに、容量上限の問題だけを解決するなら、他の選択肢としてZipファイルをS3にアップロードするなどもあったかと思いますが、これも先述の環境構築の複雑化を避けるために断念しました。

また一定規模以上の組織ならどこでもそうだと思いますが、弊社ではAWSリソースの作成・運用をIaC(ちなみにterraformを利用しています)化して管理しているインフラチームとアプリケーションを実装するチームが別であるため、アプリケーション開発チームとインフラチームの責任分界点が分けやすい方法という意味でも今回の方法は良いやり方だったと思っています。
もし仮にLayerを分割しつつ、lambda関数を実行する方法だと、アプリケーション側の事情でlambda関数本体だけでなく Lambda Layer用のリソースを作成し管理する(しかも複数)など、正直やりたいこと以上に複雑な対応が必要になってしまいますし、管理の仕方や責任分界点の設定も上記にやり方に比べて不明瞭になってしまいそうです。

コンテナイメージの作成

前置きが長くなりましたが、実際にコンテナイメージを作成していきます。
一部改変していますが、Dockerfileは下記の通りです。

Dockerfile
FROM public.ecr.aws/lambda/nodejs:16

# ALAS2-2022-1877 対策
RUN yum -y update expat

COPY src/ src/
COPY package.json .
COPY tsconfig.json .
COPY yarn.lock .
# 日本語フォントの.ttfファイルをコンテナ内にCOPYする
COPY resources/fonts resources/fonts

# canvasを立ち上げるために必要な設定
# Error: /lib64/libz.so.1: version `ZLIB_1.2.9' not found (required by /var/task/node_modules/canvas/build/Release/libpng16.so.16) 対策
# see: https://github.com/Automattic/node-canvas/issues/1779
ENV LD_PRELOAD=/var/task/node_modules/canvas/build/Release/libz.so.1
# see: https://github.com/Automattic/node-canvas/issues/1939#issuecomment-1003727738
ENV LD_LIBRARY_PATH=/var/task/node_modules/canvas/build/Release:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib
# yarn だけ先にinstallする
RUN npm install -g yarn 

RUN yarn install --frozen-lockfile && yarn build

CMD [ "out/handler.main" ]

いくつかポイントを説明します。
ベースイメージはAWSが用意しているものがpublic ECRに用意されているのでそれを使います。今回は nodejs:16をベースにしています。

nodejsのスクリプトを開発するにあたっては、Typescriptを用い、lambda用のコンテナイメージをbuildする際にtypesctipt → Javascriptのコンパイルも行っています。

src/.ts ファイル、out/ にコンパイル済みの .js ファイルを置いています

冒頭で紹介した参考記事にもある通り、そのままだと日本語が豆腐になってしまうため、resources/font 配下に日本語フォントファイルを用意しコンテナ内に配置しています。

canvasを立ち上げる際、そのままだと正常に起動しないのですが、記載している環境変数の追加を行うことで回避できます

合わせて package.json についても例を記載します。

package.json
{
  "name": "d3-chart-drawing-demo-on-aws-lambda",
  "version": "1.0.0",
  "scripts": {
    "ts": "npx ts-node",
    "build": "yarn clean && yarn compile && cp -f package.json yarn.lock out/ && cp -r resources/ out/ && cd out/ && yarn install --production --frozen-lockfile && rimraf yarn.lock",
    "compile": "tsc",
    "clean": "rimraf out/"
  },
  "author": "paulxll",
  "license": "MIT",
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-s3": "^3.194.0",
    "@xmldom/xmldom": "^0.8.6",
    "canvas": "^2.10.1",
    "canvg": "^4.0.1",
    "d3": "^7.6.1",
    "jsdom": "^20.0.1",
    "node-fetch": "^3.2.10",
    "winston": "^3.8.2"
  },
  "devDependencies": {
    "@aws-sdk/credential-providers": "^3.204.0",
    "@types/aws-lambda": "^8.10.109",
    "@types/d3": "^7.4.0",
    "@types/jsdom": "^20.0.0",
    "@types/node": "^18.8.2",
    "@typescript-eslint/eslint-plugin": "^5.42.0",
    "@typescript-eslint/parser": "^5.42.0",
    "eslint": "^8.26.0",
    "eslint-plugin-unused-imports": "^2.0.0",
    "prettier": "^2.7.1",
    "rimraf": "^3.0.2",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.4",
    "yarn": "^1.22.19"
  }
}

留意したポイントは下記です。

コンパイル済みのJavascriptファイルを格納するためのディレクトリは yarn build を実行した時に都度作成しています

ローカルで関数単体を実行してdebugするために ts-node を利用し、Typescriptファイルを直接実行しています

アプリケーションの実装

アプリケーションの大まかな流れは下記のとおりです。

  1. 特定のS3 bucketにinputファイルが置かれたことをトリガーにlambda関数がkickされる
  2. S3 Eventからアップロードされたinputファイルを取得し、グラフを描画しpngファイルを作成
  3. 作成したpngファイルをS3にアップロード

ポイントとして、pngファイルの作成にあたり canvas およびCanvg を使ってsvgのHTMLElementからpngファイルを作成しています。

実装箇所は下記の通りです。

render.ts
import { writeFileSync } from "node:fs";
import { DOMParser } from "@xmldom/xmldom";
import * as canvas from "canvas";
import fetch from "node-fetch";
import { Canvg, presets } from "canvg";

export const saveChartAsPng = async (
  svgElement: HTMLElement,
  width: number,
  height: number,
  outDir: string
) => {
  const preset = presets.node({
    DOMParser,
    canvas,
    fetch,
  });
  const canvasElement = new canvas.Canvas(width, height);
  // 日本語化するための設定
  canvas.registerFont("/var/task/resources/fonts/NotoSansJP-Regular.ttf", {
    family: "Noto Sans JP",
  });
  const ctx = canvasElement.getContext("2d");
  const svgData = svgElement.innerHTML;
  const v = Canvg.fromString(ctx, svgData, preset);
  await v.render();
  const png = canvasElement.toBuffer();
  writeFileSync(`${outDir}/donuts.png`, png);
};

この処理を行うことで下記のようなグラフが得られます。

サンプルアウトプット

なお、このデータは架空のデータを使って作成したもので実際の業務におけるユースケースとは関係ありません。

利用したコードについては github にあげています。

AWSリソースの構築

lambda関数を始め、AWSリソースを構築することで、実際にlambda上で動かすことができます。

terraform で構築する際にはaws_lambda_functionpackage_type"Image"にすることでコンテナイメージが利用できます。

また、運用上、lambda関数そのもののライフサイクルとアプリケーション開発のライフサイクルは必ず一致しない(上記の例と同様、全社はインフラチームがterraformなどでIaC管理し、後者はアプリケーションチームにより管理されるケースが多いと思います)ため、「先にLambda関数のAWSリソースだけ作っておいて、実際のアプリケーションはterraform外管理とし、アプリケーションチームがデプロイする」ケースが想定されます。その際のtipsとして、lambda関数の package_type は後から変更することができないため、インフラチームに「コンテナイメージを使うlambda関数」の専用のterraform moduleなどを準備してもらう(あるいはご自身で書く)必要があることにも注意が必要です。

今回利用するAWSリソースの構成例については、githubinfra-sample 配下に配置しているので、参考に作成してください。なおproviderの定義などは書いていないので、そのまま動作させることを期待したものではないので、あくまでも参考例として用意していることにご注意ください。

まとめ

以上の流れで、AWS lambdaを用いてD3.jsベースのグラフ描画を行うことができました。

FOLIOではアプリケーションの開発言語としてScalaをメインに利用しています。一方で、今回実装する事になったグラフ描画を行う上では、Javascriptがライブラリの選択肢が多く優れている現状もあり、今回のグラフ描画ではJavascript(Typescript)を開発言語として利用することにしました。

また、AWS lambdaを実行環境としまたS3 Eventをフックとして発火させることで、Scalaベースのバッチ処理とJavascriptベースの処理を管理コスト低く共存させることができたと思います。さらにそこに、今回はLambda関数のコンテナイメージを利用することで、 canvas のような環境構築にややクセのあるライブラリも比較的シンプルに環境構築し利用することができました。

少しでも参考になれば幸いです。

Discussion