技術本部 Digitization 部 Bill One Entry グループの山﨑です。インボイス管理サービス「Bill One」のデータ化を行うサービス Bill One Entry を開発するチームでエンジニアをやっています。
Bill One Entry が抱えていた課題
Bill One Entry では、TypeScript/Express.js で書かれたバックエンドアプリケーションを Google App Engine のフレキシブル環境で Node.js ランタイムを使って動かしています。 バックエンドで処理される様々な特徴の Web API や Google Cloud Tasks の App Engine キューを使って連携される非同期タスク、いくつかの定期バッチなどを歴史的な経緯からひとつの App Engine サービスで処理していました。
アプリケーションの規模が小さい頃はそれでも問題ありませんでしたが、Bill One の成長に伴いデータ化サービスの Bill One Entry でもトラフィック量が増加し、また Web API の数や非同期タスクの数も増えたことで、最近では徐々にパフォーマンスが悪化していました。具体的には CPU 利用率が100%に近い値に張り付いたままになるインスタンス*1が頻繁に発生するようになり、リクエスト中の Web API のレスポンスがいつまで経っても返らずタイムアウトしてしまうことが頻繁に起こるようになりました。
App Engine のフレキシブル環境のインスタンスで使うことのできる準備チェック*2の機能を使うことで CPU の利用率が高いインスタンスをリクエストのルーティング先から外す対策もしていましたが、ルーティングから外された時点で処理中だったリクエストはそのインスタンスからのレスポンスを待つしかなく、それなりに多くのインスタンスがこのルールに引っかかっていたため Web API のパフォーマンス悪化の解決には至りませんでした。
CPU が100%に張り付いてしまう原因の仮説
CPU が100%に張り付いてしまう原因としていくつかの仮説はありましたが、様々な処理を一つのサービスで処理しているためログやメトリクスから原因を特定することが非常に困難でした。仮説には以下のようなものがありました。
- Web API には一部バーストするものがあり、コールドスタートの問題があるためスケールアウトが間に合っていない。
- 非同期のタスクなど CPU リソースを激しく消費する処理がある。
対応策の検討
前述のとおり原因の特定はできていませんでしたが、いくつかの仮説があったため以下のアプローチを検討しました。
Cloud Run に移行する
1つ目の仮説であるスケールアウトの問題に対するアプローチで、アプリケーションのホスト先を App Engine から Cloud Run に変更することで、いまよりバーストに強いシステムにする案です。現状のアプリケーションをそのまま Cloud Run にホストすることはできないためアプリケーションの書き換えが必要になり、おそらく最も工数がかかる案だったということと、変化が大きいため新たに別の問題が発生する可能性もあることから、仮説の段階ではここまでの変更には踏み切れず今回は見送りました。*3*4
App Engine のスタンダード環境に変更する
2つ目のアイデアもスケールアウト問題に対する案です。スタンダード環境はフレキシブル環境と比べて相対的にインスタンスの起動にかかる時間が短くスケールアウト問題の改善が期待できます。同じ App Engine のためアプリケーションの変更は最小限で済みますが(済むはずですが)、ネットワーク面の制約があり以前から何度か検討されていたものの見送られていました。また、スタンダード環境ではフレキシブル環境で使える前述の準備チェックの機能が使えず、問題が解決しなかった場合に CPU 利用率が100%に張り付いたインスタンスがリクエストのルーティング先から外れなくなってしまうため、まずは次の案を実行して原因の特定を目指すことにしました。
App Engine のサービスを複数のサービスに分割する
最終的に採用することになった3つ目のアイデアは、いまあるひとつの App Engine のサービスを複数のサービスに分割するというアイデアです。2つ目の仮説に対するアプローチで、特徴の異なる処理に応じてサービスを分けることで、原因の調査がしやすくなるということと原因と分割された他のサービスが影響を受けなくなることが期待できます。分割する単位としては、非同期のタスク、Web API のうちリクエスト数が比較的安定しているもの、Web API のうちリクエスト数の変化が激しいものの3つに分けることにしました。2つに分割した Web API は前者と後者で利用者が異なるため、分割すること自体は設計としても大きく違和感のあるものではなくチームでも問題なく受け入れられました。仮説が正しければ CPU の消費が激しいのは非同期のタスクであるため、Web API のパフォーマンスは影響を受けなくなり、当初の課題であった Web API のパフォーマンス悪化を解決することができます。
実際に分割してみた
というわけで、App Engine のサービスを複数のサービスに分割することに決まりましたが、分割のやり方にもいくつかの方法が考えられました。インフラチームのメンバーとも相談しながら検討し、最終的には最も簡単で追加コストがかからない、App Engine の dispatch.yaml という機能*5を使って分割を行うことにしました。なお、今回は開発コストを最小限に抑えるため、すべての案で共通してコードベースは分割することなく複数のサービスに同じコードベースをデプロイする方針としました。
検討した案は以下の3つでした。
- リクエスト元の実装を変更し、それぞれのサービスに直接リクエストする。
- リクエスト元の実装は変更せず、ロードバランサーを追加して、パスベースのルーティングルールを設定する。
- リクエスト元の実装は変更せず、dispatch.yaml で適切なサービスにルーティングする。
dispatch.yaml の実装
このパートについてはあまり書くことはありません。公式ドキュメントを参考にしながら dispatch.yaml を書いていきました。 強いてあげるとすると、以下の2点に気をつけました。
- 同じプロジェクト内ではバックエンドの App Engine 以外にも複数のサービスを運用しているため、万一の事故がないようルーティングルールはパスだけでなくホスト名まで含めて書きました*6(下記の例を参照)。
- 非同期タスクの連携に使っていた Cloud Task の App Engine キューではリクエスト時に直接サービスを指定することもできますが、ルーティングルールが散在するとわかりづらいので今回はその方法をやめて dispatch.yaml にルーティングルールの記述を集約しました。
※ dispatch.yaml の例(公式ドキュメントより引用)
# Send any path that begins with “simple-sample.uc.r.appspot.com/mobile” to the mobile-frontend service. - url: "simple-sample.uc.r.appspot.com/mobile*" service: mobile-frontend # Send any domain/sub-domain with a path that starts with “work” to the static backend service. - url: "*/work*" service: static-backend
結果
App Engine のサービスを複数に分割しリクエストの特徴ごとに異なるサービスにルーティングするようにした結果、CPU 利用率が100%に張り付くインスタンスは非同期のタスクを処理するサービスでのみ発生することがわかり、非同期のタスクで CPU リソースを激しく消費する処理があるという仮説が正しいことを確かめることができました。この案の採用時の期待通り Web API のパフォーマンスが改善し、当初の課題を解決することもできました。また、副次的な効果としてサービスごとにリクエストの特徴が異なるのでそれぞれの特徴にあった対応を行うこともできるようになりました。具体的には、非同期のタスクを処理するサービスに実行チェック*7の機能を導入し、CPU の利用率が高くレスポンスが規定の時間内に返らないインスタンスを再起動させることで無駄なインスタンスを減らしコストを削減することができました。
今後は、非同期のタスクを処理するサービスのログやメトリクスを見つつ、原因となっている処理を特定し改善していきたいと思っています。
まとめ
以上、Bill One Entry が抱えていた Web API のパフォーマンスが悪化していたという課題を、Google App Engine の dispatch.yaml という機能を使ってモノリシックなサービスを分割することで解決したという話でした。
*1:App Engine のコンピューティング単位のこと。https://cloud.google.com/appengine/docs/flexible/how-instances-are-managed?hl=ja
*2:インスタンスが受信リクエストを処理できる状態かどうかを確認する機能のこと。準備チェックに合格していないインスタンスは、使用可能なインスタンスのプールから除外される。https://cloud.google.com/appengine/docs/flexible/reference/app-yaml?hl=ja&tab=node.js#readiness_checks
*3:Bill One Entry では過去に Cloud Run を一部のシステムで導入していますが、現状ではあまり積極的に活用できておらずそれほど知見が溜まっていないということと、使っている言語もメインのアプリケーションとは異なるため、積極的に移行しようという決断がしづらかったです。
*4:チーム内ではコンテナベースのアプリケーションに乗り換えたいという声が以前から根強くあります。アプリケーションの Cloud Run への移行に興味のある方はぜひコチラまで!
*5:App Engine サービスへのリクエストのルーティングルールをオーバーライドする機能。https://cloud.google.com/appengine/docs/flexible/reference/dispatch-yaml?hl=ja&tab=node.js
*6:dispatch.yaml はプロジェクト内で1つだけ設定できるもののため、設定したルーティングルールは同じプロジェクト内のすべての App Engine サービスに対するリクエストに適用されます。
*7:VM と Docker コンテナが実行中かどうかを確認する機能のこと。異常と見なされたインスタンスは再起動される。https://cloud.google.com/appengine/docs/flexible/reference/app-yaml?hl=ja&tab=node.js#liveness_checks