MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

JVMアプリの暖機運転の導入と効果

京都研究所・Tech Labの郭です。 今回は、マイクロアドが提供する広告配信プラットフォーム「UNIVERSE Ads」の広告配信システムを開発する中で取り組んだ、JVMアプリの暖機運転について紹介していきたいと思います。

JVMの暖機運転とは

以下、JIT コンパイラー からの引用です。

Just-In-Time (JIT) コンパイラーはランタイム環境のコンポーネントであり、実行時にバイトコードをネイティブ・マシン・コードにコンパイルすることによって Java™ アプリケーションのパフォーマンスを向上させます。

Java プログラムは、多数の各種コンピューター・アーキテクチャー上で JVM が解釈できる、プラットフォームに依存しないバイトコードを含むクラスで構成されています。ランタイムには、JVM がクラス・ファイルをロードし、個々のバイトコードの意味構造を判断し、適切な計算を実行します。解釈処理中にプロセッサーおよびメモリーを追加使用すると、Java アプリケーションがネイティブ・アプリケーションよりも低速で実行されることになります。JIT コンパイラーは、ランタイムにバイトコードをネイティブ・マシン・コードにコンパイルすることで、Java プログラムのパフォーマンスの改善を支援します。

JIT コンパイラーはデフォルトで有効になっています。 メソッドのコンパイルが完了すると、JVM はメソッドを解釈する代わりにそのメソッドのコンパイル済みコードを直接呼び出します。理論上、コンパイルでプロセッサー時間やメモリーの使用が必要ない場合、すべてのメソッドをコンパイルすることによって Java プログラムの速度をネイティブ・アプリケーションの速度に近くできる可能性があります。

JIT コンパイルでは、プロセッサー時間とメモリーの使用が必要です。JVM が最初に開始すると、何千ものメソッドが呼び出されます。これらのメソッドをすべてコンパイルすると、プログラムのピーク時パフォーマンスが非常に良い場合であっても、起動時間に深刻な影響を及ぼすことがあります。

課題

配信システム全体はマイクロサービスアーキテクチャを採用しており、各コンポーネントサービスは互いに独立して開発、デプロイ、実行、スケールすることができるため、いわゆる高速なイテレーションが可能です。

コンポーネントを改修して本番環境にデプロイして、JITコンパイラーによる最適化が十分されてない状態でサービスインすると、初期に受け付けたリクエストの処理時間がかかり過ぎてタイムアウトになってしまいます。

例えばimpression サービスは、広告の表示回数や予算消化金額のリアルタイム計算を担当しており、click サービスは、広告のクリック回数計測や広告主のランディングページに飛ばすという重要な役割を担っていますが、制限時間内で処理が終わらないとこれらのサービスを提供できなくなり、プラットフォームの品質に大きく影響します。

アプローチ

アイディア

配信システムのデプロイは Ansible によって自動化されており、サービスを中断しないため、毎回のデプロイ対象サーバを全体の 10% に制限しています。

詳細は以下記事をご覧ください。

developers.microad.co.jp

以下はざっくりの手順です。

  1. Load Balancer(以下「LB」という)からリクエストの受け付を停止する
  2. アプリのデプロイ
  3. LBからリクエストの受け付けを再開する

3を開始する前に、しばらく暖機運転用リクエストを送って、主要メソッドをネイティブコードにコンパイルさせるのが今回のアイディアです。 以下のようなイメージです。

  1. LBからリクエストの受け付を停止する
  2. アプリのデプロイ
  3. しばらく暖機用リクエストを送付する
  4. LBからリクエストの受け付を再開する

設計と実装

本番リクエストを暖機用に使うと業務に影響するので使えません。改めて要件を整理しました。

  1. 業務に影響しない暖機用リクエストの設計
  2. なるべくアプリ内の多くのメソッドをカバーする
  3. 保守しやすい

1,2番に対しては、既にテストするための仕組みがアプリ内に用意されており、テストフラグが付いたリクエストを受け付けたら、ビジネスロジックは通しますが業務には影響しないことが担保されています。

問題は3番ですね。静的な暖気用リクエストを使うと今後アプリの業務ロジックが変更されたら使えなくなるかもしれません。 そして、毎回ビジネスロジックの変更に伴い静的な暖機用リクエストを新しく作るのは保守性がかなり悪いです。

そこで工夫したのは、暖機用リクエストの生成実行をアプリ内で行うことでした。 つまり、外部から暖機運転を要求したら、アプリ内で暖機用リクエストを自動生成して暖機運転を開始します。 また、業務用サービスと区別するため外部に公開しない別ポート(管理ポート)でリクエストを受け付けるようにしています。

  • 業務用ポート番号: 8080
  • 暖機用ポート番号: 8081

暖機運転要求URL例:

http://locolhost:8081/wamingup?times=3000&parallel=10

1. times: 暖機用リクエスト実行件数
2. parallel: 暖機運転時間を短縮するための並列実行数

暖機用リクエストの構成例:

def impUrl: String = s"""
     |http://${ServerModule.httpHost}
     |${WebServer.impPath}
     |?${ImpRequestAdapter.bidParameter}=${bidValue(sspId, bidCpm)}
     |&${ImpRequestAdapter.winningPriceParameter}=$encodedWinningPrice
     |&${ImpRequestAdapter.isTestParameter}=${ImpRequestAdapter.isTestValue}
     |""".stripMargin.replaceAll("\n", "")

これで暖機用リクエストが動的に生成されるため、業務ロジックの変更に影響されなくなり、保守性が向上しました。

  1. LBからリクエストの受け付を停止する
  2. アプリのデプロイ
  3. アプリに暖機運転を要求
  4. アプリの暖機運転が終わったらLBからリクエストの受け付を再開する

イメージしやすいように Ansibletask の設定も貼り付けます。

- name: 1. ロードバランサからの割当てを停止
  (省略)

- name: 2.1. アプリを停止
  (省略)

- name: 2.2. アプリを開始
  (省略)

- name: 2.3. アプリが起動するまでポーリング
  (省略)

- name: 3. アプリの暖機運転
  uri:
    url: http://{{ inventory_hostname }}:{{ admin_port }}/warmup?times={{ warmup_times }}&parallel={{ warmup_parallel }}
    return_content: yes
  register: response
  until: response.status == 200
  retries: 3
  delay: 1
  when:
    - not skip_warmup | bool

- name: 4. ロードバランサからの割当てを再開
  (省略)

効果

改善前後の処理時間をKibanaで可視化しました。

f:id:kaku_gi:20211110183823p:plain
before-after

平均処理時間が改善前の100ms以上から15msに大幅に短縮できまして、タイムアウトが完全になくなりました。

この状態でサービスインしたら安心できますね。

最後に

いかがでしょうか。今回はJVMアプリの暖機運転事例を一つを紹介しました。

ご参考いただければ幸いです。