「とりあえずSELECT 1」という素朴な猛毒

Webアプリケーションの開発を始めるとき、多くのフレームワークやチュートリアルで紹介される「標準的なヘルスチェック実装」があります。エンドポイント /health または /healthz が叩かれたら、データベースに対して PING や SELECT 1 を発行し、正常に応答があれば HTTP 200 OK を、失敗すれば HTTP 503 Service Unavailable を返すというコードです。

// よくある「死に至る」ヘルスチェック実装
func HealthHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()

        // データベースに接続できなければ「異常」とみなす
        if err := db.PingContext(ctx); err != nil {
            http.Error(w, "Database Unavailable", http.StatusServiceUnavailable)
            return
        }

        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    }
}

このコードを書いたエンジニアは、こう考えるはずです。「データベースが落ちていたら、どうせこのWebサーバーはユーザーのリクエストを処理できない。だから素直に異常を報告し、ロードバランサーにトラフィックを遮断してもらうのが自然だ」と。

一見すると極めて誠実で理にかなった設計に見えます。しかし、これこそが本番環境の大規模障害において、システム全体を瞬時に沈黙させる最大の爆弾です。

「外部依存が死んでいるから自分も死ぬ」という連帯責任の発想は、分散システムにおいて最も悪質な自己破壊パターンである。

カスケード障害のシナリオ: 全サーバーが自爆するまで

なぜこの実装が破滅をもたらすのか。現場で実際に起きる「カスケード障害(連鎖倒壊)」の生々しいタイムラインを追ってみましょう。

1. わずかな負荷スパイクの発生

夜間のバッチ処理が走った、あるいは突発的なアクセス急増により、データベースのCPU使用率が一時的に90%近くまで跳ね上がったとします。この時点では、通常のクエリが少し遅延しているだけで、DB自体は必死に処理を続けています。

2. ヘルスチェックのタイムアウト

DBのクエリキューが詰まり始めると、ヘルスチェックハンドラが設定している 2秒 のタイムアウトを超過するPod(またはWebサーバー)がポツポツと現れ始めます。ロードバランサーやオーケストレーターは、そのサーバーを「Unhealthy」と判定します。

3. 犠牲者の切り離しと負荷集中

ロードバランサーは「親切にも」、異常判定されたサーバーへのトラフィック供給を停止します。例えば、10台あったサーバーのうち2台が切り離されたとします。

ここで重要なのは、ユーザーからの総トラフィック量は1ミリも減っていないという点です。残された8台のサーバーに、以前と同じ100%のトラフィックが集中します。

4. ドミノ倒しと全滅(Zero Endpoints)

残された8台のサーバーは、それぞれ従来の1.25倍のリクエストを処理しなければなりません。当然、それらのサーバーからDBへ発行されるクエリの密度も上がり、DBの負荷はさらに悪化します。

すると、残りの8台でもヘルスチェックのタイムアウトが多発し始めます。8台から5台へ、5台から2台へ、そして0台へ――。わずか数十秒の間に、クラスタ内のすべてのサーバーが次々とUnhealthy判定を受け、ロードバランサーから完全に消滅します。結果、ユーザーの前にはインフラ層からの冷徹な 503 Service Temporarily Unavailable が叩きつけられます。

Liveness ProbeにDBを入れると「集団自殺」が始まる

Kubernetesなどのコンテナオーケストレーター環境では、事態はさらに悪夢的な展開を迎えます。Kubernetesには主に2つの死活監視プローブが存在します。

もしあなたが livenessProbe の監視対象にDB疎通を含めていたら何が起きるでしょうか?

# 壊滅的障害を引き起こす設定例
livenessProbe:
  httpGet:
    path: /health # ← ここで db.Ping() を叩いている
    port: 8080
  initialDelaySeconds: 15
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3

DBの高負荷によって /health が3回連続でタイムアウトした瞬間、Kubeletは「このコンテナは内部的にハングアップしており、再起動しか救いようがない」と判断し、Podに対して容赦なくSIGKILLを叩き込みます。

クラスタ内の数十台、数百台のPodが一斉に強制再起動されます。そして立ち上がった無数のPodは、起動処理の一環としてコネクションプールを一斉に確立しようと、弱り切ったDBに対して怒涛のTCPハンドシェイクと認証クエリ(Thundering Herd)を浴びせかけます。

瀕死のDBにとどめを刺し、起動したPodもDB接続が間に合わず即死。システムは永遠の再起動ループ(CrashLoopBackOff)に陥ります。外部の誰も攻撃していないのに、自分たちのオーケストレーターが味方のサーバーを虐殺し続けるという地獄絵図の完成です。

Readiness Probeなら安全という幻想

「なるほど、LivenessにDBを入れてはいけないのは分かった。ならReadiness ProbeにDB接続確認を入れれば、再起動はされないから安全ではないか?」

シニアエンジニアを目指すなら、この甘い誘惑も退けなければなりません。Readiness Probeであっても、DB疎通を判定基準にした瞬間に、前述した「全台トラフィック切り離し(Zero Endpoints)」のドミノ倒しは全く同じように発生します。

