Actuatorを入れて /actuator/healthUP を返すようになると、次に気になるのが「自前のサービスやレガシーAPIの死活もここに出したい」という話ですよね。標準の health はDataSourceなど組み込みのIndicatorしか見てくれないので、外部依存の状態はそのままでは反映されません。

この記事では、外部依存の死活を表現するカスタムHealthIndicatorの実装から、readiness/livenessへの割り当て、さらに @Endpoint を使った運用向けの独自エンドポイントの作り方までを扱います。Actuator自体の導入や標準エンドポイントの公開設定は Spring Boot Actuatorの入門記事 に任せるので、ここでは「もう一歩先」に絞って進めていきます。

標準のhealthでは足りない場面

Kubernetes上で動かしていると、readinessProbeに「このアプリが本当にリクエストをさばける状態か」を反映させたくなります。ところがアプリが決済用の外部APIや社内のレガシーサービスに依存している場合、その死活は標準の health には出てきません。

つまり、依存先が落ちているのにPodは UP のまま、というズレが起きます。ここを埋めるのがカスタムHealthIndicatorです。この記事で作るものは次の3つです。

  • 外部依存の死活を返すカスタムHealthIndicator
  • それをreadiness/livenessに振り分けるhealth groups
  • 運用操作を叩ける独自の @Endpoint

HealthIndicatorの基本

まずは最小構成から。HealthIndicator を実装して health() をオーバーライドし、Health オブジェクトを組み立てて返すだけです。

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class LegacyServiceHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        boolean ok = checkLegacyService();
        if (ok) {
            return Health.up()
                    .withDetail("service", "legacy-billing")
                    .build();
        }
        return Health.down()
                .withDetail("service", "legacy-billing")
                .build();
    }

    private boolean checkLegacyService() {
        // 実際の疎通確認を書く
        return true;
    }
}

Health.up() / Health.down() のほか、任意のステータスを表したいときは Health.status("OUT_OF_SERVICE") のように書けます。withDetail() で付けた情報は、詳細表示が有効なときにレスポンスへ含まれます。原因調査に役立つ値を入れておきましょう。

もうひとつ大事なのが命名規則です。Bean名から HealthIndicator を除いた部分が、エンドポイント上のキーになります。上の例なら legacyService として出ます。

{
  "status": "UP",
  "components": {
    "legacyService": {
      "status": "UP",
      "details": { "service": "legacy-billing" }
    }
  }
}

外部依存の死活を安全にチェックする

実運用では、疎通確認そのものがhealthを不安定にしないよう気をつける必要があります。特に タイムアウトを短く明示する ことが重要です。相手のレスポンスが遅いと、health全体がそれに引きずられてしまうからです。

外部APIには、できれば軽量なpingやHEADリクエストを使います。例外は必ず捕捉して down(e) に変換し、原因を残しましょう。

@Component
public class PaymentApiHealthIndicator implements HealthIndicator {

    private final RestClient restClient;

    public PaymentApiHealthIndicator(RestClient.Builder builder) {
        ClientHttpRequestFactorySettings settings =
                ClientHttpRequestFactorySettings.defaults()
                        .withConnectTimeout(Duration.ofSeconds(1))
                        .withReadTimeout(Duration.ofSeconds(2));
        this.restClient = builder
                .baseUrl("https://payment.example.com")
                .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
                .build();
    }

    @Override
    public Health health() {
        try {
            restClient.get().uri("/ping").retrieve().toBodilessEntity();
            return Health.up().withDetail("target", "payment-api").build();
        } catch (Exception e) {
            return Health.down(e).withDetail("target", "payment-api").build();
        }
    }
}

チェックのコストが高い場合は、毎回叩くのではなく数秒キャッシュする、あるいは非同期で定期的に更新した結果を返す、といった工夫も検討してください。health は監視やprobeから高頻度で呼ばれるので、そこがボトルネックになると本末転倒です。

readiness/livenessに独自Indicatorを割り当てる

Kubernetesではreadinessとlivenessで意味が違います。readinessは「トラフィックを受けられるか」、livenessは「プロセスが生きているか」です。外部依存の死活は readinessに寄せる のが基本です。

Spring Bootはhealth groupsという仕組みでこれを表現できます。application.yml でグループごとに含めるIndicatorを指定しましょう。

management:
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      group:
        readiness:
          include: readinessState,paymentApi,legacyService
        liveness:
          include: livenessState
  endpoints:
    web:
      exposure:
        include: health,info

