GAミント至上主義

Web Monomaniacal Developer.

Headless Chromeを使ったPDF変換サーバーがエラーで止まってしまったので調査

yagishで使っているPDF変換サーバーが落ちてしまったので、エラーログを確認して対策を考える。
復旧は再起動だけでひとまず完了。

比較的アクセスの少ない朝4時から10時ぐらいまでの営業日だったのが幸い。昼間や連休中だったら大変だった。

Stackdriverで周辺のログを見るといろんな種類のものが出ていたけど、一番わかりやすいのが3つ目の「Page crashed! 」

2018-09-25 03:59:11.000 JST
{ Error: Protocol error (Page.printToPDF): Printing failed undefined 
at Promise (/hcep/node_modules/puppeteer/lib/Connection.js:202:56) 
at new Promise (<anonymous>) 
at CDPSession.send (/hcep/node_modules/puppeteer/lib/Connection.js:201:12) 
at Page.pdf (/hcep/node_modules/puppeteer/lib/Page.js:851:39) 
at app.post (/hcep/app/pdf-server.js:136:31) 
at <anonymous> 
at process._tickCallback (internal/process/next_tick.js:182:7)

2018-09-25 04:00:07.000 JST
ServiceUnavailableError: Response timeout 
at IncomingMessage.<anonymous> (/hcep/node_modules/connect-timeout/index.js:84:8) 
at IncomingMessage.emit (events.js:180:13) 
at Timeout.<anonymous> (/hcep/node_modules/connect-timeout/index.js:49:11) 
at ontimeout (timers.js:466:11) at tryOnTimeout (timers.js:304:5) 
at Timer.listOnTimeout (timers.js:267:5)

2018-09-25 04:00:07.000 JST
(node:1) UnhandledPromiseRejectionWarning: Error: Page crashed! 
at Page._onTargetCrashed (/hcep/node_modules/puppeteer/lib/Page.js:156:24) 
at CDPSession.Page.client.on.event (/hcep/node_modules/puppeteer/lib/Page.js:132:56)
at CDPSession.emit (events.js:180:13) 
at CDPSession._onMessage (/hcep/node_modules/puppeteer/lib/Connection.js:232:12) 
at Connection._onMessage (/hcep/node_modules/puppeteer/lib/Connection.js:119:19) 
at WebSocket.emit (events.js:180:13) 
at Receiver.receiverOnMessage (/hcep/node_modules/ws/lib/websocket.js:718:20) 
at Receiver.emit (events.js:180:13) 
at Receiver.dataMessage (/hcep/node_modules/ws/lib/receiver.js:414:14) 
at Receiver.getData (/hcep/node_modules/ws/lib/receiver.js:346:17)

2018-09-25 04:00:07.000 JST
(node:1) UnhandledPromiseRejectionWarning: 
Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)

2018-09-25 04:00:07.000 JST
(node:1) [DEP0018] DeprecationWarning: 
Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

2018-09-25 04:00:11.000 JST
ServiceUnavailableError: Response timeout at IncomingMessage.<anonymous> (/hcep/node_modules/connect-timeout/index.js:84:8) 
at IncomingMessage.emit (events.js:180:13) 
at Timeout.<anonymous> (/hcep/node_modules/connect-timeout/index.js:49:11) 
at ontimeout (timers.js:466:11) at tryOnTimeout (timers.js:304:5) 
at Timer.listOnTimeout (timers.js:267:5)

2018-09-25 04:20:07.000 JST
ServiceUnavailableError: Response timeout 
at IncomingMessage.<anonymous> (/hcep/node_modules/connect-timeout/index.js:84:8) 
at IncomingMessage.emit (events.js:180:13) 
at Timeout.<anonymous> (/hcep/node_modules/connect-timeout/index.js:49:11) 
at ontimeout (timers.js:466:11) 
at tryOnTimeout (timers.js:304:5) 
at Timer.listOnTimeout (timers.js:267:5)

