Lesson 08 / 22
Defining a New Service
The unit file's sections and fields are established; what three restart policies hide across three exit types is counted over nine combinations, and how far the start limit brings the hidden time down is swept.
Contents
The previous lesson counted that the restart policy hides failure: four of six units showed
“active” while all six were failing. One row of the table was left open. The process unit’s
policy was “on-failure” — a supposedly more measured choice than “always” — but the result was
the same: sixty restarts, six hundred seconds of invisibility. The difference between the two
policies never showed up in that run.
This lesson brings the difference out. First it looks at where the policy is written, the unit file; then it multiplies three policies by three exit types and counts which failure disappears in which of the nine combinations. Finally, the mechanism that breaks the concealment is measured: the start limit.
The Unit File’s Sections
A unit file is plain text divided into sections, and a service unit has three of them. The first section places the unit in the graph: its description, what it requires, what it starts after are written here. The second section says how the unit is run: which command, which user, which working directory, which environment variables, unit type, and restart policy. The third section says what enabling means: which target this unit links under. A unit file that skips the third section can be built and started by hand, but cannot be enabled; this is one way to produce the previous lesson’s “not enabled but running” combination.
The file below defines the mock server’s ingest unit. It has not been run; it is written to
show the fields’ layout, and field names vary by service manager:
[Unit] Description=Data ingestion unit After=network.target [Service] Type=notify ExecStart=/usr/local/bin/ingest --config /etc/ingest.conf User=app Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
Two fields are directly relevant to this lesson. The Type field determines when the manager
counts the unit as “started”; the notification-waiting type closes the readiness gap covered in
the previous lesson. The Restart field is the restart policy, and it is what this lesson
measures. RestartSec gives the wait before restarting; a value close to zero can revive a
crashing unit several times a second and keep the processor busy.
If the manager is not told to reread after the file is written, nothing changes; the in-memory copy of the graph stays old. This is the practical counterpart of the point established in lesson one. A syntax error in the file also does not show up at write time but at reread or start time; the manager skips the bad line and loads the unit with an incomplete definition. After writing, seeing what got loaded through the command that prints the unit’s merged definition is more informative than reading the file one more time.
Unit Type Determines When “Started” Is Said
The Type field looks small and affects the entire chain. In the plainest type, the manager
runs the command, and the instant the run returns successfully, it counts the unit as started.
In the forking type, the process backgrounds itself, the first process exits, and the manager
finds the real process from a PID file or the process tree; this type is the most exposed to the
manager and the program lying to each other. In the one-shot type, the unit does a job and
exits; exiting is not a failure, and this type is the legitimate owner of the “exited” state seen
in the previous lesson. In the notifying type, the program explicitly tells the manager when it
is ready, and the chain advances only then.
Choosing the wrong type produces wrong behavior with no error message at all. Defining a program that backgrounds itself with the plainest type lets the manager watch the short-lived process that existed before backgrounding and count the unit as crashed once that process exits. If the restart policy is “always,” the result is exactly the loop measured in the previous lesson: the unit restarts once a second, the state always reads “active.”
A Command That Works by Hand Does Not Work as a Unit
The most common fault unit-file writers run into is a command working in the shell but not as a unit. The reason fits in one sentence: the manager is not a shell. The configuration files read at login are not read, the search path differs from the operator’s, the working directory is the root directory if not specified, environment variables are empty, and if no user is given, the process runs with the same privilege as the manager. There is no shell expansion either: wildcards, quotes, and pipes do not work as expected, because the command line is not handed to a shell.
The consequence is that the fault is looked for in the wrong place. The program exits because it cannot find a file, the error message says “no such file,” and the operator searches for the file; the file is right where it should be, what is missing is the working directory. The right habit is writing paths as absolute in the unit file, stating the working directory and user explicitly, and having required environment variables read from a separate file. Where a unit’s output goes and how it is found there is not this topic’s question, it is the logs topic’s.
Three Policies, Three Exit Types
There is more than one way for a process to stop running. Clean exit: the process ends on its own with a success exit code. Error exit: it ends with a nonzero code. Termination by signal: it is killed by an external signal or by kernel intervention. Restart policies split these three types, and where they split differs from one another.
Policy “no” never restarts, under any circumstance. Policy “on-failure” restarts on error exit and termination by signal, not on clean exit. Policy “always” restarts under all three. The difference only shows up when a clean exit occurs, and that is exactly where it is dangerous: a service expected to be long-lived exiting with code zero is a failure, but the “on-failure” policy does not count it as one.
SV21 — the unit is designed to be long-lived; every exit, whatever its code, is a failure. SV22 — the unit is genuinely failing in all nine of the nine combinations; the oracle knows this. SV23 — the crash interval is seeded between 8 and 12 seconds. SV24 — restarting is instant and never fails. SV25 — a unit not restarted on clean exit has state “exited,” not failed. SV26 — a unit not restarted on error exit or termination by signal has state “failed.” SV27 — a restarted unit’s state is “active.” SV28 — the operator’s diagnosis is drawn from three status words: healthy if “active,” exited normally if “exited,” failed if “failed.”
SEED = 20260218 PERIOD = 600 POLICY = ("no", "on-failure", "always") EXIT = ("clean", "error", "signal") def generator(seed): d = seed def next_value(n): nonlocal d d = (d * 1103515245 + 12345) % 2147483648 return d % n return next_value def will_restart(policy, exit_): if policy == "always": return True if policy == "on-failure": return exit_ in ("error", "signal") return False def crash_moments(seed=SEED, duration=PERIOD, at_least=8, at_most=12): """The unit crashes at an irregular interval; the gap is seeded.""" r = generator(seed) t, items = 0, [] while True: t += at_least + r(at_most - at_least + 1) if t >= duration: return items items.append(t) def run(policy, exit_, moments, limit=0, window=100, duration=PERIOD): """The manager gives up if `limit` restarts are exceeded within `window` seconds.""" if not will_restart(policy, exit_): return {"restarts": 0, "state": "exited" if exit_ == "clean" else "failed", "hidden": duration if exit_ == "clean" else 0} done = [] for t in moments: if limit and sum(1 for x in done if x >= t - window) >= limit: return {"restarts": len(done), "state": "failed", "hidden": t} done.append(t) return {"restarts": len(done), "state": "active", "hidden": duration} A = crash_moments() print("oracle: unit crashes", len(A), "times in", PERIOD, "seconds") print() print("policy exit restarts seen state operator's diagnosis correct") wrong = 0 for p in POLICY: for c in EXIT: k = run(p, c, A) diagnosis = {"active": "healthy", "exited": "exited normally", "failed": "failed"}[k["state"]] correct = diagnosis == "failed" wrong += 0 if correct else 1 print(f" {p:10s} {c:7s} {k['restarts']:7d} {k['state']:13s}" f" {diagnosis:17s} {'yes' if correct else 'NO'}") print(" wrong diagnosis:", wrong, "/ 9") print() print("start limit restarts seen state hidden time wrong diagnosis") for limit in (0, 20, 10, 5, 3): k = run("always", "error", A, limit=limit) wt = len([t for t in range(0, PERIOD, 10) if t < k["hidden"]]) print(f" {'none' if limit == 0 else str(limit):11s} {k['restarts']:7d}" f" {k['state']:13s} {k['hidden']:13d} {wt:11d}")
oracle: unit crashes 60 times in 600 seconds policy exit restarts seen state operator's diagnosis correct no clean 0 exited exited normally NO no error 0 failed failed yes no signal 0 failed failed yes on-failure clean 0 exited exited normally NO on-failure error 60 active healthy NO on-failure signal 60 active healthy NO always clean 60 active healthy NO always error 60 active healthy NO always signal 60 active healthy NO wrong diagnosis: 7 / 9 start limit restarts seen state hidden time wrong diagnosis none 60 active 600 60 20 60 active 600 60 10 10 failed 106 11 5 5 failed 62 7 3 3 failed 41 5
What Each Policy Hides
7 of nine combinations give the operator a wrong diagnosis. What gets hidden sits in different places depending on the policy.
Policy “no” hides only 1 of the three exit types: clean exit. On error exit and termination by signal, the unit shows “failed” and the diagnosis comes out right. It is the policy that hides the least, because it offers no resilience at all. Policy “on-failure” hides all 3 of the three types, but in two different forms: on error and signal it revives the unit and shows “active”; on clean exit it does not revive it and shows “exited.” The second form is sneakier, because “exited” is a normal result for one-shot jobs and goes unnoticed. Policy “always” hides all 3 types the same way: all of them “active.”
The result ranks in reverse. As resilience rises, visibility falls, and no setting balances the two on its own. “Always” is the most direct way to keep a server up, and at the same time the option that best hides failure.
The table’s second reading says policy choice cannot be made without knowing the exit-type contract. The “on-failure” policy relies on the assumption that the program reports failure with a nonzero code. This assumption does not hold for every program: one that cannot read its configuration can print an error and still exit with code zero, an error in the shutdown path can be swallowed, a wrapper script can return its own exit code and hide the real program’s. The exit-code contract was established in the M03/K02 Shell Programming course; here, the cost of breaking that contract shows up as “exited normally” in two of nine rows.
The selection rule that follows has three parts. For a long-lived service, “always” is chosen and a ceiling is always placed next to it. For a one-shot job, “no” is chosen; that unit exiting really is normal. “On-failure” is chosen only once the program’s exit-code contract has been verified, and verifying it requires either reading the code or deliberately breaking the program and reading its exit code.
The Start Limit Breaks the Concealment
Service managers carry a second mechanism to close this trap: the start limit. If a unit restarts more times than a set count within a set window, the manager gives up trying and marks the unit failed. The limit is not resilience being turned off, it is resilience having a ceiling.
The sweep table shows the ceiling’s effect. With no limit in a hundred-second window, the unit restarts 60 times over 600 seconds and never shows failed: an operator polling every ten seconds gets a wrong diagnosis 60 times. With the limit at 20, the table does not change, because the crash rate in the mock data never reaches twenty within a hundred-second window: a limit that is set does the same as no limit. At 10, the unit is given up on at the 106th second and wrong diagnosis drops to 11. At 5, hidden time is 62 seconds, wrong diagnosis 7; at 3, 41 seconds and 5.
The 20 row carries a warning of its own. A limit that is in the configuration, that shows in the output, that is written with correct syntax, does nothing here at all. Whether a setting is effective cannot be read from the configuration unless it is compared against the oracle; a setting existing and a setting working are not the same thing. Whether the limit is effective depends on the crash rate exceeding the limit within the window, and the crash rate does not appear in the configuration.
The gain in between is large: hidden time drops from 600 seconds to 41, wrong diagnosis from 60 to 5. The cost is just as clear: a unit that hits the limit no longer comes back on its own. At limit 3, a forty-second wobble stops the service permanently. The right value for the limit is where the trade-off between visible failure and the service staying up is placed, and that trade-off varies by environment; there is no single correct number.
SV29 — the start-limit window is 100 seconds. SV30 — a unit that hits the limit stays failed until started by hand. SV31 — the operator polls every ten seconds; wrong diagnosis is the number of polls before the moment of giving up.
Three Numbers and the Second Seed
Oracle: the unit crashes 60 times in 600 seconds and is failing in all nine of nine combinations. Tool output: across nine combinations, five times “active,” twice “exited,” twice “failed”; in the start-limit sweep, restart counts of 60, 60, 10, 5, 3. Wrong diagnosis: 7 in the policy table; 60 with no limit, 11 at limit 10, 7 at limit 5, 5 at limit 3.
When crash moments are regenerated with a second seed (20260219), the crash count still comes
out 60, and hidden times come out as: 600 and 600 with no limit, 106 and 107
at limit 10, 62 and 56 at limit 5, 41 and 36 at limit 3. The order of magnitude
holds across both seeds. Individual seconds depend on the mock data; that hidden time drops by
an order of magnitude once a limit is set does not. The nine-combination table is not touched by
the seed at all, because what it counts there is not crash moments but the policy’s decision;
that table depends only on the model itself.
Summary
- A unit file has three sections: placing in the graph, running, and enabling. A unit without the third section can be started by hand but not enabled.
- There are three ways for a process to stop — clean exit, error exit, termination by signal — and policies split these types in different places.
- In 7 of nine combinations, the diagnosis drawn from the status word is wrong: “no” hides one type, “on-failure” and “always” both hide all three.
- The “on-failure” policy, which does not count clean exit as failure, shows a long-lived service’s silent stop as exited normally.
- The start limit breaks the concealment: hidden time drops from 600 seconds to 41 seconds at limit 3, wrong diagnosis from 60 to 5.
- The limit’s cost is that a unit that hits it does not come back on its own; the right value is a trade-off set according to the environment.
Next Step
So far a single unit has been measured: one that crashes on its own and revives on its own. In the mock server, units are not alone; the graph built in the first lesson is still there, and restarting one unit does not concern only that unit. The next lesson builds the chain that begins with the machine powering on — from firmware to bootloader, from kernel to first process and targets — and counts how many units a restart stops and starts. The command prints one line; the oracle’s number is five.
To keep your progress and take notes, Log in
My notes
Log in to take notes.