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 before SIGKILL. Tune with --time if your longest job needs more.
  • Set terminationGracePeriodSeconds in the pod spec for Kubernetes. Kube also starts with SIGTERM 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.

Last updated on