[serverless]AWS Lambdaでnode-canvasを利用した画像生成をする

# 前書き
OGPの生成などnode.jsで画像生成をしたいというケースは最近増えてきているのでは無いでしょうか。その中でもクライアントで実行可能な[Canvas](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API)を利用したいケースというのも同様に多いと思います。

またサーバーレスの台頭と共にデプロイに[serverless](https://www.serverless.com/)を利用しているケースもあると思います。

私自身がその様な環境で試す中、実行するまでに躓いた箇所があったので実際に動く簡単なアプリを作りながら途中の問題の解決法を解説していこうと思います。

# 実行環境
デプロイ: [serverless](https://www.serverless.com/)(テンプレート: [aws-nodejs-typescript](https://github.com/serverless/serverless/tree/master/lib/plugins/create/templates/aws-nodejs-typescript))
実行環境: AWS Lambda (node.js v14)
ライブラリ: [node-canvas](https://github.com/Automattic/node-canvas)
その他: GitHub Actions

# 完成形
AWS Lambdaで関数を実行すると`Slack`に`node-canvas`で生成した画像が投稿されるBotを作ります。

# 手順

## 1. serverlessテンプレートから雛形を生成
※ serverlessが導入されていなければ `npm install -g serverless` を実行

```
sls create -t aws-nodejs-typescript -p sls-node-canvas-test
```

大体このような構成で生成されます。

```
.
├── handler.ts
├── package.json
├── serverless.ts
├── tsconfig.json
└── webpack.config.js
```

## 2. テンプレートの不要部分削除とruntimeの更新
- api-gateway 部分を削除
- runtime を node.14 に変更

適用後の `serverless.ts` ↓

```typescript
import type { Serverless } from 'serverless/aws';

const serverlessConfiguration: Serverless = {
  service: {
    name: 'sls-node-canvas-test',
    // app and org for use with dashboard.serverless.com
    // app: your-app-name,
    // org: your-org-name,
  },
  frameworkVersion: '>=1.72.0',
  custom: {
    webpack: {
      webpackConfig: './webpack.config.js',
      includeModules: true
    }
  },
  // Add the serverless-webpack plugin
  plugins: ['serverless-webpack'],
  provider: {
    name: 'aws',
    runtime: 'nodejs14.x',
  },
  functions: {
    hello: {
      handler: 'handler.hello'
    }
  }
}

module.exports = serverlessConfiguration;

```

## 3. 依存関係のインストール

```
npm install node-canvas @slack/web-api
npm install
```

## 4. コードの実装

`handler.ts` を以下のように実装します。

Slackの`OAuth Access Token`を取得して各自入れてください
必要な権限は `chat:write` `chat:write.public` `files:write` です

```typescript
import 'source-map-support/register'
import { createCanvas } from 'canvas'
import { WebClient } from "@slack/web-api"

const token = "Your OAuth Access Token";

const web = new WebClient(token);

// 画像の生成
const createBufferImage = (): Buffer => {
  const canvas = createCanvas(100, 100)
  const ctx = canvas.getContext('2d')

  const text = "TEST"
  ctx.font = '30px'
  ctx.fillText(text, 50 - ctx.measureText(text).width / 2, 50)

  return Buffer.from(canvas.toDataURL().split(",")[1], "base64")
}

// slackにポスト 
const postSlack = async (image: Buffer) => {
  const res = await web.files.upload({
    file: image,
  });

  await web.chat.postMessage({
    channel: "general",
    text: res.file.permalink,
  });
}

export const hello = async () => {
  const image = createBufferImage()
  await postSlack(image)
}
```

## 5. ローカルで実行する

以下のコマンドでローカルで動いているか実行してみます。

```
sls invoke local -f hello
```

うまく行けば↓のように投稿されます。

![スクリーンショット 2021-05-16 0.18.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/450fa552-5460-eac6-7de4-b0a8bb39e240.png)

## 6. とりあえず、デプロイしてみる

私はMacで開発してますが、以下のコマンドでとりあえずデプロイしてみます。

```
sls deploy
```

デプロイできましたが、実行した所でエラーが発生しました。
![スクリーンショット 2021-05-16 0.24.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/ffd96c33-975c-6d6c-6363-f03f25c37aab.png)

ネイティブコードを含んでいるため、Macでビルドした物はAWS Lambda上では動きませんでした。

## 7. GitHub Actions でデプロイする

`node-gyp`のビルド処理がOSにより分かれるのでLinux系のOS上でビルドします。

Dockerでも良いですが、デプロイの自動化も同時にできるので今回は GitHub Actions を利用します。

`.github/workflows/ci.yml` に↓のファイルを作成する事で `main` ブランチにプッシュすると自動でデプロイされるようになります。

※ 事前にGitHubリポジトリの設定ページからsecretにAWSのキーを登録しておいてください

```yml
name: Deploy 

on:
  push:
    branches: [main]

defaults:
  run:
    shell: bash

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        node-version: [14.x]

    steps: 
      - uses: actions/checkout@v2
      - name: Install dependencies & Deploy
        run:  |
          sudo apt-get install -y build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
          npm ci
          npx serverless deploy --stage prod
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
```

デプロイされたので、実行してみます。今度は別のエラーが表示されました。
![スクリーンショット 2021-05-16 1.03.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/242176e9-efec-3d92-cfc8-66fe2d9d3685.png)

## 8. ビルド時に共有ライブラリをコピーする
手順7のエラーでは必要なsoファイルが見つからないとなっているので、`sls deploy` で実行されるwebpackのビルド時に含めるようにします。

`libuuid.so.1` が見つからないとなっていますが、実際に追加すると `libmount.so.1` と `libblkid.so.1` も見つからないと同様のエラーが表示されるので追加します。


soファイルはディストリビューション等により置いてる場所は違いますが、GitHub Actionsの `ubuntu-latest` (`ubuntu-20.04`) では必要なファイルは `/usr/lib/x86_64-linux-gnu/` に存在しているのでコピーします。

`webpack.config.js` にコピープラグインを追加して以下のように書き換えます。

```bash
npm install copy-webpack-plugin@6 --save-dev
```


```javascript
const path = require('path');
const slsw = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
  context: __dirname,
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  entry: slsw.lib.entries,
  devtool: slsw.lib.webpack.isLocal ? 'cheap-module-eval-source-map' : 'source-map',
  resolve: {
    extensions: ['.mjs', '.json', '.ts'],
    symlinks: false,
    cacheWithContext: false,
  },
  output: {
    libraryTarget: 'commonjs',
    path: path.join(__dirname, '.webpack'),
    filename: '[name].js',
  },
  target: 'node',
  externals: [nodeExternals()],
  module: {
    rules: [
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      {
        test: /\.(tsx?)$/,
        loader: 'ts-loader',
        exclude: [
          [
            path.resolve(__dirname, 'node_modules'),
            path.resolve(__dirname, '.serverless'),
            path.resolve(__dirname, '.webpack'),
          ],
        ],
        options: {
          transpileOnly: true,
          experimentalWatchApi: true,
        },
      },
    ],
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: "/usr/lib/x86_64-linux-gnu/libuuid.so.1", to: "node_modules/canvas/build/Release/libuuid.so.1" },
        { from: "/usr/lib/x86_64-linux-gnu/libmount.so.1", to: "node_modules/canvas/build/Release/libmount.so.1" },
        { from: "/usr/lib/x86_64-linux-gnu/libblkid.so.1", to: "node_modules/canvas/build/Release/libblkid.so.1" },
      ],
    }),
  ],
};

```

これでデプロイして実行すると... 別のエラーが表示されます。

![スクリーンショット 2021-05-16 3.59.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/8ea6efa0-0ef0-d4e7-9456-e64c7d9ba583.png)

## 9. node-canvasのバージョンを2.6.1にする
2021年5月16日現在、node-canvasの最新バージョンはv2.7.0で特にバージョンを指定せずに入れるとv2.7.0が入ると思いますが、手順8のエラーが解決できなかったので1つ前のv2.6.1を指定して入れ直します。

これでデプロイして実行し直すと実行には成功しますが、lambdaにはデフォルトフォントが存在しないので文字部分が豆腐で表示されています。

![スクリーンショット 2021-05-16 4.14.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/270ece7f-329a-ffbf-f52d-abb533d16515.png)

![スクリーンショット 2021-05-16 4.14.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/a2b34e49-d279-7557-d1f5-9932ca89fa7a.png)


## 10. フォントを導入する
特に指定はありませんが、今回は[Noto Sans](https://www.google.com/get/noto/#sans-lgc)を利用したいと思います。ダウンロードして `NotoSans-Regular.ttf` を `handler.ts` と同じ階層に配置します。

`NotoSans-Regular.ttf` を `registerFont` を使いフォントを読み込むように更新します。

```typescript
import 'source-map-support/register'
import { createCanvas, registerFont } from 'canvas'
import { WebClient } from "@slack/web-api"
// @ts-ignore
import ttf from "./NotoSans-Regular.ttf"

registerFont(ttf, { family: "Noto Sans" })

const token = "Your OAuth Access Token";

const web = new WebClient(token);

// 画像の生成
const createBufferImage = (): Buffer => {
  const canvas = createCanvas(100, 100)
  const ctx = canvas.getContext('2d')

  const text = "TEST"
  ctx.font = '30px'
  ctx.fillText(text, 50 - ctx.measureText(text).width / 2, 50)

  return Buffer.from(canvas.toDataURL().split(",")[1], "base64")
}

// slackにポスト 
const postToSlack = async (image: Buffer) => {
  const res = await web.files.upload({
    file: image,
  });

  await web.chat.postMessage({
    channel: "general",
    text: res.file.permalink,
  });
}

export const hello = async () => {
  const image = createBufferImage()
  await postToSlack(image)
}
```

このままでは` webpack` でのビルド時にエラーが出るので `file-loader` を導入します。

```bash
npm install --save-dev file-loader
```

`webpack.config.js` で `ttf` ファイルを `file-loader` で処理するように記述を追加します。

```javascript
const path = require('path');
const slsw = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
  context: __dirname,
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  entry: slsw.lib.entries,
  devtool: slsw.lib.webpack.isLocal ? 'cheap-module-eval-source-map' : 'source-map',
  resolve: {
    extensions: ['.mjs', '.json', '.ts'],
    symlinks: false,
    cacheWithContext: false,
  },
  output: {
    libraryTarget: 'commonjs',
    path: path.join(__dirname, '.webpack'),
    filename: '[name].js',
  },
  target: 'node',
  externals: [nodeExternals()],
  module: {
    rules: [
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      {
        test: /\.(tsx?)$/,
        loader: 'ts-loader',
        exclude: [
          [
            path.resolve(__dirname, 'node_modules'),
            path.resolve(__dirname, '.serverless'),
            path.resolve(__dirname, '.webpack'),
          ],
        ],
        options: {
          transpileOnly: true,
          experimentalWatchApi: true,
        },
      },
      {
        test: /\.ttf$/i,
        loader: 'file-loader',
      },
    ],
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: "/usr/lib/x86_64-linux-gnu/libuuid.so.1", to: "node_modules/canvas/build/Release/libuuid.so.1" },
        { from: "/usr/lib/x86_64-linux-gnu/libmount.so.1", to: "node_modules/canvas/build/Release/libmount.so.1" },
        { from: "/usr/lib/x86_64-linux-gnu/libblkid.so.1", to: "node_modules/canvas/build/Release/libblkid.so.1" },
      ],
    }),
  ],
};


```

デプロイして、実行...

![スクリーンショット 2021-05-16 10.37.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74405/f4f389f1-3373-c672-5833-e4155b3a959e.png)

やっとできました。