ほりひログ

所属組織の製品 (Azure とか) に関連する内容が多めだけど、個人の見解であって、所属組織を代表する公式情報ではないです。

Azure Functions (Linux) で Puppeteer が使えるようになってた

f:id:horihiro:20200830083143p:plain

※ App Service プランでの動作について追記しました

はじめに

タイトルの通り、Azure Functions (Linux) で Headless Browser が動作するようになりました。

f:id:horihiro:20200830084830p:plain # お、Extension Bundles v2 なんてのもあった

youtu.be

どこかで見た話題かと思ったら、大体 1 年前に App Service (Web App for Containers) で頑張ってたみたいです。

uncaughtexception.hatenablog.com

この時は、Dockerfile から自作し、カスタム コンテナーを使って、docker build の段階で apt でいろいろモジュールを入れていましたが、これと同じようなことを、Azure Functions の既定の Node.js 用コンテナーでやっているようです。

github.com

# なお Windows 版の App Service / Azure Functions は、GDI 関連が制限されたサンドボックス内で動作しているため Puppeteer が動きません。

動かしてみる

ソースコードは、Visual Studio Code の Azure Functions 拡張で作った、TypeScript の HTTP Trigger をベースに、クエリー パラメーター url で受け取った URL の Web ページを PNG として返すだけのサンプルです。

import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import * as puppeteer from "puppeteer"

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
    context.log('HTTP trigger function processed a request.');
    const url = req.query.url || "https://example.com";

    try {
        const browser = await puppeteer.launch({
            args: process.env.PUPPETEER_ARGS?.split(' ')
        });
        const page = await browser.newPage();

        await page.goto(url, { waitUntil: 'networkidle2', timeout: 0 });
        await page.emulateMediaType('screen');
        const body = await page.screenshot({type: 'png'});
        browser.close();

        context.res = {
            headers: {
                "Content-Type": "image/png"
            },
            body
        };
    } catch (e) {
        context.res = {
            body: e.toString()
        };
    }
};

export default httpTrigger;

※ App Service プランでホスティングする場合は、アプリケーション設定 PUPPETEER_ARGS--no-sandbox を設定してください。

ローカルで動いたことを確認してから、func azure functionapp publish %FUNCTION_APP_NAME% でデプロイしてみます。

f:id:horihiro:20200829183025p:plain

が、デプロイ自体は成功しているものの、見事に動きません。
Firefox が何とかと出てますが、とにかく Puppeteer に問題がありそうです。

ローカル PC の node_modules 配下にある Puppeteer のモジュールを見てみると、Windows 版のバイナリーが入っているので (ローカル PC が Windows なので)、これを Linux 版の Azure Functions にデプロイしても、動くわけがありませんね。。。

f:id:horihiro:20200829181555p:plain

Linux 用 Puppeteer のバイナリーをインストールするために、Azure Functions へのデプロイ後の npm install 等を実行するリモート ビルドを試してみます。
リモート ビルドでのデプロイは func コマンド実行時に -b remote を追加するだけです。

リモートビルドをする時は、ローカル PC 上の node_modules をデプロイする必要がないので、ついでに .funcignorenode_modules を追記しておきます。
必須ではないと思いますが、デプロイ パッケージのサイズが減るのでお勧めです。

再挑戦

リモート ビルドを指定してデプロイすると、結論から言うと、デプロイ自体に失敗しました。一歩後退😥。

以下、失敗時の出力。

>func azure functionapp publish %FUNCTION_APP_NAME% -b remote
Getting site publishing info...

# (省略)

Remote build in progress, please wait...
Updating submodules.
Preparing deployment for commit id 'd5e7b9c099'.
Repository path is /tmp/zipdeploy/extracted
Running oryx build...

# (省略)

Running 'npm install --unsafe-perm'...

npm WARN puppeteer-functions@1.0.0 No repository field.
npm WARN puppeteer-functions@1.0.0 No license field.

# (省略)

Running 'npm run build'...

> puppeteer-functions@1.0.0 build /tmp/zipdeploy/extracted
> tsc

Version 3.9.7
Syntax:   tsc [options] [file...]

# (省略)

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! puppeteer-functions@1.0.0 build: `tsc`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the puppeteer-functions@1.0.0 build script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /home/.npm/_logs/2020-08-29T09_35_27_450Z-debug.log
npm WARN puppeteer-functions@1.0.0 No repository field.\nnpm WARN puppeteer-functions@1.0.0 No license field.\n\nnpm ERR! code ELIFECYCLE\nnpm ERR! errno 1\nnpm ERR! puppeteer-functions@1.0.0 build: `tsc`\nnpm ERR! Exit status 1\nnpm ERR! \nnpm ERR! Failed at the puppeteer-functions@1.0.0 build script.\nnpm ERR! This is probably not a problem with npm. There is likely additional logging output above.\n\nnpm ERR! 
A complete log of this run can be found in:\nnpm ERR!     /home/.npm/_logs/2020-08-29T09_35_27_450Z-debug.log\n/opt/Kudu/Scripts/starter.sh oryx build /tmp/zipdeploy/extracted -o /home/site/wwwroot --platform nodejs --platform-version ~12
Remote build failed!

‘ 正常にリモートでの npm install は動いて、Puppeteer などはインストールできているようですが、npm build ( tsc )で失敗しています。

