paint-brush
Освоение плавного завершения работы в Go: полное руководство по Kubernetesк@gopher

Освоение плавного завершения работы в Go: полное руководство по Kubernetes

к Alex6m2024/08/14
Read on Terminal Reader

Слишком долго; Читать

В этом руководстве мы погрузимся в мир корректного завершения работы, уделив особое внимание его реализации в приложениях Go, работающих в Kubernetes.
featured image - Освоение плавного завершения работы в Go: полное руководство по Kubernetes
Alex HackerNoon profile picture

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


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


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

Сигналы в системах Unix

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


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

  • SIGTERM - отправляется процессу для запроса на его завершение. Используется чаще всего, и мы рассмотрим его позже.
  • SIGKILL — «немедленно выйти», нельзя вмешиваться.
  • SIGINT - сигнал прерывания (например, Ctrl+C)
  • SIGQUIT - сигнал выхода (например, Ctrl+D)


Эти сигналы могут быть отправлены пользователем (Ctrl+C / Ctrl+D), другой программой/процессом или самой системой (ядром/ОС). Например, SIGSEGV , также известный как ошибка сегментации, отправляется ОС.


Наша служба поддержки морских свинок

Чтобы исследовать мир изящных отключений в практической обстановке, давайте создадим простую службу, с которой мы сможем поэкспериментировать. Эта служба «подопытного кролика» будет иметь одну конечную точку, которая имитирует некоторую реальную работу (мы добавим небольшую задержку) путем вызова команды INCR Redis. Мы также предоставим базовую конфигурацию Kubernetes для проверки того, как платформа обрабатывает сигналы завершения.


Конечная цель: гарантировать, что наш сервис изящно обрабатывает отключения без потери каких-либо запросов/данных. Сравнивая количество запросов, отправленных параллельно, с конечным значением счетчика в Redis, мы сможем проверить, успешно ли реализовано наше изящное отключение.

Мы не будем вдаваться в подробности настройки кластера Kubernetes и Redis, но полную настройку вы можете найти в нашем репозитории Github .


Процесс проверки следующий:

  1. Разверните приложение Redis и Go в Kubernetes.
  2. Используйте vegeta для отправки 1000 запросов (25/с в течение 40 секунд).
  3. Во время работы vegeta инициализируйте скользящее обновление Kubernetes, обновив тег образа.
  4. Подключитесь к Redis, чтобы проверить «счетчик», он должен быть равен 1000.


Начнем с нашего базового Go HTTP-сервера.

hard-shutdown/main.go

 package main import ( "net/http" "os" "time" "github.com/go-redis/redis" ) func main() { redisdb := redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), }) server := http.Server{ Addr: ":8080", } http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) server.ListenAndServe() } func processRequest(redisdb *redis.Client) { // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }

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


Это явно означает, что мы потеряли часть данных во время обновления. 😢

Обработка сигналов в Go

Go предоставляет пакет сигналов , который позволяет обрабатывать сигналы Unix. Важно отметить, что по умолчанию сигналы SIGINT и SIGTERM приводят к завершению работы программы Go. И для того, чтобы наше приложение Go не завершалось так внезапно, нам нужно обрабатывать входящие сигналы.

Есть два варианта сделать это.


Использование канала:

 c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTERM)


Использование контекста (предпочтительный подход в настоящее время):

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


NotifyContext возвращает копию родительского контекста, помеченную как выполненную (его канал Done закрыт), когда поступает один из перечисленных сигналов, когда вызывается возвращенная функция stop() или когда закрывается канал Done родительского контекста, в зависимости от того, что произойдет раньше.


В нашей текущей реализации HTTP-сервера есть несколько проблем:

  1. У нас есть медленная горутина processRequest, и поскольку мы не обрабатываем сигнал завершения, программа автоматически завершается, что означает, что все работающие горутины также завершаются.
  2. Программа не закрывает никаких соединений.


Давайте перепишем это.


graceful-shutdown/main.go

 package main // imports var wg sync.WaitGroup func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() // redisdb, server http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { wg.Add(1) go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) // make it a goroutine go server.ListenAndServe() // listen for the interrupt signal <-ctx.Done() // stop the server if err := server.Shutdown(context.Background()); err != nil { log.Fatalf("could not shutdown: %v\n", err) } // wait for all goroutines to finish wg.Wait() // close redis connection redisdb.Close() os.Exit(0) } func processRequest(redisdb *redis.Client) { defer wg.Done() // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }


Вот краткий обзор обновлений:

  • Добавлен signal.NotifyContext для прослушивания сигнала завершения SIGTERM.
  • Введена sync.WaitGroup для отслеживания текущих запросов (goroutines processRequest).
  • Обернул сервер в горутину и использовал server.Shutdown с контекстом, чтобы корректно прекратить прием новых подключений.
  • Использовал wg.Wait() , чтобы убедиться, что все текущие запросы (goroutines processRequest) завершены перед продолжением.
  • Очистка ресурсов: добавлен redisdb.Close() для корректного закрытия соединения Redis перед выходом.
  • Чистый выход: используется os.Exit(0) для указания успешного завершения.

Теперь, если мы повторим наш процесс проверки, мы увидим, что все 1000 запросов обработаны правильно. 🎉


Веб-фреймворки / HTTP-библиотека

Такие фреймворки, как Echo, Gin, Fiber и другие, будут создавать goroutine для каждого входящего запроса, давая ему контекст, а затем вызывать вашу функцию/обработчик в зависимости от выбранной вами маршрутизации. В нашем случае это будет анонимная функция, заданная HandleFunc для пути «/incr».


Когда вы перехватываете сигналы SIGTERM и просите свой фреймворк корректно завершить работу, происходят 2 важные вещи (упрощая):

  • Ваш фреймворк перестал принимать входящие запросы
  • Он ждет завершения всех существующих входящих запросов (неявно ожидая завершения горутин).


Примечание: Kubernetes также прекращает направлять входящий трафик от балансировщика нагрузки к вашему модулю после того, как он помечает его как Terminating.

Необязательно: тайм-аут выключения

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


 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() go func() { if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("could not shutdown: %v\n", err) } }() select { case <-shutdownCtx.Done(): if shutdownCtx.Err() == context.DeadlineExceeded { log.Fatalln("timeout exceeded, forcing shutdown") } os.Exit(0) }

Жизненный цикл завершения Kubernetes

Поскольку мы использовали Kubernetes для развертывания нашего сервиса, давайте углубимся в то, как он завершает pod. Как только Kubernetes решает завершить pod, произойдут следующие события:

  1. Pod устанавливается в состояние «Завершение» и удаляется из списка конечных точек всех служб.
  2. Если определено, выполняется preStop Hook.
  3. Сигнал SIGTERM отправляется в pod. Но эй, теперь наше приложение знает, что делать!
  4. Kubernetes ждет в течение льготного периода ( terminateGracePeriodSeconds ), который по умолчанию составляет 30 секунд.
  5. Сигнал SIGKILL отправляется на модуль, и модуль удаляется.

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

Заключение

Мягкие отключения защищают целостность данных, поддерживают бесперебойный пользовательский опыт и оптимизируют управление ресурсами. Благодаря своей богатой стандартной библиотеке и акценту на параллелизме Go позволяет разработчикам легко интегрировать методы мягкого отключения — необходимость для приложений, развернутых в контейнерных или оркестрованных средах, таких как Kubernetes.

Код Go и манифесты Kubernetes можно найти в нашем репозитории Github .

Ресурсы