Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +50 XP

Graceful shutdown

Сервис из урока 17 собирается по слоям, конфигурация из урока 18 даёт ему среду — но рождение лишь половина жизненного цикла, и вторая половина по умолчанию некрасивая: SIGTERM от оркестратора убивает процесс мгновенно, недоделанные запросы обрываются на полуслове, а консул-запись из урока 12 остаётся зомби в реестре. Тема урока — graceful shutdown go: корректная остановка go-процесса, при которой сервис перестаёт принимать новое, доделывает текущее, вычёркивается из ротации и закрывает ресурсы. Ты поймаешь SIGTERM через signal.NotifyContext, соберёшь секвенцию остановки в обратном порядке сборки, исполнишь обещание урока 13 про baseServer.GracefulStop() и обещание урока 12 про Deregister до смерти процесса. go-kit graceful shutdown не добавляет своего модуля — библиотека и здесь оставляет управление тебе, а этот урок даёт канон: полный go kit graceful shutdown — это порядок, а не магия.

Содержание урока · 11 разделов

Два способа умереть#

Процесс сервиса заканчивает жизнь одним из двух путей. Путь грубый: оркестратор или оператор посылает сигнал, обработчик сигналов по умолчанию в Go завершает процесс немедленно — в какой бы точке кода ни шло исполнение. Запрос на середине обработки теряет ответ, клиент из урока 11-lb-proxy видит обрыв, gRPC-стрим из урока 13 рвётся, запись в реестре из урока 12-discovery остаётся висеть до красного health check, фоновый батч умирает недописанным. Путь корректный — тот, что называется корректная остановка go: сервис ловит сигнал и сам управляет завершением. Сигналы надо знать в лицо: SIGTERM — штатный запрос на остановку, его шлют Kubernetes, docker stop и supervisor'ы; SIGINT — то же самое от Ctrl+C в терминале; SIGKILL — приговор, который поймать нельзя, и его поведение мы обсудим отдельно. Полный graceful shutdown go-цикл звучит как четыре команды: новое не принимать, старое доделать, из списков вычеркнуться, ресурсы вернуть. Всё остальное в уроке — механика этих команд в порядке, обратном порядку сборки из урока 17.

Ловим SIGTERM: signal.NotifyContext#

Штатный канал сигналов — package os/signal; современный вход — signal.NotifyContext, появившийся в Go 1.16 и идеально совпадающий с каноникой курса: он возвращает контекст, который отменяется при получении заданного сигнала. Это значит, что остановка вписывается в ту же механику context, которой уже живут дедлайны из урока 04 и лимитеры из урока 09: никто в проекте не учится отдельному API ради остановки. Второе возвращаемое значение — функция stop: она снимает обработчик и восстанавливает поведение по умолчанию; вызови её через defer, иначе повторный SIGTERM не убьёт зависший процесс — а это последняя надежда дежурного, когда дренаж подвис. Ловить стоит ровно два сигнала: os.Interrupt (SIGINT от Ctrl+C) и syscall.SIGTERM (оркестратор); остальное — аварийные и системные, их ловить не нужно. Важная деталь: сигнал получает весь процесс, а значит и всё, что в нём живёт, — оба слушателя, горутины, регистратор; поэтому остановка сервиса делается в одном месте — в композиционном корне из урока 17, а не рассыпается по пакетам, где каждый закрыл бы себя сам и в неудачном порядке.

Контекст, отменяемый сигналом

GO
func run() error {
        // signal.NotifyContext: ctx отменится на SIGINT (Ctrl+C) и SIGTERM
        ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
        defer stop() // повторный Ctrl+C снова умеет убить процесс

        cfg, err := config.Flags()
        if err != nil { return err }
        // ... сборка из урока 17: logger, tracer, repo, svc, eps, транспорты ...

        // запуск слушателей в горутинах — ниже в уроке
        // ...

        <-ctx.Done() // ждём сигнал: здесь процесс живёт, пока идёт работа
        logger.Log("during", "shutdown", "msg", "signal received")
        // ... секвенция остановки — ниже в уроке ...
        return nil
}

