Graceful Shutdown for gocron
When you run a service in a container the orchestrator like Docker and Kubernetes stops it by sending SIGTERM
then after a grace period SIGKILL
.
If your code ignores SIGTERM
, the kernel eventually delivers SIGKILL
and the process dies at once. Open files stay unsynced, in‑memory work is lost, clients see half‑finished jobs.
Cron style schedulers are extra vulnerable because jobs often do real work like sending emails, moving money and generating reports.
A graceful shutdown listens for the signal, stops accepting new work, waits for running work to finish and then exits cleanly.
gocron in a nutshell
gocron is a small friendly scheduler for Go.
gocron doesn’t have first‑class graceful shutdown support and it doesn’t wait until every job is done before terminating. So we need to implement it by ourselves.
Step‑by‑step example
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-co-op/gocron/v2"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}))
scheduler, err := gocron.NewScheduler()
if err != nil {
logger.Error("failed to create scheduler", "error", err)
return
}
// Add a dummy job that runs every minute
_, err = scheduler.NewJob(gocron.CronJob("TZ=UTC * * * * *", false), gocron.NewTask(worker, logger))
if err != nil {
logger.Error("failed to schedule job", "error", err)
return
}
// Cancel the root context on SIGINT or SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("scheduler started")
scheduler.Start()
}()
// Block until the signal arrives
<-ctx.Done()
stop()
logger.Info("scheduler shutdown initiated")
// Create a timeout context for graceful shutdown (10 seconds)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Create a channel to signal when shutdown is complete
shutdownDone := make(chan error, 1)
go func() {
shutdownDone <- scheduler.Shutdown()
}()
// Wait for either shutdown completion or timeout
select {
case err := <-shutdownDone:
if err != nil {
logger.Error("scheduler graceful shutdown failed", "error", err)
return
}
logger.Info("scheduler shutdown completed")
case <-shutdownCtx.Done():
logger.Warn("scheduler shutdown timed out after 10 seconds")
}
}
func worker(logger *slog.Logger) {
logger.Info("worker entered")
time.Sleep(5 * time.Second)
logger.Info("worker exited")
}
Run it and press Ctrl-C:
$ go main.go
{"time":"2025-05-28T18:44:58.143268+02:00","level":"INFO","msg":"scheduler started"}
{"time":"2025-05-28T18:45:00.001335+02:00","level":"INFO","msg":"worker entered"}
^C{"time":"2025-05-28T18:45:01.501263+02:00","level":"INFO","msg":"scheduler shutdown initiated"}
{"time":"2025-05-28T18:45:05.002373+02:00","level":"INFO","msg":"worker exited"}
{"time":"2025-05-28T18:45:05.002429+02:00","level":"INFO","msg":"scheduler shutdown completed"}
The worker finished its five‑second nap before exit. No error lines appeared.
Structured logging for observability
Plain text logs are fine for your eyes but machines love structure.
From Go 1.22 the standard library ships with slog. It gives you compact JSON that GCP Cloud Logging, Loki, Elastic or Datadog can parse without regex.
That means you can build dashboards, set alerts and slice logs by fields such as "msg":"worker exited"
with zero pain.
Container side notes
docker stop
waits ten seconds beforeSIGKILL
. Tune with--time
if your longest job needs more.- Set
terminationGracePeriodSeconds
in the pod spec for Kubernetes. Kube also starts withSIGTERM
so the same code path works. - During shutdown flip your
/healthz
endpoint to unhealthy so the load balancer stops sending requests.
Wrapping up
Graceful shutdown protects data and keeps noise out of your logs.