Azure Static Web Apps で Puppeteer を動かしたかった

f:id:horihiro:20200920200504p:plain

はじめに

前回のエントリーでは、Azure Functions で Puppeteer を動かして、特定のページのスクリーンショットを取得する Web API を作ってみました。 uncaughtexception.hatenablog.com

Web API で動作がするのは確認できましたが、Web ブラウザーのアドレスランを編集して URL クエリーに対象の URL を入れるのがやや面倒なので、Web API を使用しつつ Web ページで UI を作ってみたいと思い、 静的ページとバックエンド API を一緒にデプロイできる Azure Static Web Apps (以下 SWA、ちな Preview) で試してみました。

Azure Static Web Apps を作る

クイック スタートがあるので、そのまま使います。

docs.microsoft.com

ざっくりいうと、

  1. GitHub 上のテンプレート リポジトリから自分のリポジトリを作成
  2. Azure に SWA リソースを作り、その時に 1. で作ったリポジトリを指定

です。

すると、テンプレート状態のリポジトリに、GitHub Action が追加されます。
f:id:horihiro:20200921121140p:plain

あとはこのリポジトリに git push していけば GitHub Action が良しなにビルド&デプロイしてくれるはずです。

github.com

API を追加する

出来上がったレポジトリーはフロントエンド用のコードのみなので、バックエンド API を追加します。

# ここからは VSCode + Azure Functions 用拡張機能を使います。

  1. api ディレクトリーを追加
    f:id:horihiro:20200921145611p:plain
  2. 追加した api ディレクトリーをワークスペースに追加
    f:id:horihiro:20200921145814p:plain

    f:id:horihiro:20200921145906p:plain
  3. api ディレクトリーに Azure Functions 用コードを追加
    1. コマンドパレット (Ctrl+shift+p) から Create Function... を選択
      f:id:horihiro:20200921150040p:plain
    2. api ディレクトリーを選択
      f:id:horihiro:20200921150111p:plain
    3. 空のディレクトリーなので「新規のプロジェクトを作る?」と聞かれるので、もちろん Yes
      f:id:horihiro:20200921150501p:plain
    4. 言語は Javascript 、トリガーは HTTP を選択
      f:id:horihiro:20200921150658p:plain f:id:horihiro:20200921150733p:plain

ここまでやるとこういう構成が出来上がります。
f:id:horihiro:20200921150859p:plain

スクリーンショット API 実装

Puppeteer でスクリーンショットを作成する API を実装します。

まずは api ディレクトリーで Puppeteer をインストール。

npm i puppeteer

次に api/screenshot/index.js を実装。
どこにでもありそうなサンプルです。

const puppeteer = require("puppeteer");

module.exports = async function (context, req) {
    const url = (req.body && req.body.url) || req.query.url || "https://microsoft.com/ja-jp/";
    const browser = await puppeteer.launch({
        timeout: 0
    });
    const page = await browser.newPage();
    await page.goto(url);
    const screenshotBuffer = await page.screenshot({ fullPage: true });
    await browser.close();

    context.res = {
        body: screenshotBuffer,
        headers: {
            "content-type": "image/png"
        }
    };
};

SWA にパブリッシュしてみる。

ローカルで動くことを確認したら、git push。

Git push が無事成功し、GitHub Action でビルドが実行され、無事 SWA 上にデプロイが完了、、、しません。

f:id:horihiro:20200921165138p:plain

Zipping Api Artifacts
Done Zipping Api Artifacts

The content server has rejected the request with: BadRequest
Reason: The size of the function content was too large. The limit for this static site is 30000000 bytes.

30MB、、、?ドキュメントには 100MB ってあるけど??

f:id:horihiro:20200921172331p:plain

docs.microsoft.com

似た issue があったので、便乗して聞いてみみるとすぐ返信が。

github.com

so currently we have 2 size limits: one for you app (that is the well documented 100 MB limit), the other limit is for the total size of your function. It seems you are hitting the function size limit (30 MB).

要は、プレビュー期間中のサイズ制限は 2 つあって、

  1. app (恐らくフロントエンドの vue とか React とかのアプリ) は 100MB まで
  2. Function のコードは 30MB まで

とのこと。

今回は Puppeteer に含まれる Chromium がデカかったため、30MB の制限を超えていました。
たとえ 100MB だとしても、Chromium がデカすぎて入りきらない気がします。
※ 念のためもう1回いますが、プレビュー期間中の制限です。

そういえば、前回 Azure Functions へデプロイした時は、リモート ビルドを使っていました。
リモート ビルドにより Puppeteer や Chromium のバイナリーはデプロイ後にインストールしていたので、デプロイ パッケージ自体は大したサイズではありませんでした (node_modules が丸ごと含まれないので)。

回避策 (非推奨。どうしても今すぐ試した人向け)

GitHub Action からのデプロイが、リモート ビルドに対応してくれればいいんですが、どうも GitHub Action で実行されるビルド用コンテナー次第なので、今すぐどうこうすることは難しそうです。

というわけで、関数コードの実装で無理やり回避してみます。

CAUTION!

以下、とてもお勧めできない実装が続きますので、用法用量を守って正しくお使いください。


まず GitHub Action でのビルドで Puppeteer がインストールされないよう、 api/package.jsondependencies から puppeteer を消しておきます。 # この対応の時点でもうね。。。

次にユーティリティー関数として、実行時に npm install でモジュールをインストールする requireAsync という関数を定義します。

api/common/preinvocation.js

const util = require("util");
const exec = util.promisify(require("child_process").exec);

const moduleMap = {};

const requireAsync = async function (module) {
    if (moduleMap[module]) return moduleMap[module];
    try {
        moduleMap[module] = require(module);
    } catch {
        await exec(`cd /home && npm i ${module}`);
        moduleMap[module] = require(module);
    }
    return moduleMap[module]
};
exports.requireAsync = requireAsync;

指定されたモジュールがロードできれば、それをキャッシュしつつそのまま返し、
ロードできなければ、/home 配下に npm install して、それをロードして返します。
/home に移動しているのは、関数コードがある /home/site/wwwroot は読み取り専用になっている (はず) だからです。

この requireAsync 関数を使うように、api/screenshot/index.js の冒頭も変えます。

api/screenshot/index.js (変更部分のみ)

const { requireAsync } = require("../common/preinvocation");

module.exports = async function (context, req) {
    const puppeteer = await requireAsync("puppeteer");

結果、関数実行時に /home/node_modules に Puppeteer がインストールされていなければ、そのタイミングでインストールします。
あればロードされるはずです。

想像つくと思いますが、初回の実行は極めて遅いです。
そりゃそうですよね、Puppetter をインストールしているですから。

完成

と言っていいのかわかりませんが、一通り動くものがこちら。

github.com

※フロント エンドのコードは、Vue を全く知らない人が想像で書いたレベルです。

結論

早く GA して、サイズ制限を取っ払ってほしい。