Table of contents
Startup is a sequence of steps that can each fail, arranged so that the ones most likely to fail happen first and report clearly. Get the order wrong and a service crashes with a nil pointer dereference and no log line, because logging was configured after the thing that broke.
Four files, in the order the process visits them:
| File | Responsibility |
|---|---|
cmd/main.go | Process entry, default logger, exit codes |
internal/cli/cli.go | Command-line surface, configuration, lifecycle handoff |
internal/cli/config.go | The configuration schema, defaults and validation |
internal/cli/bind.go | The dependency graph |
main is small on purpose
func main() {
logattr := []logutil.Attr{
slog.String("program", cli.AppName),
slog.String("version", programVersion),
slog.String("release", programRelease),
}
logcfg, _ := logutil.NewConfig(
logutil.WithOutWriter(os.Stderr),
logutil.WithFormat(logutil.FormatJSON),
logutil.WithLevel(logutil.LevelDebug),
logutil.WithCommonAttr(logattr...),
)
l := logsrv.NewLogger(logcfg)
rootCmd, err := cli.New(programVersion, programRelease, bootstrap.Bootstrap)
if err != nil {
l.With(slog.Any("error", err)).Error("UNABLE TO START THE PROGRAM")
exitFn(1)
} else {
err = rootCmd.Execute()
if err != nil {
l.With(slog.Any("error", err)).Error("UNABLE TO RUN THE COMMAND")
exitFn(2)
}
}
}
A logger comes first, before anything that could fail: structured JSON on stderr, debug level, tagged with the program name, version and release. It is deliberately not the configured logger, because the configuration has not been read yet and reading it is one of the things that can go wrong. Without it, a service with a malformed configuration file dies with a message from the Go runtime and nothing saying which service or which build it was.
programVersion and programRelease are package-level variables set at compile
time with linker flags, shown in page 19.
They answer the question that starts most incident investigations: which build is
running.
Two exit codes
1 means the program could not be constructed. 2 means it was constructed and
then failed while running.
The distinction is for whatever is watching the process. Exit 1 is almost always a bad flag or a broken build: restarting will produce the same result, and a crash loop is the correct visible symptom. Exit 2 can be transient, an unreachable database at startup being the common case, and a restart may well fix it. One generic exit 1 for both would make those indistinguishable from outside.
exitFn
var exitFn = os.Exit
A package-level variable rather than a direct os.Exit call, so a test can
replace it and assert on the code without terminating the test binary. Testing
the error paths of main is otherwise close to impossible.
That variable is also why the success path is an else branch where Go style
would normally use an early return. os.Exit does not come back, so the
compiler and the reader both treat the call as terminal; a test double does come
back, and execution would fall through to rootCmd.Execute() on a nil command.
The else is load-bearing precisely because the call above it is replaceable.
The command surface
cli.New builds a cobra root command with
three optional flags:
rootCmd.Flags().StringVarP(&argConfigDir, "configDir", "c", "",
"Configuration directory to be added on top of the search list")
rootCmd.Flags().StringVarP(&argLogFormat, "logFormat", "f", "",
"Logging format: CONSOLE, JSON")
rootCmd.Flags().StringVarP(&argLogLevel, "logLevel", "o", "",
"Log level: EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG")
That is a deliberate ceiling. Everything else the service can be told is configuration, which has a defined precedence and a schema (page 04). Flags that shadow configuration keys give you two sources for one value and no rule about which wins.
These three are needed before configuration is usable. configDir says where to
look for the configuration, so it cannot come from it. The log flags turn up the
detail on a failing startup, including one that fails while loading
configuration.
There is also a version subcommand, which prints the version and exits. Package
managers, deployment scripts and support tickets all want it.
Flags are parsed before the command runs
err := rootCmd.ParseFlags(os.Args[1:])
if err != nil && !errors.Is(err, pflag.ErrHelp) {
return nil, fmt.Errorf("failed parsing command-line arguments: %w", err)
}
return rootCmd, nil
Cobra would parse the flags itself inside Execute(). Doing it early in New
moves an invalid flag from an exit 2 to an exit 1, which is the correct code for
it: a typo in an argument is a configuration error, not a runtime failure, and it
will recur on every restart.
pflag.ErrHelp is the wrinkle. Cobra registers its own -h and --help flag
only inside Execute, so an early ParseFlags sees -h as unknown and returns
ErrHelp. Swallowing it here lets Execute print the help text and exit
normally.
bootstrapFn is a parameter
type bootstrapFunc func(bindFn bootstrap.BindFunc, opts ...bootstrap.Option) error
func New(version, release string, bootstrapFn bootstrapFunc) (*cobra.Command, error)
main passes bootstrap.Bootstrap. A test passes a stub that records what it
was given and returns.
Without this, testing cli.New means testing a function whose final act is to
block until it receives a signal, having bound three ports. With it, a test can
assert that configuration was loaded, that the log level override was applied,
and that the options handed to the lifecycle carry the right shutdown timeout,
all in microseconds and with nothing bound.
The shape recurs throughout the service: whatever blocks, touches the network or
reads the clock arrives as a parameter. sqlconn takes a WithSQLOpenFunc,
bootstrap takes a WithCreateMetricsClientFunc, and each is a seam a test can
reach.
What Execute does
rootCmd.RunE is the body of the service’s startup. Six steps.
1. Load the configuration.
cfg := &appConfig{}
err := config.Load(AppName, argConfigDir, appEnvPrefix, cfg)
if err != nil {
return fmt.Errorf("failed loading config: %w", err)
}
Defaults, then files from a search path, then a remote provider if one is configured, then environment variables, then validation. A failure here returns before anything is allocated, and the process exits 2 with the field name that failed. Page 04 is this call in full.
2. Build the logger configuration.
logcfg, err := newLogConfig(cfg, version, release, argLogFormat, argLogLevel)
The CLI flags win over the configured values:
if argLogFormat != "" {
cfg.Log.Format = argLogFormat
}
logFormat, err := logutil.ParseFormat(cfg.Log.Format)
if err != nil {
return nil, fmt.Errorf("log config error: %w", err)
}
Parsing happens here rather than at the point of use, so logLevel=WRONG fails
during startup with a message naming the problem. A service that accepts the
value and silently falls back to a default is one where the operator believes
they turned on debug logging and did not.
3. Assemble the program identity.
appInfo := &jsendx.AppInfo{
ProgramName: AppName,
ProgramVersion: version,
ProgramRelease: release,
}
These three strings appear in every response envelope the service sends, which means a response captured from a client says which build produced it. Page 10 is about that envelope and what it costs.
4. Create the metrics client.
mtr := metrics.New()
This constructs the service’s own collectors. The backend client is built later,
by bootstrap, from the CreateMetricsClientFunc passed in step 6. The
two-step arrangement exists so custom collectors can be registered before the
backend exists, and page 13 covers why.
5. Create the two shutdown primitives.
wg := &sync.WaitGroup{}
sc := make(chan struct{})
A wait group that every long-lived component registers with, and a channel that gets closed when shutdown starts. Both are created here and passed down through everything. Page 05 is entirely about why there are two.
6. Hand over to the lifecycle.
return bootstrapFn(
bind(cfg, appInfo, mtr, wg, sc),
bootstrap.WithLogConfig(logcfg),
bootstrap.WithCreateMetricsClientFunc(mtr.CreateMetricsClientFunc),
bootstrap.WithShutdownTimeout(time.Duration(cfg.ShutdownTimeout)*time.Second),
bootstrap.WithShutdownWaitGroup(wg),
bootstrap.WithShutdownSignalChan(sc),
)
bind(...) returns a function rather than calling anything. The configuration,
program identity, metrics and shutdown primitives are captured in a closure that
bootstrap will call once it has built the context, the logger and the metrics
backend.
This function returns when the service stops. Everything after it is teardown.
Inside bootstrap
Bootstrap does five things before your code runs again.
It creates a cancellable context from the root context, which becomes the lifetime of the whole service.
It creates the metrics client by calling the function from step 6, so that a failure to reach a metrics backend is a startup failure with a clear message.
It builds the logger from the log configuration, installs a hook that increments
a metrics counter for every log record broken down by level, and replaces the
process-wide default via slog.SetDefault. That last part means a library
somewhere calling slog.Info lands in your configured output rather than on
stderr in a different format.
It registers signal handling before calling your bind function. The ordering
is deliberate: a SIGTERM that arrives while the service is still wiring itself
up is caught by a buffered channel and honoured as soon as binding finishes,
instead of hard-killing a half-constructed process. Deployments that roll back
quickly hit this more often than you would expect.
Then it calls your BindFunc:
type BindFunc func(ctx context.Context, l *slog.Logger, m metrics.Client) error
A context, a logger and a metrics client. Everything else the service needs was captured in the closure.
Where the graph is built
bind is the function that holds the whole service. It runs once, it returns
nil when everything is up, and any error it returns aborts startup with the
reason.
The order it works in is a dependency order:
jsx := jsendx.NewJSXResp(httputil.NewHTTPResp(l))
logRedactor := newLogRedactor()
httpClientOpts := []httpclient.Option{ /* logger, metrics, trace, redaction */ }
ipifyClient, err := newIpifyClient(cfg, httpClientOpts)
if err != nil {
return err
}
serviceBinderPrivate, serviceBinderPublic, statusHandler, err :=
bindServiceHandlers(ctx, cfg, appInfo, jsx, l, mtr, wg, sc)
if err != nil {
return err
}
// ... three servers created and started
Response writing, then redaction, then the shared client options, then the clients, then the handlers and their databases, then the servers. Nothing is constructed before the things it needs.
The last step creates the three servers from
page 01 and starts each one. Server
construction binds the listener, so when bind returns, all three ports are
held and the service is answering.
httpMonitoringServer, err := httpserver.New(ctx, httpserver.NopBinder(), httpMonitoringOpts...)
if err != nil {
return fmt.Errorf("error creating monitoring HTTP server: %w", err)
}
httpMonitoringServer.StartServer()
Page 07 covers what New validates before it
binds, and why a route with a lowercase method is rejected there rather than
becoming an endpoint nothing can reach.
Back in Bootstrap, with bind returned and no error, the process logs
application started and blocks. From here it is doing nothing except serving
requests until a signal arrives, which is
page 05.
The shape to keep
The ordering carries the principle. A bad flag never reaches configuration loading, a bad configuration never reaches a database connection, and a database that will not answer never reaches a bound port. The alternative is a service that binds first, discovers its problems on the first request, and so reports itself healthy while failing traffic. Page 07 has more on front-loading validation.