WEELWORKS

失敗より、無音のほうが怖い

第5回から3回続けて、同じ穴を残したままにしてきました。毎晩動いているかどうかを、誰も見ていません。

第1回で挙げた要件の1つ目は「失敗が失敗として見えること」でした。今回それを塞ぎます。

成功通知は機能しない

まず、やってはいけない形から確認します。「バックアップが終わったらメールを送る」です。

毎朝メールが届きます。1週間は読みます。2週目には件名だけ見て消します。1か月後にはフィルタで自動振り分けされ、誰の目にも触れません。そして、メールが届かなくなったことには誰も気づきません。

これは注意力の問題ではありません。正常が99%を占める通知は、必ず無視されるようになります。人間がそういうふうにできているので、運用ルールでは直せません。

検知すべき3つの状態

バックアップジョブの状態は3つに分かれます。

状態何が起きているか検知の難しさ
成功正常に終わった
失敗エラーで終わった終了コードで分かる
無音起動していない/途中で消えた自力では分からない

3つ目が厄介です。サーバが落ちている、systemd timer の登録が消えている、ディスクが埋まってプロセスが OOM killer に殺された。こうしたとき、そのサーバは通知を出すことすらできません。

無音を検知するには、外から見る仕組みが要ります。「一定時間内に連絡が来なければ異常とみなす」という考え方で、デッドマンスイッチと呼ばれます。

healthchecks を立てる

Healthchecks はまさにこれをやるサービスです。ホスティング版もありますが、この連載ではセルフホストします。読者が同じ手順を再現でき、URL を伏せる必要もないからです。

services:
  healthchecks:
    image: healthchecks/healthchecks:latest
    container_name: rl-hc
    environment:
      SECRET_KEY: lab-secret-key-for-verification-only
      SITE_ROOT: http://rl-hc:8000
      SITE_NAME: restic-lab
      ALLOWED_HOSTS: "*"
      DB: sqlite
      DB_NAME: /data/hc.sqlite
      SUPERUSER_EMAIL: [email protected]
      SUPERUSER_PASSWORD: labpass
      REGISTRATION_OPEN: "False"
    volumes:
      - hcdata:/data
    ports: ["8000:8000"]

DB_NAME について DB: sqlite だけを指定した状態では、検証環境で unable to open database file が出て起動しませんでした。DB_NAME に書き込み可能な絶対パスを明示すると解決します。

起動したらブラウザで開き、チェックを1つ作ります。設定するのは2つの値です。

  • Period(周期): 1 day — 「1日に1回は連絡が来るはず」
  • Grace(猶予): 1 hour — 「予定を過ぎても1時間は待つ」

この2つが揃うと、連絡が来なかったときに healthchecks 側から通知が飛びます。 これが無音の検知です。バックアップするサーバが完全に死んでいても、healthchecks は生きているので気づけます。

チェックを作ると URL が発行されます。

http://rl-hc:8000/ping/c48ba9c7-78c2-4cec-9b8d-6ad27cecb0c3

この URL に GET すれば「成功」、末尾に /start を付ければ「開始」、/fail を付ければ「失敗」の記録になります。

resticprofile から ping を送る

resticprofile には HTTP を送るフックがあります。第7回のコマンド実行フックと対になる形で、4種類あります。

フックタイミング
send-before実行前
send-after成功時
send-after-fail失敗時
send-finally成否によらず最後

これを healthchecks の3つの URL に対応させます。

version: 2

profiles:
  monitored:
    repository: "/srv/backup/repo"
    password-file: "/root/.config/restic/password"

    backup:
      source: [/srv/data]
      send-before:
        - method: GET
          url: "http://rl-hc:8000/ping/c48ba9c7-.../start"
      send-after:
        - method: GET
          url: "http://rl-hc:8000/ping/c48ba9c7-..."
      send-after-fail:
        - method: GET
          url: "http://rl-hc:8000/ping/c48ba9c7-.../fail"

send-before/start を打つのがポイントです。これがあると healthchecks 側で所要時間が記録されます。いつもは3分で終わるバックアップが40分かかるようになった、という変化に気づけます。

実行して確認します。

2026/08/14 12:21:23 profile 'monitored': finished 'backup'

healthchecks 側の記録です。

status: up
03:21:23 success
03:21:22 start

startsuccess が1秒差で並び、状態は up になりました。

失敗させてみる

リポジトリのパスを存在しないものに変えて実行します。

