はじめに
こんにちは。リアーキテクティングチームの髙橋と申します。 この記事では、アンドパッドで PDF 生成のロジックを独立したサービスとして切り出そうと試み、さまざまな技術的問題にぶつかり、アプローチを考え直すという判断に至るまでの顛末をお話しします。
つまるところ、初期の設計考慮漏れによる失敗談です。ですが、各レイヤーで何が起きていたのかを1つずつ突き止めていく過程は、Web アプリケーションがどんな部品の上に乗って動いているのかを改めて学ぶ良い機会になりました。どなたかの参考になれば幸いです。
教訓
最初に、今回得られた教訓を箇条書きにします。 こうしてまとめると初歩的なことではあるのですが、うっかり視点が抜けてしまうと痛い目に遭います。実際に遭いました。
- 設計時点でデータ量は見積もる
- 桁の変化は質の変化
- ユースケースの理解が大事
- すでに育った既存サービスの改修では特に大事
- 1つのレイヤーで起きた問題は他のレイヤーでも問題になりうる
- データ量は見積もる(大事なことなので)
できごと
元々の状態
アンドパッドのサービスでは、さまざまなシチュエーションでPDF 生成および印刷という機能が必要になります。 もちろん全てがサービス内で電子的に完結するならそれが理想です。とはいえそうもいかないケースが多々あります。郵送が必須であったり、アンドパッドを利用していない会社や施主様とのやりとりだったり、持ち込みに電子機器より紙が適した現場だったり、お客様の利用方法によっては色々な需要が発生します。
そうした要求に応えるため、サービスには PDF を生成する仕組みが随所に組み込まれています。
PDF 生成ロジックの刷新
今回の改修対象は、Rails から自由形式で PDF を生成する部分のロジックでした。 Rails アプリケーションの中に PDF 生成のロジックやテンプレート HTML が丸ごと入っていて、データをそこに流し込んで PDF を生成し、ユーザーに返す造りです。 これを単体のサービスとして切り出すのが刷新計画の概要でした。
(余談ですが、HTML ほど自由度が必要ない部分はまた別のロジックで PDF を生成している箇所もあったりします)
新しいサービスの構成をざっくり説明すると、以下のようなシンプルな Rails アプリケーションです。
- HTTP で HTML を受け取り
- Grover → Puppeteer → Google Chrome Headless という流れで Chrome にレンダリングをしてもらい
- できあがった PDF をバイナリで返す
新旧のレンダリング結果を比較して互換性をチェックし、死活監視など必要なものを揃え、あとは性能チェックというところで問題が発生しました。
ユースケースとアーキテクチャのミスマッチ
顧客が実際に PDF を生成するユースケースを改めて確認したところ、設計当初に想定していたよりも桁違いに多い枚数の写真を、1つの PDF に埋め込むようなケースが見つかりました。 当初の見積もりと比べて、数十倍から数百倍という規模感です。
データ量が桁違いになってくると、問題の質が変わってきます。 これによって、速度の問題、メモリの問題、安定性の問題など、解決しなければいけない課題が次々と発生することになりました。
さまざまな問題
データ量が一定の規模を超えると、アーキテクチャの各レイヤーで、それぞれ別の限界にぶつかっていきます。 大きく分けると、以下の 3 つの方向の問題に集約されました。
メモリ使用量増加 → OOM
巨大なデータを扱うことでメモリ使用量が増え、OOM(Out of Memory)が発生してプロセスが強制終了します。
実行時間増加 → タイムアウト
データ量が増えると実行時間が長くなり、通信経路上のさまざまなレイヤーでタイムアウトが発生するようになります。
実行時間増加 → ユーザー体験低下
仮にタイムアウトしないように設定値を緩和したとしても、実行時間が長いままではユーザーの体験を損ねてしまい、業務を妨げる結果につながります。
問題の詳細
ここからは、実際にぶつかった一つひとつの壁を、メモリ系と速度・タイムアウト系に分けて紹介します。
メモリ問題
SIGTRAP(レンダラプロセスのクラッシュ)
まず最初にぶつかったのが、Chrome のレンダラプロセスのクラッシュです。エラーコードとしては 133 が返ってきました。
これは少し不思議な数字に見えますが、128 + 5 の合成です。プロセスがシグナルで終了したことを示すフラグ 128 と、SIGTRAP(シグナル番号 5)の合算で、要するに「Chrome がシグナルを受けて落ちた」ことを意味します。
原因は、Chrome が大量の画像をメモリ上に展開する際、OS 側のメモリマッピング上限などの制限を超えてしまうことでした。 このあたりはカーネルパラメータ(メモリマップ数の上限など)の影響も大きく、環境によって限界値が大きく変わる要因にもなりました。 ローカル環境と実デプロイ環境とで限界値が異なり、デバッグを困難にさせる要因でもありました。
JSON.stringify の限界(Invalid array length)
次にぶつかったのが Invalid array length というエラーです。
新サービスでは、Node.js 側で生成した PDF のバイナリを、標準出力経由(JSON 文字列)で Ruby 側へ受け渡す造りになっていました。 PDF が十分に大きくなると、この受け渡しの際に V8 エンジンが許容する文字列・配列の最大長を超えてしまい、クラッシュします。 PDF の生成自体は正常に完了しているものの、受け渡し方式に問題があるケースでした。
Node のヒープ上限
Invalid array length を解決してさらに大きなサイズでテストをしていると、JavaScript heap out of memory と言うエラーで落ちることもありました。
Node.js が Chrome から受け取った巨大な PDF バイナリをバッファに読み込む段階で V8 エンジンのヒープ上限を超えた、ということのようです。
このようにメモリ上でのデータの受け渡しのあらゆる箇所が、データサイズの増加とともに次々と限界を迎えていました。
速度、タイムアウト問題
メモリの問題をかいくぐれたとしても、今度は時間との戦いが待っていました。 通信経路には複数のタイムアウト設定が存在し、処理が長引くとそのいずれかに引っかかります。
Puppeteer の PDF レンダリングのタイムアウト
Chrome 側の処理が一定時間内に終わらないと、Page.printToPDF timed out のようなエラーが発生します。
Puppeteer には protocolTimeout という設定でこれを緩和する手段があるのですが、間に挟まっている Grover からはこの値を渡せませんでした。設定だけでは回避できない、という制約にぶつかった形です。
Chrome の HTML レンダリングのタイムアウト
PDF 化の前段、HTML をレンダリングする段階でも Navigation timeout が発生しました。
特に、SVG を多用するような重い HTML では DOM 処理とレンダリングが重なり、Chrome 側の処理時間が大きく膨らみます。
ロードバランサーのタイムアウト
さらにその外側、ロードバランサーにもタイムアウトがあり、長時間の処理に対して 504 を返すようになっていました。 アプリケーションの設定だけを見ていても解決しない、インフラのレイヤーまで含めた全体での時間設計が必要だと痛感した部分です。
呼び出し元アプリケーションでのタイムアウト
一連の重い処理を待っている呼び出し元(Ruby)の HTTP クライアントも、待ちきれずにタイムアウトして接続を切ってしまいます。 サーバー側が頑張って処理を続けていても、呼び出し元がすでに諦めている、という状況です。
コネクション問題
最後に、これがなかなか厄介でした。 呼び出し元から新サービスへの通信で、処理に一定以上の時間がかかると、サーバーがレスポンスを返しても呼び出し元がそれを受け取れない、という現象が起きました。長い無通信時間の間に、通信経路のどこかでコネクションが切断されていたようです。
対策
ぶつかった問題に対しては、1つずつ手を打っていきました。いくつかは確かに前進しました。
タイムアウト設定の見直し
タイムアウト時間を設定から変更できる部分では、現実的な範囲でタイムアウトを緩和することで対応しました。
受け渡しの見直し
巨大なバイナリを標準出力(JSON)で渡すのをやめ、いったんディスク上の一時ファイルに書き出してファイルパスで受け渡す方式に変更しました。これでメモリバッファと文字列長の限界を回避できるようになりました。あわせて、Grover の内部処理を差し替えることで、これまで渡せなかったタイムアウト設定なども調整できる余地を作りました。
PDF の分割と結合
Chrome で PDF を出力する際にページ範囲を指定することで、巨大な PDF を Chrome に作成させるのをやめてメモリ削減を図りました。 出来上がった PDF たちは pdf-lib を使い結合し、最終的な PDF を作成しました。
PDF 結合時のメモリ削減
上記の pdf-lib では、すべてをメモリ上のオブジェクトとして扱うため、分割前ほどではないにしろメモリを大量に消費していました。これは結合ツールを qpdf に置き換えることで解消しました。
レスポンス送信時のメモリ削減
生成した PDF をいったんすべてメモリ上の文字列として読み込んでから送信していたのをやめ、ファイルとしてそのまま送る方式に変更し、メモリ消費を削減しました。
コネクション切断対策
TCP keep-alive 有効化により、長時間処理でのコネクション切断を解消しました。
呼び出し元では Faraday で Net::HTTP アダプタを使っていて、また別の事情で他の keep-alive 対応のアダプタを使うことはできませんでした。そのため、内部の @socket.io に #setsockopt を呼び出すように無理やりモンキーパッチを当ててどうにかしました。
乗り越えられなかった最後の壁
こうしてメモリ・安定性・通信まわりの問題は1つずつ潰していけたのですが、最後に処理速度だけはどうにもなりませんでした。
新しい構成は、既存の実装と比べてどうしても処理時間が長くなる傾向にありました。同じ条件で比較すると、数倍遅くなるケースがありました。 特に、必ずユーザーの操作に同期してレスポンスを返す必要がある画面では、タイムアウトしてユーザーに 504 エラーが返ってしまう、という致命的な状況になります。 これは設定値の調整でどうにかなる種類の問題ではなく、「同期的に HTML を受け取って PDF を返す」というアーキテクチャそのものに起因するものでした。
まとめ
個別の問題はかなりの数を解消できましたが、速度に関しては現在の仕組みの延長線上では十分な改善が見込めない、という結論に至りました。その結果として、我々はアーキテクチャ設計から再チャレンジする、という選択を取りました。再チャレンジについてはまたいつかお話しできたらいいなと考えています。
ここで冒頭の教訓に戻ります。データ量は設計の時点で見積もるべきでした。当たり前のことほど、いざという時に抜け落ちます。
今回のアプローチは残念な結果に終わってしまいましたが、こうして各レイヤーを一枚ずつ剥がしながら原因を突き止めていく仕事は、個人的にはとても面白い領域だと思っています。 アンドパッドでは、こうした「なぜ動かないのか」「どう作り直すべきか」と向き合う仲間を募集しています。ここまで読んでくださりどうもありがとうございました。