失敗より、無音のほうが怖い
第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
start と success が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_NAME | monitored |
$PROFILE_COMMAND | backup |
$ERROR | backup on profile 'monitored': exit status 10 |
$ERROR_EXIT_CODE | 10 |
$ERROR_COMMANDLINE | "/usr/local/bin/restic" "backup" "--host=backup-host" ... |
$ERROR_STDERR | Fatal: 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 から始めるのが無難です。
監視の全体像
役割がきれいに分かれています。
- healthchecks — 「動いていない」を検知する。サーバが死んでいても機能する
- Discord — 「何が起きたか」を届ける。サーバが生きているときだけ機能する
この2つは代替関係ではありません。Discord だけでは無音を検知できず、healthchecks だけでは原因が分かりません。
通知先を1か所にまとめない
もうひとつ、設計として押さえておく点があります。healthchecks 自身が落ちたら誰が気づくのか、という問いです。
セルフホストしている以上、healthchecks も落ちます。バックアップ対象と同じサーバ・同じ電源・同じネットワークに置いていれば、まとめて落ちます。そうなれば「連絡が来ない」ことに気づく主体がいなくなります。
healthchecks は、バックアップ対象とは別の障害単位に置いてください。 別のホスト、できれば別の拠点です。それが難しければ、ホスティング版の healthchecks.io を使うほうが目的に適います。監視を対象と同居させたら、監視ではなくなります。
今回の到達点
第1回で挙げた5つの要件のうち、これで1つ目が満たせました。
失敗が失敗として見えること← 今回復元が簡単であること← 第3回保管したデータが壊れていないと確認できること← 第3回(check)- 保管先を信用しなくてよいこと
世代を持てて、容量が破綻しないこと← 第3回
残るのは4つ目です。ここまでリポジトリは全てローカルディスクに置いてきました。サーバが物理的に失われたら、バックアップも一緒に失われます。
次回
次回から保管先を外に出します。SFTP と SMB 共有です。そこで、この連載でいちばん危険な設定に出会います。NAS がマウントされていない状態でバックアップが「成功」し、ローカルディスクに空のリポジトリが新規作成されるという挙動です。
この回で使ったツール
- Healthchecks — cron ジョブの死活監視サービス(セルフホスト版のリポジトリ)
- resticprofile — restic を設定ファイルから駆動するラッパー(HTTP フックのドキュメント)
- Discord — Webhook で通知を受け取るチャットサービス