1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ログ駆動の「サービス正常」は嘘をつく — 能動ヘルスチェックの設計

1
Last updated at Posted at 2026-06-13

要約

  • ダッシュボードの「サービスヘルス」が全部グリーンなのに、実機は ADB 接続が切れて操作できなかった
  • 原因はヘルス表示が「ログに error が無い=正常」という受動的な集計だったこと。誰も操作しなければ失敗ログも出ないので、死んでいても緑になる
  • 対策: 画面を開いた瞬間に各プロトコルへ実際に疎通を投げる能動ヘルスチェックに作り替えた
  • あわせて、30分ごとの定期チェック+「正常↔異常が切り替わった瞬間だけ通知」する設計にした

背景

自宅IoT(複数の TV を ADB 制御、Raspberry Pi 経由の照明、TTS、各種外部API)をまとめて1つのバックエンドから操作している。構造化ログを DB に貯めて、管理画面で「サービスヘルス」を出していた。

そのヘルスがこういう作りだった:

  • 直近のログを service 単位で集計
  • error が無ければ緑

症状

ある日、ダッシュボードでは TV サービスが緑なのに、実際は ADB 接続が落ちていて操作できなかった。

受動的なログ集計は「試した操作がすべて成功した」ではなく「何も試していないから失敗ログもない」状態を区別できない。監視としては嘘をついている。

設計方針

3つに分けて作り直した。

  1. 画面を開いたとき、各サービスへ実際に疎通を1回投げる(能動チェック)
  2. サーバー側で30分ごとに同じチェックを回して履歴をDBに残す
  3. 正常↔異常が切り替わった瞬間だけ通知する(毎回通知しない)

能動チェックの中身

サービスごとに「生きているか」を一番軽い方法で確認する。負荷はかけない。

種別 チェック方法
ADB デバイス adb connect host:port が connected を返すか
HTTP ゲートウェイ 任意のHTTPレスポンスが返るか(到達確認のみ)
外部API 資格情報が設定済みか(APIは叩かない)
内部プロセス ロックファイルのPIDが生きているか

各チェックは独立に Promise.allSettled で並列実行し、1つが固まっても全体が止まらないようにする。各チェックを薄くラップして latencyMs も測る。

async function wrapCheck(service, fn) {
  const startedAt = Date.now();
  try {
    const result = await fn();
    return { service, status: result.ok ? "ok" : "error",
             message: result.message, latencyMs: Date.now() - startedAt };
  } catch (error) {
    return { service, status: "error",
             message: error?.message, latencyMs: Date.now() - startedAt };
  }
}

const settled = await Promise.allSettled(
  checks.map(([service, fn]) => wrapCheck(service, fn))
);

ポイント: 失敗を throw で投げない。死んでいる宛先を「error ステータスの正常な結果」として返す。監視コードが例外で落ちたら本末転倒。

HTTP の疎通確認は「到達」だけ見る

ありがちな罠として、/health エンドポイントが無い相手に response.ok を要求すると 404 で「異常」になってしまう。死活監視としては、4xx でも「ホストに届いて返事がある=生きている」と見なすべき。

async function checkHttpConnectivity(url) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
    return { ok: true, message: `HTTP ${res.status}` };  // 4xxでもok
  } catch (error) {
    return { ok: false, message: error?.message };        // 到達不能だけerror
  }
}

ADB の実体パスでハマる

ADB チェックで spawn adb ENOENT が出た。adb が PATH に無く、フルパス指定が必要だった。サービス(NSSM)として起動していると、対話シェルの PATH が効かないことがある。env で実行ファイルパスを明示して解決。

状態遷移だけ通知する

定期チェックのたびに「異常です」を飛ばすと、落ちている間ずっと鳴り続けてうるさい。前回のステータスと比較して、切り替わった瞬間だけ通知する。

const degraded  = (prev === "ok" || prev === "warn") && curr === "error";
const recovered = (prev === "error" || prev === "warn") && curr === "ok";
// error -> error は何もしない

異常化したらアラート、復旧したら復旧通知。error -> error の継続中は黙る。これでノイズなく「変化」に気づける。

ダッシュボード側

  • 画面を開いた初回(と期間変更時)だけ能動チェックを実行。自動更新で5秒ごとに ADB を叩いて TV を起こしてしまわないよう、ヘルスチェックは初回限定にする
  • 各サービスに「いつ時点のチェックか」と latency を出す
  • 履歴は1サービス1行のドット列(🟢🟡🔴)で、いつから赤くなったか後から追える

過去ログの集計(受動)と、いま叩いた結果(能動)は別物として両方見せる。受動は「最近エラーが出たか」、能動は「今この瞬間に生きているか」。

まとめ

  • ログ駆動の「正常」は、誰も操作していないだけの「無風」を正常と誤認する
  • 死活は受動集計ではなく能動プローブで測る
  • チェックは throw せず error ステータスとして返し、並列実行で互いに巻き込まない
  • HTTP 死活は 4xx でも「到達=生存」と見なす
  • 定期通知は状態遷移時だけにしてノイズを消す
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?