LambdaでPuppeteer/Headless Chrome

以前、AWS LambdaでPhantomJS日本語フォント対応という記事を参考にLambdaで動くPhantomJSを利用したスクリーンショットの実装を作ったのですが、今はHeadless Chrome/Puppeteerがメインストリームなのでこれにどのように乗り換えられるか試してみました。Chromeless等の専用ツールやServerless Frameworkでもプラグインがありますが、既存の実装から大きく変えたくなかったのでなるべくシンプルに入れられる方法を模索してます。

前提

  • Serverless Frameworkを利用
  • Lambdaの環境はNode.js v6.10を利用
  • Chromeはserverless-chromeで利用されているバイナリを利用
  • fontconfigをビルドして利用
  • ビルドする環境としてDockerを利用
  • 日本語フォントにIPAexフォントを利用

上述のPhantomJSの方式からは大きく変わっていません。PhantomJSがChromiumになりWebshotがPuppeteerに変わっています。

ビルド環境の構築とビルド

上述の記事ではVagrantが使われていましたが、今回はDockerを使ってみました。Dockerだとvagrant sshするような手間なく直接docker exec -it ...だったりdocker-compose run ... ...等のコマンドでやりたいことが出来るので環境の使い分けが少なくなりスッキリします。

まず最初にserverless-chromeのリリースページからChromiumをダウンロードしておきます。この手順では便宜的に保存先はプロジェクトのルートとしています。

$ wget https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-37/stable-headless-chromium-amazonlinux-2017-03.zip

そして同じルートに下記のようなlocal.confとDockerfileを作成します。

local.conf

<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <dir>/tmp/fontconfig/usr/share/fonts</dir>
</fontconfig>

Dockerfile

FROM lambci/lambda:nodejs6.10
USER root
ADD stable-headless-chromium-amazonlinux-2017-03.zip /tmp/stable-headless-chromium-amazonlinux-2017-03.zip
ADD local.conf /tmp/local.conf

RUN yum update -y \
	&& yum install -y wget git unzip awscli \
	&& yum install -y gperf freetype-devel libxml2-devel libtool autopoint autoconf autoreconf \
	&& wget https://bootstrap.pypa.io/ez_setup.py -O - | python \
	&& easy_install pip \
	&& pip install lxml six \
	&& wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo \
	&& yum install -y yarn \
	&& npm install -g serverless@1.11.0 \
	# /tmp上にfontconfigをビルドし配備しておく
	# 後にこの生成されたfontconfigを一緒にLambdaにデプロイする
	&& cd /tmp \
	&& git clone http://anongit.freedesktop.org/git/fontconfig \
	&& cd fontconfig \
	&& git checkout -b 2.12.4 refs/tags/2.12.4 \
	&& ./autogen.sh --sysconfdir=/tmp/fontconfig/etc --prefix=/tmp/fontconfig/usr --mandir=/tmp/fontconfig/usr/share/man --enable-libxml2 \
	&& make \
	&& make install \
	&& mkdir -p /tmp/fontconfig/etc/fonts/ \
	&& cp /tmp/local.conf /tmp/fontconfig/etc/fonts/local.conf \
	&& mkdir -p /tmp/fontconfig/usr/share/fonts/ \
	&& cd /tmp \
	&& wget http://dl.ipafont.ipa.go.jp/IPAexfont/ipaexg00301.zip \
	&& unzip ipaexg00301.zip \
	&& cp ipaexg00301/ipaexg.ttf /tmp/fontconfig/usr/share/fonts/ \
	&& LD_LIBRARY_PATH=/tmp/fontconfig/usr/lib/ /tmp/fontconfig/usr/bin/fc-cache -fs

lambci/lambda:nodejs6.10というDockerイメージはLambdaの模擬環境です(非公式)。今回はfontconfigをこの環境でビルドし、またnode_moduleもこの環境下でインストールしたものをLambdaにアップロードするのでLambda環境と互換性が保たれます。

DockerのVolume設定等を宣言的に書いておきたかったので今回はdocker-composeを利用しています。下記のようなdocker-compose.ymlをルートに用意します。

version: '2'

services:
  example:
    build: .
    working_dir: /serverless
    volumes:
      - .:/serverless/
    env_file: .env
    entrypoint: ''

また、Dockerの環境変数を設定したいので、下記のような.envファイルを作成しルートに保存しておきます。AWSのクレデンシャルはsls deployでAWSにデプロイするためのものです。PUPPETEER_SKIP_CHROMIUM_DOWNLOADはPuppeteerのインストール設定、LD_LIBRARY_PATHとFONTCONFIG_PATHはChromiumがfontconfigを読み込むために必要になります。

AWS_ACCESS_KEY_ID=FIXME
AWS_SECRET_ACCESS_KEY=FIXME

# https://github.com/GoogleChrome/puppeteer/issues/244
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

# https://gist.github.com/nat-n/c3429d29f2478ccb3de243810bb12956
LD_LIBRARY_PATH=/tmp/fontconfig/usr/lib/
FONTCONFIG_PATH=/tmp/fontconfig/etc/fonts/

下記のようにビルドします。

