Graceful shutdown for long-running Go components: one group, reverse-order draining, and optional zero-downtime upgrades on SIGHUP via tableflip.
- Any component, not just HTTP.
Add(name, start, stop)takes a pair of functions.startmay block for the component's life or return after spawning its own work; a non-nil error shuts the whole group down. - Listeners built for upgrades.
Listen(name, addr, srv)creates the listener insideRun, through tableflip when upgrades are enabled, so an upgraded process inherits the socket.*http.ServersatisfiesServer. - One shutdown path. Cancellation, component failure and upgrade handoff all drain the same way: reverse registration order, one shared deadline, drain failures joined onto the returned error.
- Zero-downtime upgrades.
WithUpgrade()makes SIGHUP re-exec the binary and hand off listeners; Windows falls back to plain listeners. - systemd aware. Under
NOTIFY_SOCKETthe group reports its own state, so aType=notify-reloadunit gets a synchronous, zero-downtimesystemctl reload. - Structured logging of every lifecycle event through
log/slog.
go get github.com/libtnb/gracefulRequires Go 1.27+.
package main
import (
"context"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/libtnb/graceful"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Handler: mux}
g := graceful.New(
graceful.WithUpgrade(), // SIGHUP = hot upgrade
graceful.WithShutdownTimeout(30*time.Second),
)
g.Add("cron", cron.Start, cron.Stop) // any start/stop pair
g.Listen("http", ":8080", srv) // upgrade-aware listener
if err := g.Run(ctx); err != nil { // blocks until shutdown
log.Fatal(err)
}
}Run starts entries in registration order and drains them in reverse, so the
listener above stops accepting before the scheduler stops and in-flight
requests can still schedule work.
| Trigger | Behavior |
|---|---|
ctx cancelled (e.g. SIGINT/SIGTERM via signal.NotifyContext) |
stop accepting, drain every component, return nil (a *DrainError if a component failed to stop) |
a start returns non-nil |
drain every component, return name: err |
SIGHUP (with WithUpgrade) |
re-exec the binary, hand listeners to the child, drain, return nil |
Nothing to configure in Go. With NOTIFY_SOCKET set, the group sends
READY=1 once every listener accepts, brackets a SIGHUP upgrade with
RELOADING=1 and the child's READY=1 + MAINPID=, and sends STOPPING=1
on shutdown. That is the whole Type=notify-reload contract, so
systemctl reload becomes a zero-downtime upgrade that returns once the new
process serves:
[Service]
Type=notify-reload
NotifyAccess=all
ExitType=cgroup
ExecStart=/opt/app/appNotifyAccess=all lets the upgraded process report before systemd knows it
as the main one. ExitType=cgroup keeps the unit up while the old process
drains and exits, because the child claims MAINPID= only after systemd
adopts it: systemd cannot wait for a main process that is not its child, so a
stop would escalate straight to SIGKILL. Without WithUpgrade() the reload's
SIGHUP terminates the process. Replace the binary in place before reloading;
the child re-executes the same path.
starterrors are fatal,stoperrors are collected. A component that cannot run brings everything down. One that cannot stop must not block the rest, so its error is logged and joined ontoRun's result.- A
startthat returns nil is a successful launch, soAddfits both blocking accept loops and fire-and-forget starters without adapters. - Registration order is the dependency order. Register infrastructure first and entry points last; reverse draining closes the front door before the back office.
- The caller owns shutdown, the group owns SIGHUP. Shutdown arrives through the context, so any cancellation source works and tests need no real signals; SIGHUP stays internal because upgrades are the group's own feature.
- The group owns lifecycles, not resources. Pools, log writers and the like belong to whatever built them; the group only coordinates start and stop.
MIT