Graceful Shutdown в Go на практике
Graceful Shutdown в Go на практике

Корректное завершение любого приложения обычно делает три вещи:

  1. Закрывает точку входа для новых запросов или сообщений из HTTP, pub/sub источников и т.д. При этом исходящие соединения с базами данных, кешами сохраняются активными.

  2. Ждет завершения всех исходящих запросов. Если запрос работает слишком долго, возвращается корректная ошибка.

  3. Освобождает важные ресурсы, как базы данных, блокировки на файлы или подписки на сетевые источники.

Эта статья фокусируется на работе с HTTP сервером и контейнеризацией, однако основные идеи подходят и для других видов приложения.

1. Получаем сигнал

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

Что это за сигналы?

В системах основанных на Unix сигналы - программные прерывания. Они уведомляют процесс, что-то случилось и необходимо на это отреагировать. Когда операционная система отправляет этот сигнал, она прерывает обычную работу процесса, чтобы доставить уведомление.

Есть несколько способов, как отреагировать:

  • Обработчик сигнала: процесс регистрирует свой обработчик для конкретного сигнала. Обработчик запускается при получении сигнала.

  • Поведение по-умолчанию: используется обработчик по-умолчанию. Процесс может завершиться, остановиться, продолжить свою работу или проигнорировать сигнал.

  • Не блокируемые сигналы: некоторые сигналы (например SIGKILL - signal number 9), не могут быть перехвачены или проигнорированы. Такие сигналы могут завершить процесс.

При запуске вашего Go приложения, до старта главного метода main , рантайм Go регистрирует обработчики для многих сигналов (SIGTERMSIGQUITSIGILLSIGTRAPи другие). Для корректного завершения работы обычно нужно только три:

  • SIGTERM (Завершение): стандартный способ сообщить процессу о завершении. При этом процесс еще не останавливается принудительно. Kubernetes отправляет такой сигнал, когда хочет, чтобы приложение завершило работу, до принудительной остановки.

  • SIGINT (Прерывание): отправляется если пользователь хочет остановить процесс из терминала, обычно нажимая Ctrl+C.

  • SIGHUP (Обновление): изначально использовался при отключении терминала. Сейчас часто используется, как сигнал для перезагрузки конфигурации.

Сейчас в основном используют SIGTERM и SIGINT. SIGHUP используют реже для завершения работы, больше для перезагрузки конфигурации. Более подробно об этом можно посмотреть в статье - SIGHUP Signal for Configuration Reloads.

По умолчанию рантайм Go завершает приложение при получении сигналов SIGTERM , SIGINT или SIGHUP.

Когда ваше приложение на Go получает SIGTERM в первый раз используется встроенный обработчик. Он проверяет, зарегистрирован ли отдельный обработчик. Если отдельного обработчика нет, то рантайм временно блокирует свой встроенный обработчик и посылает тот же сигнал (SIGTERM) в приложение снова. В этот момент, операционная система обрабатывает второй сигнал по-умолчанию (завершение процесса).

Можно зарегистрировать собственный обработчик системных сигналов через пакет os/signal :

func main() {
  signalChan := make(chan os.Signal, 1)
  signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

  // Setup work here

  <-signalChan

  fmt.Println("Received termination signal, shutting down...")
}
Настройка сигналов для корректного завершения
Настройка сигналов для корректного завершения

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

Мы выбрали буфферизированный канал с емкостью единица (1) для надежной обработки сигналов. Под капотом Go отправляет эти сигналы на наш канал с помощью оператора select с default веткой:

select {
case c <- sig:
default:
}

Это отличается от обычного select с каналами на прием данных. Для отправки сигнала логика работы следующая:

  • в буфере есть место, сигнал отправляется и код продолжает работать

  • буфер заполнен, сигнал отбрасывается и выполняется default ветка. Если использовать небуферизированный канал и нет активной получающей горутины, сигнал будет утерян.

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

