Kotlinサーバーの起動時に暖機運転を導入した話

前提知識: 暖機運転とは

JVM系言語はコンパイル型の言語ですが、直接機械語を出力せずにプラットフォーム共通の中間言語形式をビルド結果として出力するのが特徴です。 起動直後は最適化が不十分な状態で、100%のパフォーマンスを発揮できないような仕組みになっています。

JVM起動後に必要な最適化処理には例えば以下のようなものがあり、これらの処理をサーバー稼働開始前に終わらせるために行われるのが暖機運転です。

JITコンパイル

ビルド時に生成されたJVM中間言語は基本的に実行時の逐次解釈で機械語に変換されますが、頻繁に実行されるコードは変換後の機械語コードがメモリ上に保存されます。 この仕組みがJITコンパイルです。

メモリ上に保存された機械語コードはCPUがそのまま理解して実行可能な形式なので、中間言語を逐次解釈する場合に比べて格段にパフォーマンスが向上します。

しかし、対象箇所の特定にある程度蓄積されたコード実行結果のプロファイリングが必要であるため、起動直後のサーバーではJITコンパイルはほとんど効果を発揮できません。

クラスロード

JVM系言語のビルド結果は .class という拡張子のクラスファイルとして出力されますが、これらのファイルは実行時に全て読み込むのではなく、必要になったタイミングで遅延読み込みが発生します。

コードの初回実行のタイミングでは必要なクラス全てに対してファイル読み込みが発生するため、実行速度が大幅に低下します。

その他

それ以外にも、ネットワークの接続確立・設定ファイルなどの読み込み完了・CPUキャッシュの準備完了などの要因で、2回目以降のコード実行はより高速になる傾向があります。

暖機運転導入の背景

上記のようなJVM起動時の低パフォーマンス現象は開学時点からある程度発生していました。 ただ、Kotlinで書かれたZEN大学向けサーバーの多くは以下のどちらかのようなリクエストパターンだったため、今まで起動直後の低パフォーマンスが大きな問題になることはありませんでした。


ZEN Study 向けサーバー

  • 多くの学生が毎日利用するシステムであり、安定したレスポンスを返す必要がある
  • 一日を通して一定以上のリクエストが続くので、デプロイ時1台ずつサーバーを置き換えている間にある程度の暖機が完了する

内部システム用サーバー

  • 大学関係者が業務で内部利用するシステムで、ユーザー数が少なく、体感できるほどのレスポンス遅延もある程度許容される
  • リクエストがまばらで総数も少ないので、暖機完了状態になるまでに時間がかかる

しかし、学期末に実施される単位認定試験では前提が大きく異なりました。


単位認定試験用サーバー

  • 試験開始まではほとんどリクエストが無いので、事前に自然と暖機状態になることはない
  • 試験開始のタイミングで急激に大量のリクエストが来る
  • 同時に試験を受ける人数が、ZEN Study での日々の学習の同時リクエスト数よりも多い
  • 成績に影響する大事な試験なため、非機能的な要求が通常時よりも高い

これらの条件をサーバー増強だけで達成しようとするとインフラコストが高くついてしまうため、今回は事前の意図的な暖機処理を新しく実装することになりました。

実装方針

暖機運転を実現する方法としては、通常と同じエンドポイントに対して暖機用のリクエストを送る方法暖機専用のコードを新たに書く方法 の大きく2つが考えられ、両者はおおよそ以下のようなトレードオフ関係にあります。


通常エンドポイントに暖機リクエストを送る方法

  • メリット: サーバーのコードを新しく実装する必要がない
  • デメリット: 暖機リクエストを送るため、新たなインフラ等の準備が必要

暖機専用コードを書く方法

  • メリット: 既存のサーバー等の構成に手を加える必要がない
  • デメリット: 暖機用コードの新規実装が必要

今回は、コードのレイヤー分けによって暖機用コードを書くコストがそれほど大きくなかったことと、暖機対象のエンドポイントが少なかったこと、そして後述のゴミデータ問題への対策を考慮して、後者の暖機専用コードを書く方法を選択しました。 雰囲気的には以下のような形で、main関数でサーバー起動前に暖機コードを実行する形になります。

fun main() {
    setup() // 設定の読み込みやDB接続など

    runBlocking() {
        listOf(
            runWarmup1Async(), // エンドポイント1の暖機
            runWarmup2Async(), // エンドポイント2の暖機
            runWarmup3Async(), // エンドポイント3の暖機
        ).awaitAll()
    }

    Server.start()
}

