/healthが「プロセスが生きているか」だけを見ていると、共有ストレージの障害で保存系APIが全滅していても正常を返し続けてしまいます。ヘルスチェックの設計では「何が動いていれば”健康”と呼ぶのか」の定義を先に決める必要があると考えた話です。
お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、ヘルスチェックの盲点を見つけました。DevPickは、開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。
外形監視(UptimeRobot)で/healthを叩いていたのですが、ある障害パターンだと「監視は正常、でもユーザーから見るとサービスが壊れている」という状態が起きることが分かりました。今回は、その盲点に気づいてから検知を足すまでの経緯を書きます。
Redis停止で保存系APIが503を返しているのに、/health は200だった
DevPickでは、レート制限の管理にRedisを使っています。記事の閲覧だけなら制限を緩くしていますが、プロフィール更新やWebhook設定の保存など、書き込み系の操作にはRedisを介したレート制限をかけています。
このRedisが停止すると、書き込み系のレート制限が機能しなくなります。DevPickでは、レート制限が効かない状態で書き込みを通すのは危険と判断して、保存系のAPIをfail-closed(安全側に倒して拒否)にしていました。つまり、Redis停止時は保存系APIが一律503を返す設計です。
ここまでは意図通りの動作です。問題は、この状態で/healthを叩くと200が返ってくることでした。
/healthエンドポイントは、記事の取得バッチが正常に動いているか、分析処理が止まっていないか、メール配信キューが詰まっていないかなど、いくつかの項目を確認して結果を返す作りにしていました。ただし、共有レート制限ストレージ(Redis)の状態はチェック対象に入っていませんでした。
記事取得やフィード閲覧は正常に動いているので、/healthは素直に「問題ありません」と返します。外形監視はそれを見て「サービスは正常」と判定し、アラートは飛びません。しかし実際には、ユーザーがプロフィールを保存しようとしても通知設定を変えようとしても、すべて503で弾かれ続けている状態でした。
テスト環境で再現したところ、レート制限ストレージへのアクセスに同じエラーを注入した状態で、保存系APIは503を返すのにGET /healthは200を返すことを確認できました。
IT女子 アラ美なぜ見逃していたか ── /health が見ていた範囲と見ていなかった範囲
/healthの既存チェック項目を整理すると、次のようになっていました。
✅ 記事取得バッチの最終実行時刻(一定時間以上止まっていないか)
✅ 分析処理のキュー長(滞留していないか)
✅ メール配信の直近成否
✅ ダイジェスト配信の直近成否
❌ レート制限ストレージ(Redis)の疎通 ← チェックしていなかった
上の4つは、すべてDevPickの「コンテンツ配信」に関わる項目です。記事を取得し、分析し、ユーザーに届ける。この一連の流れが止まっていなければ「サービスは動いている」と判定する設計でした。
一方、レート制限のストレージは「保存系APIを安全に提供するための基盤」です。記事の配信とは直接関係がないため、/healthの対象に入れていませんでした。DevPickの読み取り系は、Redisが落ちてもインメモリのレート制限にフォールバックして動き続けます。この縮退動作は意図通りなので、読み取りの可用性という観点からは正しい判断でした。
ただ、結果として「読み取りが動いている=サービスは健康」という定義になってしまい、保存系の機能停止が監視の死角に入っていました。記事が読めることと、プロフィールが保存できることは、ユーザーにとっては両方とも「サービスが動いている」の一部です。healthの定義が、開発者の関心(コンテンツ配信の継続)に偏っていたことになります。
以前、バックアップが取れていても復元できるかは確かめていなかった問題を直したときも、「ある機能が動いている」ことと「その機能の成果物が使える」ことの間にある溝を見落としていました。今回も、読み取りが動いていることと、サービス全体が使えることの間に同じ種類の溝がありました。



検討した選択肢 ── 別エンドポイントか、既存の拡張か
Redis障害を検知できるようにする方法として、2つの選択肢を検討しました。
1つ目は、/healthとは別に、保存系の可用性を返す専用エンドポイントを新設する案です。/health/storageのようなパスを作り、外形監視の対象を2つに分ける形です。
2つ目は、既存の/healthのチェック項目にRedis疎通を追加する案です。
結果として2つ目を採用しました。理由は、外形監視の設定を分けると、片方の監視漏れや通知の優先度設定のミスが起きやすいと考えたためです。個人開発で監視対象を2つに分けると、片方を忘れている期間が長くなりがちです。1つのエンドポイントに集約し、どれか1つでも問題があれば異常と判定するほうが、見落としにくい構成になります。
もう1つ、Redis停止時にレート制限をfail-open(制限なしで通す)に切り替えれば保存系APIは動き続けるのでは、という案もありました。ただし、これはレート制限の意味がなくなります。もともとfail-closedにしたのは「制限なしで書き込みを受け付けるリスクのほうが大きい」という判断からです。監視の検知を足すために、安全側に倒した設計を崩す必要はないと考え、この案は採用しませんでした。



