ARTICLE / GO BACKEND

All articles

Stopping a Go Service Under Load: HTTP, gRPC, and Kafka

How to stop a Go service within the Kubernetes grace period: drain requests, stop consumers, commit safe offsets, and test forced shutdown.

The full text is in Russian.

GoKubernetesKafkagRPCObservability

Во время развёртывания Pod получает сигнал завершения. HTTP-запрос ещё сохраняет заказ, consumer обрабатывает сообщение, а relay ждёт подтверждения Kafka. Если сразу закрыть пул PostgreSQL и вернуть управление из main, обычный выпуск превратится в серию оборванных операций. Если ждать без ограничения, процесс доживёт до принудительного завершения и всё равно потеряет работу в памяти.

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

Разберём обычный Go-сервис с HTTP или gRPC, consumer Kafka и PostgreSQL. Полный запускаемый пример ниже покрывает HTTP; для остальных компонентов важен порядок действий и границы гарантий конкретного клиента.

Что считать принятой работой

До написания обработчика SIGTERM стоит перечислить все места, где процесс получает работу: HTTP, gRPC, Kafka, планировщик, локальная очередь и запущенные обработчиками goroutine. У каждого входа должен быть способ прекратить приём и дождаться уже начатого.

Особенно опасен ответ 202 Accepted, после которого задание живёт только в памяти. Успешное завершение HTTP-обработчика не делает такое задание долговечным. Если потеря недопустима, подтверждать приём следует после записи задания или намерения в устойчивое хранилище. Связка бизнес-изменения и события разобрана в статье про transactional outbox.

Практический критерий для каждой принятой операции: она завершена, оставлена для безопасного повтора либо сохранена с явно неизвестным исходом и способом его выяснить. Последняя ветка нужна для внешнего эффекта: отмена HTTP-запроса к партнёру не доказывает, что партнёр ничего не сделал.

Graceful shutdown помогает при штатном завершении. Он не сработает при SIGKILL и не заменит протокол восстановления после внезапной потери процесса. Идемпотентность и долговечное состояние нужны независимо от качества обработчика сигнала.

Бюджет начинается раньше SIGTERM

В Kubernetes terminationGracePeriodSeconds охватывает весь процесс завершения. Если настроен preStop, он выполняется до отправки TERM и уже расходует этот бюджет. По истечении grace period оставшиеся процессы могут получить SIGKILL. Нельзя отвести 30 секунд hook и рассчитывать ещё на 30 секунд приложения. Это следует из порядка завершения Pod.

Например, из условных 30 секунд можно заложить до 5 на подготовку и распространение изменений маршрутизации, до 20 на обработку начатого и 5 на финальные действия и запас. Это пример планирования, а не рекомендуемые значения. Реальные границы определяются временем операций и поведением инфраструктуры. Приложение должно знать свой оставшийся бюджет: новый таймер на полные 30 секунд после TERM не учитывает уже выполненный hook.

Перевод readiness в неготовое состояние тоже не является мгновенным запретом трафика. Обновление маршрутов происходит асинхронно; у terminating endpoints есть отдельные условия ready, serving и terminating. Конкретная маршрутизация зависит от Service и прокси. Это описано в условиях EndpointSlice.

Поэтому задержку между снятием готовности и закрытием входа проверяют через реальный ingress. В этот промежуток сервис может продолжать обслуживать короткие запросы, если они укладываются в оставшееся время, или отклонять новые операции по заранее определённой политике. Фиксированный sleep лишь даёт время распространению изменений; он не подтверждает, что новых обращений больше не будет. Liveness при штатном завершении не следует превращать в сигнал аварии.

Не отменять всю работу первым сигналом

signal.NotifyContext удобно использовать как уведомление о начале остановки. Однако передавать этот же контекст всем запросам через BaseContext, а затем ожидать их спокойного завершения противоречиво: первый сигнал сразу отменит дочерние контексты.

Нужны разные сроки жизни:

Контекст shutdown нельзя создавать дочерним от уже отменённого signalCtx: он тоже будет отменён немедленно. Здесь подходит context.Background() с ограничением времени. После первого сигнала можно вызвать функцию stop, возвращённую NotifyContext, чтобы снять подписку; при отсутствии других подписчиков следующий сигнал снова получит стандартное поведение. См. документацию os/signal и правила наследования отмены context.

HTTP требует ожидания в main

http.Server.Shutdown закрывает listeners и idle-соединения, затем ждёт активные запросы. При этом Serve уже возвращает http.ErrServerClosed. Если main завершится вслед за Serve, ожидание обработчиков потеряет смысл.

