agentleFS
AllThingsSmittyAllThingsSmitty/proven-skills/skills/go-ops/SKILL.md

go-ops

Skill1 starsChanged 27 days agoMITView on GitHub

Production Go operations advisor. Always use this skill when deploying or operating Go applications, handling graceful shutdown, signal handling, health checks, structured logging, profiling with pprof, or managing configuration. Use when the user asks 'how do I handle SIGTERM in Go', 'graceful shutdown in Go', 'how do I add health checks', 'how do I profile a Go service', 'structured logging in Go', 'slog vs zerolog vs zap', 'how do I read config from env vars', or 'Go kubernetes'. Read this skill before advising on any Go production or deployment topic.

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 5 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never use log.Fatal(http.ListenAndServe(...)).

    line 8
  2. Very specific:

    Use Shutdown(ctx) not Close(): Shutdown waits for in-flight requests.

    line 63
  3. Distinctive:

    Always give the shutdown a deadline (30s is a good default).

    line 64
  4. Distinctive:

    Liveness must be trivial: If liveness checks the database and the database goes down, Kubernetes restarts every pod, making an outage worse.

    line 78
  5. Very specific:

    Use log/slog (stdlib, Go 1.21+) for new projects.

    line 131
  6. Distinctive:

    Always carry context through your logs:

    line 159
  7. Very specific:

    When to use zerolog or zap instead: Only when benchmarks prove slog is a bottleneck (rare).

    line 164
  8. Distinctive:

    Follow 12-factor: read config from environment variables, not files, not hardcoded constants.

    line 170
  9. Very specific:

    Ignoring context cancellation in database calls: Always pass r.Context() (or a derived context) to db.QueryContext, db.ExecContext, etc.

    line 338
  10. Distinctive:

    Before shipping a Go service to production:

    line 344

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Graceful Shutdown

Never use log.Fatal(http.ListenAndServe(...)). It makes your service unkillable gracefully: Kubernetes sends SIGTERM and your pods drop in-flight requests.

The correct pattern uses signal.NotifyContext (Go 1.16+) or signal.Notify with a channel:

package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: routes(),
    }

    // Catch SIGINT and SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    // Start serving in a goroutine
    go func() {
        slog.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    // Block until signal received
    <-ctx.Done()
    slog.Info("shutdown signal received")

    // Give in-flight requests up to 30 seconds to complete
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("shutdown error", "err", err)
        os.Exit(1)
    }

    slog.Info("server stopped cleanly")
}

Key rules:

  • Use Shutdown(ctx) not Close(): Shutdown waits for in-flight requests; Close drops them.
  • Always give the shutdown a deadline (30s is a good default). Without it, a hung request hangs your pod forever.
  • Kubernetes default terminationGracePeriodSeconds is 30s, so align your timeout to be slightly less.

Health Checks

Two endpoints, two different jobs:

EndpointPurposeFailing behavior
/healthzLiveness: is the process alive?Kubernetes restarts the pod
/readyzReadiness: can it serve traffic?Kubernetes removes pod from load balancer

Liveness must be trivial. If liveness checks the database and the database goes down, Kubernetes restarts every pod, making an outage worse. Liveness should only confirm the process is not deadlocked.

// Liveness — always 200 as long as the process is running
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
})

// Readiness — check actual dependencies
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        slog.Error("readiness: db ping failed", "err", err)
        http.Error(w, "db unavailable", http.StatusServiceUnavailable)
        return
    }

    if err := redisClient.Ping(ctx).Err(); err != nil {
        slog.Error("readiness: redis ping failed", "err", err)
        http.Error(w, "redis unavailable", http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
})

Kubernetes probe config:

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3

Structured Logging

Use log/slog (stdlib, Go 1.21+) for new projects. Never use fmt.Printf in production: you can't query or filter it.

import "log/slog"

// Default logger goes to stderr as text; switch to JSON for production
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

// Log with structured key-value attributes
slog.Info("request handled",
    "method", r.Method,
    "path", r.URL.Path,
    "status", statusCode,
    "duration_ms", duration.Milliseconds(),
    "request_id", requestID,
)

// Log errors with context
slog.Error("database query failed",
    "err", err,
    "query", queryName,
    "user_id", userID,
)

Always carry context through your logs:

  • request_id: correlate all log lines for a single request
  • trace_id / span_id: if using distributed tracing (OpenTelemetry)
  • service / version: if running multiple services

When to use zerolog or zap instead: Only when benchmarks prove slog is a bottleneck (rare). Both zerolog and zap have steeper APIs. Prefer slog unless you're logging millions of lines per second.


Configuration

Follow 12-factor: read config from environment variables, not files, not hardcoded constants.