やったこと ── PING疎通チェックとdegraded状態の導入
修正の方針は、/healthのチェック項目に「保存系レート制限ストレージへのPING」を追加し、疎通できない場合はdegraded(機能低下)として503を返すことです。
具体的には、保存系のレート制限で使っているストレージに対してPINGコマンドを送り、応答が返ってくるかどうかだけを見ます。レート制限のカウンタを読み書きする必要はなく、ストレージが生きているかどうかの疎通確認です。
# 正常時
{
"status": "ok",
"checks": {
"fetch": "ok",
"analysis": "ok",
"email": "ok",
"rate_limit": "ok" <-- 新規追加
}
}
# Redis障害時
{
"status": "degraded", <-- okからdegradedに変化
"checks": {
"fetch": "ok",
"analysis": "ok",
"email": "ok",
"rate_limit": "degraded" <-- 疎通失敗
}
}
# HTTPステータスも503を返す
statusをok/degradedの2段階にしたのは、読み取り系が正常に動いていることと、保存系が止まっていることを同時に表現するためです。全部が止まっているわけではないのでerrorやdownではなく、一部の機能が使えない状態をdegradedとしました。外形監視はHTTPステータスコードで判定するため、503が返ればアラートが飛びます。
Redisが復旧すれば、次の/healthリクエストでPINGが通り、rate_limitはokに戻ります。障害→復旧の状態遷移も、特別な操作なしでhealthリクエストごとに最新の状態を反映する作りです。



気をつけた点 ── タイムアウト・例外非公開・開発環境の誤検知
healthチェックにRedis疎通を足すにあたって、3つの点に気をつけました。
PING自体がhealthを遅くしないようにする
Redisが停止しているとき、PINGコマンドは接続タイムアウトまで待ちます。デフォルトのまま放置すると、healthリクエスト自体の応答が何十秒も遅れ、外形監視が「healthが遅い」というまた別のアラートを出す可能性があります。
そのため、Redis接続と応答にそれぞれ1秒のタイムアウトを設定し、再試行なしにしました。1秒以内に応答がなければ「疎通失敗」と判定します。healthチェックのために本番のレスポンスが遅くなるのは本末転倒なので、短めに設定しています。
例外の詳細を公開しない
PING失敗時のエラーには、Redisの接続先ホスト名やポート番号、TLS設定の詳細が含まれることがあります。/healthは認証なしで叩けるエンドポイントなので、これらの内部情報がレスポンスに含まれると、攻撃者にインフラ構成を教えることになります。
エラーの場合、レスポンスには"rate_limit": "degraded"とだけ返し、例外メッセージはサーバーサイドのログにだけ記録する作りにしました。
開発・テスト環境で誤検知しない
開発環境やCI環境では、Redisの代わりにインメモリのストレージを使ってレート制限を動かしています。この場合、「Redisに疎通できない」のは正常な状態です。
インメモリストレージが指定されている環境では、Redis疎通チェックをスキップしてokを返すようにしました。開発者がローカルでRedisを立てていなくても、/healthがdegradedを返し続けるといった混乱が起きないようにするためです。
テストでは、障害→検知→復旧の一連のシナリオと、全レート制限が障害になった場合でも読み取り系が縮退で継続すること、保存系がfail-closedのままであることをそれぞれ確認しました。



まとめ
/healthエンドポイントを用意していても、それが何をもって「健康」と判定するかの定義が偏っていると、実際には障害が起きているのに正常を返し続ける盲点が生まれます。
今回の問題は「healthが壊れていた」のではなく、「healthの定義がサービス全体をカバーしていなかった」ことでした。読み取り系の可用性だけを基準にしていたために、保存系の機能停止が監視の死角に入っていました。
healthチェックの項目を追加するときに意識したのは、次の3つです。
- チェック自体がレスポンスを遅くしないこと(タイムアウトを短く設定する)
- チェック結果から内部構成を推測されないこと(例外詳細を返さない)
- 本番以外の環境で誤検知しないこと(ストレージの種類を見て判定をスキップする)
/healthの設計は、最初に一度作って終わりではなく、サービスの機能が増えるたびに「この機能が止まったとき、healthは異常を返すか」を確認する必要がある作業だと改めて感じました。