$ docker-compose build

少し時間がかかりますが、これでDockerコンテナの中の/tmp/fontconfig/にfontconfigがビルド済みのDockerイメージが出来上がります。

実際に動かしてみる

Puppeteerを下記のようにインストールしておきます。ここではpackage.json等は既にありbabelが設定されている前提です。

$ docker-compose run example yarn install

次に下記のようなファイルをルートに用意します。

test.js

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    executablePath: `${__dirname}/headless-chromium`,
    args: [
      '--no-sandbox',
      '--disable-dev-shm-usage',
      '--disable-setuid-sandbox',
      // https://github.com/GoogleChrome/puppeteer/issues/1523#issuecomment-354240987
      '--single-process'
    ]
  });
  const page = await browser.newPage();
  await page.goto('https://www.nikkei.com/');
  await page.screenshot({path: 'nikkei.png'});

  await browser.close();
})();

test.run.js

require('babel-core/register');
require('./test');

prepare-binaries.sh

#!/bin/bash
cd /tmp
unzip stable-headless-chromium-amazonlinux-2017-03.zip
mv headless-chromium /serverless/

次に下記のようなコマンドでプログラムを実行します。

$ docker-compose run example bash -c "bash prepare-binaries.sh && node test.run.js"

無事、日本語でキャプチャが取得できました。

Lambdaとして実行する

ここからはServerless Frameworkの話になりますが、ぼくの環境では下記のように容量を絞っています。あまり良いプラクティスなのかは分かりませんが、自分なりに工夫したのは下記のような感じです。

  • Webpackでbundleしたものをアップロードする
  • bundleに含められないnode_modules(ネイティブコードが含まれるような)はpackageのincludeでアップロードに含める

参考までに下記のようなserverless.ymlになっています。下記のサンプルは動作検証してないのであくまでコンセプトは下記のような感じという事で理解して頂ければと思います。

package:
  include:
    - handler.js
    - .env
  exclude:
    - node_modules/**

functions:
  example:
    handler: handler
    events:
      - http:
          path: example
          method: post
          ...諸々設定
    memorySize: 512
    timeout: 30
    environment:
      LD_LIBRARY_PATH: /tmp/fontconfig/usr/lib/
      FONTCONFIG_PATH: /tmp/fontconfig/etc/fonts/
    package:
      individually: true
      include:
        # buffer-shimsがないと実行時エラーになったため含める
        - node_modules/buffer-shims/**
        # puppeteerの依存パッケージをリスト(puppeteerだけインストールしたプロジェクトでnode_modulesをリストアップ)
        - node_modules/agent-base/**
        - node_modules/async-limiter/**
        - node_modules/balanced-match/**
        - node_modules/brace-expansion/**
        - node_modules/concat-map/**
        - node_modules/concat-stream/**
        - node_modules/core-util-is/**
        - node_modules/debug/**
        - node_modules/es6-promise/**
        - node_modules/es6-promisify/**
        - node_modules/extract-zip/**
        - node_modules/fd-slicer/**
        - node_modules/fs.realpath/**
        - node_modules/glob/**
        - node_modules/https-proxy-agent/**
        - node_modules/inflight/**
        - node_modules/inherits/**
        - node_modules/isarray/**
        - node_modules/mime/**
        - node_modules/minimatch/**
        - node_modules/minimist/**
        - node_modules/mkdirp/**
        - node_modules/ms/**
        - node_modules/once/**
        - node_modules/path-is-absolute/**
        - node_modules/pend/**
        - node_modules/process-nextick-args/**
        - node_modules/progress/**
        - node_modules/proxy-from-env/**
        - node_modules/puppeteer/**
        - node_modules/readable-stream/**
        - node_modules/rimraf/**
        - node_modules/safe-buffer/**
        - node_modules/string_decoder/**
        - node_modules/typedarray/**
        - node_modules/ultron/**
        - node_modules/util-deprecate/**
        - node_modules/wrappy/**
        - node_modules/ws/**
        - node_modules/yauzl/**

そしてデプロイですが、まず先程作成したprepare-binaries.shを下記のように書き加えます。

prepare-binaries.sh

#!/bin/bash
rm -rf /serverless/fontconfig/
cp -Rf /tmp/fontconfig /serverless/

cd /tmp
unzip stable-headless-chromium-amazonlinux-2017-03.zip
mv headless-chromium /serverless/

handler.jsは下記のような感じです。

handler.js

const exec = require('child_process').exec;

function prepareFontConfig() {
  return new Promise((resolve, reject) => {
    exec(`ln -nfs ${__dirname}/fontconfig /tmp/`, (error) => {
      if (error) {
        return reject(error);
      }
      return resolve();
    });
  });
}

async function handler(event, context, callback) {
  try {
    await prepareFontConfig();

    // Puppeteerの処理

  } catch (error) {
    // エラー処理
  }
}

module.exports = handler;

下記のようなコマンドでデプロイします。

$ docker-compose run example bash -c "bash prepare-binaries.sh && sls deploy"

僕の場合は実際にはこのデプロイ時にwebpackコマンドなのでビルド等も同時に行ったりしています。