Можно вызвать Notify многократно. Go будет посылать сигнал каждому зарегистрированному каналу.

Если нажать Ctrl+C больше одного раза, это автоматически не завершает приложение. Первое нажатие  Ctrl+C посылает SIGINT активному процессу. Повторное нажатие подсылает еще один SIGINT , не SIGKILL. Большинство терминалов, такие как bash или другие Linux оболочки, не повышает сигнал повторный SIGINT до уровня SIGKILL. Чтобы принудительно остановить процесс, нужно послать сигнал SIGKILL с помощью kill -9.

Это не очень удобно для локальной разработки, когда вы ожидаете, что повторный Ctrl+C завершит приложение принудительно. Однако можно запретить слушать дальнейшие сигналы через  signal.Stop сразу после получения первого сигнала:

func main() {
  signalChan := make(chan os.Signal, 1)
  signal.Notify(signalChan, syscall.SIGINT)

  <-signalChan

  signal.Stop(signalChan)
  select {}
}

С Go 1.16 можно сделать немного проще с signal.NotifyContext , который связывает обработку сигнала и контекст отмены:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Setup tasks here

<-ctx.Done()
stop()

Но все еще нужно вызывать stop() после ctx.Done(), чтобы повторный Ctrl+C принудительно останавливал приложение.

2. Позаботимся о таймаутах

Важно знать сколько времени есть у приложения на завершение после получения сигнала. К примеру в Kubernetes по-умолчанию это 30 секунд, который можно переопределить в поле terminationGracePeriodSeconds . После завершения этого периода, Kubernetes принудительно посылает  SIGKILL для завершения приложения. Этот сигнал не может быть перехвачен или обработан.

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

Пусть по-умолчанию у нас есть 30 секунд. Хорошей практикой будет резервирование 20% от этого периода для запаса прочности, чтобы остановка приложения выполнилась точно после очистки ресурсов. Таким образом для корректного завершения работы приложения нам нужно угнать сделать это за 25 секунд.

3. Не обрабатываем новые входящие запросы

В пакете net/http для корректного завершения используется метод -  http.Server.Shutdown. После вызова метода, http-сервер больше не принимает входящие запросы и ждет пока все текущие активные запросы будут обработаны, после чего останавливает все неактивные соединения.

Детально это работает так:

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

  • Если клиент пытается создать соединение во время остановки, это приводит к ошибке, так как все обработчики запросов уже закрыты на сервере. Обычно это ошибка connection refused.

При использовании контейнеризации, облачной архитектуры (там где есть оркестрация контейнеров и балансировщики нагрузки) - остановка на обработку новых запросов не всегда срабатывает мгновенно. Даже после того как pod помечается "для остановки", фактически он может все еще принимать траффик (непродолжительное время), потому что системе нужно время для обновления сервиса и балансировщика нагрузки.

Особенно актуально для readiness проверки в Kubernetes, так как останавливающийся pod может все еще получать траффик, даже если другие end-points не готовы.

Проверка readiness определяет, когда контейнер готов принимать траффик. Система периодически проверяет контейнер через настроенные HTTP запросы, TCP коннекты или запуск команд. Если проверка не проходит (в действительности можно настроить счетчик ошибок), Kubernetes удаляет pod из сервиса, чтобы контейнер не получал траффик, пока снова не станет готовым к работе.

Чтобы избежать ошибок соединения в этот короткий период, правильная стратегия - возвращать ошибку на readiness проверку. Таким образом оркестратор будет понимать, что контейнер не готов к работе:

var isShuttingDown atomic.Bool

