LambdaでPuppeteer(chrome)を使ってPDF出力するためにつまづいた9のこと

Serverlessを添えて

LambdaでPuppeteer(chrome)を使ってPDF出力するためにつまづいた9のこと

Serverlessを添えて

仕事で、webと全く同じ見た目でPDFを出力するという業務要件に遭遇したので、Serverless+Lambda+Puppeteer+Typescriptで戦ってみた。いつか戦う運命だと感じていた

つまづいた9のことを残す。

1. @serverless-chrome/lambdaは利用できない

serverlessを利用するので、 「serverless chrome pdf」でぐぐると、Serverless公式のexampleとしてPuppeteerを利用したサンプルプロジェクトが出てきた

https://www.serverless.com/examples/aws-node-puppeteer

https://github.com/serverless/examples/tree/master/aws-node-puppeteer

公式のexampleだと思って試してみたが、Lambdaにデプロイした後invokeすると

ERROR Error occured in serverless-plugin-chrome wrapper when trying to ensure Chrome for hello() handler. { flags: [] } Error: Unable to start Chrome.

とエラーが出た。調べるとライブラリの@serverless-chrome/lambdaに一部不具合があり利用できないようだった。

https://github.com/adieuadieu/serverless-chrome/issues/249

2. PuppeteerでPDF出力を行う場合はheadless: trueでなければならない

chrome-aws-lambdaを利用すべきだと先ほどのissueで言われたので、chrome-aws-lambdaを使っていそうなデモライブラリを見つけてきた

https://github.com/crespowang/serverless-lambda-puppeteer

試してみたけどいい感じに動いたので page.pdf()を呼び出すもエラーになった

UnhandledPromiseRejectionWarning: Error: Protocol error (Page.printToPDF): PrintToPDF is not implemented

原因はヘッドレスモードではなかったため。ヘッドレスモードでしかPDF出力できなかった。

https://github.com/puppeteer/puppeteer/issues/5059

3. PDF出力時の見た目をブラウズ時と一致させる場合、page.emulateMedia("screen")が必要

https://pptr.dev/#?product=Puppeteer&version=v3.1.0&show=api-pageemulatemediatype

4. chrome-aws-lambdaPuppeteerのバージョンはできるだけ最新に

ローカルでいい感じに動いたのでデプロイしたが、lambdaで改めて実行するとまたエラー

Cannot find module 'iltorb'\nRequire stack:\n- /var/task/node_modules/chrome-aws-lambda/source/index.js\n- /var/task/functions/pdf.js\n- /var/runtime/UserFunction.js\n- /var/runtime/index.js","code":"MODULE_NOT_FOUND","requireStack":["/var/task/node_modules/chrome-aws-lambda/source/index.js

Node.jsのバージョンが10.15に上がった段階で変更が入ったように読める。

https://github.com/alixaxel/chrome-aws-lambda/issues/55

書かれてる通り yarn add iltorbしてみたがダメ。

https://github.com/alixaxel/chrome-aws-lambda/issues/55#issuecomment-568113363

chrome-aws-lambdaのREADME読んでねぇと思ったので読んでたらPuppeteerのバージョンが全然違うことに気づいた。1.19.0ってどんなけ前のやつ使ってたんや…

/img/2020-07-09_01.png

Puppeteer関連とchrome-aws-lambdaのバージョンを上げてみたらすっきり解決

  • puppeteer-core
    • 1.19.0 → 3.1.0
  • puppeteer
    • 1.19.0 → 3.1.0
  • chrome-aws-lambda
    • 1.19.0 → 3.1.1

5. Puppeteerで日本語を表示するならフォントを読み込む

日本語が文字化けして豆腐になっていた…😇

/img/2020-07-09_04.png

何もわからない

この方法を発見したがややこしそうなのでパスした

https://qiita.com/zyyx-matsushita/items/c33f79e33f242395019e#%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88%E8%A8%AD%E5%AE%9A

chrome-aws-lambdaのREADMEにfontに関する説明があったので、すすめられた通りraw.githack.comを介してNoto-Sans-CJK-JPのttfファイルを取得して設定した

import chromium from "chrome-aws-lambda";
const FONT_PATH = process.env.FONT_PATH;
// ...

const init = async (): Promise<Page> => {
    const readFontProcess = chromium.font(FONT_PATH);
    // ...
    await readFontProcess;
    // ...
}

fontはこちらを利用した。

https://github.com/minoryorg/Noto-Sans-CJK-JP

6. lambdaを通すと リクエストヘッダーのkeyが小文字になる

X-LAMBDA-ACCESS-KEYというヘッダーを設定してリクエストを送ると、lambda内でヘッダーを取得した際には キーがx-lambda-access-keyとなってしまう

もしかするとmiddyが変換処理をしている可能性がある.要調査

7. lambdaにデプロイした場合のみ、bodyがbase64でエンコードされている

ローカルでの実行時には、json形式のリクエストボディを取得する際

const bodyObject = JSON.parse(event.body);

で取得できたが、Lambdaでの実行時には base64でエンコードされていた。isBase64Encodedをもとにデコードするか判断することにした

  let body = event.body;
  if (!body) {
    throw new Error("body can't loaded.");
  }

  if (event.isBase64Encoded) {
    const buff = Buffer.from(body, "base64");
    body = buff.toString("ascii");
  }

  const bodyObject = JSON.parse(body);

8. Puppeteerのサイトアクセス時のヘッダーを加工するにはpage.setRequestInterception(true)page.on("request", _)を利用する

今回の場合、特定のwebサーバーにlambdaで印刷する専用の画面を用意し、そこにPuppeteerがアクセスしてPDFを出力する。

となるとlambda以外からのリクエストは全て弾く必要があるので、リクエストヘッダーに独自トークンをつける必要が出てきた

page.setRequestInterceptionを利用すると以下のように実装することができた

  await page.setRequestInterception(true).then(() => {
    page.on("request", (request) => {
      const headers = request.headers();
      headers["X-HOGEHOGE-TOKEN"] = accessToken;
      request.continue({
        headers,
      });
    });
  })

9. middyとTypescriptの相性は少し悪い?

const handler: APIGatewayProxyHandler = async (
  event
) => {
    // ....
}

と書くとmiddyにhandlerを登録する時、型が不一致になる

/img/2020-07-09_02.png

Context型が不足している?

middy公式の方法を試しても変わらず。

If you are using TypeScript, you will also want to make sure that you have installed the @types/aws-lambda peer-dependency:

npm install --save-dev @types/aws-lambda

結局、このエラーはAPIGatewayProxyHandler型を利用せずに、APIGatewayProxyEvent型とAPIGatewayProxyResult型を利用することで回避できた

const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
    // ...
}

所感

コードはこちら

https://gitlab.com/morifuji/lambda-puppeteer

  • 最近コード書く機会がないので楽しかった
  • 微妙にしか知らないスタック同士を組み合わせたものを作る時、学ぶことがとてつもなく多い
    • 初心者がそういう動きをするとどのスタックで詰まっているのかわからず死ぬと思った
  • いくつか対応できてない
    • コールドスタート対策
    • エラーハンドリング
      • middyのhttpErrorHandlerを有効活用できていない
    • ビジネスロジック分離
  • middy初めて使ってみた👀

See also