Истечение контекста Shutdown прекращает ожидание, но само по себе не вызывает Close. Принудительную ветку нужно выполнить явно. WebSocket и другие hijacked-соединения требуют отдельного учёта; фоновые goroutine обработчика сервер также не считает самостоятельной принятой работой. Эти ограничения перечислены в контракте net/http.

Ниже самостоятельный пример для Linux или macOS с Go 1.20 и новее. /work изображает операцию длительностью три секунды; параметр duration нужен только для проверки. Сервер слушает loopback. Для доступа через Kubernetes Service потребовался бы listener на :8080. Код демонстрирует HTTP-этап и не включает readiness, Kubernetes hooks или фоновые workers; это не готовая production-конфигурация.

package main

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

func main() {
	if err := run(); err != nil {
		log.Print(err)
		os.Exit(1)
	}
}

func run() error {
	signalCtx, stopSignals := signal.NotifyContext(
		context.Background(), os.Interrupt, syscall.SIGTERM,
	)
	defer stopSignals()

	mux := http.NewServeMux()
	mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
		duration := 3 * time.Second
		if raw := r.URL.Query().Get("duration"); raw != "" {
			parsed, err := time.ParseDuration(raw)
			if err != nil || parsed < 0 || parsed > time.Minute {
				http.Error(w, "invalid duration", http.StatusBadRequest)
				return
			}
			duration = parsed
		}
		log.Print("work started")
		timer := time.NewTimer(duration)
		defer timer.Stop()
		select {
		case <-timer.C:
			_, _ = fmt.Fprintln(w, "done")
		case <-r.Context().Done():
			return
		}
	})

	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       30 * time.Second,
	}
	listener, err := net.Listen("tcp", "127.0.0.1:18080")
	if err != nil {
		return fmt.Errorf("listen: %w", err)
	}
	defer listener.Close()

	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(listener) }()
	log.Print("listening on 127.0.0.1:18080")

	var serveResult error
	select {
	case serveResult = <-serveErr:
	case <-signalCtx.Done():
	}
	stopSignals() // Восстанавливаем обычную реакцию на следующие сигналы.

	shutdownCtx, cancel := context.WithTimeout(
		context.Background(), 10*time.Second,
	)
	defer cancel()
	log.Print("HTTP drain started")
	shutdownErr := srv.Shutdown(shutdownCtx)
	if shutdownErr != nil {
		log.Printf("HTTP drain failed: %v", shutdownErr)
		shutdownErr = errors.Join(shutdownErr, srv.Close())
	}
	if serveResult == nil {
		serveResult = <-serveErr
	}
	if errors.Is(serveResult, http.ErrServerClosed) {
		serveResult = nil
	}
	if err := errors.Join(serveResult, shutdownErr); err != nil {
		return err
	}
	log.Print("HTTP drain completed")
	return nil
}

У run есть ограниченная процедура завершения и для сигнала, и для ошибки Serve. При штатной остановке HTTP-запросы получают время закончиться. При исчерпании бюджета соединения закрываются, а процесс возвращает ошибку. Это не обещание завершить произвольную goroutine: обработчик обязан реагировать на отмену и ограничивать обращения к зависимостям.

Для локальной проверки сохраните пример в main.go и соберите отдельный бинарный файл:

go build -o shutdown-demo main.go
./shutdown-demo

Во втором терминале запустите curl 'http://127.0.0.1:18080/work?duration=3s'. После сообщения work started нажмите Ctrl+C в первом терминале. Клиент должен получить done, а сервер завершиться после HTTP drain completed. Затем повторите с duration=30s: после десятисекундного бюджета ожидаются обрыв запроса, сообщение об ошибке drain и ненулевой код выхода. Для проверки именно TERM отправьте kill -TERM <PID> процессу собранного бинарного файла. У go run есть отдельный процесс запуска, поэтому он менее удобен для такого теста.

gRPC и долгие соединения

У gRPC отдельный сервер и отдельная процедура остановки. GracefulStop прекращает приём новых RPC и ждёт активные вызовы; контекст в этот метод не передаётся. Долгоживущий stream может удерживать ожидание неограниченно. Документация grpc-go различает GracefulStop и принудительный Stop.

Обычный координатор запускает GracefulStop отдельно и ждёт его завершения в пределах общего deadline. При исчерпании времени инициирует Stop, также не оставляя координатор в неограниченном ожидании этой ветки. Нельзя добавлять безусловное ожидание всех goroutine: обработчик, игнорирующий отмену, способен сорвать остановку. Механизм принудительного завершения процесса остаётся последней границей. Руководство gRPC рекомендует предусматривать forceful shutdown по тайм-ауту.

