Содержание урока · 11 разделов
- Два способа умереть
- Ловим SIGTERM: signal.NotifyContext
- Порядок остановки: обратный порядку сборки
- Дренаж HTTP: Shutdown против Close и hijacked
- Дренаж gRPC: GracefulStop — обещание урока 13
- Фоновые горутины: WaitGroup и канал остановки
- Карта корректной остановки
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Два способа умереть#
Процесс сервиса заканчивает жизнь одним из двух путей. Путь грубый: оркестратор или оператор посылает сигнал, обработчик сигналов по умолчанию в 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, а не рассыпается по пакетам, где каждый закрыл бы себя сам и в неудачном порядке.
Контекст, отменяемый сигналом
GOfunc 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, метод флаша спанов.
Полная секвенция корректной остановки
Расставь шаги остановки по канону: от сигнала до выхода.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карта корректной остановки#
| Шаг | Инструмент | Что делает | Если пропустить |
|---|---|---|---|
| Поймать сигнал | signal.NotifyContext | отменяет ctx вместо мгновенной смерти | дефолтное убийство в произвольной точке кода |
| Вычеркнуться из реестра | registrar.Deregister (урок 12) | новые вызовы уходят к живым соседям | новый трафик в умирающий инстанс до последнего |
| Дренаж HTTP | srv.Shutdown(ctx) | закрыть слушателя, доделать запросы | оборванные ответы и потерянные ответы клиентов |
| Дренаж gRPC | baseServer.GracefulStop (урок 13) | закрыть слушателей, дождаться вызовов | оборванные стримы и unary посреди обработки |
| Остановить горутины | close + sync.WaitGroup | батчи и флешеры доделывают итерации | потерянные недописанные порции данных |
| Флаш телеметрии | tracer.Close (урок 08) | вытолкнуть недозакрытые спаны | хвосты трейсов потеряны навсегда |
| Закрыть зависимости | repo.Close | вернуть соединения хранилищам | утечки коннектов и упавшие транзакции |
| Честный exit-код | run() error в main | 0 на корректную остановку, не 0 на аварию | оркестратор врёт в статусах рестартов |
Две границы, которые обязан помнить. Первая: GracefulStop без таймера ждёт вечные стримы бесконечно — страховочный Stop() по истечении бюджета не опция, а требование. Вторая: у оркестратора свой таймер — terminationGracePeriodSeconds в Kubernetes (дефолт 30s); если твой дренаж длиннее, оркестратор пошлёт SIGKILL посреди секвенции, и все старания обнулятся. Правило: твой GracePeriod строго меньше grace-периода оркестратора, с запасом на Deregister, флаш трейсера и закрытие базы.
Секвенция из семи шагов — готовый маршрут расследования: когда ночью что-то пошло не так, по строкам logger.Log видно, до какого шага процесс дожил и где встал. Договоритесь в команде о форме: пара during/shutdown плюс msg с именем шага — и grep по логам из урока 06 показывает весь жизненный цикл инстанса от register до bye. Дешёвая дисциплина, которая экономит часы дежурств.
Остановку надо прочувствовать руками: в песочнице курса сторонние модули и процессы не исполняются, поэтому интерактивы построены на чтении кода. Локально: подними stringsvc с двумя слушателями и регистрацией в Consul, пошли SIGTERM командой kill -s TERM и проследи по логам секвенцию до bye; затем попробуй kill -9 и убедись, что defer не сработал, а запись вычеркнулась только страховкой реестра.
Термины урока
Что дальше#
Жизненный цикл замкнулся: сервис из уроков 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 стоит первым шагом.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации