Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

a tiny daemon in go, finally shipped

A small Go daemon that watches a directory and posts a webhook, and the unglamorous details of graceful shutdown and systemd that made it actually shippable.

A terminal with a Go program running

I shipped a tiny daemon today. It watches a directory, and when a file lands and stops changing, it posts a webhook with the filename. That is the entire feature. The interesting part, as ever, was not the feature; it was the hundred small decisions that separate a script you run in a terminal from a thing that runs forever without you watching it.

Go is very good at exactly this shape of problem. A single static binary, no runtime to install on the box, fsnotify for the watching, and the standard library for the HTTP. The whole thing is one file and it cross-compiles to the little ARM box it actually lives on with GOOS=linux GOARCH=arm64 go build. No Docker, no interpreter, no apt-get dance. Copy the binary, drop in a unit file, done.

The part that took the time was shutdown. A daemon that exits cleanly is a daemon you can deploy without fear, and Go makes the pattern obvious once you've seen it:

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

<-ctx.Done()
log.Println("shutting down, draining in-flight webhooks")

When systemd sends SIGTERM, the context cancels, the watch loop falls out, and any webhook mid-flight gets a few seconds to finish before the process leaves. Before I wired that up, a restart could drop an event on the floor, and dropped events are the kind of bug you only notice three weeks later when someone asks where their file went.

The systemd unit is almost the whole operational story:

[Service]
ExecStart=/usr/local/bin/watchd --dir /srv/incoming
Restart=on-failure
RestartSec=5

Restart=on-failure with a backoff means that if it does fall over, it comes back, and I get told about it through the same monitoring as everything else rather than discovering a silent gap. The daemon also debounces: a large file being written triggers a flurry of filesystem events, and I do not want a webhook per write syscall. So it waits for a file to go quiet for a couple of seconds before deciding it has truly arrived. That debounce was the single most important line of logic, and it is the one I'd have got wrong if I'd shipped on the first afternoon.

None of this is clever. There is no goroutine ballet, no channel I'm proud of, no benchmark to wave around. It is a small program that does one thing and then gets out of the way, which is the highest praise I have for software at the moment. The pleasure of Go here is that the boring, correct version is also the easy version: the standard library hands you signals and contexts and an HTTP client, and the path of least resistance lands you somewhere you can trust.

It has been running for a few hours now without incident, which for a daemon is the only review that matters. Ask me again in a month.