09 · systemd Deep Dive¶
Level 1 introduced systemd as "the init system that starts your service". That is the tutorial version. In a product, systemd is the thing that decides whether a failed daemon takes the device down, whether a hung board recovers by itself at 3 a.m., and whether your logs destroy the flash. This module is about those decisions: dependency and ordering semantics, restart policy, activation, timers, watchdogs, and journald sizing.
Dependencies and ordering are separate axes¶
The single most common systemd mistake is assuming Requires= implies
ordering. It does not.
| Directive | Meaning |
|---|---|
Requires=b.service |
If b fails to start, a is not started. No ordering. |
Wants=b.service |
Try to start b; carry on regardless. No ordering. |
BindsTo=b.service |
Like Requires, and a is stopped if b stops |
After=b.service |
Ordering only: start a after b. No dependency. |
Before=b.service |
Ordering only, the other direction |
Conflicts=b.service |
Starting a stops b |
You almost always want both: Requires= (or Wants=) and After=.
Requires= alone starts both units in parallel and your daemon races the
thing it depends on — a bug that reproduces on 1 boot in 50, only on the
fastest hardware.
Wants= is the right default for anything optional, because a Requires=
chain propagates failure further than people expect.
A production service unit¶
# /etc/systemd/system/appd.service
[Unit]
Description=Appliance control daemon
Documentation=man:appd(8)
Wants=network-online.target
After=network-online.target data.mount
RequiresMountsFor=/data
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=notify
ExecStart=/usr/sbin/appd --config /data/etc/appd.conf
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=20s
WatchdogSec=30s
User=appd
Group=appd
RuntimeDirectory=appd
StateDirectory=appd
# Hardening — cheap, and each line removes a class of exploit
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/data/appd
RestrictSUIDSGID=yes
MemoryMax=64M
[Install]
WantedBy=multi-user.target
Points worth dwelling on:
Type=decides when systemd considers the service "up".simple(default) means "immediately after fork" — almost always wrong, because dependent units start before you are ready.forkingneedsPIDFile=.notifyis correct: the daemon callssd_notify(0, "READY=1")when it is genuinely serving.oneshot+RemainAfterExit=yesis for setup tasks.StartLimitIntervalSec/StartLimitBurstcap the restart loop. Five failures in five minutes and systemd gives up and leaves the unitfailed. Without this,Restart=alwayson a daemon that crashes at startup spins the CPU forever and writes a journal entry every time.ProtectSystem=strictmakes the entire filesystem read-only for this service except whatReadWritePaths=allows. On a read-only rootfs (module 8) this costs nothing and is nearly free defence in depth.StateDirectory=appdcreates and owns/var/lib/appdwith the right permissions — nomkdirin anExecStartPre=.
root@target:~# systemd-analyze verify /etc/systemd/system/appd.service
root@target:~# systemctl daemon-reload && systemctl enable --now appd
root@target:~# systemctl status appd
● appd.service - Appliance control daemon
Loaded: loaded (/etc/systemd/system/appd.service; enabled)
Active: active (running) since Mon 2026-08-04 09:02:11 UTC; 3min ago
Main PID: 310 (appd)
Status: "Polling 4 sensors"
Memory: 18.2M (max: 64.0M)
systemd-analyze verify catches typos, unknown directives and missing
dependencies without starting anything — run it in CI on every unit you
ship.
Targets and boot time¶
root@target:~# systemctl get-default
multi-user.target
root@target:~# systemd-analyze
Startup finished in 1.412s (kernel) + 2.980s (userspace) = 4.392s
multi-user.target reached after 2.901s in userspace
root@target:~# systemd-analyze blame | head -5
1.204s appd.service
812ms systemd-udev-settle.service
344ms systemd-networkd-wait-online.service
root@target:~# systemd-analyze critical-chain appd.service
multi-user.target @2.901s
└─appd.service @1.697s +1.204s
└─network-online.target @1.688s
blame shows the slowest units; critical-chain shows the ones that
actually delayed boot — a unit can be slow and irrelevant. On embedded,
systemd-udev-settle.service and *-wait-online are the usual culprits,
and both are usually removable once you express your real dependency
(a specific device unit, or nothing at all).
Socket and path activation¶
Activation lets a service not run until it is needed — which on a memory-constrained board is the difference between 12 and 20 resident daemons.
# /etc/systemd/system/diagnostics.socket
[Unit]
Description=Diagnostics API socket
[Socket]
ListenStream=127.0.0.1:9000
Accept=no
[Install]
WantedBy=sockets.target
# /etc/systemd/system/diagnostics.service
[Unit]
Description=Diagnostics API
Requires=diagnostics.socket
After=diagnostics.socket
[Service]
ExecStart=/usr/sbin/diagd
StandardInput=socket
systemd holds the listening socket from boot; the first connection starts
diagd. Connections made while it starts are queued, not refused — so
there is no race for the client either.
Path activation reacts to the filesystem:
# /etc/systemd/system/config-reload.path
[Path]
PathChanged=/data/etc/appd.conf
Unit=config-reload.service
[Install]
WantedBy=multi-user.target
Timers instead of cron¶
# /etc/systemd/system/log-rotate.timer
[Unit]
Description=Rotate application logs
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=900
AccuracySec=1min
[Install]
WantedBy=timers.target
root@target:~# systemctl list-timers --all
NEXT LEFT UNIT ACTIVATES
Tue 2026-08-05 00:11:43 UTC 14h left log-rotate.timer log-rotate.service
root@target:~# systemd-analyze calendar "daily"
Normalized form: *-*-* 00:00:00
Next elapse: Tue 2026-08-05 00:00:00 UTC
Three things cron cannot do and a fleet needs. Persistent=true runs a
missed job after a device that was powered off comes back. RandomizedDelaySec
stops ten thousand devices hitting your server in the same second.
OnBootSec=/OnUnitActiveSec= express "5 minutes after boot, then every
hour" without arithmetic. And the job's output goes to the journal
automatically instead of a mail spool that does not exist.
Watchdogs¶
Two layers, and you want both.
Software watchdog — systemd kills and restarts a service that stops
pinging it. WatchdogSec=30s in the unit, plus in the daemon:
Only ping from the code path that proves the daemon is working — pinging from a timer thread while the main loop is deadlocked is a watchdog that reports health it does not have.
Hardware watchdog — the SoC resets the board if the kernel itself
hangs. systemd drives /dev/watchdog:
# /etc/systemd/system.conf.d/watchdog.conf
[Manager]
RuntimeWatchdogSec=60s
RebootWatchdogSec=10min
ShutdownWatchdogSec=10min
root@target:~# wdctl
Device: /dev/watchdog0
Identity: imx2+ watchdog [version 0]
Timeout: 60 seconds
Pre-timeout: 0 seconds
root@target:~# journalctl -b -1 -n 5 # what the *previous* boot said
journalctl -b -1 is the first command to run after an unexplained reboot:
if the last boot's log just stops mid-sentence, the watchdog fired.
journald on flash¶
Default journald settings will happily write hundreds of MB. On eMMC that is module 6's failure mode.
# /etc/systemd/journald.conf.d/embedded.conf
[Journal]
Storage=volatile
RuntimeMaxUse=16M
RuntimeMaxFileSize=2M
MaxLevelStore=info
ForwardToSyslog=no
RateLimitIntervalSec=30s
RateLimitBurst=200
Storage=volatile keeps the journal in /run (RAM) — nothing touches
flash, and logs are lost on reboot. That is the right default for most
appliances, paired with shipping important events off-device. If you must
persist, use Storage=persistent with SystemMaxUse=32M and put
/var/log/journal on the data partition, never the rootfs.
Traps¶
systemd traps
Requires=withoutAfter=. Parallel start, intermittent race. This is the classic.Restart=alwayswith no start limit. A daemon that crashes on startup restarts forever, pins a core, and floods the journal — which, if the journal is persistent, then fills the flash.Type=simplefor something that needs to be ready. Dependents start against a service that has not opened its socket yet.- A watchdog pinged by the wrong thread hides exactly the hangs it exists to catch.
RuntimeWatchdogSecshorter than your slowest legitimate operation reboots healthy devices during, say, a firmware update. Coordinate the value with every long-running task, and disable the watchdog explicitly around updates.- Editing units under
/lib/systemd/systemon a read-only rootfs is impossible, and on a writable one gets overwritten by the next update. Use/etc/systemd/system/orsystemctl edit. - Forgetting
systemctl daemon-reloadafter changing a unit — systemd keeps running the old definition and you debug a file that is not in effect. ProtectSystem=strictwithoutReadWritePaths=makes a previously-working service fail with EROFS in a way whose error message points at the application, not the unit.
Cheat sheet¶
| Item | Purpose |
|---|---|
Requires= + After= |
Dependency and ordering — you need both |
Wants= |
Optional dependency; failure does not propagate |
BindsTo= |
Stop this unit when the other stops (device units) |
Type=notify + sd_notify(READY=1) |
Accurate "service is up" signalling |
Restart=always, RestartSec= |
Restart policy and backoff |
StartLimitIntervalSec/StartLimitBurst |
Cap the restart loop |
WatchdogSec= + WATCHDOG=1 |
Per-service software watchdog |
RuntimeWatchdogSec= in system.conf |
Hardware watchdog via /dev/watchdog |
ProtectSystem=strict, ReadWritePaths= |
Filesystem hardening |
StateDirectory=, RuntimeDirectory= |
Managed /var/lib/X, /run/X |
.socket + StandardInput=socket |
Socket activation — start on first use |
.path + PathChanged= |
Run a unit when a file changes |
.timer, Persistent=, RandomizedDelaySec= |
cron replacement for fleets |
systemd-analyze verify \| blame \| critical-chain |
Lint / slowest / boot-critical |
systemctl edit <unit> |
Override a vendor unit correctly |
journalctl -b -1 -n 50 |
Previous boot's log — did the watchdog fire? |
journald.conf: Storage=volatile, RuntimeMaxUse= |
Keep logs off the flash |
On verification
Every directive above is used per its documented semantics in
systemd.unit(5), systemd.service(5), systemd.timer(5),
systemd.socket(5) and journald.conf(5), and the units are written to
pass systemd-analyze verify. They were not booted on a target while
writing this page — run systemd-analyze verify yourself after copying
them, since directive availability varies with systemd version.
How It Actually Works¶
Ordering (After=/Before=) and dependency (Requires=/Wants=)
are genuinely separate graphs that systemd merges only at transaction
time. Internally, every unit is a node with two distinct edge sets:
requirement edges (should this unit even start because of that one) and
ordering edges (if both are starting, which goes first). systemd's
manager_run_generators+transaction_add_job machinery builds the job
transaction by first pulling in everything required, then topologically
sorting the ordering edges among the units actually in that transaction —
which is exactly why Requires= without a matching After= starts both
units in parallel with no guaranteed order, a foot-gun this section's
"two separate axes" framing exists specifically to prevent.
Socket activation works because the socket itself — not the service
— is what's listening at boot. systemd calls bind()+listen() on
the socket described by a .socket unit itself, then, on first
connection, does the fork()+exec() of the matching .service and
hands it the already-listening file descriptor over sd_listen_fds()
(a documented fd-numbering contract starting at fd 3). The service never
calls bind() at all in this mode — this is exactly why a socket-
activated service can restart or crash without dropping a single pending
connection: the kernel's accept queue for that socket is unaffected by
the service process's lifecycle, only fd hand-off changes.
A hardware watchdog is petted by systemd only if the manager itself
is still making forward progress — that's the whole point.
WatchdogSec= in a unit configures software watchdog behavior
(systemd kills and restarts the unit if it stops calling
sd_notify(WATCHDOG=1) in time), but the separate RuntimeWatchdogSec=
in system.conf arms the SoC's actual hardware watchdog timer via
/dev/watchdog, and PID 1 itself pets that device on a timer only as
long as its own main loop is still running. If systemd itself wedges —
not just a service — nothing pets the hardware timer, and the SoC's
watchdog IP block forces a hard reset with no software involved at all,
which is the one failure mode software-only supervision (respawn units,
Restart=) structurally cannot cover.
journald on flash is bounded by explicit rotation, and volatile
storage is a deliberate wear-leveling decision, not a limitation.
Storage=volatile in journald.conf makes journald write only to a
tmpfs-backed /run/log/journal, discarding history on reboot;
SystemMaxUse=/RuntimeMaxUse= cap the on-disk (or in-RAM) journal size
and journald enforces it by rotating to a new .journal file and
deleting the oldest once the cap is hit, checked on every write — the
same log-structured-append-then-reclaim pattern as JFFS2/UBIFS, applied
here to log data instead of a filesystem, for the identical reason:
bounding total writes to flash.
Exercise¶
(1) Write appd.service as above for any small daemon, run
systemd-analyze verify on it, then deliberately remove the After= line
and describe a boot scenario in which the service now fails intermittently.
(2) Convert the service to Type=notify (a five-line sd_notify call is
enough) and show, with systemctl status and systemd-analyze
critical-chain, that a dependent unit now starts at the right moment.
(3) Give it WatchdogSec=10s, stop pinging deliberately, and capture the
journal lines showing systemd killing and restarting it; then add
StartLimitBurst=3 and show the unit going to failed instead of looping.
(4) One paragraph: your device must apply a firmware update that takes up
to four minutes with the storage busy, while a 60-second hardware watchdog
is armed. Describe exactly how you would keep the watchdog protection for
normal operation without the update rebooting the device halfway through —
and what the failure mode of your scheme is if the update itself hangs.