そもそも、根本的な問いに立ち返る必要があります。「データベースが遅延している、あるいは一時停止しているとき、Webサーバーは本当にトラフィックを受け取ってはいけないのか?」

答えは断じて「NO」です。現代のWebアプリケーションにおいて、DBが停止していてもサーバーが果たすべき責務は山のようにあります。

Podをネットワークから完全に切り離してしまうと、これらすべての制御が奪われます。ロードバランサーのエラー画面(味気ない白黒の502/503)が返るだけで、開発者は手も足も出なくなります。

本番を生き残るヘルスチェック設計の3大原則

システムを死の連鎖から守り、真の弾力性(Resilience)を獲得するための設計原則を整理します。

原則1: Liveness Probeは「自己完結(Shallow Check)」に徹する

Liveness Probeの唯一無二の目的は、「自プロセスがデッドロックや無限ループに陥り、再起動以外に回復手段がない状態を検知すること」です。

外部のデータベース、Redis、サードパーティAPIなどのネットワークI/OをLivenessに含めてはなりません。自プロセス内のHTTPイベントループが回っているか、メモリリークで致命的な状態になっていないかなど、内部状態のみをミリ秒単位で判定してください。

原則2: ヘルスチェック自体が負荷源になる「増幅」を防ぐ

ロードバランサーが50台のターゲットに対し、5秒間隔でヘルスチェックを投げているとします。もしヘルスチェックハンドラが律儀に SELECT 1 を発行していたら、それだけで 毎秒10クエリ、1分間に600回 の無駄なクエリが定常的にDBへ流れ込みます。

平常時は些細な負荷に見えても、DBが危機的状況に陥ったとき、このヘルスチェッククエリそのものがDBのコネクション枠とCPU時間を奪い、復旧を妨げるノイズとなります。

原則3: 「ルーティング判定」と「依存アラート監視」を完全に切り離す

「DBが死んでいることを検知したい」という要求は極めて正当です。しかし、その検知結果を「ロードバランサーのトラフィック遮断」と直結させてはいけません。

実践: 自己治癒を阻害しない堅牢な実装パターン

では、現場では具体的にどう実装すべきでしょうか。Go言語における実践的な分離パターンを示します。

package main

import (
    "net/http"
    "sync/atomic"
)

type HealthServer struct {
    isReady atomic.Bool
}

func NewHealthServer() *HealthServer {
    hs := &HealthServer{}
    hs.isReady.Store(false)
    return hs
}

// SetReady は初期化完了時やGraceful Shutdown時に状態を変更する
func (hs *HealthServer) SetReady(ready bool) {
    hs.isReady.Store(ready)
}

// LivenessHandler: 外部I/Oなし。自身のプロセス生存のみを証明する
func (hs *HealthServer) LivenessHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // イベントループが生きていること自体が証明
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"alive"}`))
    }
}

// ReadinessHandler: 起動完了フラグのみを見る(DBの死活で道連れにしない)
func (hs *HealthServer) ReadinessHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !hs.isReady.Load() {
            http.Error(w, `{"status":"not_ready"}`, http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ready"}`))
    }
}

この設計において、データベースとの疎通が途絶えた場合はどうすべきでしょうか。答えは、「各ビジネスロジックの中でサーキットブレーカーを発火させ、フォールバック応答を返す」です。

// ユーザーリクエストを処理するハンドラ側で制御する
func UserHandler(cb *CircuitBreaker, db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !cb.Allow() {
            // DB過負荷時はヘルスチェックで自爆せず、アプリ層で即座に縮退応答する
            w.Header().Set("Retry-After", "30")
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte(`{"error":"Service temporarily overloaded, please retry later"}`))
            return
        }

        // 通常のDB処理...
    }
}

このように実装しておけば、たとえデータベースがダウンしても、Webサーバー群は自陣のポジションを死守し続けます。オーケストレーターによる無駄な再起動も起きず、ロードバランサーからの理不尽な全台切り離しも発生しません。データベースの負荷が落ち着いた瞬間、システムは何の介入手続きもなく自律的に通常稼働へと復帰します。

まとめ: ヘルスチェックは「運命共同体」を作る道具ではない

「自分が依存している相手が倒れたなら、自分も一緒に倒れるべきだ」――このナイーブな連帯意識は、マイクロサービスや分散システムにおいて最も避けるべきアンチパターンです。

堅牢なシステムを構築するための鉄則は、バルクヘッド(隔壁)を作ることです。船の一部に浸水があったとしても、水密隔壁を閉じることで船全体の沈没を防ぐ。それと同じように、データベースに障害が発生したとしても、Web層はその衝撃を境界線で食い止め、自らは毅然として稼働を続けなければなりません。

ヘルスチェックのエンドポイントから外部依存へのPingを削除してください。自らの足で立ち、隣が倒れても生き残る覚悟を決めること。それこそが、本番の高波を乗り越えるシニアエンジニアのシステム設計論です。