systemd OnFailure= × ntfy で『静かに死ぬ自動化』を撲滅する — Pi 5 常時稼働 30 日の通知設計
要約
systemd user timer に切り替えてから「ジョブが落ちた瞬間に気づけない」事故が 2 回続いた。journalctl を毎朝目で追うのも限界がある。本記事では、各 service unit に OnFailure= を 1 行足すだけで失敗時に ntfy 経由で手元のスマホに通知が飛ぶ最小構成と、30 日運用で効いた 4 つの設計判断を共有する。
- 通知は 失敗時のみ(成功時は黙る)
- 通知本文は どの unit が・何の exit code で・直前 10 行の log で 落ちたかが分かる
- ntfy のサーバーは 自宅 Pi 上のセルフホストではなく公式 SaaS の
ntfy.sh(理由は本文) - OnFailure 用の通知 service は 1 つだけ用意して全 unit から共有する(unit 増殖を避ける)
起きた事故(再現)
1 回目: 取得スクリプトが Python の依存解決で死んでいた
朝 9 時の市況データ取得が走らない日があった。systemctl --user status を見ると Active: failed (Result: exit-code)。journalctl --user -u <unit> で ModuleNotFoundError。pip 依存を 1 つ更新したときに venv の activate が後回しになり、その朝の cron 相当の起動だけが該当依存を踏んでいた。
問題は 24 時間気づかなかったこと だ。次の朝、ダッシュボードの値が前日のままで初めて気づいた。
2 回目: ネットワーク順序問題でメール送信が落ちていた
夕方の通知メールが届かない日があった。Pi の Wi-Fi が DHCP 取得前にユニットが起動し、gaierror: Name or service not known で失敗。再起動直後にだけ起きるため、再現性が低く調査が遅れた。
このときも、journalctl を能動的に見るまで気づかなかった。
両方とも「失敗が静かに起きると、次の入力が必要になるまで気づかない」という同じ構造だった。
採用した最小構成
Step 1: ntfy 通知用の service を 1 個だけ作る
~/.config/systemd/user/notify-failure@.service:
[Unit]
Description=Send ntfy notification on unit failure (%i)
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -lc '\
UNIT="%i.service"; \
STATE=$(systemctl --user show -p SubState --value "$UNIT"); \
CODE=$(systemctl --user show -p ExecMainStatus --value "$UNIT"); \
TAIL=$(journalctl --user -u "$UNIT" -n 10 --no-pager | tail -n 10); \
curl -s -H "Title: [FAIL] $UNIT" -H "Priority: high" -H "Tags: warning,skull" \
-d "state=$STATE exit=$CODE\n---\n$TAIL" \
https://ntfy.sh/$NTFY_TOPIC'
Environment=NTFY_TOPIC=__YOUR_TOPIC__
@ を付けたテンプレート unit にしておくと、OnFailure=notify-failure@<unit-name>.service の形で どの unit でも 1 行で再利用できる。%i には呼び出し時のインスタンス名(= 落ちた unit 名)が入る。
ntfy のトピック名は 第三者に推測されない長い文字列にする。SaaS なので、トピックさえ知られればメッセージは誰でも読める。トピック名はリポジトリにも貼らない(環境変数で渡すか、
Environment=部分だけローカルに別ファイル化)。
Step 2: 通知させたい service ごとに OnFailure= を 1 行足す
例: my_daily_batch.service:
[Unit]
Description=Daily batch job
OnFailure=notify-failure@my_daily_batch.service
[Service]
ExecStart=/home/example/Workspace/myproject/scripts/daily_batch.sh
OnFailure= には @ の 後ろに対象 unit 名を再度書くのがポイント。systemd は OnFailure= の値をテンプレートのインスタンス名としてそのまま渡してくれるので、上の notify-failure@.service の %i に流れる。
Step 3: 通知が来ることをわざと壊して確認する
# 一時的に exit 1 する unit を流して通知到着を確認
systemctl --user start my_daily_batch.service # 後で必ず戻す
スマホで ntfy アプリの該当トピックを購読しておけば、数秒で push が届く。届かない場合は、
-
notify-failure@<unit>.service単体でsystemctl --user startを試す -
curl部分だけ手で叩いて ntfy.sh に届くかを切り分ける - それでもダメなら
Environment=NTFY_TOPIC=を見直す(systemd の Environment はEnvironmentFile=から引いた方が事故が少ない)
なぜ自宅 Pi に ntfy を立てなかったか
最初は「Pi の中で完結したい」と思って ntfy をセルフホストする案を検討した。やめた理由は 2 つ:
- Pi 自身が落ちたときに通知が出ない: 当然だが、通知系を被監視ノード上に置くと、最も知りたい『ノード障害』が通知できない。SPOF の典型。
- 公式 SaaS は無料・無認証で動く: ntfy.sh はトピック名さえ秘密にすれば、認証なしで pub/sub できる。コストゼロ・運用ゼロ。
セルフホストが妥当になるのは、複数のノードがあって、片方が落ちてももう片方が通知を出せる構成のときだけ。1 台運用ではメリットが薄い。
30 日運用で効いた 4 つの設計判断
1. 通知本文に journalctl -n 10 を必ず含める
通知に「失敗した unit 名」だけ入っていても、結局 SSH してログを見に行くことになる。push 通知の本文に直前 10 行のログを混ぜておくと、スマホの通知センターを開いた瞬間に「ああ、依存が壊れたやつだ」と分かる。気づいてから対処までの時間が体感で 1/5 になった。
2. 通知 service は Type=oneshot
Type=simple にすると ntfy への curl が完了する前に systemd が「成功」と判断して継ぎ足しの処理が抜けることがある。oneshot で ExecStart の終了を待たせる方が、特に通知本文の組み立てを bash でやるときには安定する。
3. Priority: high と Tags: warning,skull を付ける
ntfy アプリの設定で「high 以上だけ通知音を鳴らす」にしている。普段の low/normal は静かに溜めて、本当に止まった時だけ鳴るように分けると、通知疲れせずに済む。
4. 通知が連発したら抑制せず原因を直す
「通知が増えたのでスロットリング機構を入れる」を一瞬考えたが、入れなかった。失敗が連発するということは原因が継続しているということで、通知を止めると原因にも気づかなくなる。
代わりに、unit 側に Restart=on-failure と RestartSec=30s を入れて自動再試行で吸収するパターンと、再試行しても直らないものだけが通知に残る形にした。
ありがちな落とし穴
OnFailure= は service unit 側に書く(timer ではない)
timer unit に OnFailure= を書いても、timer 自体が落ちたときにしか発火しない。実際の処理(service unit)側に書かないと、ジョブの失敗は捕まえられない。
journalctl --user は 環境変数 XDG_RUNTIME_DIR が要る
通知 service の ExecStart で journalctl を bash 経由で呼ぶときは、bash -lc のように ログインシェル相当で起動するか、明示的に Environment=XDG_RUNTIME_DIR=/run/user/1000 を渡す。これを忘れると --user ログが空で返ってきて、通知本文に何も入らない。
ntfy のトピック名はリポジトリに置かない
git に上げると検索でヒットする。.gitignore に環境変数ファイルを入れる、Environment= 部分だけ unit と分離する、などの一手間が安全側。
最終的な構成図
[各 service unit]
└ OnFailure=notify-failure@<unit>.service
│
▼
[notify-failure@.service]
└ journalctl で直前 10 行を取得
└ curl で ntfy.sh に POST
│
▼
[ntfy.sh] ──push──▶ [手元スマホ]
unit 側は 1 行追加するだけ。共通通知 unit は 1 個だけ。30 日運用したが、メンテナンスコストはゼロ。
まとめ
- systemd の
OnFailure=は『失敗時の hook』として極めて軽量 -
@テンプレート unit にすると、対象が増えても 1 行追加で済む - 通知系は被監視ノードの外に置く(自宅 Pi の中に立てない)
- 本文に直前 10 行ログを混ぜると、対処開始までの時間が劇的に短くなる
- 通知の連発は抑制せず原因を直す。スロットリングを足す前に Restart= を考える
「ジョブが静かに死ぬ」のは時間が経つほどダメージが膨らむ事故だが、unit 1 個と OnFailure= 1 行で構造的に防げる。Pi 5 を 1 台で常時稼働させる構成では、入れない理由が思いつかない。
重要事項: 本記事は、Raspberry Pi 上で動作する自動化ジョブの監視・通知設計に関する技術的な解説であり、特定のサービス・製品の利用を推奨するものではありません。記載された設定値・スクリプトは筆者の環境での動作を確認したものであり、読者の環境で同じ動作を保証するものではありません。実運用に投入する際は、ご自身の環境で十分にテストの上、自己責任で導入してください。
Discussion