Что такое Graceful Shutdown и как его реализовать в Go?

Ответ

Graceful Shutdown (корректное завершение) — это процесс остановки приложения, при котором оно не прерывает текущие операции, а дожидается их завершения, закрывает все соединения (с БД, брокерами сообщений) и освобождает ресурсы перед полной остановкой.

Зачем это нужно?

  • Предотвращение потери данных: Запросы, которые уже находятся в обработке, успевают завершиться.
  • Сохранение консистентности: Незавершённые транзакции в БД могут быть корректно обработаны.
  • Нулевое время простоя (Zero-downtime deployment): В системах с балансировщиком нагрузки старый экземпляр приложения корректно завершает работу, пока новый уже принимает трафик.

Пример реализации для HTTP-сервера в Go:

func main() {
    // Создаем контекст, который будет отменен при получении сигнала
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    server := &http.Server{
        Addr:    ":8080",
        Handler: nil, // ваш http.Handler
    }

    // Запускаем сервер в отдельной горутине, чтобы не блокировать основной поток
    go func() {
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %sn", err)
        }
    }()
    log.Println("Server started")

    // Ждем сигнала на завершение (SIGINT, SIGTERM)
    <-ctx.Done()

    log.Println("Shutting down server...")

    // Даем 5 секунд на корректное завершение. 
    // Если за это время сервер не остановится, он будет принудительно завершен.
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exiting")
}

Ключевые шаги реализации:

  1. Перехват сигналов ОС: Используем signal.NotifyContext для создания контекста, который отменяется при получении SIGINT или SIGTERM.
  2. Запуск сервера в горутине: Основной поток блокируется в ожидании отмены контекста (<-ctx.Done()), а HTTP-сервер работает в отдельной горутине.
  3. Вызов server.Shutdown(): При получении сигнала вызывается метод Shutdown(), который мягко останавливает сервер. Он перестаёт принимать новые запросы, но ждёт завершения текущих.
  4. Использование context.WithTimeout: Shutdown() вызывается с контекстом, который имеет таймаут. Это гарантия того, что если текущие запросы "зависли", приложение всё равно завершится через заданное время.

Ответ 18+ 🔞

А, вот эта тема про то, как сервис не должен просто так сдохнуть, как последний мудак, когда его выключают! Graceful Shutdown, блядь, или "корректное завершение" — это когда твоё приложение не бросает всё на полпути, а как порядочный человек, дожидается, пока закончит текущие делишки, закроет все коннекты к базе и прочим сервисам, и только потом ложится спать.

А нахуя это вообще нужно, спросишь ты?

  • Чтобы не терять данные, как последний распиздяй. Запросы, которые уже в работе, должны спокойно доделаться.
  • Чтобы не наебать с консистентностью. Незавершённые транзакции в базе — это пиздец, они могут всё похерить.
  • Чтобы было "нулевое время простоя", ёпта. В продвинутых схемах развёртывания старый экземпляр тихо-мирно доделывает работу, пока новый уже вовсю ловит трафик. Красота!

Смотри, как это выглядит на Go для обычного HTTP-сервера:

func main() {
    // Создаём контекст, который словит пизды, когда прилетит сигнал от ОС
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    server := &http.Server{
        Addr:    ":8080",
        Handler: nil, // сюда твой http.Handler
    }

    // Запускаем сервер в отдельной горутине, чтобы не тормозить тут
    go func() {
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %sn", err)
        }
    }()
    log.Println("Server started")

    // Сидим, ждём пизды. Как только прилетит SIGINT (Ctrl+C) или SIGTERM — контекст отменится.
    <-ctx.Done()

    log.Println("Shutting down server...")

    // Даём серверу 5 секунд на то, чтобы прибраться за собой.
    // Не успеет — получит пинка под зад, и контекст с таймаутом его прибьёт.
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exiting")
}

А теперь, сука, разжёвываю, что тут происходит:

  1. Ловим сигналы от системы. signal.NotifyContext — это наш сторожевой пёс. Как только кто-то шлёт SIGINT (это когда Ctrl+C жмёшь) или SIGTERM (это когда оркестратор типа Kubernetes хочет убить под), контекст тут же отменяется.
  2. Сервер — в отдельную горутину. Основной поток тогда свободен и может просто ждать сигнала, а сервер в это время пашет.
  3. Вызов server.Shutdown() — это магия. Когда сигнал прилетел, мы вызываем этот метод. Сервер перестаёт принимать новые запросы, но терпеливо ждёт, пока все текущие доложат о выполнении. Умница, блядь.
  4. Таймаут — наша последняя надежда. Обернули вызов в контекст с таймаутом. Если какой-то запрос завис, как мудак, и не завершается, через 5 секунд мы ему говорим "всё, поезд ушёл" и принудительно вырубаем всё. Чтобы не висеть до скончания веков.

Вот и вся философия. Не бросать всё на полпути, а завершать дела как взрослый.