ゴミデータ問題

暖機運転のためには実際の処理と同じコードを実行する必要があります。 暖機対象が副作用のない処理の場合これは何も問題になりませんが、例えば試験の答案提出のようにデータの保存を伴う処理を対象とする場合は、暖機実行のたびにDBに余計なレコードが作成されてしまうという問題が発生します。

DBのデータ量が増えればパフォーマンスの悪化につながりますし、ユーザー利用以外で発生するデータは分析の際にも邪魔なノイズとなります。 特に本番環境においては、こういった不要データの発生は極力避けたいです。

今回の暖機実装ではこのゴミデータ問題について、自動テストのフレームワークで利用されているような、DBトランザクションを使った方法で解決しました。

暖機用コードのトランザクション管理の部分に一部手を加えて、コミットする直前に意図的なエラーを発生させ、強制的にロールバックするように実装しました。 これによって、INSERT や UPDATE のクエリの実行部分まで実行しつつ、暖機完了後には一切ゴミデータを発生させない暖機運転が実現できます。

「暖機専用のコードが必要になる」というデメリットも、見方を変えれば「暖機用にコード内容を微調整できる」というメリットになります。

効果測定

暖機用コードの実装完了後、簡易的な負荷試験によって性能比較を行いました。

以下は試験開始のリクエストを1台のpodに対して秒間20回で5秒間、計100回送信したときのレスポンス時間をパーセンタイルでまとめて、暖機回数ごとに比較した結果です。

100回のリクエストに対するパーセンタイルなので、例えば P25 の値はちょうど25番目に早かったレスポンス、P75 の値はちょうど75番目に早かったレスポンスの時間と一致します。 また、リクエストを重ねるほど暖機が進んで性能が向上し、リクエストの送信順とレスポンス時間はある程度相関するはずなので、以降では「例えば P25 の時間は75回目に送られたリクエスト (レスポンスがおよそ25番目に早いリクエスト) の時間と近似する」という仮定をおいて結果を分析します。

暖機回数 P25(ms) P50(ms) P75(ms) P90(ms) P95(ms)
暖機なし 2148 2521 2768 2971 3045
暖機1回 92 363 764 884 919
暖機10回 67 109 438 550 601
暖機20回 41 72 221 430 478
負荷試験2回目 27 30 33 56 94

まず「暖機なし」の結果を見ると、全ての値が2秒を超えています。 暖機運転なしだと全くリクエスト速度に追いつけておらず、この状態では本番試験を迎えることはできません。

「暖機1回」した後の負荷試験では、性能が大きく改善します。 1回のコード実行だけではJITコンパイルはほとんど有効にならないはずなので、これは主にクラスロードの完了による効果だと思われます。

「暖機10回」の効果は「暖機1回」と比べると、特に P25 の値ではそこまで劇的ではありません。 しかし P75 より遅い範囲では300ms程度の改善があり、立ち上がり時のパフォーマンス改善に結構な効果が期待できそうです。

「暖機20回」になるとさらに効果の幅は小さくなります。 今回の方式だと、暖機回数とサーバー起動にかかる時間がトレードオフになるので、このあたりが現実的な限界でしょう。 (20回の暖機にかかる所要時間がおよそ10秒程度でした。)

「負荷試験2回目」は、「暖機なし」の結果を計測した後にもう一度同じ負荷試験を実行したときの結果です。 100回のリクエストを送信した後なので、暖機100回と同じくらいの効果があると言えます。 早いレスポンス・遅いレスポンス時間の差が小さく、暖機が完全に完了した状態であるとみなして良さそうです。

「負荷試験2回目」全体と同じ水準のレスポンス時間 (約100ms以内) を他の試行から探してみると、「暖機1回」の P25、「暖機10回」と「暖機20回」の P50 が該当します。 暖機回数とリクエスト回数を合計して考えると、今回の対象のエンドポイントでは完全な暖機完了までに 60 ~ 80 回程度の運転が必要だと言えそうです。

まとめ

  • 大学試験実施にあたって、Kotlinサーバーの暖機運転を実装しました
  • 暖機運転は main 関数開始時に自動で実行されるようにしました
  • 暖機運転でゴミデータが発生しないように、暖機中はDBトランザクションが必ずロールバックされるようにしました
  • 暖機の効果は最初の1回が最も大きく、回数を重ねるごとに小さくなっていきました
  • 完全な暖機完了までには60 ~ 80回の実行が必要そうです