Spring Boot+Kubernetesでサービスイン前にウォームアップ処理を行う

HotSpot JVMを用いた一般的なJavaは、起動直後はインタプリタのように動作するため、パフォーマンスが出ません。
サービスインからすぐにパフォーマンスを出すためには、ウォームアップ(暖機運転)が必須です。
本記事ではSpring Boot+Kubernetesという環境という前提で、その対応方法を紹介します。

サンプルコード

こちらに置いております。
abekoh/spring-warmup-on-k8s

アプリケーションについて

ユーザ登録を行うだけのWebAPIを用意しています。実際にはDB書き込みなどは行わず、標準出力ログとして流れるだけです。

$ cat /tmp/req.json
{
    "firstName": "Taro",
    "lastName": "Yamada",
    "birthYear": 1990,
    "birthMonth": 5,
    "birthDate": 3
}
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:30080/api/users -d @/tmp/req.json | jq .
{
  "isSucceeded": true,
  "userAddResponse": {
    "user": {
      "userId": {
        "id": "59036d2f-55e5-4977-bbe7-caaa515ba030"
      },
      "name": {
        "firstName": "Taro",
        "lastName": "Yamada"
      },
      "birthday": {
        "date": "1990-05-03"
      },
      "isDummy": false
    }
  }
}


ウォームアップ処理について

本題です。

Liveness Probe / Readiness Probe の設定

まず、ウォームアップが終わってサービスインができるか否かを判別できるように設定を施します。
Kubenetes(以下k8s)では「アプリケーションが生きているか」を確認するLiveness Probe, 「アプリケーションがリクエストを受け付けて良いか」を確認するRediness Probeという仕組みがあります。
Configure Liveness, Readiness and Startup Probes | Kubernetes

この仕様に簡単に対応できる設定が、Spring Boot 2.3にて追加されました。
Liveness and Readiness Probes with Spring Boot
有効にするには Spring Boot Actuator を導入、 management.endpoint.health.probes.enabled=true または spring.main.cloud-platform=kubernetesと設定を追記すればOKです。
後者だと他にもk8sに関する設定がなされるようなので、サンプルでは後者を選んでいます。

k8sのdeployment.yamlは次のように設定します。

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080

ここではデフォルトのままですが、起動ループに陥る場合などはperiodSeconds, initialDelaySeconds, failureThreshold の設定を見直しましょう。


ウォームアップの実行

ウォームアップとして行うことは単純で、事前にリクエストをたくさん投げておくことです。
ここでは Spring Bootの ApplicationRunner を実装したクラスにその処理を書きます。

ApplicationRunnerはアプリケーション起動後、サービスイン前という状態で実行されます。
このとき、LivenessProbe=OK、ReadinessProbe=NGという状態になります。
Spring Boot Reference Documentation / 4.1.6. Application Availability
ウォームアップ処理のために事前リクエストを行うにはちょうど良いタイミングです。

以下はそのサンプルです。
HTTPクライアントは何でも良いですが、Spring WebFluxのWebClientがMVCでも使えるとのことで、それで実装してみました。

@Slf4j
@Component
public class WarmupRunner implements ApplicationRunner {

  private final WebClient webClient;

  private final WarmupProperty warmupProperty;

  public WarmupRunner(
      WebClient.Builder webClientBuilder,
      WarmupProperty warmupProperty,
      @Value("${server.port}") Integer port) {
    this.webClient =
        webClientBuilder.baseUrl(String.format("http://localhost:%d/api/users", port)).build();
    this.warmupProperty = warmupProperty;
  }

  @Override
  public void run(ApplicationArguments args) throws Exception {
    if (warmupProperty.getRequestCount() == null || warmupProperty.getRequestCount() <= 0) {
      log.info("skip warmup");
      return;
    }
    var request =
        WebApiUserAddRequest.builder()
            .firstName("Taro")
            .lastName("Yamada")
            .birthYear(1970)
            .birthMonth(1)
            .birthDate(1)
            .isDummy(true)
            .build();
    log.info("start warmup");
    webClient
        .post()
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(request)
        .retrieve()
        .bodyToMono(Object.class) // 結果は使わないので適当なところにマッピング
        .repeat(warmupProperty.getRequestCount())
        .blockLast();
    log.info("finish warmup");
  }
}

リクエストを実行する回数はWarmupPropertyというクラスに設定値が入るようにしております。

ベンチマーク

試しにどれくらいウォームアップによる効果があるのか、ベンチマークを実施してみました。
scripts/generate_request.pyにランダムなリクエストを生成できるPythonスクリプトを置き、vegetaを使ってテストしました。

python3 scripts/generate_requests.py | vegeta attack -rate=1000/s -lazy -format=json -duration=60s > /tmp/result.bin


対象はreplicas=2として2podsで動くアプリケーションで、1000RPSで60秒間投げてみました。
ウォームアップのリクエスト回数別の結果は次のとおりです。

min mean 50 90 95 99 max
0 0.499 11.074 0.660 0.988 1.338 529.671 1396.000
100 0.507 1.608 0.703 1.052 1.287 2.65 286.235
1,000 0.518 1.688 0.720 1.042 1.267 2.314 312.099
10,000 0.512 1.309 0.652 0.823 0.918 1.384 242.643

行はそれぞれウォームアップのリクエスト数が0回、100回…となります。各セルの単位はミリ秒です。
50,90,95,99はそれぞれパーセンタイル値で、例えば「99%のリクエストはこのレスポンスタイムに抑えられる」という値となります。

0回とその他では平均値、最大値が大きく異なります。100回と1,000回ではあまり変わらない結果となりました。(むしろ悪化してるところも)
10,000回では特に最適化されているように見えます。
さらに細かく調整すればより良くなると思いますが、ウォームアップの有用性が確認できました。

参考資料

以下の資料が非常に参考になりました。