14 - Early Userspace: dracut Hooks, udev and the initqueue¶
Backlink: 00 - Track Overview
Module 08 walks the boot chain end to end and hands off at the initramfs. This module lives inside that handoff. Early userspace is a small self-contained Linux system whose only job is to assemble enough of a machine to mount the real root and then get out of the way. It is also the one environment where the usual debugging reflexes fail: no persistent logs, no package manager, a shell that may not exist, and a failure mode that presents as a cursor blinking beside a message about a device you have never heard of. The staff-level skill is answering three questions fast: which component was supposed to do this, did it run, and what is it waiting for.
Before you start¶
Prerequisites. 08 - Boot and Init for the chain, firmware, and rd.break recovery workflow. 07 - systemd for units, jobs, targets, and generators. 05 - Storage and LVM helps, because stacked storage supplies most of the devices early userspace waits on.
What you will be able to do afterwards.
- Enumerate what is in an initramfs, and what can run in it, without booting anything.
- Attribute an early-boot behaviour to a specific script and line.
- Explain why re-triggering device events cannot defeat a property gate.
- Diagnose a boot that is waiting, by reading what it is waiting for rather than guessing.
- Name which component mounts the real root, and it is not a dracut hook.
Time. Deep-dive about 90 minutes. Labs 1 and 3 are 20 minutes each. Labs 2, 4 and 5 involve building images and rebooting; budget an hour each. Lab 5 deliberately breaks bootability, so snapshot first.
Six terms the rest of this module assumes.
| Term | Meaning |
|---|---|
| hook | a shell script in /lib/dracut/hooks/<point>/, run by the systemd unit owning that point |
| coldplug | the moment a synthetic add event is replayed for every device already present, so rules that would have fired at plug-in fire now |
| settled | the udev event queue is empty; every event dispatched so far has been processed |
| gate | a rule-level early exit keyed on a udev property, meaning "the operator did not ask for this" |
| initqueue | a retry loop that re-runs queued work until every exit condition is satisfied |
$initdir / $moddir |
inside module-setup.sh: the staging tree being assembled into the image, and the module's own source directory |
The shape of early userspace¶
Two layers do the work, and conflating them is the most common source of confusion. systemd generators and units own the structure. dracut hooks are shell scripts bolted onto that structure at defined points.
dracut-cmdline.service → hooks/cmdline/*.sh parse /proc/cmdline + /etc/cmdline.d/
(also: check root= and rootok are set)
dracut-pre-udev.service → hooks/pre-udev/*.sh load modules, act on the final root
systemd-udevd.service udevd starts
dracut-pre-trigger.service → hooks/pre-trigger/*.sh LAST chance to set udev state
systemd-udev-trigger.service COLDPLUG: every device replays now
dracut-initqueue.service → hooks/initqueue/*.sh the retry loop, default 180s
→ initqueue/settled/* when udev has settled
→ initqueue/online/* when an interface comes up
→ initqueue/timeout/* at HALF of rd.retry, loop continues
→ initqueue/finished/* all return 0 → loop ends
sysroot.mount ← systemd-fstab-generator, NOT a hook
dracut-mount.service → hooks/mount/*.sh
initrd-parse-etc.service re-reads /sysroot/etc/fstab
dracut-pre-pivot.service → hooks/pre-pivot/*.sh
→ hooks/cleanup/*.sh
initrd-switch-root.target PID 1 re-execs into the real root
Three properties of that diagram drive almost every early-boot bug:
- Everything before the coldplug is setup; everything after is reaction. A hook that wants to influence how device events are processed must run before
systemd-udev-trigger.service. A hook that wants to react to devices that appeared must run at or after the initqueue. - The initqueue is a loop, not a sequence. It runs until every
finished/hook returns 0. If one never does, the boot does not fail. It waits, and by default it waits forever. - The thing that mounts root is a generator, not a hook. If you go looking for the hook that mounts
/sysroot, you will not find one.
Concept deep-dive¶
1. Reading an initramfs¶
An initramfs is a compressed cpio archive the kernel unpacks into a tmpfs and executes /init from. On a systemd distro /init is systemd, so early userspace is a real systemd instance with its own unit tree, running from RAM.
Do not guess at contents. Read them:
IMG=/boot/initramfs-$(uname -r).img
lsinitrd -m "$IMG" # which dracut modules are in it
lsinitrd "$IMG" # full file listing
lsinitrd -f etc/fstab "$IMG" # print one file straight out, no unpacking
lsinitrd --unpack "$IMG" # extract the main archive here
lsinitrd --unpackearly "$IMG" # extract the early (microcode) archive instead
lsinitrd -f is the command you will reach for most often. Most early-boot arguments are settled by reading the file that is actually in the image, which is frequently not the one on disk in /usr/lib/dracut/modules.d/ (see §7).
On x86 the image is two concatenated archives: an uncompressed early cpio holding CPU microcode, then the compressed main archive. lsinitrd and --unpack operate on the main archive and skip the early one, which is why an unpacked tree contains no kernel/x86/microcode. That is not a failed extraction. early_microcode defaults to yes and can be disabled, so two archives is a default rather than a law. 08 - Boot and Init §4 covers the on-disk format properly; this is the working summary.
Is this even the image that booted? Compare the image's kernel version against uname -r, and its mtime against your last change. An initramfs edited after the last boot is not the one you are running.
2. The systemd skeleton the hooks hang on¶
This section exists because the hook-centric mental model is incomplete in a way that strands people. On a stock Rocky 9 boot with a plain LVM root, most dracut hooks are no-ops and the boot is essentially generators, udev, and device units.
Generators run first, before unit loading, and synthesise units from non-unit configuration.
| Generator | What it produces |
|---|---|
systemd-fstab-generator |
sysroot.mount, from root=, rootfstype=, rootflags= on the cmdline |
systemd-cryptsetup-generator |
systemd-cryptsetup@.service instances, from rd.luks.* and /etc/crypttab |
systemd-gpt-auto-generator |
discovers root by GPT partition type UUID, with no root= at all |
So root= is consumed by a generator. No dracut hook mounts the root filesystem.
The target chain, documented in dracut.bootup(7):
initrd-root-device.target → sysroot.mount → initrd-root-fs.target
→ dracut-mount.service → initrd-parse-etc.service → initrd-fs.target
→ initrd.target → initrd-cleanup.service → initrd-switch-root.target
sysinit.target and basic.target also exist inside the initramfs, which is what makes the unit and job model from 07 - systemd transfer directly.
initrd-parse-etc.service is the one to remember. After root is mounted it re-reads /sysroot/etc/fstab and pulls in mounts for entries carrying the x-initrd.mount option. That option is what promotes an ordinary fstab line into something the initramfs must satisfy before switch-root, which is why adding it can convert a working boot into a hang. Lab 5 uses exactly this.
3. The dracut module system¶
A dracut module is a directory under /usr/lib/dracut/modules.d/ named NNname, where NN orders installation. Each contains module-setup.sh with up to four functions:
| Function | Purpose | Returns |
|---|---|---|
check() |
should this module be included? | 0 = yes, 1 = no, 255 = only if another module requires it |
depends() |
echo module names this one needs | pulled in automatically |
install() |
install userspace: binaries, scripts, rules, hooks | |
installkernel() |
install kernel modules |
The helpers:
inst_multiple cryptsetup awk # binaries plus their library deps
inst /etc/crypttab # one file, same path in the image
inst_hook pre-trigger 30 "$moddir/parse-crypt.sh" # place a hook at a point, with a priority
inst_rules 10-dm.rules # pull a rule from the system rules dirs
inst_rules "$moddir/99-mine.rules" # or ship your own
instmods '=drivers/md' # a whole kernel driver subtree
inst_hook <point> <priority> <file> places a script at hooks/<point>/<priority><name>.sh. The priority is a two-digit string sorted lexically. There is no dependency resolution between hooks. Ordering within a point is entirely that number, and a module author choosing 01 over 30 is making a load-bearing decision nothing validates.
Choosing your own priority: list what is already there for that point (ls hooks/<point>/ in an unpacked image) and place yourself relative to it deliberately. By convention 01–09 is for things that must precede all parsing; parsers cluster at 30.
check() returning 255 is why a module you did not ask for appears in the image: something else depends() on it.
4. Hook points¶
Each hook point is executed by a systemd unit. dracut.modules(7) is authoritative; the practical mapping:
| Hook | Driven by | Use it for |
|---|---|---|
cmdline |
dracut-cmdline.service |
parsing kernel args; writing rules that depend on them |
pre-udev |
dracut-pre-udev.service |
loading modules; runs after root/rootok are checked |
pre-trigger |
dracut-pre-trigger.service |
last chance to set udev state before the coldplug |
initqueue and its settled/online/timeout/finished subdirs |
dracut-initqueue.service |
see §8 |
pre-mount |
dracut-pre-mount.service |
just before /sysroot is mounted |
mount |
dracut-mount.service |
mounting the real root |
pre-pivot, cleanup |
dracut-pre-pivot.service |
final actions before switch-root |
shutdown |
the shutdown pivot | see §11 |
Two traps:
pre-mount is later than you think. If the boot is blocked waiting on a device, pre-mount may never run, because the mount it precedes is the thing that is stuck. Debug hooks placed there are useless for diagnosing a device that never appears. Use pre-trigger or initqueue.
pre-trigger is a cliff edge. After it the coldplug fires and every device replays its add event. Anything that changes how those events are interpreted must already be in place.
Note: 08 - Boot and Init §5 describes
pre-mountas where LUKS unlock and array assembly happen. That is the common case when those devices appear promptly; it is not where the waiting happens. When a device never arrives, the boot blocks beforepre-mount.
5. How a hook executes¶
Hooks are sourced, not forked, by the hook runner, so they inherit the dracut shell environment. Two consequences: you get the dracut-lib.sh helpers for free, and $0 is the sourcing shell, not your script — so a hook cannot identify itself from $0. A hook that calls exit can also take down more than itself.
Standard preamble:
type getarg > /dev/null 2>&1 || . /lib/dracut-lib.sh
| Function | Behaviour |
|---|---|
getarg foo= |
first value of foo=; non-zero if absent |
getargs foo= |
all values, space separated (repeatable options) |
getargbool <default> foo |
boolean with a default; handles foo=0, foo=1, bare foo |
udevproperty KEY=value |
set a global udev property on the running udevd |
wait_for_dev <path> |
register a wait on a device path (see §8) |
info / warn |
log to console and the dracut log |
getargbool <default> name is where subtle logic lives: an option can be absent (take the default), present bare, or explicitly =0/=1. Reading a parse-*.sh correctly means reading those defaults, because "absent" and "set to 0" often take different branches.
6. Where the kernel command line comes from¶
The effective command line is not just /proc/cmdline. Dracut concatenates it with configuration inside the image. All the surfaces, in the order you should check them:
| Surface | Notes |
|---|---|
| the bootloader entry | on BLS, /boot/loader/entries/*.conf, the options line |
/proc/cmdline |
what the kernel actually received |
/etc/cmdline.d/*.conf inside the image |
shipped in the initramfs; see §9 for what generates these |
/etc/cmdline inside the image |
single file, deprecated, still parsed |
/etc/conf.d/* inside the image |
sourced, so it sets shell variables directly |
/etc/kernel/cmdline |
on BLS, what a kernel update rebuilds the entry from |
That last one is the operational trap: an argument present in the running /proc/cmdline but absent from /etc/kernel/cmdline disappears at the next kernel install, and the failure surfaces at a reboot long after the change that caused it.
dracut --print-cmdline prints the storage and root arguments dracut would bake in for this host's current root stack (root=, rd.lvm.lv=, rd.luks.uuid=, rd.md.uuid=). It is not a general "expected cmdline", so do not diff it against /proc/cmdline and expect a match.
7. udev in the initramfs, and build-time rule surgery¶
Rules are read from four directories: /etc/udev/rules.d, /run/udev/rules.d, /usr/local/lib/udev/rules.d, and /usr/lib/udev/rules.d.
All rules files are collectively sorted and processed in lexical order, regardless of which directory they live in. So 10-foo.rules in /etc runs before 60-bar.rules in /usr/lib. Files with identical filenames replace each other, and for that replacement /etc wins over /run, which wins over /usr.
That replacement rule gives you the supported override: a symlink in /etc/udev/rules.d/ with the same name as a shipped rule, pointing at /dev/null, disables it entirely. Prefer that to editing anything package-owned.
/run/udev/rules.d/ matters here more than its usual obscurity suggests: it is where a hook can write a rule at runtime.
Dracut may modify a rule as it installs it. A module-setup.sh can sed the copy it just placed in $initdir, so the rule inside the image can differ from the one on your running system. A module that intends to replace a stock behaviour will often strip the stock rule out of its own copy, and that is invisible unless you look inside the image.
# Read the copy that is actually in the image, not the one on disk
lsinitrd -f usr/lib/udev/rules.d/10-dm.rules "$IMG"
# Which shipped modules do this?
grep -rl 'sed .*\$initdir' /usr/lib/dracut/modules.d/*/module-setup.sh
# Enumerate everything in the image that can invoke a program
mkdir -p /var/tmp/ir && cd /var/tmp/ir && lsinitrd --unpack "$IMG"
grep -rn 'RUN+=\|IMPORT{program}' etc/udev/rules.d usr/lib/udev/rules.d
If the tool you expect to run appears in no RUN+= or IMPORT{program}= line inside the image, no amount of triggering events will make it run. That check ends a lot of speculation in five minutes.
8. Properties as gates, and why ordering decides everything¶
udevadm control --property=KEY=value sets a global property applied to every subsequent event. Rules match it with ENV{KEY}:
ENV{rd_NO_LVM}=="?*", GOTO="lvm_end"
...
RUN+="/sbin/lvm vgchange -ay ..."
LABEL="lvm_end"
This is the standard dracut idiom for "the operator did not ask for this, so skip it". A parse-*.sh decides, sets the property, and every event processed after that point is gated.
The real instances, so the pattern is not abstract:
| Disable | Positive assertion |
|---|---|
rd.lvm=0, rd.md=0, rd.dm=0, rd.luks=0, rd.multipath=0 |
rd.lvm.lv=, rd.md.uuid=, rd.luks.uuid= |
rd.auto (enable autoassembly generally; off by default since dracut 024) |
|
rd.driver.pre=, rd.driver.post=, rd.driver.blacklist=, rd.modules-load= |
Three consequences that generalise well beyond storage:
Global properties are applied to events, not written to the udev database. udevadm info --query=property --name=/dev/sda will not show one. Its absence there proves nothing. Observe it with udevadm monitor --property, or by having a rule act on it.
A property set after an event was processed cannot retroactively affect that event.
Therefore re-triggering events later does not undo a gate. A udevadm trigger --action=add at initqueue time replays events straight back into the same gate. Re-triggering only helps if the state changed between the original event and the replay.
The failure pattern to watch for is a hook that triggers device events before the parse-*.sh meant to gate them. Anything that probes hardware or calls udevadm trigger early has this effect, and the ordering that decides it is nothing but two-digit hook priorities in modules that never reference each other. It is invisible in normal operation and timing-sensitive: adding a udevadm settle elsewhere can change the outcome. Lab 4 constructs this deliberately.
9. hostonly, and where /etc/cmdline.d/ comes from¶
hostonly defaults to yes. So your /boot image is host-specific unless something overrode that.
| Setting | Effect |
|---|---|
hostonly="yes" (default) |
install only what this host needs |
hostonly_mode="sloppy" / "strict" |
how aggressively to prune drivers; strict is minimal and least survivable across hardware change |
hostonly_cmdline |
defaults to no, but is forced to yes when hostonly=yes unless set explicitly |
That last row closes a loop §6 left open. hostonly_cmdline=yes is what generates the /etc/cmdline.d/*.conf files inside the image, derived from the host's actual storage stack. A generic image (-N / --no-hostonly) has no such file, which is why it needs rd.lvm.lv= / rd.luks.uuid= / rd.md.uuid= supplied on the bootloader entry instead.
08 - Boot and Init §5 covers the clone-to-new-hardware failure mode this creates.
10. The initqueue, and the initqueue command¶
dracut-initqueue.service runs a loop. Each pass it executes initqueue/*.sh; when udev settles it runs initqueue/settled/*.sh; then it evaluates initqueue/finished/*.sh. When every finished hook returns 0, the loop exits and boot proceeds.
Work gets into the queue with the initqueue command. This is the mechanism connecting §7's rules to this loop, and it is why a RUN+= rule does not do the work itself — udev rule execution must not block:
initqueue --settled --onetime --unique --name=lvm_scan lvm vgchange -ay
| Flag | Meaning |
|---|---|
--settled / --finished / --timeout / --online |
which subdirectory to land in |
--onetime |
self-delete after running once |
--unique |
do not queue a duplicate |
--name <n> |
the filename to use, so you can find it later |
So the full story is three parts: a udev rule defers, the initqueue executes, a finished/ hook decides.
wait_for_dev <path> is sugar that does more than register a condition: it writes a finished/ test for that path, a timeout/ warning, an emergency/ hook, and on systemd initramfses the corresponding .device unit dependency. That last part is the bridge to §12 — the initqueue wait and the "start job is running" message are two faces of one call.
The two knobs, both in dracut.cmdline(7):
rd.retry=<seconds>, default 180. How long the loop retries.initqueue/timeout/*.shfires when the loop counter reaches half of this, so about 90 seconds by default, and the loop then keeps running for the remaining half. Timeout hooks are a mid-flight fallback whose job is to change the state thefinished/hooks are testing, so the second half of the budget can succeed. They are not a death rattle.rd.timeout=<seconds>, default 0, meaning forever. How long dracut waits for devices to appear. This is why a missing device produces an indefinite hang rather than an error. [VERIFY: on Rocky 9 this is plumbed through systemd's device job timeout; the man page states only the wait semantics.]
Find what the loop is waiting for directly:
ls /lib/dracut/hooks/initqueue/finished/
cat /lib/dracut/hooks/initqueue/finished/*.sh
Those filenames are frequently self-documenting: a devexists-\x2fdev\x2fdisk\x2fby-uuid\x2f... script names precisely the path that is missing.
11. Network root, and the shutdown pivot¶
Network root is why the initqueue is a loop. A link takes seconds to come up and DHCP can fail and be retried, so a sequence would not work. initqueue/online/*.sh runs whenever an interface comes up. rd.neednet=1 brings up networking even without a network root. root=iscsi:... / root=nfs:... name a network root, and the ip= option configures the interface (dracut.cmdline(7) has the full grammar; do not memorise it).
The rootok contract. pre-udev runs after a check that root and rootok are set. A parse-*.sh that handles a root type must set rootok=1 to claim the root spec. Failing to is the cause of dracut: FATAL: Don't know how to handle 'root=...'. This is the one gate that aborts the boot outright rather than silently skipping work. Diskless HPC provisioning is the CIQ-relevant instance.
The shutdown pivot. dracut-shutdown.service unpacks the initramfs into /run/initramfs during normal boot, so that at shutdown systemd can pivot back into it to unmount and deactivate the root stack it cannot otherwise release: detach LUKS, deactivate the VG, stop the array. There are shutdown and shutdown-emergency hook points for this. It is the answer to "boots fine, hangs on reboot" and "failed to unmount /oldroot", and it is also why /run/initramfs exists at all, which matters for §13.
12. Device units and the "start job is running" message¶
systemd in the initramfs turns udev devices into .device units. A .mount or .service that Requires= one blocks until udev tags that device ready. So:
A start job is running for /dev/disk/by-uuid/… (1min 30s / no limit)
is a device unit that has not appeared. no limit is rd.timeout=0. The device is not slow; nothing will change unless it appears.
Two failure shapes look identical on the console:
- The device does not exist because whatever would create it never ran: a gated rule, a missing kernel module, a rule not present in the image.
- The device does not exist because its backing device never appeared: a LUKS volume on an array that was never assembled, an LV in a VG whose PV is missing.
Distinguishing them is the whole game, which is why the workflow in §13 starts by enumerating what did appear rather than staring at what did not.
13. The troubleshooting toolkit¶
Start here. After a failed boot, dracut writes /run/initramfs/rdsosreport.txt — a packaged snapshot of the cmdline, dmesg, udev state, /proc/mdstat, dm tables, mounts, and the hook inventory. It survives switch-root, so it is also readable after a boot that merely went slowly.
Getting it off a broken box: /run is a tmpfs and will not survive the reboot, so mount something writable and copy it out, or scrape the console.
mkdir /mnt/x && mount /dev/sdX1 /mnt/x && cp /run/initramfs/rdsosreport.txt /mnt/x/
It contains the full kernel command line and device topology, including UUIDs. Sanitise before attaching to a ticket.
Boot arguments, in the order you should reach for them:
| Argument | Effect |
|---|---|
rd.info |
informational messages from dracut scripts |
rd.debug |
set -x tracing of every dracut shell script; the highest-value flag |
rd.udev.info / rd.udev.log_level=<level> |
udev's own logging |
rd.break=<stage> |
shell at a named stage: cmdline, pre-udev, pre-trigger, initqueue, pre-mount, mount, pre-pivot, cleanup |
rd.break |
shell just before switch-root |
rd.shell |
shell on failure instead of emergency |
rd.emergency=reboot\|poweroff\|halt |
what to do on critical failure instead of waiting at a prompt (needs rd.shell=0) |
Choosing the break stage is the skill. For a device that never appears, break at pre-trigger or initqueue. Breaking at pre-mount will not fire, per §4. 08 - Boot and Init owns the rd.break recovery workflow; this module owns which stage to pick.
rd.debug output attributes behaviour to an exact line:
///lib/dracut/hooks/pre-trigger/30-parse-crypt.sh@12(source): udevproperty rd_NO_LUKS=1
That format — file, line, expanded command — is what makes the noise worth it.
Runtime interrogation, once you have a shell:
cat /proc/cmdline # what the kernel got
ls /lib/dracut/hooks/*/ # every installed hook, in order
ls /lib/dracut/hooks/initqueue/finished/ # what the loop is waiting for
udevadm monitor --property --udev # events as they are processed
udevadm info --query=property --name=/dev/sda # the database view of one device
udevadm test /sys/class/block/sda # replay rule evaluation, no state change
udevadm trigger --action=add --subsystem-match=block
journalctl -b -o short-monotonic # after the fact, with timings
udevadm test is underused: it replays rule evaluation for one device and prints which rules matched, without touching anything. When the question is "why did no rule create this symlink", it answers directly.
The initramfs journal is flushed into the main journal at switch-root, so journalctl -b sees rd.debug output after a successful boot. After a failed one, rdsosreport.txt is the fallback.
14. When the image is inside a UKI¶
A Unified Kernel Image packs kernel, initramfs and cmdline into one signed PE binary in EFI/Linux/. Two things this module relies on stop working:
- There is no
/boot/initramfs-*.imgto read. Extract withobjcopy -O binary --only-section=.initrd <uki>.efi initrd.img, orukify inspect. Thenlsinitrdthe result. - The command line may be immutable. It lives in the signed
.cmdlinesection, so appendingrd.debugat the boot menu may be impossible, ignored, or permitted only wheresystemd-bootcmdline editing is explicitly enabled. That affects Labs 3 and 4 directly.
Fallback: build a debug UKI carrying rd.debug in .cmdline, or boot a non-UKI rescue entry. 08 - Boot and Init §7 teaches UKI itself.
15. Building and regenerating the image¶
The labs build to scratch paths. Shipping a fix means regenerating the real image, and the distinction matters.
dracut -f # rebuild the RUNNING kernel's image, in place
dracut -f --kver 5.14.0-570.el9 # a specific kernel
dracut -f --regenerate-all # EVERY installed kernel
Use --regenerate-all. A fix applied with bare dracut -f leaves every other installed kernel unfixed, and the next incident reboots into an older entry where the bug is still present.
What rebuilds it automatically. kernel-install(8) and the drop-ins in /usr/lib/kernel/install.d/*.install, invoked by the kernel RPM scriptlets. So a dnf update kernel picks up your module change for the new kernel only.
Config precedence (dracut.conf(5)), later wins: /etc/dracut.conf, then /usr/lib/dracut/dracut.conf.d/*.conf, then /etc/dracut.conf.d/*.conf, then /run/initramfs/dracut.conf.d/. Same-name files in /etc replace those in /usr/lib. CLI options override all config files — which is why a drop-in setting hostonly="yes" is silently defeated by --no-hostonly on the command line.
Two traps worth internalising:
The padding spaces are load-bearing. add_dracutmodules+=" lvm " — += is naive string concatenation, so omitting the spaces welds two module names into one nonexistent name, silently.
dracutmodules= / -m replaces the entire module set; add_dracutmodules+= / --add extends it. Reaching for -m mymodule to test the module you just wrote produces an unbootable image and no clue why.
16. kdump has its own initramfs¶
Not an edge case; a frequent support issue. kdump builds and maintains a separate image, conventionally /boot/initramfs-<kver>kdump.img, rebuilt by kdumpctl rather than dracut -f, using its own argument set from dracut_args in /etc/kdump.conf.
Consequences:
- Fixing the primary initramfs does not fix kdump's.
- A kdump initramfs failure does not look like a boot failure. It looks like
kdump.servicefailing, or worse, a crash that produces no vmcore, discovered during the incident you needed the vmcore for. - It must reach the dump target, so network and remote storage are in scope even on a local-root machine.
[VERIFY: exact image naming and whether kdumpctl rebuild or kdumpctl restart is current on your Rocky 9 minor.]
Cross-subsystem coupling (the staff lens)¶
- 08 - Boot and Init owns the chain, firmware, UKI, and the
rd.breakrecovery workflow. This module owns what happens inside the initramfs and which stage to break at. - 07 - systemd owns units, jobs, targets and generators. "Start job is running" is a systemd job; the generators in §2 are the initramfs instance of what 07 teaches.
- 05 - Storage and LVM supplies most of the devices being waited on. Stacked storage means one missing layer presents as a missing device several layers up, and the console names only the top.
- 12 - SELinux and Hardening matters because the initramfs runs before policy loads, so an emergency shell here is an unconfined root shell on the console with no auditd. On a machine that unlocks storage from the initramfs,
rd.shellis a security control, not a convenience. - The generalisable lesson is ordering coupling between modules sharing a flat namespace. Hook priorities, udev rule filenames, and unit ordering are all flat namespaces with no dependency checking. Two components that never reference each other can be tightly coupled through them, and the coupling surfaces only as a timing-dependent bug.
Hands-on labs¶
The VM. All labs need a disposable Rocky 9 VM with console access, not SSH. Labs 4 and 5 change boot behaviour; Lab 5 deliberately breaks bootability.
# Snapshot before Labs 2, 4 and 5. Revert instead of repairing.
virsh snapshot-create-as <vm> pre-lab --atomic
virsh snapshot-revert <vm> pre-lab
Every lab builds test images to /var/tmp/, never to /boot, except Lab 5 which must use the real image to be realistic.
Booting a test image. Labs 2 and 4 need this, so here it is once. Copy the running BLS entry and point it at your image:
cp /boot/initramfs-test.img /boot/ # must be under /boot for GRUB to read it
E=$(ls /boot/loader/entries/*.conf | head -1)
cp "$E" /boot/loader/entries/zz-test.conf
sed -i 's/^title .*/title TEST IMAGE/; s|^initrd .*|initrd /initramfs-test.img|' \
/boot/loader/entries/zz-test.conf
grubby --info=ALL | grep -A2 'TEST IMAGE' # confirm it registered
# Reboot and pick "TEST IMAGE" at the menu. Remove zz-test.conf when finished.
/var/tmp is frequently a separate LV that GRUB cannot read, which is why the image is copied to /boot first.
Lab 1 — Inventory an image and prove what can run¶
Objective. Build the habit of reading the image rather than the running system, and learn to answer "can this tool even be invoked here" without booting.
Setup. Any Rocky 9 VM. No snapshot needed; read-only.
Steps.
IMG=/boot/initramfs-$(uname -r).img
lsinitrd -m "$IMG" # modules
mkdir -p /var/tmp/ir && cd /var/tmp/ir && lsinitrd --unpack "$IMG"
for d in usr/lib/dracut/hooks/*/; do echo "== $d"; ls "$d"; done
grep -rn 'RUN+=\|IMPORT{program}' etc/udev/rules.d usr/lib/udev/rules.d | head -40
# Find a module that edits a rule as it installs it, then diff that rule
grep -rl 'sed .*\$initdir' /usr/lib/dracut/modules.d/*/module-setup.sh
Pick one module from that last list, identify which rule file its sed targets, and diff the image copy against the on-disk copy:
diff <(lsinitrd -f usr/lib/udev/rules.d/<rule> "$IMG") /usr/lib/udev/rules.d/<rule>
Prove it. You can state, for one named rule, what dracut removed and why, citing the sed in that module's install(). If lsinitrd -f returns nothing, that rule's module is not in your image — which is itself the §9 hostonly lesson. Rebuild generically and try again:
dracut --force --no-hostonly /var/tmp/l1.img "$(uname -r)"
lsinitrd -m /var/tmp/l1.img | tr ' ' '\n' | sort > /var/tmp/generic.mods
lsinitrd -m "$IMG" | tr ' ' '\n' | sort > /var/tmp/host.mods
diff /var/tmp/host.mods /var/tmp/generic.mods && echo "SAME" || echo "hostonly pruned the above"
Teardown. rm -rf /var/tmp/ir /var/tmp/l1.img /var/tmp/*.mods
Lab 2 — Instrument every hook point and observe the real order¶
Objective. Replace this module's diagram with output you generated. Also see first-hand why $0 cannot identify a sourced hook.
Setup. Snapshot. You will build a module and one test image.
Steps. Note the loop writes one script per hook point with the name baked in, because a sourced hook cannot learn its own path from $0 (§5).
M=/usr/lib/dracut/modules.d/99hooktrace
mkdir -p "$M"
cat > "$M/module-setup.sh" <<'EOF'
#!/bin/bash
check() { return 0; }
depends() { return 0; }
install() {
for h in cmdline pre-udev pre-trigger initqueue initqueue/settled pre-mount pre-pivot cleanup; do
tag=$(echo "$h" | tr '/' '_')
printf '#!/bin/sh\necho "HOOKTRACE $(cut -d" " -f1 /proc/uptime) %s" > /dev/kmsg 2>/dev/null\n' \
"$tag" > "$initdir/trace-$tag.sh"
chmod +x "$initdir/trace-$tag.sh"
inst_hook "$h" 99 "$initdir/trace-$tag.sh"
rm -f "$initdir/trace-$tag.sh"
done
}
EOF
chmod +x "$M/module-setup.sh"
dracut --force --no-hostonly /boot/initramfs-test.img "$(uname -r)"
lsinitrd /boot/initramfs-test.img | grep trace-
Add the zz-test.conf BLS entry from the lab preamble, reboot into it, then:
dmesg | grep HOOKTRACE
Prove it.
dmesg | grep HOOKTRACE | awk '{print $3}' | head -3 | tr '\n' ' '
# Must begin: cmdline pre-udev pre-trigger
[ "$(dmesg | grep -c HOOKTRACE)" -ge 5 ] && echo "SUCCESS: hook order captured"
Uptimes must increase monotonically and place pre-trigger before initqueue. Note where initqueue_settled lands relative to plain initqueue. If pre-mount never appears, explain it using §4.
Teardown. rm -rf "$M" /boot/initramfs-test.img /boot/loader/entries/zz-test.conf then revert the snapshot if anything is odd.
Lab 3 — Read an rd.debug trace and attribute behaviour to a line¶
Objective. Move from "I think that ran" to "it ran, at that line, with that value".
Setup. Any VM you can reboot with an edited command line. No snapshot needed; nothing is persisted.
Steps. At the GRUB menu press e and append rd.debug rd.info rd.lvm=0 to the linux line. The rd.lvm=0 guarantees a parse hook takes its gating branch, so there is something to find. Boot, then:
journalctl -b -o short-monotonic | grep 'hooks/' | head -40
journalctl -b | grep -i 'udevproperty'
journalctl -b | grep 'parse-.*\.sh@'
Prove it.
journalctl -b | grep -m1 'parse-lvm.*udevproperty' && echo "SUCCESS: attributed to a line"
Produce the single line showing a parse-*.sh taking a branch, with file, line number and expanded command. Then read the surrounding conditional with lsinitrd -f and state, in one sentence, what would have had to differ on the command line for the other branch.
Teardown. None; the edit was not persisted.
Lab 4 — Build a gate, then prove a late trigger cannot defeat it¶
Objective. Construct §8's failure deliberately: prove that a late udevadm trigger replays events straight back into an existing gate.
Setup. Snapshot. You will build two images that differ by one line.
Steps.
M=/usr/lib/dracut/modules.d/99gatelab
mkdir -p "$M"
cat > "$M/99-gate.rules" <<'EOF'
ACTION!="add", GOTO="gate_end"
SUBSYSTEM!="block", GOTO="gate_end"
ENV{rd_NO_GATELAB}=="?*", GOTO="gate_end"
RUN+="/bin/sh -c 'echo $env{DEVNAME} >> /run/gatelab.hits'"
LABEL="gate_end"
EOF
cat > "$M/setgate.sh" <<'EOF'
#!/bin/sh
type udevproperty > /dev/null 2>&1 || . /lib/dracut-lib.sh
getargbool 0 gatelab.off && udevproperty rd_NO_GATELAB=1
EOF
cat > "$M/retrigger.sh" <<'EOF'
#!/bin/sh
udevadm trigger --action=add --subsystem-match=block
udevadm settle
EOF
cat > "$M/module-setup.sh" <<'EOF'
#!/bin/bash
check() { return 0; }
depends() { echo udev-rules; return 0; }
install() {
inst_rules "$moddir/99-gate.rules"
inst_hook pre-trigger 30 "$moddir/setgate.sh"
[ -n "$GATELAB_RETRIGGER" ] && inst_hook initqueue 01 "$moddir/retrigger.sh"
}
EOF
chmod +x "$M"/*.sh
# Image A: gate only. Image B: gate plus a late retrigger.
dracut --force --no-hostonly /boot/initramfs-gateA.img "$(uname -r)"
GATELAB_RETRIGGER=1 dracut --force --no-hostonly /boot/initramfs-gateB.img "$(uname -r)"
lsinitrd /boot/initramfs-gateB.img | grep retrigger # present
lsinitrd /boot/initramfs-gateA.img | grep retrigger # absent
Three boots, using the BLS-entry recipe from the preamble, adding rd.break=pre-mount so you get a shell:
| Run | Image | Command line adds |
|---|---|---|
| 1 | A | (nothing) |
| 2 | A | gatelab.off=1 |
| 3 | B | gatelab.off=1 rd.debug |
At each shell: cat /run/gatelab.hits
Prove it.
# Run 1
[ -s /run/gatelab.hits ] && echo "SUCCESS: ungated, rule fired"
# Runs 2 and 3
[ ! -s /run/gatelab.hits ] && echo "SUCCESS: gate held"
# Run 3 only, proving the retrigger really executed
grep -q 'retrigger.sh' /run/initramfs/rdsosreport.txt && echo "SUCCESS: retrigger ran anyway"
Run 3 is the point: the retrigger hook demonstrably executed and the gate still held. Write one sentence explaining why, in terms of when the property was set relative to when the events were processed.
Extension. Move setgate.sh from pre-trigger 30 to pre-trigger 01, and add a hook at pre-trigger 05 running udevadm trigger --action=add; udevadm settle. Predict the outcome before booting. That is the ordering-coupling failure from §8, built on purpose.
Teardown. rm -rf "$M" /boot/initramfs-gate[AB].img /boot/loader/entries/zz-test.conf
Lab 5 — Diagnose a hang from first principles¶
Objective. Practise the three questions against a failure you built, so the workflow is muscle memory when it is someone else's.
⚠️ This lab intentionally makes the current kernel unbootable. Snapshot first. Recovery is in the teardown and depends on the previous kernel's BLS entry existing, which on a freshly installed VM it may not. Do not skip the snapshot.
Setup.
virsh snapshot-create-as <vm> pre-lab5 --atomic # from the host, not the guest
Steps. No placeholders; every value is derived.
truncate -s 512M /var/tmp/lab5.img
LOOP=$(losetup -f --show /var/tmp/lab5.img); echo "using $LOOP"
head -c 64 /dev/urandom > /var/tmp/lab5.key; chmod 600 /var/tmp/lab5.key
cryptsetup luksFormat --batch-mode "$LOOP" /var/tmp/lab5.key
cryptsetup open --key-file /var/tmp/lab5.key "$LOOP" lab5
mkfs.xfs -q /dev/mapper/lab5
UUID=$(blkid -s UUID -o value /dev/mapper/lab5); echo "uuid $UUID"
mkdir -p /mnt/lab5
# x-initrd.mount promotes this into a mount the initramfs must satisfy (see §2)
echo "UUID=$UUID /mnt/lab5 xfs defaults,x-initrd.mount 0 0" >> /etc/fstab
dracut -f # rebuilds the REAL image for the running kernel
reboot
The loop device does not exist in the initramfs, so the mount cannot be satisfied. After roughly 90 seconds the device job fails and you land in the emergency shell. If you instead sit at "a start job is running… no limit", reboot and add rd.shell rd.timeout=60.
Prove it. Answer all three questions with evidence, not inference.
- What is it waiting for?
ls /lib/dracut/hooks/initqueue/finished/and read the filenames. Name the exact device path. - Which layer is actually missing?
losetup -a,dmsetup ls,lsblk,cat /proc/mdstat. The console named the top of the stack; identify the lowest absent layer. - Did the responsible component run? Reboot with
rd.debugand either find the hook that should have created that layer, or prove withlsinitrdthat nothing in the image could have.
Then state which of §12's two failure shapes this was.
Teardown. From the emergency shell, repair the real root before rebooting:
mount -o remount,rw /sysroot
sed -i '/lab5/d' /sysroot/etc/fstab
exit # continue boot, or reboot and pick the previous kernel
Then, from the booted system:
dracut -f --regenerate-all
cryptsetup close lab5 2>/dev/null || true # no-op after a reboot; loops do not persist
losetup -j /var/tmp/lab5.img | cut -d: -f1 | xargs -r losetup -d
rm -f /var/tmp/lab5.img /var/tmp/lab5.key
If any of that fails, virsh snapshot-revert <vm> pre-lab5 is the answer. Note the cryptsetup close and losetup -d are expected to be no-ops after a reboot, because loop devices and dm mappings do not survive one — a silent failure there is not a problem.
Curated resources¶
Primary
dracut.bootup(7)— https://man7.org/linux/man-pages/man7/dracut.bootup.7.html — the unit and target ordering tree. This is the authoritative source for the diagram at the top of this module.dracut.modules(7)— https://man7.org/linux/man-pages/man7/dracut.modules.7.html — hook points, thecheck/depends/installcontract, and the half-of-rd.retrytimeout behaviour.dracut.cmdline(7)— https://man7.org/linux/man-pages/man7/dracut.cmdline.7.html — everyrd.*option, the eightrd.breakstages, and the defaults (rd.retry180,rd.timeout0).dracut.conf(5)— https://man7.org/linux/man-pages/man5/dracut.conf.5.html — config precedence,hostonly,hostonly_mode,hostonly_cmdline, and the+=padding convention.dracut(8)— https://man7.org/linux/man-pages/man8/dracut.8.html —--regenerate-all,--print-cmdline,-mvs--add.lsinitrd(1)— section 1, not 8.-f,-m,--unpack,--unpackearly.udev(7)— https://man7.org/linux/man-pages/man7/udev.7.html — rule syntax, the four rules directories, collective lexical sorting, and the/dev/nullsymlink masking idiom.udevadm(8)— https://man7.org/linux/man-pages/man8/udevadm.8.html —monitor,trigger,info,control,settle,test.systemd.generator(7),systemd-fstab-generator(8),systemd-cryptsetup-generator(8)— what actually createssysroot.mount.bootup(7)— the systemd-side counterpart; read its initrd section alongsidedracut.bootup(7).
Source, when the man page is not enough
/usr/lib/dracut/modules.d/98dracut-systemd/dracut-initqueue.sh— the loop body. Settles the half-of-rd.retryquestion in a dozen lines./usr/lib/dracut/modules.d/99base/initqueue.sh— theinitqueuecommand./usr/lib/dracut/modules.d/99base/dracut-lib.sh—getarg,getargbool,udevproperty,wait_for_dev./usr/lib/dracut/modules.d/90crypt/and90lvm/— worked examples of the gate idiom with realrd_NO_*properties.- Upstream moved to
dracut-ng; RHEL/Rocky 9 tracks the older line. Behaviour you read in dracut-ng may not match your box.
Practical
- Fedora wiki, "How to debug Dracut problems" — the canonical
rd.debug/rdsosreport.txtcompanion to §13. - https://rockyman.org — verify exact flags and config keys against your Rocky version before trusting them in a recovery on a live box. Several claims in this module are dracut-version-specific;
udevadm controluses--log-levelon Rocky 9, where older material says--log-priority.
Senior signal¶
- Mid: "the initramfs didn't assemble it." Senior: "no rule in the image can invoke that tool, here is the
lsinitrdoutput, so triggering will not help." - Mid: reaches for
rd.breakfirst. Senior: reaches forlsinitrd -mand the hook listing first, because half of these are answered without booting. - Mid: treats a hang as a crash and looks for an error. Senior: knows the boot is waiting correctly, goes to
initqueue/finished/, and reads the device path out of the filename. - Mid: "it's a race." Senior: names the two things racing, the hook priorities that order them, and whether the ordering is deterministic.
- Mid: debugs the running system's
/usr/lib/udev/rules.d/. Senior: debugs the copy inside the image, knowing modules edit rules at install time. - Mid: looks for the hook that mounts root. Senior: knows a generator produced
sysroot.mountfromroot=, and goes tosystemctl cat sysroot.mount. - Mid: fixes it with
dracut -f. Senior: uses--regenerate-all, because the next incident may reboot into a different kernel. - Mid: compares package versions across releases. Senior: diffs the built images, because the same package set can produce different hook ordering.
- Mid: enables
rd.shellfleet-wide for convenience. Senior: treats it as a security control on any machine that unlocks storage from the initramfs, and reaches forrd.emergencyinstead.
See also¶
- 08 - Boot and Init — the boot chain, firmware, UKI, and the
rd.breakrecovery workflow - 07 - systemd — units, jobs, targets, and
systemd.generator(7); the generators in §2 are its initramfs instance - 05 - Storage and LVM — the stacked storage early userspace waits on
- 04 - Filesystems and the VFS — what
sysroot.mountis actually doing - 12 - SELinux and Hardening — why an early-boot shell is a hardening concern
- 11 - Observability and Tracing with eBPF — the post-boot half of the same instinct