
※ App Service プランでの動作について追記しました
はじめに
タイトルの通り、Azure Functions (Linux) で Headless Browser が動作するようになりました。
# お、Extension Bundles v2 なんてのもあった
どこかで見た話題かと思ったら、大体 1 年前に App Service (Web App for Containers) で頑張ってたみたいです。
uncaughtexception.hatenablog.com
この時は、Dockerfile から自作し、カスタム コンテナーを使って、docker build の段階で apt でいろいろモジュールを入れていましたが、これと同じようなことを、Azure Functions の既定の Node.js 用コンテナーでやっているようです。
# なお 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% でデプロイしてみます。

が、デプロイ自体は成功しているものの、見事に動きません。
Firefox が何とかと出てますが、とにかく Puppeteer に問題がありそうです。
ローカル PC の node_modules 配下にある Puppeteer のモジュールを見てみると、Windows 版のバイナリーが入っているので (ローカル PC が Windows なので)、これを Linux 版の Azure Functions にデプロイしても、動くわけがありませんね。。。

Linux 用 Puppeteer のバイナリーをインストールするために、Azure Functions へのデプロイ後の npm install 等を実行するリモート ビルドを試してみます。
リモート ビルドでのデプロイは func コマンド実行時に -b remote を追加するだけです。
リモートビルドをする時は、ローカル PC 上の node_modules をデプロイする必要がないので、ついでに .funcignore に node_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 をデプロイしないので、リモート ビルド時 tsc が tsconfig.json を見つけられず失敗する、て感じのようです。
TypeScript & リモート ビルドの時は、.functignore から tsconfi.json を削除しておきましょう。
再々挑戦
.funcignore から tsconfig.json を削除して再々挑戦です。
デプロイが成功してアクセスしてみると、無事画像が表示され、、、
てない!
日本語フォントが一つもでてきていない。
Google のトップページでも試してみると、、、
ところどころに豆腐が。。。
明らかにフォント周りで何か起こっています。
フォントが足りないのか、もしくは読み込まれていないのか。
フォント問題の調査
"Puppeteer" "日本語" で検索してみると、追加フォントのインストールが必須のようでした。
そういえば、以前 Web App でやった時に、似たようなことをした記憶があります。忘れてました。
エラいぞ、1 年前の自分。
念のためフォントが読み込まれているのか確認するため、強引に Azure Functions 上で fc-list を実行してみます。
予想通り、日本語フォントっぽいものはありません。
(こちらも強引に) /etc/fonts/fonts.conf を探ってみると、フォントはこのあたりに置いといたらよさそうです。

フォント問題に挑戦
ただし /home 以外はそう簡単にいじれそうもないので((App Service プランの場合は、/usr/share/fonts への書き込みが可能のようです。))、フォントの配置は /home/.fonts しか選択肢がなさそうです (will be removed in the future らしいけど。。。)。
しかも、Azure Functions にはスタートアップ スクリプトのような仕組みもないので、デプロイ時にいろいろやりたいところですが、デプロイ時は /home/site/wwwroot の下しか変更できません (それ以外を変更したとしても実行時にはなかったことになる *1 )。
なので、以下のように、デプロイ時の処理 (1) と関数実行時 (2) との組み合わせでやってみます。
- package.json の
postinstallのスクリプトで、- フォントのダウンロード
-
/home/site/wwwroot以下に仮で展開
- 関数実行時は、
-
/home/.fontsがあるかをチェック、あれば以降はスキップ - なければ
/home/.fontsを作成 - 1-b で
/home/site/wwwrootに展開しておいたフォントを/home/.fontsにコピー - 最後の
fc-cacheでフォント キャッシュを更新
-
# 本当にこれしかないのか、、、?
1 は、以下のようなスクリプト (postinstall.sh) を用意し、package.json の scripts > 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(); : }
うーん、堂々と公開する方法ではないかもしれない。。。
再々々挑戦
以上のコード等を反映してデプロイしてみた結果、、、


日本語フォントが出ました!🎉🎉🎉
fc-list の結果からもフォントが正常にロードされているようです。

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