open atlas
↑ Back to track
Go, zero to senior GO · 13 · 02

Daemons and signals: no fork dance, the SIGTERM contract, signal.NotifyContext as the one shutdown path

Modern daemons never fork: systemd or the container runtime owns the lifecycle. SIGTERM means stop accepting and drain before TimeoutStopSec becomes SIGKILL, SIGHUP means atomic config reload, signal.NotifyContext unifies shutdown, and a second signal exits immediately.

GO Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The queue consumer corrupted its own state file on every single deploy, and nobody noticed for a month because the recovery path papered over it — until the day it could not. The daemon drained a local disk queue, and its author had wired signal.Notify(ch, syscall.SIGTERM) with a goroutine that logged “shutdown requested” and then… kept going. Graceful shutdown was a TODO. Here is the cruelty of the signal API: the moment you call Notify, Go disables the default die-on-SIGTERM behavior — the half-finished handler did not merely fail to shut down cleanly, it made the process unkillable by anything short of SIGKILL. So every deploy, Kubernetes sent SIGTERM, the daemon logged a cheerful line and kept writing, the kubelet waited out its 30-second grace period, and then SIGKILL landed mid-write to the queue file. Torn entry, checksum mismatch, crash-loop on the next boot. The postmortem found the smoking log line — “shutdown requested” — printed thirty seconds before every corruption, a confession nobody had read.

You are not the daemon — the supervisor is

Ten minutes from now you will understand why an incomplete signal handler is worse than no handler at all — and exactly how to wire the one that never corrupts state on deploy.

The classic Unix daemonization ritual — fork, setsid to escape the controlling terminal, fork again, chdir to /, close every descriptor, write a pidfile — solved a 1980s problem: surviving the logout of the admin who launched you from a shell. Go programs should never perform it, for two reasons. The practical one: a multithreaded runtime cannot safely fork without exec — after fork in a threaded process, only async-signal-safe operations are legal in the child, and the Go runtime is anything but. The architectural one: there is nothing left to gain. systemd with Type=simple, or a container runtime, is the daemonizer — it detaches you from any terminal, owns your process group, captures your output, restarts you on failure, and tracks your PID in its own bookkeeping. Pidfiles are legacy for the same reason: they exist so an init script could find a self-forked process; a supervisor that started you already knows exactly who you are. Your binary just runs in the foreground and logs to stderr — journald or the container log collector stamps, indexes, and rotates it.

The signal contract

Signals are the supervisor’s vocabulary, and each one is a promise you must keep. When you see a deployment corruption or a boot-loop in a postmortem, nine times out of ten one of these promises was broken:

  • SIGTERM — graceful stop. The contract has three clauses: stop accepting new work, drain what is in flight, exit 0 — and do all of it before the supervisor’s patience runs out: TimeoutStopSec in systemd (default 90 s), terminationGracePeriodSeconds in Kubernetes (default 30 s). Past the deadline comes SIGKILL, which no process can catch, block, or delay.
  • SIGINT — the same stop, interactively. Ctrl-C delivers it to the foreground process group; handle it identically to SIGTERM so local runs behave like production.
  • SIGHUP — reload configuration. Historically “terminal hang-up”, and its default action still terminates the process — which is why an unhandled SIGHUP kills programs when a terminal closes. Under a supervisor there is no controlling terminal, so the signal is free real estate: the convention is re-read config and apply it atomically.
  • SIGKILL and SIGSTOP — not yours. Uncatchable by design; the existence of SIGKILL is why the SIGTERM contract has a deadline.

Together these four signals define the full lifecycle vocabulary — miss any one and either you cannot be stopped politely or you cannot reload without an outage. And the mechanism that powered the Hook: signal.Notify does not observe a signal, it claims it — the runtime disables the default disposition for every signal you register. A registered-but-ignored SIGTERM produces a process that cannot be stopped politely at all.

Quiz

A daemon calls signal.Notify(ch, syscall.SIGTERM), but no goroutine acts on the channel beyond logging. An operator runs kill on the PID. What happens?

signal.NotifyContext: shutdown is just cancellation

Your whole service already speaks one cancellation language — context. signal.NotifyContext translates signals into it, which collapses “shutdown logic” into the propagation you built in the HTTP and lifecycle lessons:

func main() { os.Exit(run()) }

func run() int {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	srv := newServer()
	go srv.ListenAndServe()          // serves until Shutdown is called

	<-ctx.Done()                     // first SIGTERM/SIGINT lands here
	stop()                           // restore default disposition: second signal kills NOW

	drainCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()
	if err := srv.Shutdown(drainCtx); err != nil {
		return 1                     // drain budget blown — exit dirty, loudly
	}
	return 0
}

