1. Зачем Shutdown и почему таймаут обязателен
Когда вы впервые поднимаете HTTP‑сервер, кажется, что «остановить» его — это просто выйти из main(). Увы, сервер — существо социальное: у него есть активные соединения, запросы «в процессе», буферы, и клиенты, которые не обязаны вести себя прилично. В этом разделе разберём, что именно делает Shutdown, и почему без таймаута вы рискуете получить процесс-зомби: он «вроде должен завершиться», но почему-то нет.
(*http.Server).Shutdown(ctx) — это мягкая остановка. Смысл такой: сервер перестаёт принимать новые соединения и даёт уже начатым запросам шанс завершиться «нормально». Но ключевое слово тут — шанс. Если запрос завис, клиент держит соединение, обработчик слишком долгий или вы где-то ждёте внешний ресурс — ожидание может затянуться.
И вот тут появляется важная идея: контекст задаёт верхнюю границу ожидания. Контекст в Go умеет сообщать «время вышло» через deadline/timeout и отмену. А context.WithTimeout — это стандартный способ создать производный контекст с дедлайном.
Если вызвать Shutdown(context.Background()), вы говорите: «жди сколько угодно». Для продакшна это как «я на пять минут» — и пропал на три дня. Поэтому правило простое и жёсткое:
Shutdown всегда вызываем с контекстом, созданным через context.WithTimeout (таймаут обязателен).
Небольшая таблица для ориентира:
| Метод | Что делает по смыслу | Что будет с активными запросами | Где подвох |
|---|---|---|---|
|
мягкая остановка | пытается дождаться завершения | без таймаута может ждать бесконечно |
|
жёсткая остановка | соединения рвутся | пользователи могут получить обрывы/ошибки |
2. Канонический шаблон shutdown с WithTimeout
Базовый паттерн: WithTimeout + defer cancel() + Shutdown
Многие новички пытаются «срезать угол»: написать srv.Shutdown(context.Background()) или создать таймаут, но забыть cancel(). Это нормально: мозг экономит энергию и считает, что «ну оно само». Но у контекста есть ресурсы (таймеры, связи), и правильный шаблон — это не бюрократия, а гигиена кода.
Канонический паттерн для остановки выглядит так: создаём контекст с таймаутом, сразу ставим defer cancel(), затем вызываем Shutdown. WithTimeout создаёт производный контекст, который автоматически отменится по истечении времени.
Мини-пример (в вакууме, без сигналов и без сервера «вокруг»):
package main
import (
"context"
"log"
"net/http"
"time"
)
func stopServer(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
Здесь важно не только то, что таймаут есть, но и то, что cancel() вызывается гарантированно. Да, контекст и так отменится по таймауту, но cancel() — это «убрать за собой со стола»: освободить связанные ресурсы раньше, если мы завершились быстрее.
Как логировать ошибку Shutdown
Новички часто ожидают, что Shutdown либо «успешен», либо «сломался». В реальности есть третий режим: «мы старались, но время вышло». И это не всегда катастрофа — но это всегда сигнал, что ваше приложение не идеально уважает отмену или у вас реально слишком маленький таймаут.
Если таймаут истёк, вы обычно увидите ошибку, связанную с context.DeadlineExceeded. А ctx.Err() у такого контекста тоже будет context.DeadlineExceeded. Это штатный механизм: дедлайн наступил — контекст отменился — операции должны завершаться.
Пример более «говорящей» диагностики без лишней драмы:
package main
import (
"context"
"errors"
"log"
"net/http"
"time"
)
func shutdownWithLog(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Printf("shutdown: timeout exceeded")
return
}
log.Printf("shutdown error: %v", err)
}
}
Важно не путать два слоя ошибок:
- http.ErrServerClosed — это ожидаемая ошибка из ListenAndServe, когда сервер штатно закрывается (её фильтруем там, где запускаем сервер).
- context.DeadlineExceeded — это сигнал, что shutdown‑процедура не уложилась в дедлайн (её логируем в shutdown‑ветке).
3. Как выбрать таймаут shutdown
Хочется дать универсальное число: «ставьте 5 секунд и будет счастье». Но в программировании магические числа плохо работают (кроме 42, но это другой курс). Вместо этого нужен способ рассуждать: сколько времени серверу нужно, чтобы корректно завершить запросы?
Логика такая: таймаут shutdown должен быть достаточно большим, чтобы ваши «обычные» запросы успели закончиться, и достаточно маленьким, чтобы процесс не висел вечность. Если вы делаете API, где типичный запрос работает 20–50 миллисекунд, то 30 секунд shutdown звучит щедро. Если у вас есть операции, которые реально занимают 2–3 секунды (например, тяжёлая агрегация), то shutdown в 1 секунду будет регулярно «рубить» процесс раньше времени.
Поскольку в Go context распространяет дедлайны и отмену вниз по стеку вызовов, правильная архитектура обычно приводит к тому, что обработчики и зависимости либо успевают завершиться в разумное время, либо корректно отменяются по контексту.
В учебном сервере задач (условный todo/tasks) можно начать с 5 секунд. Главное — не число, а то, что оно есть и явно написано в коде.
Хороший стиль — вынести таймаут в константу, чтобы он не затерялся среди логики:
package main
import "time"
const shutdownTimeout = 5 * time.Second
4. Graceful shutdown в main(): сигнал, таймаут, Shutdown
Общая схема
В main() сервер обычно запускается и блокирует поток через ListenAndServe(). При этом сигнал завершения прилетает «снаружи», поэтому нам нужно место, где мы дождёмся сигнала и инициируем остановку. Мы делаем это через signal.NotifyContext и ожидание <-sigCtx.Done(), а затем добавляем правильный Shutdown с таймаутом.
Схематично это выглядит так:
flowchart TD
A[main: запускаем HTTP сервер] --> B[ListenAndServe блокируется]
A --> C["goroutine: ждём <-sigCtx.Done()"]
C --> D["создаём shutdownCtx = WithTimeout(...)"]
D --> E["вызываем srv.Shutdown(shutdownCtx)"]
E --> F[ListenAndServe завершается]
Запуск сервера и ожидание сигнала
Код запуска (предположим, что mux уже собран):
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
srv := &http.Server{Addr: ":8080", Handler: mux}
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go shutdownOnSignal(sigCtx, srv)
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}
Самое интересное спрятано в shutdownOnSignal. Реализуем её так, чтобы таймаут был обязателен:
package main
import (
"context"
"log"
"net/http"
"time"
)
const shutdownTimeout = 5 * time.Second
func shutdownOnSignal(sigCtx context.Context, srv *http.Server) {
<-sigCtx.Done() // ждём “событие остановки”
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
Обратите внимание на деталь: мы делаем WithTimeout(context.Background(), ...), а не WithTimeout(sigCtx, ...). Почему так? Потому что sigCtx уже отменён в момент, когда мы дошли до shutdown (мы же вышли из <-sigCtx.Done()), и если сделать его родителем, получим контекст «отменён сразу». Тогда shutdown фактически не будет ждать вообще.
Именно поэтому в shutdown‑ветке часто используют отдельного «нейтрального» родителя — context.Background().
Почему Shutdown нельзя вызывать просто «после ListenAndServe»
Типичная ловушка начинающих выглядит так:
err := srv.ListenAndServe()
srv.Shutdown(...)
Кажется логичным: «когда сервер закончит работать — тогда и shutdown». Но ListenAndServe — блокирующий вызов: пока сервер работает, эта строка не вернётся. Значит, до Shutdown вы просто не дойдёте.
Поэтому shutdown запускается «рядом», чаще всего через горутину: основной поток обслуживает сервер, а параллельный — ждёт сигнал остановки и вызывает shutdown. Это не «магия», а просто две параллельные задачи.
Мини-рефакторинг main() для читабельности
Когда код разрастается, main() превращается в «лапшу»: и маршруты, и DI, и логирование, и shutdown. Мы пока не делаем глубокую слоистую архитектуру, но маленький рефакторинг уже полезен: вынести сборку сервера в функцию и оставить в main() только жизненный цикл.
Пример:
package main
import (
"net/http"
)
func newServer() *http.Server {
mux := http.NewServeMux()
// mux.HandleFunc(...) // ваши handlers уже написаны раньше
return &http.Server{Addr: ":8080", Handler: mux}
}
Тогда main() становится «чище»:
package main
import (
"context"
"os"
"os/signal"
"syscall"
)
func main() {
srv := newServer()
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go shutdownOnSignal(sigCtx, srv)
runServer(srv)
}
А runServer — это место, где фильтруем http.ErrServerClosed:
package main
import (
"errors"
"log"
"net/http"
)
func runServer(srv *http.Server) {
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}
Такой разнос по функциям хорош тем, что вы теперь почти физически не сможете «случайно» вызвать Shutdown без таймаута: у вас есть одна функция, где shutdown живёт, и там уже лежит WithTimeout.
5. Типичные ошибки при Server.Shutdown(ctx) и таймаутах
Ошибка №1: Shutdown(context.Background()) «потому что так проще».
Это самая опасная версия «проще». Она превращает завершение процесса в лотерею: если какой-то запрос зависнет, Shutdown может ждать очень долго. Исправление простое и дисциплинарное: shutdown‑контекст создаём только через context.WithTimeout(...), а значение таймаута делаем явным в константе.
Ошибка №2: делают WithTimeout, но забывают cancel().
Даже если таймаут отработает сам, cancel() — это корректное освобождение ресурсов, связанных с контекстом. В Go принято вызывать cancel() всегда, обычно через defer cancel() сразу после создания контекста. Это маленькая привычка, которая неожиданно экономит много нервов в больших системах.
Ошибка №3: используют уже отменённый контекст как родителя для shutdown‑контекста.
Частый сценарий: вы дождались <-sigCtx.Done(), а затем делаете context.WithTimeout(sigCtx, 5*time.Second). Но sigCtx уже отменён, значит и дочерний контекст будет отменён сразу. В итоге shutdown «мгновенный», ожидания не будет. Правильнее в shutdown‑ветке создавать контекст от context.Background().
Ошибка №4: логируют http.ErrServerClosed как «ошибку сервера».
Это ожидаемое завершение ListenAndServe при штатной остановке. Если логировать его как ошибку, вы получите шум в логах и ложные тревоги. Фильтруйте через errors.Is(err, http.ErrServerClosed) там, где запускаете сервер.
Ошибка №5: игнорируют ошибку Shutdown.
Если Shutdown не уложился в таймаут, вы хотите хотя бы знать об этом из логов. Даже в учебном проекте полезно вывести сообщение, иначе вы будете уверены, что «всё красиво выключилось», хотя на самом деле часть запросов обрубилась по дедлайну.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