GAミント至上主義

Web Monomaniacal Developer.

【ポエム】怪物キメラシステムとその倒し方

長く続いているWEB系でよくありがち、どことは言えないいくつかの職場の知見を組み合わせた一般論のつもりポエム。

f:id:uyamazak:20190309234051p:plain

  1. ビジネス的には儲かってる、いい感じシステムVer.1ができる
  2. とりあえず動いているので、言語やフレームワーク、OSのバージョンアップとかは後回しにする
  3. なんだかんだで細かい修正、追加があってVer. 1.43みたいになってる
  4. 時は経ち、Ver.1を作った人たちはいなくなる
  5. 問題が発生しても調査や修正にすげー時間がかかるようになってくる
  6. さすがにもう後回しにできなくなってフルリニューアルプロジェクトを開始する
  7. Ver.1 で使ったフレームワークや言語は古いので、Ver.2では新しい違うものを採用する
  8. Ver.1が何世代も前の言語やら環境だし、全体を理解する人もいないので、時間もかかるし、いろんな問題が起きる
  9. なにかあったらビジネス的な影響も大きく、予算的にも難しいなどの理由でVer.1を一部残すことになる
  10. Ver.1側にも修正が入り、全体的にはVer.1.52 + Ver.2.03みたいなシステムとなり、リニューアルプロジェクトは一段落する
  11. 時は経ち、新しいリニューアルプロジェクトを開始する
  12. (略
  13. Ver1.57 + Ver.2.64 + Ver.3.07みたいなそれぞれの時代もフレームワークも異なるものを組み合わせたキメラシステムとなる
  14. 逃亡者や死人が出始める
  15. 怪物を倒せる勇者を探す
  16. (未詳
  17. そして伝説へ

キメラ(キマイラ)とは

「由来が異なる複数の部分から構成されている」という意味で使われる例もある

キメラ - Wikipedia

キメラシステムの特徴

あながちキマイラの説明で間違ってない

ライオンの頭と山羊の胴体、毒蛇の尻尾を持つ。それぞれの頭を持つとする説もある。強靭な肉体を持ち、口からは火炎を吐く。その火炎によってしばしば山を燃え上がらせていた。

キメラ - Wikipedia

キメラ化を防ぐ案

  • こまめにアップデートを行う(サポート切れのタイミングは、ビジネス的に説明しやすく良さそう)
  • 長期的な目線でこういう問題を理解でき、なんとかできる人に権限をもたせる
  • 日常的に価値の無い機能や使われていない機能をどんどん捨ててシンプルを保つ

キメラシステムの倒し方

  1. 短期的には赤字になったとしても、フルリニューアルをやり切る覚悟をもって遂行する
  2. キメラシステムはそっとしておき、全く新しいシステムを開発し、キメラシステムのユーザー、売上を奪い息の根を止める
  3. 大問題が起きてビジネス的にも継続不可能になって自滅するのをただ待つ

闘うプログラマー[新装版]

闘うプログラマー[新装版]

人月の神話【新装版】

人月の神話【新装版】

退職エントリ(とんかつカレーBBSについて)

2019年2月で2年11ヶ月いたビズオーシャンを退職しました。分社化前のMJS時代を含めると6年半ぐらいいました。

2019年3月からはDMM.com同人事業部にいます。

有給消化中はFirebaseとVue.jsでBBS的なものを作ってました(チャットかBBSかはゆらぎあり)。

f:id:uyamazak:20190302222419p:plain

自宅のPixelbookでも開発を続けやすいようGoogle Cloud Consoleを開発環境にしたり、Vue CLIも使わず、ファイル数も最小限に押さえています。

およそ一日で動く形ができたのはFirebaseとVue.jsのちから。

アクセスカウンター付き、閲覧、書き込みにはログイン必須、画像アップロード可、複数スレッドに対応してます。

他のスレッド一覧は見れず、URLを知らないと見れないのでプライベートチャットみたいなイメージ。

デザインは2000年頃のネタを散りばめながらid:shellmeがやってくれました。

(一応デモも用意したけどログインしないと何も見れない。)
tonkatsu-curry.firebaseapp.com

いろいろ説明が不十分ながらソースコードも公開してます。

github.com

今後の開発予定(未定)

  • レス番をFunctionsでなんとか付ける
  • 返信できるようにする
  • 画像のサムネイルもFunctionsで生成したい
  • 動画にも対応したい(すぐ無料枠超えそう)
  • 管理者機能(皿追加とか皿一覧とか)
  • Slack連携(うざい)

要望はGitHubのissueあたりに投げてもらうと実装される可能性が微レ存

Cloud Consoleのエディアが使いこなせれば、Vue CLI使ってPWAぐらい問題なく作れるかも。

Vue.js入門 基礎から実践アプリケーション開発まで

Vue.js入門 基礎から実践アプリケーション開発まで

Headless Chromeを使ったPDF変換サーバーが落ちないようにした対策まとめ

yagish履歴書でも使っているPDF変換サーバーがまれによく下記のエラーを吐いて落ちてしまう問題が一段落ついたのでまとめる。
2019年1月スタートのアニメはまだ何を見ていいか分からない状態です。

Error: Protocol error (Page.printToPDF): The previous printing job hasn't finished
    at Promise (/hcep/node_modules/puppeteer/lib/Connection.js:186:56)
    at new Promise (<anonymous>)
    at CDPSession.send (/hcep/node_modules/puppeteer/lib/Connection.js:185:12)
    at Page.pdf (/hcep/node_modules/puppeteer/lib/Page.js:911:39)
    at Page.<anonymous> (/hcep/node_modules/puppeteer/lib/helper.js:145:23)
    at app.route.get.post (/hcep/app/express-app.js:110:35)
    at process._tickCallback (internal/process/next_tick.js:68:7)

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

対策1:このエラーが出たらprocess.exit()して、Kubernetesのdeploymentに再起動してもらう(済)

メリット:エラーをcatchしてprocess.exit()するだけの簡単なお仕事
デメリット:ユーザーが一度はダウンロードを失敗してしまう

とりあえずの応急処置。エラーが出ると止まってしまいそのままだったので、process.exit()でコンテナごと落とした。
Kubernetes (GKE)でレプリカ数を複数にしてあるので、再度ダウンロードしてもらえば、別Podが使われるのでほぼ問題なかった。

対策2: The previous printing job has finishedしているか確認する(未)

Puppeteerのソースを確認して、前回の印刷ジョブ(今回はPDF生成処理のこと)が残っているか否かを確認しようとしたが、よく分からなくてやめた。

github.com

対策3:ヘルスチェックの間隔を長くしたりずらす(済)

メリット:yamlの数字を変えるだけの簡単なお仕事
デメリット:長くすると正常判定されるのがまでちょっと遅くなる

最近よく対策1の再起動が増えたので、ログを眺めているとヘルスチェックのアクセス多いなということに気づいた。
さらによく見ると全く同時刻にヘルスチェックへのアクセスが来ていた。

ヘルスチェックは、KubernetesIngressで必要なdeployment設定内のlivenessProbeの設定。
詳しくは以前の記事。
uyamazak.hatenablog.com

そういえば、新しいサービスも増え、今回のPDFサーバーを参照するdeploymentが増えたのであった。

そのため、間隔(periodSeconds)やinitialDelaySecondsを変えて重なりにくくした。
また30秒とかだったのを60秒とか長くもした。
ここでもしかして、同時にリクエストが来るとエラーになるのでは?と気づき始めた。

対策4:サーバーでリトライさせる(済)

メリット:デコレーターつけるだけの簡単なお仕事
デメリット:PDFサーバーが完全に落ちてるときは、ユーザーが結構待つ(3秒✕3回 + jitterだと10秒近く)

yagishではユーザーがPDFサーバーに直接リクエストするのではなく、間にDjangoを使ったAPIを挟んで、危ないタグの除去や必要なCSSの挿入を行っていた。

そのため、Djangoの方でリトライ処理を追加した。便利で使い慣れているretryというライブラリがあったのも要因。
リクエスト処理は関数になっていたのでデコレータを追加するだけで良い。

uyamazak.hatenablog.com

対策5: Headless ChromeのPageをたくさん用意して使う(検証中)

メリット:同時アクセスに強くなる
デメリット:コードがちょっと複雑に。あとメモリを食う(1ページ7MBぐらい)

Puppeteerでは、puppeteer.launch()で作るブラウザ本体と、browser.newPage()で作るページという概念があり、これまではどちらも一つだけ作って使いまわしていた。
今回のPDF処理はページ単位のため、ページがたくさんあればいいのでは?と考え実装してみた。

before
app/hc-page.js

module.exports.hcPage = async () => {
  const puppeteer = require('puppeteer')
  const launchOptions = generateLaunchOptions()
  debug('launchOptions:', launchOptions)
  // launch browser and page only once
  const browser = await puppeteer.launch(launchOptions)
  const chromeVersion = await browser.version()
  debug('chromeVersion:', chromeVersion)
  const page = await browser.newPage()
  return page
}

after
app/hc-pages.js

module.exports.hcPages = async (PagesNum) => {
  const puppeteer = require('puppeteer')
  const launchOptions = generateLaunchOptions()
  debug('launchOptions:', launchOptions)
  // launch browser and page only once
  const browser = await puppeteer.launch(launchOptions)
  const chromeVersion = await browser.version()
  debug('chromeVersion:', chromeVersion)
  const pages = []
  for(let i=0; i < PagesNum; i++){
    debug('page launched No.' + i)
    pages.push(await browser.newPage())
  }
  return pages
}

変数や関数名もpageではなくpagesにした。

これを使うexpress側では順番に取り出す関数を作った

//省略
  const pagesNum = pages.length
  console.log(`pages.length: ${pages.length}`)
  let currentPageNo = 0
  const getSinglePage = () => {
    currentPageNo++
    if (currentPageNo >= pagesNum) {
      currentPageNo = 0
    }
    debug('currentPageNo:' + currentPageNo)
    return pages[currentPageNo]
  }
//省略

まだテスト中のため、デバッグ用に今何個目を使っているかを出すようにしている。

対策2で書いたページが現在印刷処理中がどうかが分かれば、このgetSinglePage内でスキップすることができそうなので、調査したい。

実際これで解決できるのか、同時に多重リクエストするテストを書いた

test/express-app.js

  it('LAUNCH_HC_PAGES_NUM of concurrent access to POST / html=' + HTML_TEST_STRINGS, async () => {
    function task() {
      return new Promise(async function(resolve) {
        await req.post('/')
          .send('html=' + encodeURI(HTML_TEST_STRINGS))
          .expect('Content-Type', 'application/pdf')
          .expect(200)
        resolve()
      })
    }
    const tasks = []
    for (let i=0; i<LAUNCH_HC_PAGES_NUM; i++) {
      tasks.push(task())
    }
    await Promise.all(tasks)
  })

既存の普通のリクエストをPromise化し、リストにいれて、Promise.allを使って同時実行した。
とりあえず立ち上げたページと同じ数で実行したところ問題なく完了。
ためしにリクエスト数をページ数の2倍にしたところ、同じエラーがテストでも確認できた。

 Error: Protocol error (Page.printToPDF): The previous printing job hasn't finished

メモリ使用量の確認

開発サーバー上で立ち上げるページ数(n)を変えてdocker statsコマンドで確認した。
起動だけで実際のPDF生成は行わない状態での数値。

CONTAINER ID        NAME                      CPU %               MEM USAGE / LIMIT     MEM %               NET I/O             BLOCK I/O           PIDS
# n = 1
1a88b0499334        varuna-hcep-pdf-server    0.90%               135.9MiB / 31.33GiB   0.42%               40.7MB / 8.64MB     0B / 3.91MB         139
# n = 2
1a88b0499334        varuna-hcep-pdf-server    1.04%               143.3MiB / 31.33GiB   0.45%               40.7MB / 8.64MB     0B / 3.91MB         153
# n = 3
1a88b0499334        varuna-hcep-pdf-server    0.87%               150MiB / 31.33GiB     0.47%               40.7MB / 8.64MB     0B / 3.92MB         167
# n = 5
1a88b0499334        varuna-hcep-pdf-server    0.68%               164.3MiB / 31.33GiB   0.51%               40.7MB / 8.64MB     0B / 3.92MB         193
# n = 10
1a88b0499334        varuna-hcep-pdf-server    1.24%               198.6MiB / 31.33GiB   0.62%               40.7MB / 8.64MB     0B / 3.92MB         257
# n = 20
1a88b0499334        varuna-hcep-pdf-server    0.71%               267.3MiB / 31.33GiB   0.83%               40.7MB / 8.64MB     0B / 3.93MB         388
# n = 30
1a88b0499334        varuna-hcep-pdf-server    0.78%               337.2MiB / 31.33GiB   1.05%               40.7MB / 8.64MB     0B / 3.93MB         518

1ページ増やすと約7MB増えることが分かる。起動時間に関しては、最初だけだし、1ページ20-30msとだーっと流れるように立ち上げるので気にしなくて良さそう。
f:id:uyamazak:20190130150826p:plain

使えるメモリが256MBであれば、安全に5か、攻めて10ぐらいだろうか。
あとはPDF化するデータサイズも気にする必要があるかもしれない。

まだ実環境でのテストはしてないけど、1コンテナあたりのページ数は控えめにして、複数起動した方が同じリソースでも安定する可能性もある。

まとめ

ページ数、使用メモリを増やした分だけ同時アクセスに耐えられるようになったけど、エラーが出て再起動する可能性は0じゃないので、もっと根本的に解決したいところ。

同時アクセスが原因だったのをPromise.allを使った多重同時アクセスするテストコードで再現できたので、そういう手では難しいテストは特に大事。もっと早く書いておけばよかった。
当たり前だけどまずエラーの再現大事。

Nodeクックブック

Nodeクックブック