Go-сервис обрывает запросы при обновлении: как дождаться завершения HTTP-обработчиков

Новая версия уже собрана, проверка здоровья зелёная, а во время обновления часть клиентов получает обрыв соединения. В логах старого процесса нет ошибки бизнес-логики: программа просто закончилась. Такое случается, когда сервер воспринимает сигнал остановки как команду немедленно выйти, хотя внутри ещё выполняются запросы. Исправление начинается с жизненного цикла процесса: кто перестаёт принимать работу, кто ждёт её окончания и кто решает, что ждать больше нельзя.

Две разные точки завершения

В простом приложении вызов ListenAndServe занимает основную goroutine. Если после остановки слушателя main завершится, вместе с ним остановятся остальные goroutine. Поэтому недостаточно запустить Shutdown в отдельной goroutine и позволить main вернуться после ErrServerClosed. Основная goroutine должна дождаться результата остановки. Иначе код завершения существует в исходнике, но процесс не даёт ему закончить работу.

У остановки есть два события: новые соединения больше не принимаются и активные обработчики закончили работу. Между ними проходит время. Именно этот промежуток нужно согласовать с системой, которая обновляет экземпляры приложения. Если внешний менеджер убьёт процесс раньше, чем закончится внутреннее ожидание, длительный запрос всё равно оборвётся.

Минимальный порядок в main

package main

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

func run() error {
    mux := http.NewServeMux()
    mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("pong"))
    })
    srv := &http.Server{Addr: ":8080", Handler: mux,
        ReadHeaderTimeout: 5 * time.Second}
    stopped, stop := signal.NotifyContext(context.Background(),
        os.Interrupt, syscall.SIGTERM)
    defer stop()
    serveErr := make(chan error, 1)
    go func() { serveErr <- srv.ListenAndServe() }()
    select {
    case err := <-serveErr:
        if errors.Is(err, http.ErrServerClosed) { return nil }
        return fmt.Errorf("serve: %w", err)
    case <-stopped.Done():
    }
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        closeErr := srv.Close()
        return fmt.Errorf("shutdown: %w; close: %v", err, closeErr)
    }
    if err := <-serveErr; !errors.Is(err, http.ErrServerClosed) {
        return fmt.Errorf("serve: %w", err)
    }
    return nil
}

func main() {
    if err := run(); err != nil { log.Fatal(err) }
}

Контекст ожидания создаётся от Background. Контекст stopped уже отменён сигналом: использовать его в Shutdown означало бы сразу исчерпать время на завершение. Канал с результатом сервера имеет буфер, чтобы goroutine могла передать ошибку независимо от того, какая ветка select сработает первой. После сигнала run выполняет ожидание и только затем возвращается. Ошибка старта возвращается в main, где log.Fatal завершает процесс с ненулевым кодом: менеджер запуска увидит неуспешный старт.

Что происходит на границе десяти секунд

Если обработчик завершится вовремя, клиент получит обычный ответ. Если срок закончится, Shutdown вернёт ошибку. В примере следом вызывается Close: оставшиеся соединения будут закрыты, и это уже сознательная политика принудительного завершения. Десять секунд здесь — настройка примера. Для своего API выберите срок по длительности нормальных операций и по внешнему сроку остановки контейнера или процесса.

Само ожидание HTTP-сервера не заменяет управление фоновой работой. Если обработчик запустил отправку отчёта и сразу вернул 202, активного запроса уже нет. Для такого задания нужен отдельный учёт принятой работы и понятный способ восстановления после перезапуска. Долгое соединение WebSocket тоже требует собственного завершения: Shutdown не ждёт захваченные обработчиком соединения. Здесь граница механизма влияет на то, кого должен ждать main.

Как проверить обновление на своём сервисе

Добавьте тестовый обработчик, который сообщает о начале работы и ждёт разрешения завершиться. Запустите запрос, дождитесь сообщения о начале, затем инициируйте остановку сервера и разрешите обработчику вернуть результат. Проверьте два наблюдения: ответ дошёл клиенту, а программа закончилась после него. Во втором сценарии не давайте обработчику завершиться и проверьте срабатывание выбранного предела. Использование канала вместо случайного sleep делает порядок событий воспроизводимым.

После локального теста повторите сценарий при настоящем обновлении одного экземпляра. Записывайте время исключения из маршрутизации, получения сигнала и завершения последнего запроса. Если новый трафик продолжает приходить после начала остановки, нужно согласовать маршрутизатор и процесс. Настройка только одного приложения не объяснит всю цепочку.

Ещё одна полезная проверка — занятый порт при запуске. Запустите второй экземпляр на том же адресе и посмотрите код завершения. Такой процесс должен сообщить об ошибке старта, чтобы система запуска не считала его успешно отработавшим заданием. Отдельно посмотрите, что происходит с подключениями к базе: закрывать общий пул следует после завершения обработчиков, которые ещё могут им пользоваться. Если закрыть его сразу после сигнала, ожидание HTTP формально продолжится, но активные запросы начнут падать на своей зависимости.

Перед остановкой полезно отдельно проверить передачу context в зависимый HTTP-вызов: это позволит запросу закончиться при отмене клиентом. Для фоновых исполнителей разберите владение каналом и ожидание worker pool. У них свой жизненный цикл, который main должен учитывать вместе с сервером.

Продолжить от одного процесса к взаимодействию сервисов

Когда жизненный цикл одного API стал понятным, следующий вопрос — что делать с запросом, который ушёл соседнему сервису перед остановкой. В платном курсе «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker» вы разберёте HTTP- и gRPC-контракты, дедлайны, повторную доставку событий, тестирование отказов и запуск примеров через Docker Compose. Это практика рассуждения о состоянии системы между процессами. Откройте программу курса и начните с взаимодействия двух Go-программ.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.