Kesinの知見置き場

知見を共有していきたいじゃないですか

FirebaseのCloud Functionでpuppeteerを動かす解説

先日、GAEとCloud Functionsでpuppeteerを動かすコードを紹介しているエントリが公開されました。 cloud.google.com

puppeteer、というかChromeを動かすためには実は様々な依存ライブラリをインストールする必要があるのですが、Cloud Functionでは環境そのもののカスタマイズはできなかったので今まではpuppeteerを動かすことはできませんでした。
GAEとCloud Functionでnodejs 8が使えるようになったのと同時に、実行環境のベースのイメージが新しくなった(Ubuntu 18.04とのこと)のでpuppeteerが使えるようになったようです。

ちょっと試してみる分には、先程の記事のソースコードgithubにアップされているので自分のGCPのプロジェクトにデプロイすることで動くことが確認できると思います。

ただFirebaseからCloud Functionsを使っている場合、エントリで紹介されている方法では個人的に不便なところがありました。

  • Firebaseを使っているとfirebase deploy --only functions でデプロイが済むので、gcloudのコマンドを使いたくない
  • nodejs 8のランタイム指定をGUIではなく、コードの中か設定ファイルに書いておきたい
  • 同様にマシンのスペックもコードか設定ファイルに記述したい
  • firebase servefirebase function:shellを使ってローカル環境でデバッグするときにはヘッドレスモードをオフにしたい

それぞれを実現できないか調べたところ、全て可能だったので紹介していきます。

ランタイムの指定

firebase initでCloud Functionsを使うようにセットアップした場合、package.jsonのscriptsのところに”deploy": "firebase deploy --only functions”というコマンドが追加されており、npm run deployだけでデフォルトではindex.jsでexportsしている関数がすべてCloud Functionsにデプロイされます。
ただ、firebase deployでは先程のブログで紹介されているgcloud beta functions deployのようにランタイムを指定するオプションは存在しません。

ではどうするかというと、実はpackage.json”engines”: {“node” : “8” }という設定を追加することで、firebase deployしたときに自動的にランタイムがnodejs 8に設定されます。
https://firebase.google.com/docs/functions/manage-functions?hl=en#set_nodejs_version

ドキュメントによるとCloud Function 2.0.0、firebase-tools 4.0.0から可能と書いてあるので、おそらく最近増えた項目だと思われます。

実行環境のマシンスペックの指定

ランタイムの指定と同様に、firebase deployのコマンドにはマシンスペックを指定するオプションはありません。 今度はpackage.jsonではなく、デプロイする関数のコード中で関数のタイムアウト時間と実行環境のマシンスペックを指定します。

const runtimeOpts = {
  timeoutSeconds: 300,
  memory: '1GB'
}

exports.myStorageFunction = functions
  .runWith(runtimeOpts)
  .storage
  .object()
  .onFinalize((object) = > {
    // do some complicated things that take a lot of memory and time
  });

コードはこちらから https://firebase.google.com/docs/functions/manage-functions?hl=en#set_timeout_and_memory_allocation

指定できるメモリサイズは今の所128MB、256MB、512MB、1GB、2GBの5種類と決められています。

タイムアウトについて

ドキュメントにも書いてありますが、timeoutSecondsの最大はCloud Functionsの制約上540(9分)となっています。

MAX実行時間が9分というのは結構重要な制約です。大量の処理をpuppeteerで行わせる場合は、1回の実行が9分に収まるように外部にキューなどの仕組みを用意してタスクを小分けにする必要があるでしょう。

マシンスペック

実行環境のマシンのメモリとCPUは固定されており、先程のコードでmemory: '1GB'とした場合のCPUスペックは1.4GHzです。メモリ512MBの環境ではCPUは800MHz、2GBの環境ではCPUは2.4Ghzという具合です。
https://cloud.google.com/functions/pricing?hl=en#compute_time

puppeteerはそこそこ重いのでサンプルコードでも’1GB’が使われています。ただ、スペックが高いと料金に影響(詳しくは後述)するので試しにワンランク下の512MBで試したところ自分の環境では問題なく動作しました。
長期的に試していないので512MB環境での安定性はまだ分かりませんが、多少動作が遅くても問題のない用途であればスペックを抑えるという選択肢はアリかもしれません。

デバッグ時はヘッドレスモードをオフにしたい

Seleniumやpuppeteerを使ったことがある方であれば分かると思いますが、本番環境で動かす場合にはChromeのヘッドレスモードが必須であるものの、ブラウザに表示されているものが分からない状態ではデバッグすることが非常に困難です。

