Run a long-lived service¶
By the end of this, mytool serve runs a service that stays up until something
stops it. Ctrl-C or a SIGTERM shuts it down in order, and a service that keeps
failing ends the process with an exit code an orchestrator can act on. You
won't write any signal handling yourself.
This is the first part of the web-service series. Later parts put gRPC, HTTP and a gateway on the same command, and every one of them relies on what you set up here.
Allow about twenty minutes.
Before you start¶
- The
mytoolproject from Build your first CLI. Any project generated by gtb works the same way. - The same gtb release. Everything below is verified against v0.50.0.
Add the command¶
A long-running process is still a command. Generate one:
As with hello, that writes a generated pkg/cmd/serve/cmd.go and a
pkg/cmd/serve/main.go for you to fill in.
Run a service under a controller¶
The services run under a controller from the
go/controls module. You register each
service with three things: how to start it, how to stop it, and how to tell
whether it's healthy. The controller starts them, stops them in order when the
command's context ends, and keeps track of their health.
Replace pkg/cmd/serve/main.go with a controller running one service, a
heartbeat that logs a line every second:
package serve
import (
"context"
"sync/atomic"
"time"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
"gitlab.com/phpboyscout/go-tool-base/pkg/props"
"gitlab.com/phpboyscout/go/controls"
"gitlab.com/phpboyscout/go/errors"
)
const beatInterval = time.Second
func RunServe(ctx context.Context, props *props.Props, _ *ServeOptions, _ []string) error {
controller := controls.NewController(ctx,
controls.WithLogger(logger.ToSlog(props.Logger)),
controls.WithStopOnGiveUp(),
)
registerHeartbeat(controller, props)
controller.Start()
<-controller.Done()
outcome, _ := controller.Outcome()
if outcome.Cause == controls.CauseGaveUp {
return errors.Wrapf(outcome.Err, "service %s gave up", outcome.Service)
}
return nil
}
func registerHeartbeat(controller *controls.Controller, props *props.Props) {
var beats atomic.Int64
controller.Register("heartbeat",
controls.WithStart(func(ctx context.Context) error {
ticker := time.NewTicker(beatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
props.Logger.Info("beat", "count", beats.Add(1))
}
}
}),
controls.WithStop(func(context.Context) {
props.Logger.Info("heartbeat stopping", "total_beats", beats.Load())
}),
controls.WithStatus(func() error { return nil }),
)
}
The three options are the service's three verbs:
WithStartruns the heartbeat. It blocks for as long as the service runs, and returnsnilwhen its context ends.WithStopruns during shutdown, for cleanup.WithStatusanswers "is it healthy?".nilmeans yes.
ctx is the command's own context. The controller is built on it, so when the
command is told to stop, the controller stops too. controller.Done() closes
once everything has stopped, and Outcome() says why.
WithStopOnGiveUp comes into play in the last step.
main.go now imports go/controls directly, so tidy the module before you
build:
It keeps going until you stop it.
Shut it down cleanly¶
Press Ctrl-C, or send the process a SIGTERM the way Kubernetes or systemd would:
WARN received signal — shutting down gracefully (press again to force quit) signal=terminated
INFO Stopping Services component=controller cause=parent
INFO heartbeat stopping total_beats=3
INFO Stopped component=controller
Nothing you wrote handles a signal. The root command gtb generated catches
SIGINT and SIGTERM and cancels the command's context. The controller sees its
parent context end (cause=parent), cancels the context every service's start
is watching, runs each WithStop, and returns. A second Ctrl-C forces the
process to quit straight away, for a service that's stuck.
That order (cancel first, then stop) is what lets an HTTP server finish the requests it's in the middle of when the pod rolls. You get it for the heartbeat now, and for every transport you add later.
Don't add controls.WithSignals() to the controller. The root already owns the
signals, and a second handler would race it on a single Ctrl-C.
Check the exit code after a SIGTERM:
That's 128 plus the signal's number (15 for SIGTERM, 2 for SIGINT, so 130). It's the code a shell or a process manager expects from a process a signal stopped.
Give up on a service that keeps failing¶
A service that fails is restarted, if its restart policy allows. Once the
restarts run out, the service gives up. WithStopOnGiveUp makes that stop the
controller, so the command ends and the tool exits non-zero. Without it, the
process would stay up but report itself not ready.
Make the heartbeat fail on every third beat, and allow two restarts. Add two constants:
Then return an error from the ticker case, and add a restart policy beside
WithStatus:
case <-ticker.C:
count := beats.Add(1)
if count%missEvery == 0 {
return errors.Newf("missed beat %d", count)
}
props.Logger.Info("beat", "count", count)
}
controls.WithStatus(func() error { return nil }),
controls.WithRestartPolicy(controls.RestartPolicy{MaxRestarts: maxRestarts}),
The scaffold's linter rejects bare numbers like 3 in code, which is why
they're named.
Build and run it, and leave it alone for about ten seconds:
INFO beat count=1
INFO beat count=2
ERROR service failed; restarting component=controller service_name=heartbeat kind=retrying restarts=1 error="missed beat 3"
INFO beat count=4
INFO beat count=5
ERROR service failed; restarting component=controller service_name=heartbeat kind=retrying restarts=2 error="missed beat 6"
INFO beat count=7
INFO beat count=8
ERROR a registered service will never start; the controller is now reporting unready component=controller service_name=heartbeat error="max restarts exceeded: missed beat 9"
ERROR service unable to start component=controller service_name=heartbeat kind=unable_to_start restarts=2 error="max restarts exceeded: missed beat 9"
INFO Stopping Services component=controller cause=gave_up
INFO heartbeat stopping total_beats=9
INFO Stopped component=controller
ERROR service heartbeat gave up: max restarts exceeded: missed beat 9
exit 1
Two restarts, then a give-up (cause=gave_up). RunServe turned that outcome
into an error, and the root reported it and exited 1. That's the code systemd's
Restart=on-failure, or a pod's restart policy, acts on.
Take the failure back out before you carry on: delete the count%missEvery
check and the restart policy, and set the ticker case back to logging every
beat.
Where this leaves you¶
mytool serve runs a service until it's told to stop, stops it in order, and
says through its exit code how it ended. The heartbeat is a placeholder. Every
service the next parts add registers on this same controller in the same way,
and gets the same shutdown order without any extra work.
The controller also tracks liveness and readiness separately from status, which is what health endpoints read. That gets used once there's a transport to serve them from.
What to read next¶
- Manage background services: servers, workers, failure events and readiness, in one guide.
- Controls component: how gtb uses the module, and why the root owns the signals.
- controls.go.phpboyscout.uk: the full controller API.