※ 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
の結果からもフォントが正常にロードされているようです。
所感
いろいろ頑張った結果、こういうことがもっと手軽にできるカスタム コンテナーって便利だな、って思いました😅
一応、公開しています。