ここで、.funcignore (デプロイの対象から外すファイルを指定する) を見てみます。

*.js.map
*.ts
.git*
.vscode
local.settings.json
test
tsconfig.json
node_modules

デプロイの前に追加した node_modules は問題ないのですが、VSCode でプロジェクトを作った時に、最初から tsconfig.json が含まれています。

この設定では、tsconfig.json をデプロイしないので、リモート ビルド時 tsctsconfig.json を見つけられず失敗する、て感じのようです。
TypeScript & リモート ビルドの時は、.functignore から tsconfi.json を削除しておきましょう。

再々挑戦

.funcignore から tsconfig.json を削除して再々挑戦です。

デプロイが成功してアクセスしてみると、無事画像が表示され、、、 f:id:horihiro:20200829172836p:plain てない!
日本語フォントが一つもでてきていない。

Google のトップページでも試してみると、、、 f:id:horihiro:20200829172908p:plain ところどころに豆腐が。。。

明らかにフォント周りで何か起こっています。
フォントが足りないのか、もしくは読み込まれていないのか。

フォント問題の調査

"Puppeteer" "日本語" で検索してみると、追加フォントのインストールが必須のようでした。

そういえば、以前 Web App でやった時に、似たようなことをした記憶があります。忘れてました。
エラいぞ、1 年前の自分。

github.com

念のためフォントが読み込まれているのか確認するため、強引に Azure Functions 上で fc-list を実行してみます。 f:id:horihiro:20200829194808p:plain 予想通り、日本語フォントっぽいものはありません。

(こちらも強引に) /etc/fonts/fonts.conf を探ってみると、フォントはこのあたりに置いといたらよさそうです。 f:id:horihiro:20200829194936p:plain

フォント問題に挑戦

ただし /home 以外はそう簡単にいじれそうもないので((App Service プランの場合は、/usr/share/fonts への書き込みが可能のようです。))、フォントの配置は /home/.fonts しか選択肢がなさそうです (will be removed in the future らしいけど。。。)。

しかも、Azure Functions にはスタートアップ スクリプトのような仕組みもないので、デプロイ時にいろいろやりたいところですが、デプロイ時は /home/site/wwwroot の下しか変更できません (それ以外を変更したとしても実行時にはなかったことになる *1 )。

なので、以下のように、デプロイ時の処理 (1) と関数実行時 (2) との組み合わせでやってみます。

  1. package.jsonpostinstallスクリプトで、
    1. フォントのダウンロード
    2. /home/site/wwwroot 以下に仮で展開
  2. 関数実行時は、
    1. /home/.fonts があるかをチェック、あれば以降はスキップ
    2. なければ /home/.fonts を作成
    3. 1-b で /home/site/wwwroot に展開しておいたフォントを /home/.fonts にコピー
    4. 最後の fc-cache でフォント キャッシュを更新

# 本当にこれしかないのか、、、?

1 は、以下のようなスクリプト (postinstall.sh) を用意し、package.jsonscripts > postinstall に指定します。

#!/bin/sh

curl -s "https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip" -o /tmp/fonts.zip && \
unzip -o /tmp/fonts.zip -d /tmp/fonts/ && \
mkdir -p ./fonts && mv /tmp/fonts/*.otf ./fonts
{
      :
  "scripts": {
      :
    "postinstall": "sh ./postinstall.sh",
      :
  },
    :
}

2 は関数コード自体に下記のようなコードを追加します。

import * as util from "util"
import { promises as fs }  from "fs"
import * as glob from "glob-promise"
import { exec } from "child_process"

const execAsync = util.promisify(exec);

const fontUpdate = async () => {
    try {
        await fs.stat('/home/.fonts');
        return
    } catch {
    }

    // mkdir ~/.fonts
    await fs.mkdir("/home/.fonts");

    // cp /home/site/wwwroot/fonts/* ~/.fonts
    const fonts = await glob("/home/site/wwwroot/fonts/*.otf");
    await fonts.reduce((previousState, currentValue) => {
        return previousState.then(() => {
            const fontfile = currentValue.replace(/.*\/([^/]+)/, '$1')
            return fs.copyFile(currentValue, `/home/.fonts/${fontfile}`);
        })
    }, Promise.resolve());

    // fc-cache -fv
    await execAsync("fc-cache -fv");
}

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
    context.log('HTTP trigger function processed a request.');

        :

    await fontUpdate();

        :
}

うーん、堂々と公開する方法ではないかもしれない。。。

再々々挑戦

以上のコード等を反映してデプロイしてみた結果、、、

f:id:horihiro:20200829173203p:plain

f:id:horihiro:20200829173124p:plain

日本語フォントが出ました!🎉🎉🎉

fc-list の結果からもフォントが正常にロードされているようです。 f:id:horihiro:20200829194705p:plain

所感

いろいろ頑張った結果、こういうことがもっと手軽にできるカスタム コンテナーって便利だな、って思いました😅

一応、公開しています。

github.com

*1:デプロイ時のログから察するに、Linux の場合は、デプロイ処理の最後に /home/site/wwwroot 以下を SuashFS という形式で圧縮してストレージ アカウントに保存し、実行時にそれを再度 /home/site/wwwroot にマウントする、という処理をしているようです。 Run from package の一種ですね