Три строки меняют модель смерти: до них SIGTERM убивал процесс в произвольной точке, теперь сигнал лишь отменяет контекст, а что делать дальше — решает твой код. stop() в defer критичен: после него сигнал снова действует по умолчанию, и повторный Ctrl+C рвёт зависший дренаж без gdb. Заметь: <-ctx.Done() блокирует горутину run, пока сервис работает — вся жизнь сервиса проходит между NotifyContext и этим каналом, и err здесь возвращается только из аварийных путей сборки и остановки.

Порядок остановки: обратный порядку сборки#

Сборка из урока 17 шла от периферии к ядру: config, наблюдаемость, зависимости, сервис, endpoint'ы, транспорты, слушатели, регистрация. Остановка — та же лестница сверху вниз, и это не эстетика, а причинность. Первым шагом вычеркнись из реестра: registrar.Deregister() из урока 12 — обещание урока исполнено, инстанс исчезает из ротации ДО того, как процесс умрёт; клиенты через blocking queries узнают об уходе, и lb.Balancer из урока 11 перестаёт слать новые вызовы умирающему поду. Вторым — дренаж транспорта: HTTP-сервер закрывается через srv.Shutdown, gRPC — через baseServer.GracefulStop(), о которых по блоку ниже. Третьим — дождаться фоновых горутин: батчи и флешеры закрывают свою работу через sync.WaitGroup. Четвёртым — флаш телеметрии: tracer.Close() из урока 08 выталкивает недозакрытые спаны в Jaeger, без этого хвосты трейсов теряются навсегда. Пятым — закрыть зависимости: repo.Close() возвращает соединения хранилищу. И только потом stop() и выход; логи из урока 06 на каждом шаге — не украшение, а материал для расследования следующего инцидента. Порядок важен ровно потому, что каждый шаг убирает источник работы для следующего: сначала перестают приходить новые вызовы, потом доделываются старые, потом пишутся их следы.

Дренаж HTTP: srv.Shutdown

GO
// запуск: слушатель и сервер разделены, Serve живёт в горутине
srv := &http.Server{Handler: httpHandler}
go func() {
        if err := srv.Serve(httpListener); err != http.ErrServerClosed {
                errs <- err // слушатель умер сам — авария, а не остановка
        }
}()

// остановка после <-ctx.Done():
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
        logger.Log("during", "shutdown", "transport", "http", "err", err)
}

srv.Shutdown делает дренаж честно: немедленно закрывает слушателя (новые соединения не принимаются), затем ждёт, пока активные запросы доделаются, и возвращает nil, когда всё чисто. Таймаут контекста — граница терпения: если через 10 секунд что-то ещё работает, Shutdown вернёт context.DeadlineExceeded, и процесс продолжит остановку дальше по секвенции. Разделение listener и Server нужно и для запуска, и для тестов из урока 16: ошибки Serve не равны ошибке остановки — http.ErrServerClosed возвращается при штатном Shutdown, и подавать её в канал аварий нельзя, иначе сервис «упадёт» сам от того, что его корректно остановили.

Дренаж HTTP: Shutdown против Close и hijacked#

Три способа закрыть HTTP-сервер, и путать их дорого. srv.Close() — жёсткий: слушатель и все активные соединения рвутся немедленно, это анти-версия graceful shutdown go, годная только для тестов. srv.Shutdown(ctx) — дренаж: активные запросы доделываются, idle-соединения keep-alive закрываются сразу; в большинстве JSON-сервисов из урока 04 этого достаточно. Третий случай — соединения, отобранные у сервера через hijack: вебсокеты, SSE, длинные потоки; Shutdown про них НЕ ЗНАЕТ, они не считаются активными запросами, и сервер может вернуть nil, пока пятьсот вебсокетов живут своей жизнью. Лечится учётом: реестр живых hijacked-соединений, который Shutdown не видит, закрывается вручную после него, а хук srv.RegisterOnShutdown(func) даёт точку, где об этом позаботиться. Для курса это маркер: как только в сервисе появляются длинные соединения, остановка перестаёт быть бесплатной — считай соединения явно. И ещё одна граница: запросы, чей контекст уже отменён клиентом, доделывать не надо — дренаж ждёт живых, а не мёртвых; дедлайны из урока 04 работают и здесь.