Two mechanics deserve a close read. First, the drain budget: the WithTimeout value must fit inside the supervisor’s deadline with margin — 25 s under a 30 s Kubernetes grace period, leaving room for the SIGTERM to even arrive after endpoint deregistration. A drain budget larger than the grace period is a polite fiction ending in SIGKILL. Second, the double-signal discipline: calling stop() immediately after the first signal unregisters your handler, so the next SIGTERM or ctrl-C hits the restored default disposition — instant death. That is a feature, not a bug: it is the operator’s escape hatch when your drain hangs, and every well-behaved daemon ships it. One signal: graceful. Two signals: now.

Quiz

In the NotifyContext pattern, why call stop() immediately after ctx.Done() fires, before starting the drain?

SIGHUP reload: re-read, validate, swap

Reload is a separate channel from shutdown, and its contract is transactional: build the entire new config, validate it, and only then publish — with atomic.Pointer[Config], the same swap pattern from the configuration lesson. Readers Load() the pointer per request and never see a half-applied state. The failure mode this kills: mutating the live config struct field-by-field while requests read it — a data race wearing a feature’s clothes. And when the new file fails validation, the answer is to keep serving the old config and log loudly: a daemon that dies on a bad reload converts a typo into an outage.

hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
	for range hup {
		next, err := loadAndValidate(path)
		if err != nil { log.Printf("reload rejected: %v", err); continue }
		current.Store(next)          // atomic.Pointer[Config]: readers see old or new, whole
	}
}()

The systemd unit, honestly

[Service]
Type=simple
ExecStart=/usr/local/bin/queued
Restart=on-failure
TimeoutStopSec=30
WatchdogSec=30

Type=simple says it plainly: the supervisor is the daemonizer; your process just runs. Restart=on-failure restarts crashes but not clean exits — always is for processes that should never stop on purpose. TimeoutStopSec is the SIGTERM deadline you drain against. WatchdogSec deserves the honest framing: with the go-systemd library you send READY=1 via sd_notify at startup and WATCHDOG=1 periodically; miss the interval and systemd kills and restarts you. It is deadlock insurance — it catches a wedged main loop, nothing more. The classic way to make it lie: pinging from a dedicated goroutine that stays healthy while the actual work loop is deadlocked. Send the ping from the loop whose liveness you actually care about, or you have bought insurance on someone else’s house.

Why this works

Why did the fork dance exist at all, and why is it gone? A process launched from a shell belonged to that terminal’s session: logout sent SIGHUP and killed it. Double-fork plus setsid created a session-less orphan adopted by init — daemonhood by escape artistry. Supervisors inverted the model: systemd starts your process already detached, already adopted, already logged. The dance now actively hurts — a self-forking service confuses the supervisor’s process tracking (systemd must guess your main PID), and in Go the fork itself is unsafe under a multithreaded runtime. The correct amount of daemonization code in a modern Go service is zero lines.

Recall before you leave
  1. 01
    State the SIGTERM contract, its deadlines, and what an empty signal.Notify handler does to a process.
  2. 02
    Walk through the signal.NotifyContext pattern: drain budget, double-signal discipline, and where SIGHUP fits.
Recap

The fork-setsid-fork ritual is dead, and Go could never perform it safely anyway: a multithreaded runtime must not fork without exec, and a supervisor — systemd Type=simple or a container runtime — already provides everything the dance bought: detachment, adoption, restart policy, log capture, PID tracking. Pidfiles are bookkeeping for a world without supervisors. What remains genuinely yours is the signal contract. SIGTERM opens a deadline — 90 seconds under systemd by default, 30 under Kubernetes — inside which you stop accepting, drain in-flight work, and exit cleanly, because what follows the deadline is uncatchable SIGKILL. SIGINT is the same promise made interactively. SIGHUP means reload: rebuild the configuration, validate it, swap it with an atomic pointer so no reader ever sees a half-applied state, and survive a bad file by keeping the old config. Remember that signal.Notify claims signals rather than observing them — a registered SIGTERM with an empty handler creates an unkillable process and a corruption story. signal.NotifyContext folds all of this into the context tree your service already has; call stop() after the first signal so the second one kills immediately, the operator escape hatch. In the unit file, Restart=on-failure handles crashes, TimeoutStopSec is the deadline you drain against, and WatchdogSec with sd_notify is honest deadlock insurance only when the liveness ping comes from the loop that actually matters. Log to stderr; journald does the rest. Now when you read a postmortem and see “shutdown requested” printed thirty seconds before every corruption — you will know: someone called Notify and left the TODO in place.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.