Вы когда-нибудь выдергивали шнур питания из компьютера в отчаянии? Хотя это может показаться быстрым решением, оно может привести к потере данных и нестабильности системы. В мире программного обеспечения существует похожая концепция: жесткое завершение работы. Такое резкое завершение работы может вызвать проблемы, как и его физический аналог. К счастью, есть лучший способ: плавное завершение работы.
Интегрируя плавное отключение, мы предоставляем предварительное уведомление службе. Это позволяет ей завершать текущие запросы, потенциально сохранять информацию о состоянии на диске и в конечном итоге избегать повреждения данных во время отключения.
В этом руководстве мы погрузимся в мир корректного завершения работы, уделив особое внимание его реализации в приложениях Go, работающих в Kubernetes.
Одним из ключевых инструментов для достижения изящного завершения работы в системах на базе Unix является концепция сигналов, которые, говоря простыми словами, являются простым способом передачи одной конкретной вещи процессу из другого процесса. Понимая, как работают сигналы, мы можем использовать их для реализации контролируемых процедур завершения работы в наших приложениях, обеспечивая плавный и безопасный для данных процесс завершения работы.
Сигналов много, и вы можете найти их здесь , но нас интересуют только сигналы выключения:
Эти сигналы могут быть отправлены пользователем (Ctrl+C / Ctrl+D), другой программой/процессом или самой системой (ядром/ОС). Например, SIGSEGV , также известный как ошибка сегментации, отправляется ОС.
Чтобы исследовать мир изящных отключений в практической обстановке, давайте создадим простую службу, с которой мы сможем поэкспериментировать. Эта служба «подопытного кролика» будет иметь одну конечную точку, которая имитирует некоторую реальную работу (мы добавим небольшую задержку) путем вызова команды INCR Redis. Мы также предоставим базовую конфигурацию Kubernetes для проверки того, как платформа обрабатывает сигналы завершения.
Конечная цель: гарантировать, что наш сервис изящно обрабатывает отключения без потери каких-либо запросов/данных. Сравнивая количество запросов, отправленных параллельно, с конечным значением счетчика в Redis, мы сможем проверить, успешно ли реализовано наше изящное отключение.
Мы не будем вдаваться в подробности настройки кластера Kubernetes и Redis, но полную настройку вы можете найти в нашем репозитории Github .
Процесс проверки следующий:
Начнем с нашего базового 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 предоставляет пакет сигналов , который позволяет обрабатывать сигналы 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-сервера есть несколько проблем:
Давайте перепишем это.
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") }
Вот краткий обзор обновлений:
Теперь, если мы повторим наш процесс проверки, мы увидим, что все 1000 запросов обработаны правильно. 🎉
Такие фреймворки, как 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 для развертывания нашего сервиса, давайте углубимся в то, как он завершает pod. Как только Kubernetes решает завершить pod, произойдут следующие события:
Как видите, если у вас длительный процесс завершения, может потребоваться увеличить параметр terminateGracePeriodSeconds , предоставив приложению достаточно времени для корректного завершения работы.
Мягкие отключения защищают целостность данных, поддерживают бесперебойный пользовательский опыт и оптимизируют управление ресурсами. Благодаря своей богатой стандартной библиотеке и акценту на параллелизме Go позволяет разработчикам легко интегрировать методы мягкого отключения — необходимость для приложений, развернутых в контейнерных или оркестрованных средах, таких как Kubernetes.
Код Go и манифесты Kubernetes можно найти в нашем репозитории Github .