Дренаж gRPC: GracefulStop — обещание урока 13#

Урок 13 оставил обещание: «у каркаса есть baseServer.GracefulStop() — дренаж соединений разберём в уроке про graceful shutdown». Исполняем. GracefulStop делает то же, что Shutdown в HTTP, но по-своему: немедленно закрывает все слушатели, переводит сервер в режим отказа от новых вызовов и БЛОКИРУЕТСЯ, пока не завершатся все живые вызовы — unary и стримы. Возврат из GracefulStop означает: ни один вызов strings.Strings/Uppercase не оборван. Противоположность — Stop(): немедленное уничтожение, дренажа нет; держи его как страховку. Ловушка с GracefulStop одна, но коварная: он ждёт ВЕЧНО, если вызов вечный — клиентский стрим, который держит соединение, подвесит остановку на часы, и оркестратор в итоге добьёт процесс SIGKILL'ом, отменив всю аккуратность. Паттерн страховки: звать GracefulStop в главной горутине, а рядом завести таймер — по истечении бюджета вызывать Stop() как принудительный финал. И связка с discovery, ради которой Deregister идёт первым: пока идёт дренаж, новые вызовы уже не приходят (реестр вычеркнул инстанс), а клиенты, чей вызов прервётся редким сбоем, получат codes.Unavailable — и lb.Retry из урока 11 уведёт их к живому соседу; дренаж и ретраи — две половины одной бесперебойности.

Дренаж gRPC со страховочным таймером

GO
// остановка gRPC после HTTP: GracefulStop с бюджетом
done := make(chan struct{})
go func() {
        baseServer.GracefulStop() // закрыл слушатели, ждёт живые вызовы
        close(done)
}()

select {
case <-done:
        logger.Log("during", "shutdown", "transport", "grpc", "msg", "drained")
case <-time.After(10 * time.Second):
        logger.Log("during", "shutdown", "transport", "grpc", "msg", "force stop")
        baseServer.Stop() // вечный стрим не должен вешать остановку навсегда
}

GracefulStop живёт в отдельной горутине, а run() ждёт канал done или таймер — что наступит раньше. Штатный путь: все вызовы доделались, close(done), лог drained. Деградация: стрим не отпускает, таймер истекает, Stop() рвёт остатки принудительно — процесс останавливается гарантированно, пусть и с потерей живых вызовов; это дешевле, чем зависнуть навсегда и получить SIGKILL без единой строки логов. Бюджет 10 секунд берётся из конфигурации урока 18, как и таймаут HTTP-дренажа: время дренажа — поле Config, а не магия в коде.

Фоновые горутины: WaitGroup и канал остановки#

Не всё в сервисе — запросы: флешеры метрик, батчеры, подписчики на события — горутины, у которых нет клиента и потому дренаж их не ждёт. Их остановку организуй руками, и канон один: горутина выбирает из двух каналов — рабочий тик и канал остановки; на остановке канал закрывается, горутина доделывает текущую итерацию и выходит; sync.WaitGroup считает её, и wg.Wait() в секвенции не пускает процесс дальше, пока горутины не вернулись. Правила простые: горутина обязана реагировать на ctx, если он ей передан (то же правило дедлайнов из уроков 04 и 08), не писать в канал после close (владелец канала — тот, кто его закрывает), и никогда не оставлять вечно блокирующиеся операции без селекта. Особый случай — подписчики реестра: Endpointer из урока 12 и инстансер сами перестанут получать sd.Event, когда процесс завершится; их не надо «останавливать», но соединения с Consul закрывает registrar и клиенты platform из урока 17 — третья фаза остановки. Слово «корректная остановка go» в приложении к горутинам означает: у каждой есть способ узнать, что пора, и способ сообщить, что успела.

Горутина-батчер, умеющая умирать