2018-09-25 10:04:07.000 JST
{ Error: Protocol error (Runtime.callFunctionOn): Target closed. 
at Promise (/hcep/node_modules/puppeteer/lib/Connection.js:202:56) 
at new Promise (<anonymous>) 
at CDPSession.send (/hcep/node_modules/puppeteer/lib/Connection.js:201:12) 
at ExecutionContext.evaluateHandle (/hcep/node_modules/puppeteer/lib/ExecutionContext.js:79:75) 
at ExecutionContext.evaluate (/hcep/node_modules/puppeteer/lib/ExecutionContext.js:46:31) 
at Frame.evaluate (/hcep/node_modules/puppeteer/lib/FrameManager.js:326:20) 
at <anonymous> 
at process._tickCallback (internal/process/next_tick.js:182:7)

Pageはpuppeteerで下記のような感じで作るPageオブジェクト

# 疑似コード
const puppeteer = require('puppeteer')
const browser = puppeteer.launch(launchOptions)
const page = browser.newPage()

全体のソースはこれ
GitHub - uyamazak/hcep-pdf-server: Simple PDF rendering server using Headless Chrome & Express & Puppeteer

そこらへんのエラー文言で検索すると、良い記事が見つかる。
qiita.com

What is "Page crashed!" error? · Issue #1321 · GoogleChrome/puppeteer · GitHub

Page crashed!は/dev/shmというRAMディスク領域の容量不足で起きるらしい。
今回も同じ原因かはわからないけど、現在GKEで動いているものがどうなっているか調べてみる。
kubectl exec -it {podのid} bashでdfを叩いてみる

hcep-pdf-server-7799668b8c-bw6r2:/hcep$ df -h /dev/shm
Filesystem      Size  Used Avail Use% Mounted on
shm              64M     0   64M   0% /dev/shm

容量はデフォルトの64MBのまま。大きいページでは足りなくなってしまい落ちるらしい。

Dockerではrun時にオプション--shm-size=256mで変更できるらしいが、GKEではオプションが無い。

その代わり、/dev/shmにディスクをマウントしてしまうという方法が見つかった。
容量は増えてもRAMじゃなくなるから遅くなるだろうか。
stackoverflow.com

なんでshmという名前なのが気になるけど、tmpfsが以前shmfsという名前だったのが関係ありそうだけどshmの意味まではまだ分かってない。意味的にshered memory?

今後のやることとしては、まず容量の大きいページを開いて同じようにcrashするか確認して原因を特定。
原因が上記と同じ容量だったら

  • crashしても自動で復旧できるようにnodeのプログラム側を考える
  • そもそも大きいページを開かないように前段階で遮断する
  • GKEでshmにディスクマウントして容量を増やす(やりたくない)

という感じで考えている。

自動復旧はchromeのプロセスも絡み、nodeだけでは難しそうなのでエラーをcatchしてprocess.exitし、podごと落とし、再起動はkubernetesのdeployに任せる感じでいいかもしれない。

[2018/9/25 17時追記] アクセスログの調査

上記の容量オーバー説の調査のため、サーバーダウン前後のリクエストを確認した。
こちらもStackdriverでHTTPロードバランサーのログを確認する。

f:id:uyamazak:20180925170509p:plain

これをみるとPDFダウンロードのPOSTリクエストがある前に、ヘルスチェックへのGETアクセスですでにエラーが出始めている。
と、なると大きなPOSTリクエストが原因ではなさそう。

あと忘れてたけど、リクエストボディをPageに渡す前に、body-parserでlimitを10MBで指定していたのだった。
10MBだったらおそらく64MBを超えることはなさそう。

www.npmjs.com

まだ原因は特定できなさそうなので、Puppeteerのバージョンを上げたり、まるごとメイン関数をtryで包んで、catchでprocess.exitでpod終了&PDFサーバーのpod数を増やしておく応急処置をすることにした。

[2018/9/26 10時 追記] エラーハンドリングで詳細をログ出力&大きいリクエストのテスト

よく見たら最新のコードだとエラー箇所をちゃんとtryで囲っていたので、今回の「unhandledRejection」は起きなさそう。

現状のログ情報だけだとエラーの再現ができそうにない。

データ量だけでなく、質の問題もありそうなので、次エラーがでたときは、下記のようなエラーcatch時に実行する関数を作り、表示しようとしたHTMLなどの詳細もログに出しておくことにした。

  function handlePageError(e, option) {
    console.error('Page error occurred! process.exit()')
    console.error('error:', e)
    console.error('option:', option)
    process.exit()
  }

試しに大きなリクエストのテストとして青空文庫から吾輩は猫であるを全文コピーして何度か貼り付けたデータを送り付け、数MB、PDFで300ページ超でも問題なく動いてしまった。

[2018/9/27 16時追記] バージョンをそろえるためChromeではなく、Puppeteer付属のChromiumを使う

Puppeteerのバージョンと対応しているChrome、もしくはChromium のバージョンが違うと問題になることがあるらしい。ソースは見失った。

https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteer-vs-puppeteer-core

Linux向けChromeではDockerfile内でバージョン指定インストールが面倒そうなので、素直に付属のChromiumを使うことにした。
今のところPDF生成においてはChromeChromiumの違いは気づいていない。

Releases per Chromium Version:
Chromium 71.0.3542.0 - Puppeteer v1.8.0
Chromium 70.0.3508.0 - Puppeteer v1.7.0
Chromium 69.0.3494.0 - Puppeteer v1.6.2
Chromium 68.0.3419.0 - Puppeteer v1.4.0
Chromium 67.0.3392.0 - Puppeteer v1.3.0
Chromium 66.0.3347.0 - Puppeteer v1.1.1

Kubernetes完全ガイド (impress top gear)

Kubernetes完全ガイド (impress top gear)

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

Firestore + Cloud Functions + Hangouts Chatで簡易メッセージフォームを作る

yagish履歴書で、お問合せ用にGoogleフォームは設置しているけど、もっと気軽な問合せフォームみたいのを作りたい。

でも、メールで送るのも、受け取るのもちょっと面倒なので、チャットに来ればいいやと考えた。

Slackも使っているけど、会社でG Suiteを使っていることもあり、せっかくなのでHangouts Chatを使ってみることにした。

gsuite.google.co.jp


全体の流れとしては下記のような感じ。

1. Firebaseでログインしているユーザーがテキストフォームに書き込んで送信

2. Firestoreの新規ドキュメントに問い合わせ内容を書き込む

3. Hangouts ChatのWEB Hook URLを取得。

4. FunctionsのonCreateで2を受け取ってChatのAPIにPOSTする。


1はyagish履歴書で出来ているので省く。ほんとはログインなしでも書き込みさせたいけど、スパム対策とユーザーごとのディレクトリで管理しているのでそれをそのまま使いたいため、ログイン必須にする。
またログイン必須にすると名前とかメールアドレスを入れなくていいので、メッセージ蘭だけになり、シンプルにできる利点も。

2. Firestoreへの書き込み

普通にaddする。
Cloud Firestore にデータを追加する  |  Firebase

以下疑似コード。
今後運営からお返事を書くかもしれないので、データにはユーザー情報も持たせておく。あと最新順に並び替えるようにtimestampも持たせておく。

const postCardData = {
  content: message,
  author: {
    uid: user.uid,
    email: user.email
  },
  timestamp: firebase.firestore.FieldValue.serverTimestamp()
}
// {uid}は仮、ちゃんと入れないとダメ
db.collection('/userdir/{uid}/postcards/').add(postCardData)

3. Hangouts ChatのWEB Hook URLを取得

チャットルームを作り、左上のところからつくれる。場所は分かりづらいけど、名前とオプションでアイコンを設定するだけで作れる。

f:id:uyamazak:20180918143237p:plain

4. FunctionsのonCreateで2を受け取ってChatのAPIにPOST

公式通り、Firebase Functions環境を準備する。
Firebase Cloud Functions  |  Firebase

Hangouts ChatのWEB Hookについては公式参照。
Using incoming webhooks  |  Hangouts Chat API  |  Google Developers

リクエストにはrequestを使った。
www.npmjs.com

jsonという専用パラメータで送るのが分からずしばらくハマった。
jsonにオブジェクトを入れるとstringifyして送ってくれる。

まだログもいろいろ出してて、完成じゃないけどコードは下記のような感じ。
本文を作るところは何かしらテンプレート的なものを使った方がよさそう。

const functions = require('firebase-functions');
const request = require('request');
// https://firebase.google.com/docs/functions/manage-functions?hl=ja
const runtimeOptions = {
  timeoutSeconds: 30,
  memory: '128MB'
}

const WEBHOOK_URL = "https://chat.googleapis.com/v1/spaces/XXXXXXXXXXXXXXXXXXXXXXX"

exports.sendPostCard2Chat = functions
  .runWith(runtimeOptions)
  .firestore
  .document('/userdir/{uId}/postcards/{postCardId}')
  .onCreate((snap, context) => {
      console.log('snap', snap)
      console.log('context', context)
      const val = snap.data();
      console.log('bodyText', bodyText)
      const options = {
        url: WEBHOOK_URL,
        headers: {
           'Content-Type': 'application/json; charset=UTF-8'
        },
        json: {text: 'email: ' + val.author.email + ' \n' + 'uid: ' + val.author.uid + ' \n' + 'site: ' + val.site + ' \n' +  'message:\n' + val.content}
      };
      request.post(options,
        (e, r, body) => {
          console.log('callback e:', e)
          console.log('callback r:', r)
          console.log('callback body:', body)
        }
      )
    });

ハマりどころとして、タイムアウトとか、メモリ、リージョンもこのコード上で指定できるが、リージョンを日本にしてしまうと動かなかった。
おそらくFirestoreがus-centralだからかなぁと思ったがよく調べてない。

これで、いまどきのサーバーレス&チャットで簡単なメッセージフォームができた。
データもチャットだけでなくFirestoreにも残せるので夢が広がる。

問題としてはこれだけのために普段使ってないHangouts Chatを開かねばいけないところ。

あと注意点としてFirestoreもFunctionsもβなので、大事なお問合せには使わない方がよさそう。

5000兆円欲しい!.cssをHeadless Chrome+Google App Engineを使いサーバーで画像化する

Headless Chromeの登場で、HTML+CSSのPDF化だけでなく、画像化においてもWEB系開発者にとっては最強の手段となった気がする。

やりたいこと

この5000兆円欲しい!の装飾で動的にテキストを画像化したい。こんな素敵なCSSを公開してくれたVoQnさんに感謝と畏敬の念しかない
codepen.io

そしておみくじ機能?で使う(かもしれない)

5000兆円食べたい!
※画面は開発中のものです

こんなのができる
f:id:uyamazak:20180912111059p:plain

なぜわざわざサーバーで画像化するのか

最初はHTML+CSSのままやろうと思ったけど、いろいろ問題が出てきた。次にcanvasで画像化も考えたけど、難しすぎて力尽きた。
サーバー側で画像化することで下記の問題を解決できる

ブラウザ依存の問題(IE、Edge士ね)

上記のCSSChromeだとちゃんと出るけど、Edge、IEだとしょぼい

画像の方がサイズ変更が容易

スマホの時など画面幅によって改行されてしまったり、文字サイズを変えるとグラデなど装飾のバランスがおかしくなってしまう。
画像化されていればcssなどで問題なく拡大縮小できる。

シェア+保存しやすい

画像なので右クリで保存できるし、URLができるので、そのままTwitterなどでも共有できる

あとサーバーで描画処理をするので、ユーザー端末に優しいというのもある。

Headless Chromeを使う利点

  1. CSSとそれに対応するブラウザの進化でこれまでフォトショなどが無いとできないグラデなどの複雑な装飾ができるようになった
  2. 普段使いのChromeで表示されるHTML+CSSならそのまま画像化でき、ユーザー側のブラウザ(IE市ね)は全く気にしなくていい
  3. HTMLなのでプログラムでの操作が簡単で動的に内容を変更もやりやすい

つくるもの

途中まではPDF化と同じなので自作の下記アプリを流用する。
各種インストール・デプロイ詳細などはこちら参照。
github.com

express部分をいろいろ書き換える。
https://github.com/uyamazak/hcep-pdf-server/blob/gosenchoen/app/gosenExpressApp.js

HTMLテンプレートを使いたいのでEJSを使った
www.npmjs.com

受け取ったテキストなどのクエリはvalidatorを使ってバリエーションしておく。

HTMLファイルはこれ
https://github.com/uyamazak/hcep-pdf-server/blob/gosenchoen/app/gosen.html

指定した要素(今回は.gosen-preview)だけ出力したいので下記を参考にscreenshot時に座標を指定して切り出す。
Capture DOM element screenshot using Chrome headless · GitHub

フォントはfonts/にM+をダウンロードして使うやつ入れておく
M+ FONTS

screenshotの下記オプションで下記背景を透明化もできる。(デフォは白)

omitBackground: true

デフォルトだと横幅が狭く切れたり、改行したりするのでViewPortを大きくしておく
gosenExpressApp.js

await page.setViewport({ width: 3000, height: 1000 })

開発はDockerでやって、あとはGAEにデプロイする(雑)

こんな感じで動的に画像を作れるようになった
https://gosenchoen-dot-hcep-pdf.appspot.com/?line1=Google%20Pixelbook&line2=%E6%AC%B2%E3%81%97%E3%81%84%EF%BC%81

GAEは開発用でいつか止めるかもしれないので、念のため出力された画像も
f:id:uyamazak:20180912111059p:plain

まだ解決できてない問題としてキャッシュだと304でcontent typeが画像で返らないため?、Twitterなどで画像として認識されないことがある。あとファイルサイズがちょっと大きいので縮小する機能も必要かも。

ゲームとかで使うバナーなどもフォトショいらずで、全部WEBデザイナーがHTML+CSSでつくり、こんなサーバーで画像化して配信っていう未来も来るかもしれない。
すべてコードかできるのでGit管理できるしフォトショを起動しなくてもよいので変更がめっちゃ楽だと思う。

Nodeクックブック

Nodeクックブック

Node.js 超入門

Node.js 超入門