Fatal: repository does not exist: unable to open config file: stat /srv/backup/does-not-exist/config: no such file or directory
2026/08/14 12:08:04 backup on profile 'monitored': exit status 10

healthchecks 側です。

status: down
03:08:04 fail
03:08:03 start

start の直後に fail が記録され、状態が down に変わりました。この時点で healthchecks 側に設定した通知先へ連絡が飛びます。

Discord へ内容のある通知を出す

healthchecks は「異常が起きた」ことを伝えますが、何が起きたかは伝えません。障害対応の最初の一歩として、エラーの中身がほしい。ここで Discord の Webhook を使います。

resticprofile の送信フックでは、失敗の情報が環境変数として渡ります。検証で確認できたものを列挙します。

環境変数値の例
$PROFILE_NAMEmonitored
$PROFILE_COMMANDbackup
$ERRORbackup on profile 'monitored': exit status 10
$ERROR_EXIT_CODE10
$ERROR_COMMANDLINE"/usr/local/bin/restic" "backup" "--host=backup-host" ...
$ERROR_STDERRFatal: repository does not exist: ...(複数行)

これを Discord の Webhook 形式に載せます。

      send-after-fail:
        - method: GET
          url: "http://rl-hc:8000/ping/c48ba9c7-.../fail"
        - method: POST
          url: "https://discord.com/api/webhooks/【チャンネルのWebhook URL】"
          body: |
            {"username":"restic","content":"**バックアップ失敗** `$PROFILE_NAME` / exit `$ERROR_EXIT_CODE`\n```\n$ERROR\n```"}
          headers:
            - name: "Content-Type"
              value: "application/json"

send-after-fail にはリストで複数書けます。healthchecks への /fail と Discord への POST が、両方実行されます。

実際に送られた本文です。

{
  "username": "restic",
  "content": "**バックアップ失敗** `monitored` / exit `10`\n```\nbackup on profile 'monitored': exit status 10\n```"
}

Discord のメッセージとして正しく組み立てられています。

Discord への送信について 検証では実際の Discord Webhook URL ではなく、受け取った内容をそのまま返すエコーサーバへ送信し、リクエストボディと Content-Type ヘッダが期待どおりに組み立てられていることを確認しました。Discord 側での表示は確認していません。

$ERROR_STDERR のほうが情報量は多いのですが、改行がそのまま入るため JSON として壊れます。使う場合は改行のエスケープが必要です。1行で収まる $ERROR から始めるのが無難です。

監視の全体像

systemd timerresticprofilerestichealthchecks(Period 1day / Grace 1h)Discord運用担当send-before → /startsend-after → /(成功)send-after-fail → /failsend-after-fail(エラー内容)失敗した/連絡が来ない何が起きたか

役割がきれいに分かれています。

  • healthchecks — 「動いていない」を検知する。サーバが死んでいても機能する
  • Discord — 「何が起きたか」を届ける。サーバが生きているときだけ機能する

この2つは代替関係ではありません。Discord だけでは無音を検知できず、healthchecks だけでは原因が分かりません。

通知先を1か所にまとめない

もうひとつ、設計として押さえておく点があります。healthchecks 自身が落ちたら誰が気づくのか、という問いです。

セルフホストしている以上、healthchecks も落ちます。バックアップ対象と同じサーバ・同じ電源・同じネットワークに置いていれば、まとめて落ちます。そうなれば「連絡が来ない」ことに気づく主体がいなくなります。

healthchecks は、バックアップ対象とは別の障害単位に置いてください。 別のホスト、できれば別の拠点です。それが難しければ、ホスティング版の healthchecks.io を使うほうが目的に適います。監視を対象と同居させたら、監視ではなくなります。

今回の到達点

第1回で挙げた5つの要件のうち、これで1つ目が満たせました。

  1. 失敗が失敗として見えること ← 今回
  2. 復元が簡単であること ← 第3回
  3. 保管したデータが壊れていないと確認できること ← 第3回(check
  4. 保管先を信用しなくてよいこと
  5. 世代を持てて、容量が破綻しないこと ← 第3回

残るのは4つ目です。ここまでリポジトリは全てローカルディスクに置いてきました。サーバが物理的に失われたら、バックアップも一緒に失われます。

次回

次回から保管先を外に出します。SFTP と SMB 共有です。そこで、この連載でいちばん危険な設定に出会います。NAS がマウントされていない状態でバックアップが「成功」し、ローカルディスクに空のリポジトリが新規作成されるという挙動です。

この回で使ったツール