---
title: "Graceful shutdown"
description: "Graceful shutdown go-сервиса: SIGTERM и signal.NotifyContext, дренаж HTTP и gRPC-вызовов, Deregister из Consul, флаш трейсера и ожидание горутин."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/19-graceful/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Graceful shutdown

*Курс «go-kit: микросервисный тулкит», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/19-graceful/*

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

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

Процесс сервиса заканчивает жизнь одним из двух путей. Путь грубый: оркестратор или оператор посылает сигнал, обработчик сигналов по умолчанию в Go завершает процесс немедленно — в какой бы точке кода ни шло исполнение. Запрос на середине обработки теряет ответ, клиент из [урока 11-lb-proxy](/courses/go-kit/lessons/11-lb-proxy/) видит обрыв, gRPC-стрим из урока 13 рвётся, запись в реестре из [урока 12-discovery](/courses/go-kit/lessons/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(). Что произойдёт с вызовом?

- Вызов оборвётся немедленно: остановка убивает все соединения
- ✅ Вызов доделается: GracefulStop блокируется, пока живые вызовы не завершатся
- Вызов зависнет навсегда: сервер не отвечает и не рвёт
- Каркас вернёт клиенту codes.Unimplemented

GracefulStop из урока 13 закрывает слушатели (новые вызовы не принимаются) и ждёт завершения всех живых — блокирующе, без таймаута изнутри. Обещание урока 13 исполнено: дренаж — это и есть «доделать старое». Единственная опасность — вечный вызов: он подвесит GracefulStop навсегда, поэтому в секвенции рядом стоит страховочный таймер с baseServer.Stop() как принудительным финалом.

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

- SIGTERM — штатная остановка от оркестратора
- SIGINT — Ctrl+C в терминале
- ✅ SIGKILL — и поэтому нужны health check и DeregisterCriticalServiceAfter из урока 12
- SIGHUP — перечитывание конфигурации

SIGKILL (kill -9) не доставляется процессу как обрабатываемый сигнал — ядро убивает его немедленно, defer не срабатывает, секвенция не начинается. Поэтому урок 12 настаивал на двух страховках, нужных ОБЕМ: health check, который покраснеет после смерти процесса, и DeregisterCriticalServiceAfter, который вычеркнет зомби из каталога. Graceful shutdown — про вежливые сигналы; против грубых — только внешняя механика реестра.

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

- srv.Shutdown — перестать принимать HTTP-запросы
- ✅ registrar.Deregister — вычеркнуться из Consul, чтобы новые вызовы ушли к соседям
- tracer.Close — чтобы не потерять спаны
- baseServer.Stop — немедленно оборвать gRPC

Первым вычёркивается инстанс из ротации: пока ты числишься живым в реестре, lb.Balancer из урока 11 продолжает слать новые вызовы, и каждый новый запрос сокращает твоё время дренажа. Deregister запускает волну: клиенты узнают через blocking queries, новые вызовы уходят соседям, и только затем имеет смысл закрывать слушатели и дренировать остаток. Это и есть обещание урока 12: инстанс исчезает из ротации до смерти процесса.

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

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

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

**Что выведет программа?**

- defer сработает при смерти процесса, запись вычеркнется
- ✅ defer не сработает: запись останется в реестре, health check покраснеет, DeregisterCriticalServiceAfter вычеркнет её из каталога
- Consul заметит разрыв TCP-соединения и вычеркнет запись мгновенно
- Запись останется в реестре навсегда: вычистить может только сам процесс

kill -9 не оставляет процессу ни шанса выполнить deferred-функции: секвенция остановки не начинается вообще. Запись висит в реестре до тех пор, пока не сработает страховка из урока 12: health check краснеет после пропуска пингов (Interval из регистрации), а DeregisterCriticalServiceAfter вычёркивает запись из каталога после минуты в красном. Поэтому осмысленные деплой-инструменты шлют SIGTERM, а не SIGKILL; SIGKILL — это про «завис», и против него работает только внешняя механика реестра.

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

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

**Что выведет программа?**

- Shutdown вернёт nil через 30 секунд: запрос доделался, дренаж успешен
- ✅ Shutdown вернёт context.DeadlineExceeded через 10 секунд: слушатель давно закрыт, недоделанный запрос оборван, процесс продолжит секвенцию остановки
- Shutdown заблокируется навсегда: запрос держит соединение
- Процесс умрёт мгновенно: Shutdown рвёт активные соединения

Бюджет дренажа — контекст: через 10 секунд Shutdown перестаёт ждать, возвращает context.DeadlineExceeded, и твой код логирует ошибку и идёт дальше по секвенции — к gRPC, горутинам, трейсеру. Соединение с недоделанным запросом оборвётся: клиент увидит обрыв, и если вызов идемпотентен, lb.Retry из урока 11 попробует соседа. Мораль двойная: бюджет должен превышать p99 запросов (иначе дренаж не доделывает то, для чего создан), а спящие обработчики — не способ дренажа проверять.

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

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

```go
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.___()
```

Подсказка: Первый — парный к Register метод sd.Registrar из урока 12; второй — context.WithTimeout; третий и четвёртый — вежливые остановки двух каркасов (у gRPC — блокирующий дренаж); пятый — выталкивает недозакрытые спаны в Jaeger.

Правильные ответы в пропусках: `Deregister`, `WithTimeout`, `Shutdown`, `GracefulStop`, `Close`

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

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

Правильный порядок строк:

1. `signal.NotifyContext отменил ctx: сигнал пойман`
2. `registrar.Deregister(): инстанс вычеркнут из Consul, новые вызовы уходят соседям`
3. `srv.Shutdown(shutCtx): HTTP-слушатель закрыт, активные запросы доделываются в бюджете`
4. `baseServer.GracefulStop() со страховочным таймером и Stop(): живые gRPC-вызовы доделаны`
5. `close(stopBatcher) и wg.Wait(): фоновые горутины завершили итерации`
6. `tracer.Close(): недозакрытые спаны вытолкнуты в Jaeger`
7. `repo.Close(): соединения хранилищ возвращены`
8. `run() вернул nil: main выходит с кодом 0 — оркестратор считает остановку удачной`

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

| Шаг | Инструмент | Что делает | Если пропустить |
| --- | --- | --- | --- |
| Поймать сигнал | 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 на аварию | оркестратор врёт в статусах рестартов |

> **Grace без страховки виснет, grace больше бюджета оркестратора бесполезен.** Две границы, которые обязан помнить. Первая: 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 не сработал, а запись вычеркнулась только страховкой реестра.

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

- **SIGTERM / SIGINT / SIGKILL** — SIGTERM — штатная остановка от оркестратора, SIGINT — Ctrl+C: оба ловим. SIGKILL не ловится никогда — против него только health check и DeregisterCriticalServiceAfter из урока 12.
- **signal.NotifyContext** — Контекст, отменяемый сигналом, плюс stop() для возврата дефолтного поведения; остановка вписывается в канонику context курса.
- **srv.Shutdown(ctx)** — Дренаж HTTP: слушатель закрыт немедленно, активные запросы доделываются в бюджете контекста; hijacked-соединения (вебсокеты) не ждёт — закрывать вручную.
- **GracefulStop** — Дренаж gRPC из урока 13: закрыть слушателей и блокирующе дождаться всех живых вызовов; страховка — таймер и Stop() против вечных стримов.
- **Дренаж (drain)** — Фаза «доделать старое»: новые вызовы не принимаются, живые доводятся до ответа; бюджет дренажа из конфигурации должен превышать p99 запросов.
- **Порядок остановки** — Обратный порядку сборки: Deregister → дренаж HTTP → дренаж gRPC → горутины → tracer.Close → repo.Close → exit 0; каждый шаг убирает источник работы для следующего.

### Что дальше

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

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

- 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.
