Application Load Balancer 配下の SpringBoot アプリケーションを暖機運転する

# 環境
- java: 17
- spring: 6.0.11
- spring-boot: 3.1.2
- spring-boot-actuator: 3.1.2


# きっかけ
ALB (Application Load Balancer) + ECS で Java アプリケーションを公開しているとき、スケールアウトのタイミングで応答時間が一時的に劣化する問題の対応として暖機運転を実装しました。

# 方針
ヘルスチェックには spring-boot-actuator のヘルスチェックエンドポイントを使っている前提とします。

- 暖機リクエストの実行タイミング
  暖機リクエストの送信は、当然ながらアプリケーションがリクエストを受け付けられる準備が整ってから始めたいので、 Spring の `ApplicationReadyEvent` を `@EventListener` でハンドリングするようにします。

- ヘルスチェックのカスタマイズ
  spring-boot-actuator では、独自の `HealthIndicator` 実装を追加することでヘルスチェックの内容を拡張できます。
  すべての暖機リクエストを送信し終わってから初めて "UP" ステータスを返す HealthIndicator を実装することにします。

# 実装
Configuration として実装した例は、以下。
```java
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication
@Slf4j
class WarmUpConfig implements HealthIndicator {
  private final int port;
  private volatile boolean done = false;

  WarmUpConfig(@Value("${server.port:8080}") int port) {
    this.port = port;
  }

  @Override
  public Health health() {
    if (!done) {
      return Health.down().build();
    }
    return Health.up().build();
  }

  @EventListener(ApplicationReadyEvent.class)
  public void onReady() {
    try {
      doWarmUp();
    } catch (Exception e) {
      log.warn("暖機リクエスト送信中に問題が起きたけど、サーバは元気です!!", e);
    } finally {
      this.done = true;
    }
  }

  private void doWarmUp() {
    final HttpClient httpClient =
        HttpClient.create()
            .followRedirect(false)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
            .responseTimeout(Duration.ofSeconds(10));
    final WebClient client =
        WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .baseUrl("http://localhost:" + port + "/path/to/endpoint")
            .build();

    // ローカルあてに暖機リクエストを送る
  }
}
```

# 動作確認
- 暖機運転中
  ```
  $ curl -v localhost:8080/actuator/health | jq .
  *   Trying 127.0.0.1:8080...
    % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                   Dload  Upload   Total   Spent    Left  Speed
    0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* 
  Connected to localhost (127.0.0.1) port 8080 (#0)
  > GET /actuator/health HTTP/1.1
  > Host: localhost:8080
  > User-Agent: curl/7.79.1
  > Accept: */*
  > 
  * Mark bundle as not supporting multiuse
  < HTTP/1.1 503 
  < Content-Type: application/vnd.spring-boot.actuator.v3+json
  < Transfer-Encoding: chunked
  < Date: Tue, 26 Sep 2023 03:32:42 GMT
  < Connection: close
  < 
  { [23 bytes data]
  100    17    0    17    0     0   1958      0 --:--:-- --:--:-- --:--:--  5666
  * Closing connection 0
  {
    "status": "DOWN"
  }
  ```
- 暖機運転完了後
  ```
  $ curl -v localhost:8080/actuator/health | jq .
  *   Trying 127.0.0.1:8080...
    % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                   Dload  Upload   Total   Spent    Left  Speed
    0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* 
  Connected to localhost (127.0.0.1) port 8080 (#0)
  > GET /actuator/health HTTP/1.1
  > Host: localhost:8080
  > User-Agent: curl/7.79.1
  > Accept: */*
  > 
  * Mark bundle as not supporting multiuse
  < HTTP/1.1 200 
  < Content-Type: application/vnd.spring-boot.actuator.v3+json
  < Transfer-Encoding: chunked
  < Date: Tue, 26 Sep 2023 03:32:47 GMT
  < 
  { [20 bytes data]
  100    15    0    15    0     0   1469      0 --:--:-- --:--:-- --:--:--  3750
  * Connection #0 to host localhost left intact
  {
    "status": "UP"
  }
  ```

# まとめ
ApplicationReadyEvent と spring-boot-actuator を使って暖機運転を実装する例を紹介しました。

Spring 6.x からは GraalVM 対応が入ったので、今だったらネイティブイメージにしてデプロイすれば初速から全速力ということも可能なのかもしれません。
ただ、開発の優先順位もあり、そう簡単に GraalVM 対応できないんですよね。
しばらくは暖機運転のお世話になりそうです。