feat(backend): graceful shutdown on SIGTERM/SIGINT
Run the HTTP server in a goroutine and, on SIGINT/SIGTERM, call srv.Shutdown so it stops accepting connections and lets in-flight requests finish before exiting (ErrServerClosed is a clean exit). This stops uploads/streams being cut when the container is stopped or recreated on deploy. The drain deadline is configurable via SHUTDOWN_TIMEOUT (default 15s). docker-compose.yml feeds the same variable into the app's stop_grace_period, so Docker won't SIGKILL mid-drain and the two values can't drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,15 @@ JWT_REFRESH_TTL=720h
|
||||
# long as a viewing session lasts.
|
||||
CONTENT_TOKEN_TTL=6h
|
||||
|
||||
# How long a graceful shutdown waits for in-flight requests to finish after the
|
||||
# app receives SIGTERM/SIGINT (e.g. on `docker compose up --build`, which
|
||||
# recreates the container). This SAME value is fed to the container's
|
||||
# `stop_grace_period` in docker-compose.yml, so Docker won't SIGKILL the app
|
||||
# mid-drain — set it in one place here and both stay in sync. A single upload or
|
||||
# video stream that outlasts this window is still cut; raise it if you routinely
|
||||
# move very large files. Accepts Go/Compose durations (s, m, h).
|
||||
SHUTDOWN_TIMEOUT=15s
|
||||
|
||||
# Reverse-proxy hops (comma-separated CIDRs/IPs) whose X-Forwarded-For is trusted,
|
||||
# so the auth rate limiter sees real client IPs instead of the proxy's. The default
|
||||
# covers loopback and the Docker bridge ranges a host nginx reaches the container
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -142,9 +145,35 @@ func main() {
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Trigger a graceful shutdown on SIGINT/SIGTERM (the latter is what Docker
|
||||
// sends when the container is stopped or recreated on deploy).
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
// ListenAndServe returns ErrServerClosed after a graceful Shutdown; that
|
||||
// is the expected exit, not a failure.
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
// Restore default signal handling so a second Ctrl+C / SIGTERM force-quits
|
||||
// instead of waiting on the drain.
|
||||
stop()
|
||||
slog.Info("shutting down", "timeout", cfg.ShutdownTimeout)
|
||||
|
||||
// Stop accepting new connections and let in-flight requests finish, up to the
|
||||
// timeout. Docker's stop grace period reads the same SHUTDOWN_TIMEOUT, so it
|
||||
// won't SIGKILL before this returns.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("graceful shutdown failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("shutdown complete")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ type Config struct {
|
||||
// expiry and refresh rotation. Keep it only as long as a viewing session
|
||||
// plausibly lasts — it is a bearer credential for that one file until expiry.
|
||||
ContentTokenTTL time.Duration
|
||||
// ShutdownTimeout bounds how long a graceful shutdown waits for in-flight
|
||||
// requests to finish after a SIGINT/SIGTERM before the process exits. Keep it
|
||||
// in step with the container's stop grace period — docker-compose.yml reads
|
||||
// the same SHUTDOWN_TIMEOUT for `stop_grace_period`, so Docker doesn't SIGKILL
|
||||
// mid-drain. A long upload/stream can still be cut if it outlasts this window.
|
||||
ShutdownTimeout time.Duration
|
||||
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
|
||||
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
|
||||
// client IP, so this must match the proxy in front of the app — otherwise
|
||||
@@ -162,6 +168,8 @@ func Load() (*Config, error) {
|
||||
|
||||
ContentTokenTTL: parseDuration("CONTENT_TOKEN_TTL", "6h"),
|
||||
|
||||
ShutdownTimeout: parseDuration("SHUTDOWN_TIMEOUT", "15s"),
|
||||
|
||||
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
|
||||
|
||||
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||
|
||||
@@ -26,6 +26,12 @@ services:
|
||||
container_name: tfm
|
||||
restart: unless-stopped
|
||||
|
||||
# Give the app time to drain in-flight requests on stop before SIGKILL. Reads
|
||||
# the same SHUTDOWN_TIMEOUT the app uses for its graceful-shutdown deadline
|
||||
# (via env_file below), so the two never drift. Interpolated from .env at
|
||||
# `docker compose` time; defaults to 15s if unset.
|
||||
stop_grace_period: ${SHUTDOWN_TIMEOUT:-15s}
|
||||
|
||||
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
|
||||
env_file: .env
|
||||
|
||||
|
||||
Reference in New Issue
Block a user