GO
// сборка: горутина и её учёт
var wg sync.WaitGroup
stopBatcher := make(chan struct{})
wg.Add(1)
go func() {
        defer wg.Done()
        tick := time.NewTicker(time.Minute)
        defer tick.Stop()
        for {
                select {
                case <-stopBatcher:
                        return // канал закрыт — пора; текущая итерация уже доделана
                case <-tick.C:
                        flushBatch(repo) // доделывается целиком до return
                }
            }
}()

// остановка после дренажа транспорта:
close(stopBatcher) // сигнал: выбрать ветку return
wg.Wait()          // блокирует, пока батчер не вернётся
logger.Log("during", "shutdown", "msg", "batcher stopped")

Три детали делают горутину корректной. defer wg.Done() стоит первым в горутине — счётчик уменьшится при любом выходе, включая панику. select из двух каналов: горутина реагирует на остановку не позже следующего тика, а не через час. flushBatch вызывается в ветке тика и доделывается до конца — прерывать запись на середине не нужно, если запись идемпотентна и коротка; долгие записи режутся на порции, чтобы остановка не ждала вечность. Закрывает канал владелец — run(); горутина только читает. wg.Wait() стоит в секвенции после дренажа транспорта: батчеру хватает времени доделаться, пока HTTP и gRPC отпускают вызовы.

Полная секвенция: select, остановка, финал

GO
// после сборки из урока 17 и запуска слушателей в горутинах:
select {
case err := <-errs:
        // слушатель умер сам — падаем немедленно, остановка не нужна
        return err
case <-ctx.Done():
        // сигнал пойман — переходим к корректной остановке
}

logger.Log("during", "shutdown", "msg", "deregistering")
registrar.Deregister() // 1. вычеркнуться из ротации (обещание урока 12)

shutCtx, cancel := context.WithTimeout(context.Background(), cfg.GracePeriod)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil { // 2. дренаж HTTP
        logger.Log("during", "shutdown", "transport", "http", "err", err)
}
// 3. дренаж gRPC: GracefulStop с таймером и Stop() — блок выше

close(stopBatcher) // 4. остановить фоновые горутины
wg.Wait()

tracer.Close()  // 5. флаш спанов в Jaeger (урок 08)
repo.Close()    // 6. соединения хранилищ
logger.Log("during", "shutdown", "msg", "bye")
return nil      // 7. main печатает статус и выходит с кодом 0

Вот он, полный цикл: select различает аварию (слушатель умер сам — возвращаем err, exit-код ненулевой) и сигнал (переходим к секвенции). Дальше лестница в обратном порядке сборки: реестр, HTTP, gRPC, горутины, телеметрия, зависимости. cfg.GracePeriod — из Config урока 18: время дренажа конфигурируется, потому что зависит от p99 запросов сервиса. Заметь, что каждая строка логируется: по этим строкам инцидент-репорт восстановит, до какого шага процесс дожил. Возвращение nil — важно: остановка по сигналу не ошибка, и exit-код должен быть нулевым, иначе оркестратор посчитает рестарт неудачным.

Идёт активный gRPC-вызов Uppercase, получен SIGTERM, дошли до baseServer.GracefulStop(). Что произойдёт с вызовом?

Какой из сигналов невозможно перехватить обработчиком и учесть в graceful shutdown?

Оркестратор послал SIGTERM. Что в каноне курса делается ПЕРВЫМ шагом секвенции остановки?

kill -9 и консул-запись

Прочитайте код и выберите, что он выведет:

// main зарегистрировал инстанс в Consul и поставил:
defer registrar.Deregister()

// Дежурный делает:
// kill -9 <pid>

Дренаж не уложился в бюджет

Прочитайте код и выберите, что он выведет:

// GracePeriod из конфигурации: 10 секунд
// В endpoint'е из урока 04 обработчик делает time.Sleep(30 * time.Second)
// и только потом пишет ответ. Пришёл SIGTERM, активен ОДИН такой запрос.
shutCtx, _ := context.WithTimeout(context.Background(), cfg.GracePeriod)
err := srv.Shutdown(shutCtx)