これで /actuator/health/readiness に自作のIndicatorが反映され、/actuator/health/liveness は最小限のままになります。KubernetesのreadinessProbeをこのパスに向ければ、外部依存が落ちたときにPodが自動的にトラフィックから外れます。probe側のYAMLの詳細は KubernetesデプロイのガイドL を参照してください。

ここで気をつけたいアンチパターンが、外部依存をlivenessに入れてしまうことです。外部APIが一時的に落ちただけでlivenessが DOWN になると、Kubernetesはプロセスを異常とみなして再起動をかけます。依存先が復旧するまで再起動ループに陥るので、外部依存はreadinessだけに入れましょう。

詳細表示の制御とセキュリティ

show-details には never / when-authorized / always の3つがあります。デフォルトは never で、詳細は出ません。本番では when-authorized にして、認可済みのユーザーにだけ詳細を見せるのが無難です。

注意したいのは withDetail() の中身です。接続先のフルURLや認証トークン、内部ホスト名などを入れると、詳細が見える相手に筒抜けになります。診断に必要な最小限の情報にとどめましょう。認可の具体的な組み方はSecurityの領域なので、ここでは方針だけ押さえておきます。

@Endpointで独自エンドポイントを作る

healthだけでなく、運用向けの独自エンドポイントも作れます。たとえば「キャッシュの状態を見たい」「特定フラグをオン/オフしたい」といった操作です。@Endpoint でIDを付けて、操作ごとにアノテーションを付けます。

import org.springframework.boot.actuate.endpoint.annotation.*;
import org.springframework.stereotype.Component;

@Component
@Endpoint(id = "features")
public class FeatureToggleEndpoint {

    private final Map<String, Boolean> toggles = new ConcurrentHashMap<>();

    @ReadOperation
    public Map<String, Boolean> features() {
        return toggles;
    }

    @ReadOperation
    public Boolean feature(@Selector String name) {
        return toggles.getOrDefault(name, false);
    }

    @WriteOperation
    public void setFeature(@Selector String name, boolean enabled) {
        toggles.put(name, enabled);
    }

    @DeleteOperation
    public void removeFeature(@Selector String name) {
        toggles.remove(name);
    }
}

@ReadOperation がGET、@WriteOperation がPOST、@DeleteOperation がDELETEにマッピングされます。@Selector を付けたパラメータはパスの一部(/actuator/features/{name})として受け取れます。

作ったエンドポイントは、公開設定に明示的に追加しないと外から見えません。

management:
  endpoints:
    web:
      exposure:
        include: health,info,features

運用操作を書き込めるエンドポイントなので、公開範囲には特に慎重になりましょう。社内ネットワークやmanagementポートの分離と合わせて考えるのがおすすめです。

curlで動作確認する

実装したら実際に叩いて確かめます。まずはreadinessグループを見てみましょう。

curl -s localhost:8080/actuator/health/readiness | jq
{
  "status": "UP",
  "components": {
    "paymentApi": { "status": "UP", "details": { "target": "payment-api" } },
    "legacyService": { "status": "UP", "details": { "service": "legacy-billing" } }
  }
}

外部APIをわざと止めてみると paymentApiDOWN になり、グループ全体のstatusも DOWN に変わります。これでreadinessProbeが期待どおり反応するか確認できます。独自エンドポイントも叩いてみましょう。

curl -X POST localhost:8080/actuator/features/new-checkout \
  -H 'Content-Type: application/json' -d '{"enabled": true}'

curl -s localhost:8080/actuator/features | jq
# => { "new-checkout": true }

もしエンドポイントが404になるときは、exposure.include に追加し忘れていないか、IDのスペルが合っているかをまず確認します。グループにIndicatorが反映されないときは、include に書いた名前がBean名の命名規則(xxxHealthIndicatorxxx)と一致しているかを見直してください。

まとめ

カスタムHealthIndicatorで外部依存の死活を表現し、health groupsでreadiness/livenessに振り分け、@Endpoint で運用向けの操作を足す、という流れを見てきました。標準のhealthでは見えなかった依存先の状態が、これでprobeや監視にちゃんと乗るようになります。

ここから先は、状態を数値として時系列で追える MicrometerとPrometheusによる可観測性 や、デプロイ時の切り替えを安全にする グレースフルシャットダウン と組み合わせると、運用がぐっと安定します。実装するときは、チェックのコストと詳細表示のセキュリティだけは忘れずに意識しておきましょう。