type Config struct {
    Port        string
    DatabaseURL string
    RedisAddr   string
    LogLevel    string
    Debug       bool
}

func loadConfig() (Config, error) {
    cfg := Config{
        Port:     getEnv("PORT", "8080"),
        LogLevel: getEnv("LOG_LEVEL", "info"),
        Debug:    os.Getenv("DEBUG") == "true",
    }

    // Required — fail fast at startup
    cfg.DatabaseURL = os.Getenv("DATABASE_URL")
    if cfg.DatabaseURL == "" {
        return Config{}, fmt.Errorf("DATABASE_URL is required")
    }

    cfg.RedisAddr = os.Getenv("REDIS_ADDR")
    if cfg.RedisAddr == "" {
        return Config{}, fmt.Errorf("REDIS_ADDR is required")
    }

    return cfg, nil
}

func getEnv(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

Fail fast: call loadConfig() at the very start of main(). If required config is missing, crash immediately with a clear message. A service that starts without its config and silently misbehaves is harder to debug than one that refuses to start.

For complex configs, github.com/kelseyhightower/envconfig or github.com/caarlos0/env reduce boilerplate while staying 12-factor compliant.


Profiling with pprof

Register the pprof HTTP handlers in your server, but never expose them publicly. Gate them behind an internal port or require authentication.

import _ "net/http/pprof" // registers /debug/pprof/* handlers on DefaultServeMux

// Use a separate internal server, never the public one
go func() {
    internalMux := http.NewServeMux()
    internalMux.Handle("/debug/pprof/", http.DefaultServeMux)
    http.ListenAndServe("127.0.0.1:6060", internalMux)
}()

The 4 key profiles:

# CPU profile — 30s sample
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Heap profile — current allocations
go tool pprof http://localhost:6060/debug/pprof/heap

# Goroutine dump — all goroutines and their stacks
go tool pprof http://localhost:6060/debug/pprof/goroutine

# Execution trace — scheduling, GC, syscalls (use sparingly, high overhead)
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
go tool trace trace.out

In benchmarks, capture profiles with flags:

func BenchmarkHandler(b *testing.B) {
    // go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
    // go tool pprof cpu.out
}

Build and Deploy

Multi-stage Docker build (small, static, no shell):

# Stage 1: build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
    -o /bin/server ./cmd/server

# Stage 2: run
FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Embed version info at build time:

// main.go
var (
    version = "dev"
    commit  = "none"
)

func main() {
    slog.Info("starting", "version", version, "commit", commit)
    // ...
}
# Build with version info
go build -ldflags="-X main.version=$(git describe --tags) -X main.commit=$(git rev-parse --short HEAD)" ./cmd/server

Key flags:

  • CGO_ENABLED=0: fully static binary, no libc dependency
  • -s -w: strip debug symbols and DWARF info (smaller binary)
  • distroless/static: no shell, no package manager, minimal attack surface

Common Operational Mistakes

Not handling SIGTERM. Kubernetes sends SIGTERM before killing a pod. If you ignore it, in-flight requests fail and you get 502s during rolling deploys.

Unstructured log lines. log.Printf("user %d failed: %v", id, err) is unsearchable in Splunk, Loki, or CloudWatch. Use slog with key-value pairs.

Missing /readyz. Without a readiness probe, Kubernetes sends traffic to pods that haven't connected to the database yet. Always implement /readyz.

Exposing pprof publicly. The goroutine dump and heap profile reveal your entire application state. Bind pprof to localhost or an internal port only.

Panics that crash the service. An unrecovered panic kills the entire process, not just one request. Add a recover middleware:

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic recovered",
                    "panic", rec,
                    "stack", string(debug.Stack()),
                    "path", r.URL.Path,
                )
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Ignoring context cancellation in database calls. Always pass r.Context() (or a derived context) to db.QueryContext, db.ExecContext, etc. This allows queries to be cancelled when the client disconnects.


Code Review Checklist

Before shipping a Go service to production:

  • main() listens for SIGTERM/SIGINT and calls srv.Shutdown(ctx) with a deadline
  • /healthz returns 200 immediately (no dependency checks)
  • /readyz checks all dependencies with a short timeout (≤2s)
  • All log calls use slog with key-value pairs (no fmt.Printf or bare log.Println)
  • Config is loaded from env vars; missing required vars cause startup failure
  • pprof is only reachable on an internal/localhost port
  • A recover middleware is wrapping HTTP handlers
  • All database and external calls pass a context derived from r.Context()
  • Docker image uses multi-stage build with distroless or scratch
  • Binary built with CGO_ENABLED=0 for a fully static binary
  • Version and commit hash embedded via -ldflags

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.