Для stream нужен ещё и прикладной протокол: перестать принимать новые задания внутри потока, сохранить позицию, предложить клиенту переподключиться и ограничить ожидание завершения. Для SSE и WebSocket действуют те же вопросы о продолжении с сохранённой позиции, хотя API транспорта другой.

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

Kafka нельзя закрывать вместе с входным HTTP

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

Последовательность для consumer с ручной фиксацией обычно такая:

  1. Остановить выдачу новой работы в прикладные очереди и ограничить уже полученный буфер.
  2. Дождаться разрешённой части обработки, оставляя клиенту возможность поддерживать членство в группе.
  3. Зафиксировать безопасную позицию по каждой партиции и проверить исход commit.
  4. Закрыть consumer; незавершённые записи оставить для повторной доставки.

Конкретные вызовы pause, poll, commit и close зависят от Go-клиента. Остановка выдачи работы не обязательно означает остановку его сетевого цикла. При rebalance право на партицию может быть отозвано раньше локального deadline, поэтому финальный commit нельзя считать гарантированным.

В обычной consumer group offset обозначает позицию продолжения чтения, а не независимую квитанцию каждой записи. Пусть обработчики одной партиции получили записи 41, 42 и 43. Записи 41 и 43 завершены, а 42 ещё работает. Продвинуть подтверждённую позицию за 43 нельзя: после перезапуска 42 может оказаться пропущена. Безопасен только обработанный префикс выданных записей. В самих offsets возможны пропуски, поэтому проверять нужно порядок полученных записей, а не наличие каждого целого числа. Семантика позиции и ручной фиксации описана в KafkaConsumer.

Например, в kafka-go для явного управления используются FetchMessage и CommitMessages. ReadMessage в режиме consumer group автоматически фиксирует сообщения. Передача в CommitMessages поздней записи подтверждает и более ранние offsets той же партиции; это не безопасный способ отметить произвольно завершившуюся goroutine. См. раздел Explicit Commits.

Если транзакция PostgreSQL завершилась, а commit Kafka не подтвердился, следующий экземпляр может получить запись повторно. Это штатная цена такого порядка фиксации. Consumer должен поглотить повтор без второго эффекта, как разобрано в статье о надёжных интеграциях.

Зависимости закрываются после их пользователей

Порядок завершения удобно вывести из зависимостей: сначала остановить источники новой работы, затем дождаться обработчиков и только после них закрыть ресурсы, которыми они пользуются. HTTP и Kafka могут завершать независимую работу параллельно; PostgreSQL при этом остаётся доступным обоим.

Не следует последовательно выдавать каждому компоненту новый полный тайм-аут. Иначе HTTP потратит двадцать секунд, gRPC ещё двадцать, consumer ещё двадцать, хотя Pod уже превысил свой бюджет. Координатор хранит общий deadline и оставляет время на финальные commits, закрытие клиентов и телеметрию. При необходимости у этапов есть более короткие пределы.

Relay outbox необязательно опустошать таблицу перед выходом. Достаточно прекратить захват новых строк, ограниченно завершить начатые публикации и корректно оставить остальные следующему экземпляру. Аренды должны освобождаться или истекать. Если подтверждение Kafka получено, а отметка в outbox не сохранена, возможна повторная публикация. Shutdown не устраняет это окно.

После аварийного deadline уже нельзя утверждать, что вся работа закончилась корректно. Координатор прекращает ожидание, инициирует принудительную отмену и сохраняет доступные сведения о незавершённом. Протокол обработки должен оставаться безопасным, даже если процесс не успел записать последний лог.

Проверки перед развёртыванием

Одного теста без активных запросов недостаточно. Минимальное учение на стенде должно включать:

Для этих проверок полезны длительность остановки по этапам, число активных операций при начале и конце drain, причины принудительного завершения, ошибки финального commit и повторы после запуска нового экземпляра. Идентификаторы операций остаются в журнале; меткам метрик достаточно ограниченного набора компонентов и исходов. Связь транспортных сигналов с результатом процесса подробнее разобрана в материале о наблюдаемости.

Рабочая остановка определяется судьбой принятой работы. Если у каждого входа есть управляемое прекращение приёма, у ожидания есть общий предел, а у незавершённого действия есть безопасное продолжение, штатный rollout становится проверяемым сценарием восстановления.