From a6a46af12ed538c800de7862cce69ad8af0ce560 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Fri, 3 Jul 2026 08:12:10 +0300 Subject: [PATCH] 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 --- .env.example | 9 ++++++++ backend/cmd/server/main.go | 35 ++++++++++++++++++++++++++++--- backend/internal/config/config.go | 8 +++++++ docker-compose.yml | 6 ++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 11f7c11..76c75f9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0ca0195..dfde5a7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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, } - slog.Info("starting server", "addr", cfg.ListenAddr) - if err := srv.ListenAndServe(); err != nil { - slog.Error("server error", "err", err) + // 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) + // 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") } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index eaf94a7..c50870f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), diff --git a/docker-compose.yml b/docker-compose.yml index b5aa3e3..b0a1163 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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