Собери секвенцию остановки

Восстанови пять строк секвенции. Пропуски — по порядку: метод вычеркивания из реестра, конструктор контекста с бюджетом, метод дренажа HTTP, метод дренажа gRPC из урока 13, метод флаша спанов.

case <-ctx.Done(): // 1. вычеркнуться из ротации первым (обещание урока 12) registrar.() // 2. бюджет дренажа из конфигурации shutCtx, cancel := context.(context.Background(), cfg.GracePeriod) defer cancel() // 3. дренаж HTTP: доделать активные запросы if err := srv.(shutCtx); err != nil { logger.Log("during", "shutdown", "transport", "http", "err", err) } // 4. дренаж gRPC: закрыть слушатели и дождаться живых вызовов baseServer.() // 5. флаш трейсов (урок 08) tracer.()

Полная секвенция корректной остановки

Расставь шаги остановки по канону: от сигнала до выхода.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карта корректной остановки#

ШагИнструментЧто делаетЕсли пропустить
Поймать сигналsignal.NotifyContextотменяет ctx вместо мгновенной смертидефолтное убийство в произвольной точке кода
Вычеркнуться из реестраregistrar.Deregister (урок 12)новые вызовы уходят к живым соседямновый трафик в умирающий инстанс до последнего
Дренаж HTTPsrv.Shutdown(ctx)закрыть слушателя, доделать запросыоборванные ответы и потерянные ответы клиентов
Дренаж gRPCbaseServer.GracefulStop (урок 13)закрыть слушателей, дождаться вызововоборванные стримы и unary посреди обработки
Остановить горутиныclose + sync.WaitGroupбатчи и флешеры доделывают итерациипотерянные недописанные порции данных
Флаш телеметрииtracer.Close (урок 08)вытолкнуть недозакрытые спаныхвосты трейсов потеряны навсегда
Закрыть зависимостиrepo.Closeвернуть соединения хранилищамутечки коннектов и упавшие транзакции
Честный exit-кодrun() error в main0 на корректную остановку, не 0 на авариюоркестратор врёт в статусах рестартов

Термины урока

Термин

SIGTERM / SIGINT / SIGKILL

нажмите, чтобы перевернуть
Определение

SIGTERM — штатная остановка от оркестратора, SIGINT — Ctrl+C: оба ловим. SIGKILL не ловится никогда — против него только health check и DeregisterCriticalServiceAfter из урока 12.

1 / 6

Что дальше#

Жизненный цикл замкнулся: сервис из уроков 17-19 умеет рождаться в любой среде (Config из урока 18) и умирать без жертв (этот урок) — реестр из урока 12 вычеркивает инстанс заранее, клиенты из урока 11 ретраят к соседям, вызовы из уроков 04 и 13 доделываются в дренаже, спаны из урока 08 доживают до Jaeger. Остался вопрос, на который у остановки нет ответа: как понять, что сервису вообще можно слать трафик, а его пора перезапустить? Урок 20-healthcheck введёт liveness и readiness — probe'ы оркестратора, kit healthcheck и честную разницу между «процесс жив» и «готов отвечать»: после этого урока registrar.Register будет срабатывать ровно в момент готовности, а readiness-эндпоинт станет вратами трафика. Карта курса — на странице курса; конфигурация GracePeriod — в уроке 18-konfiguraciya; а балансировка с ретраями, которая делает дренаж незаметным для клиентов, — в уроке 11-lb-proxy.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Graceful shutdown go — корректная остановка go-процесса: новое не принимать, старое доделать, из списков вычеркнуться, ресурсы вернуть.
  • SIGTERM и SIGINT ловятся через signal.NotifyContext — контекст, отменяемый сигналом; stop() в defer возвращает дефолтное поведение, SIGKILL не ловится никогда.
  • Порядок остановки обратен порядку сборки из урока 17: Deregister, дренаж транспортов, горутины, телеметрия, зависимости.
  • registrar.Deregister() идёт ПЕРВЫМ: обещание урока 12 исполнено — инстанс исчезает из ротации до смерти процесса, трафик уходит соседям.
  • HTTP дренаж — srv.Shutdown(ctx) с бюджетом из Config: слушатель закрыт, активные запросы доделываются; hijacked-соединения учить вручную.
  • gRPC дренаж — baseServer.GracefulStop() из урока 13: блокируется до завершения живых вызовов; страховка — таймер и Stop() против вечных стримов.
  • Фоновые горутины умирают по close(stop-канала) и синхронизируются sync.WaitGroup; tracer.Close флашит спаны, repo.Close возвращает соединения.
  • Grace-бюджет строго меньше terminationGracePeriodSeconds оркестратора, а корректная остановка возвращает exit-код 0.