そもそもCloud Functions自体をデバッグするのもなかなか大変ですが、FirebaseにはCloud Functionsをローカル環境でエミュレートするfirebase serve --only functionsfirebase functions:shellというコマンドが用意されています。
従って、あとはローカル環境で動かすときにヘッドレスモードをオフにしてpuppeteerを起動すれば実現できそうです。

Cloud Functionsは、Googleのサーバーで実行されるときにはNODE_ENV環境変数に’production’が入っているので、環境変数に応じてpuppeteerを起動するときのオプションを変更します。

async function getBrowserPage () {
  const isDebug = process.env.NODE_ENV !== 'production'
  console.log("NODE_ENV: ", process.env.NODE_ENV)

  const launchOptions = {
    headless: isDebug ? false : true,
    args: ['--no-sandbox']
  }

  const browser = await puppeteer.launch(launchOptions)
  return browser.newPage()
}

ローカルで実行した場合にはChromiumGUIが立ち上がりますので、実際にブラウザが動く様子を見ながらpuppeteerのコードを効率よく書くことができます。

ソースコード全体

ここまで紹介した内容を冒頭のブログで紹介されているコードに適用すると、このようになります。
あとは、通常のFirebaseでCloud Functionsをデプロイするのと同じようにfirebase deploy --only functionsするだけでpuppeteerが動くfunctionをデプロイできます。

// package.json
{
  "engines": { "node": "8" },
  // 他は省略
}
// index.js
import * as functions from 'firebase-functions'
import puppeteer from 'puppeteer'

let page

async function getBrowserPage () {
  const isDebug = process.env.NODE_ENV !== 'production'

  const launchOptions = {
    headless: isDebug ? false : true,
    args: ['--no-sandbox']
  }

  const browser = await puppeteer.launch(launchOptions)
  return browser.newPage()
}

exports.screenshot = functions
.runWith({timeoutSeconds: 540, memory: '1GB'})
.https.onRequest(async (req, res) => {
  const url = 'http://www.google.com'

  if (!page) {
    page = await getBrowserPage()
  }

  await page.goto(url)

  const imageBuffer = await page.screenshot()
  res.set('Content-Type', 'image/png')
  res.send(imageBuffer)
})

料金

最後にお金の話をしましょう。
Googleは太っ腹なことに、Cloud Functionsの呼び出しをなんと200万回まで無料としてくれています。ただし、よくよくドキュメントを読んでいくと実は呼び出し回数の他に、計算に使用したメモリとCPUによっても課金されるということが分かります。
普通の用途であればデフォルトの低スペック環境なので問題になることは少ないと思いますが、puppeteerのために1GBの環境にスペックを上げていたことを思い出してください

うっかり使いすぎてクラウド破産しないよう、事前にある程度の見積もりは計算しておくことが大事です。参考までにどれぐらいであれば無料枠の範囲におさまるのか自分が計算した結果を貼っておきます。
(2018/09/01の料金表を元に計算しています。さらに自分の計算が正しいことを保証するものではありません)

  • 512MB環境で1回の実行が9分の場合: 540回/月
  • 1GB環境で1回の実行が9分の場合: 260回/月

デフォルトの200万回まで無料と比較すると圧倒的に少ない回数しか無料枠には収まらないことが分かります。
どのような用途で使用するのかしっかりと考慮した上で、GCEやGAEで動かした場合と比較して本当にCloud Functions上で動かす価値があるのかは考えましょう。

実際に見積もりをする場合にはドキュメントの計算式を参考にスプレッドシートで計算シートを作成するか、https://cloud.google.com/products/calculator/#tab=functions で見積もりをしておくとよいでしょう。

まとめ

FirebaseからCloud Functionsでpuppeteerを使う場合に必要な設定を全てコード中に含める方法や、ちょっとしたテクニックの紹介をしました。

最後にはなりましたが、そもそもクラウドでpuppeteerを使うにしてもGCEやGAEでなく、なぜあえて制約の多いCloud Functionsでやるのか?という疑問があるかもしれません。
まだ実用例がほとんど見当たらないので個人的な予想ではありますが、例えばE2Eテストの用途ではCloud Functionsの特長を生かして圧倒的に並列にスケールさせることができれば実行時間を短縮させることができるはずです。

実際にAWS lambdaではそのような使い方が紹介されていたりします。
aws.amazon.com

近いうちにCloud Functionsを使ったE2Eテストの実験してみるつもりなので、うまく実現できたらまたブログで紹介しようと思います。