func readinessHandler(w http.ResponseWriter, r *http.Request) {
    if isShuttingDown.Load() {
        w.WriteHeader(http.StatusServiceUnavailable)
        w.Write([]byte("shutting down"))
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}
Readiness проверка
Readiness проверка

Этот паттерн также используется в тестах на изображения в репозитории Kubernetes. Там закрытый канал используется для возврата HTTP 503 в readiness проверке, когда приложение готовится к завершению работы.

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

Это время указывается в конфигурации readiness; мы будем использовать 5 секунд в этой статье на примере простой конфигурации:

readinessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5

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

4. Обработка текущих запросов

Сейчас мы закрываем HTTP сервер и нам нужно выбрать таймаут на обработку запроса в соответствии с нашим лимитом по времени на закрытие приложения:

ctx, cancelFn := context.WithTimeout(context.Background(), timeout)
err := server.Shutdown(ctx)

Метод server.Shutdown возвращает управление только в двух ситуациях:

  1. все активные соединения закрыты и все обработчики закончили свою работу

  2. контекст переданный в  Shutdown(ctx) закончился по времени до момента, когда все обработчики закончили свою работу. В этом случае сервер перестает ждать.

In either case, Shutdown only returns after the server has completely stopped handling requests. This is why your handlers must be fast and context-aware. Otherwise, they may be cut off mid-process in case 2, which can cause issues like partial writes, data loss, inconsistent state, open transactions, or corrupted data.

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

Проблема в том, что обработчики по-умолчанию не знают, когда сервер завершает работу.

Тогда как мы можем узнать в обработчике, что сервер в процессе завершения работы? Ответ - использовать Context. Есть два основных пути сделать это:

a. Использовать middleware в Context для логики отмены запроса

Специальный метод оборачивает каждый запрос с контекстом, который слушает сигнал завершения работы:

func WithGracefulShutdown(next http.Handler, cancelCh <-chan struct{}) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := WithCancellation(r.Context(), cancelCh)
        defer cancel()

        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

b. Использовать глобальный BaseContext для всех соединений

Здесь мы создаем сервер с кастомным Context , с функцией отмены - она вызовется в процессе остановки сервера. Этот контекст будет передаваться во все входящие запросы:

ongoingCtx, cancelFn := context.WithCancel(context.Background())
server := &http.Server{
    Addr: ":8080",
    Handler: yourHandler,
    BaseContext: func(l net.Listener) context.Context {
        return ongoingCtx
    },
}

// After attempting graceful shutdown:
cancelFn()
time.Sleep(5 * time.Second) // optional delay to allow context propagation

Для HTTP-сервера можно использовать два вида Context

  • BaseContext

  • ConnContext

Для нашей цели (корректного завершения работы) подходит больше всего - BaseContext. При помощи него мы создаем глобальный контекст с кастомным методом cancel целиком для всего HTTP-сервера, в этом методе мы сигнализируем, что сервер завершает свою работу.

Завершение работы с задержкой для обновления системы.
Завершение работы с задержкой для обновления системы.

Все это не будет работать, если ваши обработчики не учитывают контекст завершения работы. Старайтесь избегать context.Background()time.Sleep(), или любые другие методы, которые игнорируют контекст.

К примеру , time.Sleep(duration) заменяем вариантом с учетом контекста:

func Sleep(ctx context.Context, duration time.Duration) error {
    select {
    case <-time.After(duration):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

В старых версиях Go time.After может приводить к утечкам памяти после оконачания таймера. Это было исправлено в Go 1.23 и выше. Если вы не знаете, в какой версии будет работать код - используйте связку time.NewTimer + Stop и опционально  <-t.C для проверки если Stop возвращает false.

Более подробно здесь : time: stop requiring Timer/Ticker.Stop for prompt GC

Хотя мы сфокусировались на HTTP-сервере, тот же самый принцип можно применять и к другим сервисам. К примеру в пакете database/sql есть метод DB.Close . Он закрывает коннект к базе данных и не дает делать новые запросы. Метод также ожидает, пока текущие активные запросы в базу выполняются, прежде чем завершить работу.

Ключевой принцип корректного завершения работы остается практически одинаковым для всех систем: останавливаем прием данных (запросы, сообщения, сигналы), даем текущим операциям время на завершение с заранее определенным периодом.

А можно ли вызвать метод server.Close() (он немедленно закрывает все текущие соединения, не ожидая окончания работы запросов), если  server.Shutdown()  вернул ошибку?

Если кратко - да можно, но это зависит от вашей стратегии завершения работы. Метод  server.Close() принудительно закрывает всех слушателей и все соединения:

  • активные обработчики (handlers) работающие с сетью, будут получать ошибки при попытке чтения/записи

  • клиент немедленно получит ошибку соединения ECONNRESET (socket hang up)

  • обработчики с длительными задачами не работающие с сетью могут продолжать свою работу

Поэтому передача сигнала о завершении через контекст является более надежным подходом.

5. Освобождение критических ресурсов

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

В большинстве случаев, завершение процесса вполне достаточно. Операционная система автоматически освободит ресурсы. Например:

  • память выделенная Go рантаймом, автоматически освобождается при завершении процесса

  • файловые дескрипторы закрываются операционной системой

  • ресурсы уровня операционной системы (как обработчики процесса) - освобождаются

Однако есть несколько важных случаев, когда нужно явно освобождать ресурсы:

  • коннекты к базам данных должны быть закрыты. Если есть открытые транзакции, их нужно либо закоммитить, либо откатить. Без этого база данных будет ждать таймаута.

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

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

Хорошее правило - закрывать компоненты в обратном порядке от инициализации. Это учитывает зависимости между компонентами.

Ключевое слово Go defer работает именно так - последняя deferred функция вызывается первой:

db := connectDB()
defer db.Close()

cache := connectCache()
defer cache.Close()

Некоторые компоненты могут требовать специального функционала перед завершением. К примеру, у вас есть in-memory кеш и вам надо сбросить данные на диск до выхода приложения. В этом случае нужно учитывать особенности завершения работы для такого компонента.

Итоги

В статье мы рассмотрели полностью рабочий механизм корректного завершения. Структура кода довольно простая и линейная для понимания. Этот подход можно адаптировать под свой проект с использованием  errgroupWaitGroup и других паттернов:

const (
	_shutdownPeriod      = 15 * time.Second
	_shutdownHardPeriod  = 3 * time.Second
	_readinessDrainDelay = 5 * time.Second
)

var isShuttingDown atomic.Bool

func main() {
	// Setup signal context
	rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// Readiness endpoint
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		if isShuttingDown.Load() {
			http.Error(w, "Shutting down", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "OK")
	})

	// Sample business logic
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
			fmt.Fprintln(w, "Hello, world!")
		case <-r.Context().Done():
			http.Error(w, "Request cancelled.", http.StatusRequestTimeout)
		}
	})

	// Ensure in-flight requests aren't cancelled immediately on SIGTERM
	ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background())
	server := &http.Server{
		Addr: ":8080",
		BaseContext: func(_ net.Listener) context.Context {
			return ongoingCtx
		},
	}

	go func() {
		log.Println("Server starting on :8080.")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			panic(err)
		}
	}()

	// Wait for signal
	<-rootCtx.Done()
	stop()
	isShuttingDown.Store(true)
	log.Println("Received shutdown signal, shutting down.")

	// Give time for readiness check to propagate
	time.Sleep(_readinessDrainDelay)
	log.Println("Readiness check propagated, now waiting for ongoing requests to finish.")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), _shutdownPeriod)
	defer cancel()
	err := server.Shutdown(shutdownCtx)
	stopOngoingGracefully()
	if err != nil {
		log.Println("Failed to wait for ongoing requests to finish, waiting for forced cancellation.")
		time.Sleep(_shutdownHardPeriod)
	}

	log.Println("Server shut down gracefully.")
}

Комментарии (1)


  1. manyakRus
    16.05.2025 13:45

    надо делать отдельный пакет для этого, а не в main,
    например вот такой:
    https://github.com/ManyakRus/starter/blob/main/stopapp/stopapp.go