Домашнее задание

Научи stringsvc умирать вежливо. Первое: заверши main в run() error, добавь signal.NotifyContext на os.Interrupt и syscall.SIGTERM, запусти оба слушателя в горутинах и собери select из канала ошибок и ctx.Done() — слушатель, умерший сам, должен возвращать ошибку и ненулевой exit-код. Второе: собери полную секвенцию из урока: Deregister из урока 12, srv.Shutdown с бюджетом из Config, GracefulStop со страховочным таймером и Stop(), close канала и wg.Wait для батчера, tracer.Close и repo.Close — каждый шаг с логом during/shutdown. Третье: добавь в Config поле GracePeriod из урока 18 и проверь грани: запрос с time.Sleep(30s) при GracePeriod 10s должен дать DeadlineExceeded в логе и оборваться, а при GracePeriod 35s — доделаться. Четвёртое: подними сервис с регистрацией в Consul и сравни два сигнала: kill -s TERM — по логам секвенция до bye и запись вычеркнута сразу; kill -9 — defer не сработал, check покраснел через Interval, запись ушла через DeregisterCriticalServiceAfter; запиши три временные метки. Пятое: загляни в логи при прокрутке двух инстансов подряд — убедись, что клиенты из урока 11 не увидели ни одной ошибки: lb.Retry уводил вызовы к соседу во время дренажа; если увидел — проверь, что Deregister стоит первым шагом.

Личные заметкиПусто
Хранятся только в вашем браузере (go-kit · Graceful shutdown)

Вопросы и ответы по уроку

Почему Deregister должен идти первым шагом, а не последним?

Потому что реестр — источник нового трафика. Пока инстанс числится живым, lb.Balancer из урока 11 продолжает слать вызовы, и каждый новый запрос съедает время дренажа; вычеркнувшись первым, сервис добивается главного: новые вызовы уходят соседям сразу, а дренаж остаётся на старые. Финальный Deregister при этом и не нужен: регистрация уже снята, а страховка из урока 12 подстрахует и kill -9.

srv.Shutdown ждёт вебсокеты и SSE?

Нет: Shutdown ждёт только активные HTTP-запросы; соединения, отобранные через hijack (вебсокеты, SSE), сервер больше не считает своими и не ждёт их завершения. Если сервис держит длинные соединения, веди реестр живых подключений и закрывай его после Shutdown — например, из srv.RegisterOnShutdown. Для обычных JSON-сервисов из урока 04 этого не требуется, но как только появляются стримы — остановка перестаёт быть бесплатной.

GracefulStop или Stop — когда какой?

GracefulStop — штатный путь: закрыть слушателей и дождаться всех живых вызовов, тем самым исполнив обещание урока 13 про дренаж. Stop — принудительный финал: рвёт всё немедленно, годится для тестов и как страховка. Канон урока: GracefulStop в горутине, таймер из конфигурации рядом, Stop по истечении бюджета; вечный стрим не должен превращать корректную остановку в зависание до SIGKILL.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

gRPC25 уроков~14 чпродвинутый25 уроков уже доступны

gRPC-Go: высокопроизводительный RPC

Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.

  • Писать .proto-схемы и генерировать код
  • Реализовывать unary и streaming сервисы
  • Управлять метаданными, дедлайнами и отменами
  • Строить interceptors для логов и аутентификации