TL;DR
-
systemd-analyze verifyは、Restrat=のような未知のキーやRestart=on-failurのような不正な値を警告するだけで、終了コードは 0 です。これは不具合ではなく、systemd-analyze(1) に書かれた仕様です。 - systemd 250 以降は
--recursive-errors=noを付けると、対象のunitに警告が出た時点で exit 1 になります。CIではまずこれを使います。 - ただし手元の実測では、
=noにするとRequires=の先が存在しないエラーが出力ごと消えて exit 0 になりました。依存先の欠落も止めたいなら--recursive-errors=oneを使います。=oneなら依存先も検証されます。 - systemd 250 より古い環境(例: GitHub ホストランナーの ubuntu-22.04 は 249)や、出力を1行でも許したくない場合は、「出力が空でなければ失敗」にするラッパーを使います。
検証環境: Debian 13 のコンテナ、systemd 257 (257.13-1~deb13u1)。以下のログはすべてこの環境で実行した結果をそのまま貼っています。
再現:タイプミスがあっても exit 0
検証用のファイルはすべて /tmp/qiita-verify/units/ に置き、/tmp/qiita-verify から相対パスで実行しています。
[Unit]
Description=demo agent
[Service]
ExecStart=/bin/true
Restrat=on-failure
RestartSec=10s
$ cd /tmp/qiita-verify
$ systemd-analyze verify --man=no units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=0
相対パスで渡しても、警告には絶対パスと行番号が出ます。Restart= は書かれていない扱いになるので、プロセスが落ちても再起動しません。それでも verify は成功扱いです。
再起動の設定はZennの記事を参照してください。
値の綴りを間違えた場合も同じです。
[Unit]
Description=demo agent
[Service]
ExecStart=/bin/true
Restart=on-failur
RestartSec=10s
$ systemd-analyze verify --man=no units/badvalue.service; echo exit=$?
/tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument
exit=0
原因:オプションなしでは警告があっても 0 を返す仕様
systemd-analyze(1) の --recursive-errors=MODE の項には、次のように書かれています。
If this option is not specified, zero is returned as the exit status regardless whether warnings arise during verification or not.
つまり、オプションを付けない限り、警告がいくつ出ても終了コードは 0 です。未知のキーや不正な値は「その行を無視して読み込みを続ける」扱いの警告なので、ここに当てはまります。
同じ項で、モードごとの意味は次のとおり説明されています(Added in version 250)。
| MODE | 非0で終わる条件(マニュアルの記述) |
|---|---|
yes |
指定したunitか、その依存先のどれかで警告が出たとき |
no |
指定したunitだけの検証で警告が出たとき |
one |
指定したunitか、その直接の依存先で警告が出たとき |
一方で、ExecStart= のコマンドが存在しない場合は、unitとして読み込めない「エラー」になるので、オプションがなくても exit 1 になります。セクション名の誤りは、どのセクションかで結果が分かれます。[Service] のセクション名を誤ると、その中身がまるごと無視されて ExecStart= がない扱いになり exit 1 です。[Unit] や [Install] の誤りは、ほかの警告と同じく exit 0 で、--recursive-errors=no を付けて初めて exit 1 になります。
対策1:--recursive-errors=no を付ける(systemd 250 以降)
$ systemd-analyze verify --man=no --recursive-errors=no units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=1
$ systemd-analyze verify --man=no --recursive-errors=no units/badvalue.service; echo exit=$?
/tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument
exit=1
出力は同じで、終了コードだけが 1 に変わります。複数のファイルをまとめて渡しても、1つでも警告があれば 1 になりました。
$ systemd-analyze verify --man=no --recursive-errors=no units/good.service units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=1
--man=no は、Documentation= に書いた man ページの存在確認をしないためのオプションです。この検証環境のように man コマンドが入っていない環境では、Documentation=man:demo-agent(8) のようなunitで付けないと Command 'man demo-agent(8)' failed with code 1 で exit 1 になりました。
=no では Requires= の欠落が見えなくなる
依存先のunitが存在しないケースを試すと、=no だけ結果が変わりました。
[Unit]
Description=demo agent
Requires=missing-dep.service
After=missing-dep.service
[Service]
ExecStart=/bin/true
Restart=on-failure
$ systemd-analyze verify --man=no units/requires.service; echo exit=$?
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
$ systemd-analyze verify --man=no --recursive-errors=no units/requires.service; echo exit=$?
exit=0
$ systemd-analyze verify --man=no --recursive-errors=one units/requires.service; echo exit=$?
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
オプションなしなら出るエラーが、=no では出力ごと消えて exit 0 になります。これは実装どおりの挙動で、systemd v257 の src/analyze/analyze-verify-util.c では、=no のときに MANAGER_TEST_RUN_IGNORE_DEPENDENCIES を付けて検証用のマネージャーを起動しており、依存先を読みに行きません。自分のunitの書き間違いだけを見たいなら =no、依存先の欠落も同時に止めたいなら =one を使うのが安全です。ただし CI で =one を使うなら、依存先のunitもランナー上に存在していることが前提です。たとえば Requires=postgresql.service と書いたunitは、ランナーに PostgreSQL のunitがなければ、記述が正しくても exit 1 になります。この環境では、Wants=network-online.target を持つunitでも =one・=yes で余計な警告は出ませんでした(exit 0)。
誤りの種類ごとの終了コード
systemd 257 で、誤りを1つだけ入れたunitを用意して測った結果です(--man=no は共通)。root と一般ユーザーの両方で実行し、結果は同じでした(/run/systemd が存在する環境での結果。systemd が動いていないコンテナで一般ユーザーが実行すると、Failed to create directory '/run/systemd/': Permission denied で exit 1 になることがあります。手元で /run を空の tmpfs に差し替えて uid 1000 で試したところ、正しいunitでもこのエラーで exit 1 になり、XDG_RUNTIME_DIR を設定しても変わらず、root で実行した場合だけ exit 0 でした)。
| 誤りの種類 | 出力 | オプションなしの終了コード | --recursive-errors=noの終了コード | (参考)=one |
|---|---|---|---|---|
未知のキー(Restrat=) |
Unknown key 'Restrat' in section [Service], ignoring. |
0 | 1 | 1 |
不正な値(Restart=on-failur) |
Failed to parse Restart=on-failur, ignoring: Invalid argument |
0 | 1 | 1 |
旧名のキー StartLimitInterval= を [Service] に書く |
なし | 0 | 0 | 0 |
新名の StartLimitIntervalSec= を [Service] に書く |
Unknown key 'StartLimitIntervalSec' in section [Service], ignoring. |
0 | 1 | 1 |
[Service] のセクション名の誤り([Servcie]) |
Unknown section 'Servcie'. Ignoring. と Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing. ほか1行 |
1 | 1 | 1 |
[Unit] / [Install] のセクション名の誤り([Unti] / [Instal]) |
Unknown section 'Unti'. Ignoring.([Instal] も同様) |
0 | 1 | 1 |
ExecStart= のコマンドが存在しない |
Command /opt/demo/bin/agent is not executable: No such file or directory |
1 | 1 | 1 |
Requires= の先が存在しない |
オプションなし: Unit missing-dep.service not found./=no: なし |
1 | 0 | 1 |
旧名と新名で結果が逆になる
表の3行目と4行目は紛らわしいので補足します。StartLimitIntervalSec= は [Unit] セクションのキー(systemd.unit(5))なので、[Service] に置くと未知のキーとして無視されます。一方、旧名の StartLimitInterval= は互換のため [Service] でも受け付けられ、systemd v257 のソース(src/core/load-fragment-gperf.gperf.in)でも Service.StartLimitInterval は [Unit] 側と同じ設定値に割り当てられています。つまり旧名は「警告なしで通る」のが正しい挙動で、verify では誤りとして検出できません。新しく書くunitは、新名を [Unit] に書くのに統一しておくと迷いません。
修正後の good.service
キーの綴りと値を直し、StartLimitIntervalSec= / StartLimitBurst= を [Unit] に置いたものです。
[Unit]
Description=demo agent
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
ExecStart=/bin/true
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=default.target
$ systemd-analyze verify --man=no --recursive-errors=no units/good.service; echo exit=$?
exit=0
$ systemd-analyze verify --man=no --recursive-errors=one units/good.service; echo exit=$?
exit=0
何も出力されず、exit 0 です。
対策2:出力が空でなければ失敗にするラッパー(systemd 250 未満、または警告を厳しく扱いたい場合)
--recursive-errors= は systemd 250 で追加されたオプションです。GitHub のホストランナーのイメージ情報(actions/runner-images、2026-10-12 版)では、ubuntu-24.04 の systemd は 255.4、ubuntu-22.04 は 249.11 と記載されています。ubuntu-latest は現時点で ubuntu-24.04 を指していますが、向き先は新しいイメージの提供にあわせて順次変わるので、バージョンに依存するならラベルを固定しておくと安心です。249 の環境ではこのオプションが使えないので、終了コードに加えて「出力が1行でもあれば失敗」と判定します。警告の文言はバージョンで変わり得るので、文言ではなく出力が空かどうかで見ています。
#!/usr/bin/env bash
# systemd-analyze verify を unit ごとに実行し、終了コードが 0 以外か、出力が1行でもあれば失敗にする
set -uo pipefail
if [ "$#" -eq 0 ]; then
echo "usage: $0 UNIT_FILE..." >&2
exit 2
fi
rc=0
for f in "$@"; do
out="$(systemd-analyze verify --man=no "$f" 2>&1)"; st=$?
if [ "$st" -ne 0 ] || [ -n "$out" ]; then
echo "NG: $f (exit=$st)"; printf '%s\n' "$out" | sed 's/^/ /'; rc=1
else
echo "OK: $f"
fi
done
exit "$rc"
$ ./bin/verify-units.sh units/good.service units/typo.service units/requires.service; echo exit=$?
OK: units/good.service
NG: units/typo.service (exit=0)
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
NG: units/requires.service (exit=1)
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
typo.service は verify 自体は exit 0 ですが、出力があるので NG になります。オプションを付けていないので、Requires= の欠落も拾えます。
引数が0件のまま成功してしまうと、CIでファイルの指定を間違えたときに気づけません。そのため0件は使い方を表示して exit 2 にしています。
$ ./bin/verify-units.sh; echo exit=$?
usage: ./bin/verify-units.sh UNIT_FILE...
exit=2
deploy/*.service のようにglobで渡して1件も一致しなかった場合は、bash の既定ではパターンがそのまま渡り、verify 側が失敗するのでこちらも NG になります。
$ ./bin/verify-units.sh deploy/*.service; echo exit=$?
NG: deploy/*.service (exit=1)
Failed to prepare filename deploy/*.service: Invalid argument
exit=1
まとめ
-
systemd-analyze verifyは、オプションなしでは警告があっても exit 0。マニュアルに明記された仕様です。 - systemd 250 以降は
--recursive-errors=noで、未知のキー・不正な値を exit 1 にできます。 -
=noではRequires=の先の欠落が出力ごと消えます(依存先を読まない実装)。依存先も見るなら=one。 - セクション名の誤りは、
[Service]なら exit 1、[Unit]や[Install]なら警告扱いで exit 0。 - 旧名の
StartLimitInterval=は[Service]でも有効なキーなので、verify では検出されません。 - systemd 250 未満では、出力が空でなければ失敗にするラッパーで代用します。
参考
- systemd-analyze(1): https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html
- systemd.unit(5): https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html
- systemd v257
src/core/load-fragment-gperf.gperf.in: https://github.com/systemd/systemd/blob/v257/src/core/load-fragment-gperf.gperf.in - systemd v257
src/analyze/analyze-verify-util.c: https://github.com/systemd/systemd/blob/v257/src/analyze/analyze-verify-util.c - actions/runner-images Ubuntu 24.04: https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md
- actions/runner-images Ubuntu 22.04: https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md