Day 108 — Signal Handling & Operational Controls
|
|
| Learning objective |
Handle SIGHUP (config reload), SIGUSR1 (log rotate), SIGUSR2 (goroutine dump) |
| Key packages |
os/signal, syscall, runtime, log/slog |
| Security context |
Ops teams must be able to reload security rules and rotate logs without restarts |
Key Concepts
| Signal |
Convention |
Action |
SIGTERM |
Kubernetes, systemd |
Graceful shutdown |
SIGINT |
Ctrl-C |
Graceful shutdown |
SIGHUP |
Log rotation (historically) |
Reload config |
SIGUSR1 |
User-defined 1 |
Log file rotation |
SIGUSR2 |
User-defined 2 |
Goroutine stack dump |
Exercise — Signal Handler Dispatcher
package ops
import (
"bytes"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"syscall"
"time"
)
// SignalDispatcher routes OS signals to registered handlers
type SignalDispatcher struct {
configReloader func() error
logRotator func() error
logger *slog.Logger
done chan struct{}
}
func NewSignalDispatcher(logger *slog.Logger) *SignalDispatcher {
return &SignalDispatcher{logger: logger, done: make(chan struct{})}
}
func (sd *SignalDispatcher) OnConfigReload(fn func() error) { sd.configReloader = fn }
func (sd *SignalDispatcher) OnLogRotate(fn func() error) { sd.logRotator = fn }
// Start begins listening for signals. Call in a goroutine.
func (sd *SignalDispatcher) Start() {
sigCh := make(chan os.Signal, 8)
signal.Notify(sigCh,
syscall.SIGHUP,
syscall.SIGUSR1,
syscall.SIGUSR2,
)
defer signal.Stop(sigCh)
for {
select {
case <-sd.done:
return
case sig := <-sigCh:
sd.dispatch(sig)
}
}
}
func (sd *SignalDispatcher) dispatch(sig os.Signal) {
sd.logger.Info("signal received", "signal", sig.String())
switch sig {
case syscall.SIGHUP:
sd.handleConfigReload()
case syscall.SIGUSR1:
sd.handleLogRotate()
case syscall.SIGUSR2:
sd.handleGoroutineDump()
}
}
func (sd *SignalDispatcher) handleConfigReload() {
if sd.configReloader == nil {
sd.logger.Warn("SIGHUP received but no config reloader registered")
return
}
sd.logger.Info("reloading config")
if err := sd.configReloader(); err != nil {
// CRITICAL: keep serving with old config — never apply invalid config
sd.logger.Error("config reload failed — keeping old config", "error", err)
return
}
sd.logger.Info("config reloaded successfully")
}
func (sd *SignalDispatcher) handleLogRotate() {
if sd.logRotator == nil {
sd.logger.Warn("SIGUSR1 received but no log rotator registered")
return
}
sd.logger.Info("rotating logs")
if err := sd.logRotator(); err != nil {
sd.logger.Error("log rotation failed", "error", err)
return
}
sd.logger.Info("log rotation complete")
}
func (sd *SignalDispatcher) handleGoroutineDump() {
buf := make([]byte, 1<<20) // 1MB buffer
n := runtime.Stack(buf, true)
dump := string(buf[:n])
// Count goroutines
lines := bytes.Split(buf[:n], []byte("\n"))
goroutineCount := 0
for _, line := range lines {
if bytes.HasPrefix(line, []byte("goroutine ")) {
goroutineCount++
}
}
sd.logger.Info("goroutine dump",
"goroutine_count", goroutineCount,
"dump_bytes", n,
)
// Write full dump to a timestamped file
filename := fmt.Sprintf("/tmp/goroutine-dump-%s.txt", time.Now().Format("20060102-150405"))
if err := os.WriteFile(filename, []byte(dump), 0600); err != nil {
sd.logger.Error("failed to write goroutine dump", "error", err)
return
}
sd.logger.Info("goroutine dump written", "file", filename)
}
func (sd *SignalDispatcher) Stop() { close(sd.done) }