Deep Engineering
Средний·Опубликовано·20 МИН

Graceful shutdown: откуда берутся пятисотки при каждой выкатке

«При деплое немного ошибок» — не свойство природы, а следствие одного решения: что делает процесс, получив SIGTERM. Урок измеряет обе ветки на настоящем сервере под нагрузкой: немедленный выход обрывает все запросы в работе, дренаж не обрывает ни одного и стоит остаток начатой работы.

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: «немного ошибок при каждой выкатке» — свойство кода, а не судьба. Измерено на одном и том же сервере, под одной нагрузкой и с сигналом в один и тот же момент: немедленный выход дал 20 оборванных запросов из 20, аккуратная остановка — 0 из 20. Разница только в обработчике.

Дальше — то, что отличает знающего от читавшего. Аккуратная остановка — это два действия по порядку: сначала закрыть слушающий сокет, потом дождаться уже начатого; обратный порядок под нагрузкой не сходится. Цена — остаток начатой работы, а не льготный срок: измерено 164 мс против 2 при обработке в 300 мс и сигнале на её середине. Возможен дренаж только потому, что SIGTERM перехватывается, а SIGKILL — нет, и отсюда граница измеренного нуля: 0 из 20 получилось на этом сервере, при этой нагрузке и потому, что вся начатая работа уложилась в льготный срок. Дренаж обязан быть ограничен по времени; то, что не успело, завершит SIGKILL, и обрывы вернутся.

Порог входа
Перед уроком достаточно понимать
  • сервис — это процесс: его кто-то запускает и когда-нибудь останавливает;
  • обработка запроса занимает время — между «запрос пришёл» и «ответ отправлен» есть промежуток;
  • выкатка новой версии означает, что старую в какой-то момент останавливают.
Заранее знать не нужно
  • SIGTERM, SIGKILL, льготный срок, коды выхода 143 и 137;
  • что такое слушающий сокет и очередь accept, в которой соединения ждут приложение.

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Что происходит при остановке контейнера?» — разминка, отсылающая к первому уроку линии: SIGTERM, льготный срок, SIGKILL.
  2. «Почему при выкатке появляются ошибки?» — здесь начинается содержание.
  3. «Что такое graceful shutdown?» — вопрос, на который часто отвечают «дождаться завершения», не уточняя чего именно.
  4. «В каком порядке закрывать?» — вопрос, отделяющий тех, кто это делал.
  5. «Что делать с долгими запросами, которые не влезают в льготный срок?» — вопрос про границы метода.
  6. «А балансировщик?» — вопрос про то, что дренаж начинается раньше сигнала.

Числа получены прогоном bench/shutdown/drain.py и bench/shutdown/practice.py: настоящий сервер на loopback, двадцать одновременных клиентов, обработка 300 мс, сигнал приходит на её середине.

База: остановка — это последовательность, а не мгновение

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

Остановка, устроенная по-человечески, — это четыре действия по порядку:

  1. перестать принимать новую работу — новые запросы больше не берутся, они уходят туда, где их обслужат;
  2. закончить начатую — те запросы, что уже в руках, доводятся до ответа;
  3. закрыть то, чем пользовались — соединения с базой, файлы, соединения с соседними сервисами;
  4. выйти.

Процесс, у которого этой последовательности нет, делает только четвёртое действие. И вот главный вопрос урока: что происходит с работой, которая была в руках, если сразу выполнить четвёртый шаг?

Ответ простой и неприятный: ничего не происходит — её просто больше нет. Процесса нет, значит, отвечать некому: клиент, который уже отправил запрос и ждёт, получает разрыв вместо ответа. Запрос при этом мог быть выполнен наполовину, и клиент об этом не узнает.

Отсюда и берутся «немного пятисоток при каждой выкатке». Это не свойство выкаток: это то, что процесс сделал со своей работой в момент, когда его попросили уйти.

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

Механизм 1: что именно обрывается

наблюдение замераbench/shutdown/drain.py, loopback. Выдержку 300 мс задаёт скрипт; воспроизводится соотношение: при немедленном выходе обрываются все запросы, что были в работе.

Первый режим — то, что делает процесс, у которого обработчика нет вовсе (первый урок линии) или у которого он написан как «выйти немедленно»:

1. EXIT ON SIGTERM, THE WAY A PROCESS WITHOUT A HANDLER DOES
------------------------------------------------------------
  requests broken                              20 of 20
  time from SIGTERM to exit, ms                2
  wait status: killed by signal                15
  the same as a shell reports it               143

Двадцать из двадцати. Не «немного пятисоток», а все запросы, которые были в работе в этот момент. Их доля от общего потока мала только потому, что момент короткий: обрываются не проценты запросов, а те, кому не повезло попасть в секунду остановки.

Отсюда первое уточнение, которое стоит делать в разговоре: «0,1 % ошибок при деплое» — это не характеристика надёжности, а произведение длительности обработки на частоту выкаток. Тот же код при вдвое более долгих запросах даст вдвое больше ошибок, ничего не изменив в себе.

Механизм 2: дренаж — это два действия, а не одно

Второй режим отличается обработчиком. Он делает две вещи по очереди:

  1. закрывает слушающий сокет — новые соединения больше не принимаются;
  2. дожидается, пока закончится работа по уже принятым, и только потом выходит.
2. STOP ACCEPTING, FINISH WHAT IS STARTED, THEN EXIT
----------------------------------------------------
  requests broken                              0 of 20
  time from SIGTERM to exit, ms                164
  exit code                                    0
наблюдение замераbench/shutdown/drain.py. Тот же сервер, та же нагрузка и тот же момент сигнала; изменился только обработчик.

Ноль из двадцати. И заметьте цену: 164 мс против 2 — это остаток той работы, которая уже шла. Сигнал пришёл на середине трёхсотмиллисекундной обработки, значит дожидаться пришлось около половины.

Здесь важно назвать границу этого нуля, иначе из него получится неверное правило. Ноль оборванных — результат этого прогона: этот сервер, эта нагрузка, обработка в 300 мс и льготный срок, в который вся начатая работа поместилась с запасом. «Дренаж не обрывает ничего» — не свойство дренажа, а описание случая, когда работа успела закончиться. Правило формулируется от границы: дренаж доводит до конца только то, что укладывается в отведённое ему время, и потому он обязан быть ограничен по времени сам. Ниже видно, почему.

Порядок двух действий важен, и в этом суть вопроса на собеседовании. Если сначала «дождаться завершения», а закрывать сокет потом, то за время ожидания придут новые соединения — и дренаж не закончится никогда, пока идёт нагрузка. Если закрыть сокет и сразу выйти, обрываются те же самые запросы, что и в первом режиме.

Почему это вообще возможно, известно из первого урока: SIGTERM перехватывается. Вторая половина того же правила — граница метода:

SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.

перевод

SIGKILL и SIGSTOP нельзя перехватить, заблокировать или проигнорировать.

signal(7)

То есть дренаж работает только внутри льготного срока. Всё, что не успело закончиться, будет оборвано, и никакой обработчик этого не изменит. Отсюда и цена бесконечного ожидания: обработчик, который ждёт «сколько понадобится», не получает больше времени — он получает SIGKILL в конце срока, и тогда обрывается всё, что оставалось в работе. Ноль оборванных запросов держится не на терпении обработчика, а на том, что работа успела закончиться до этого момента.

Механизм 3: почему закрытие сокета — не то же самое, что отказ

Тонкость, которую стоит знать: закрыв слушающий сокет, вы перестаёте принимать новые соединения, но соединения, уже стоящие в очереди ядра, никуда не деваются. Из прошлого урока:

It extracts the first connection request on the queue of pending connections for the listening socket.

перевод

Он извлекает первый запрос на соединение из очереди ожидающих соединений для слушающего сокета.

accept(2)

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

  • в работе — их доводит дренаж;
  • в очереди accept, но ещё не принятые — при закрытии слушающего сокета они будут сброшены; клиент получит разрыв, хотя приложение о них не знало;
  • ещё не пришедшие — они пойдут в другую реплику, если балансировщик уже знает, что эта уходит.

Отсюда третье действие, которое к самому процессу не относится: сначала вывести реплику из балансировки, и только потом останавливать. Дренаж внутри процесса не спасает от запросов, которые к нему всё ещё направляют.

Глубже: работа, которая не влезает в льготный срок

Дренаж ограничен льготным сроком, а значит, есть работа, которая в него не поместится: длинные выгрузки, потоковые ответы, большие транзакции. Здесь метод не спасает, и это стоит сказать самому.

Три способа, каждый со своей ценой.

Разрешить прервать. Если операция идемпотентна и клиент повторит её (урок про ретраи), обрыв стоит одного повтора. Это самый дешёвый вариант, и он же самый частый в жизни.

Сделать её возобновляемой. Ответ по частям с указанием позиции — клиент продолжит с того места, где оборвалось. Дороже в коде, дешевле в эксплуатации.

Вынести из запроса. Длинная работа переезжает в фоновую задачу, а запрос только ставит её в очередь. Тогда остановка процесса не обрывает ничего важного — а перезапуск фоновой задачи становится отдельным механизмом со своими свойствами.

Как отвечать на собеседовании

Короткий ответ: graceful shutdown — это два действия по порядку: перестать принимать новые соединения и доработать уже принятые; и работает он только потому, что SIGTERM перехватывается. Измерено на одном и том же сервере под одинаковой нагрузкой: без обработчика оборвано 20 запросов из 20, с дренажем — ноль, а цена дренажа — 164 мс против 2, то есть остаток уже начатой работы.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

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

И одна вещь, на которой легко переусердствовать. Сказать «с дренажем обрывов не бывает» — значит выдать один прогон за свойство метода. Ноль из двадцати получен на конкретном сервере, при конкретной нагрузке и при работе, которая уложилась в льготный срок. Точная формулировка звучит так: дренаж доводит до конца ровно то, что успевает уложиться в отведённое ему время, поэтому его самого ограничивают по времени — иначе его завершит SIGKILL, и обрывы вернутся.

Дальше спросят

Спросят дальше

В каком порядке закрывать: сначала дождаться или сначала закрыть сокет?

Короткий ответ

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

В измеренном режиме дренажа обработчик делает ровно это: закрывает сокет и затем ждёт, пока счётчик активных обработок дойдёт до нуля. Отсюда и ноль оборванных запросов при остановке за 164 мс.

Спросят дальше

Достаточно ли дренажа внутри процесса?

Короткий ответ

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

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

Спросят дальше

Что произойдёт с запросами, которые не успеют за льготный срок?

Короткий ответ

Они будут оборваны: по истечении срока приходит SIGKILL, и его нельзя ни перехватить, ни заблокировать, ни проигнорировать. Ни один обработчик тут не поможет — процесс просто перестаёт существовать.

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

Спросят дальше

Насколько дренаж замедляет выкатку?

Короткий ответ

На остаток уже начатой работы, а не на весь льготный срок. Измерено: 164 мс против 2 при обработке в 300 мс и сигнале на её середине — то есть примерно на половину одного запроса.

Ошибка в рассуждении, которую это исправляет: «дренаж стоит тридцать секунд» неверно. Тридцать секунд — это предел ожидания, а не цена; реальная цена равна времени самого долгого из запросов, которые были в работе.

Частые заблуждения

Утверждение

небольшой процент ошибок при выкатке неизбежен

На самом деле

Измерено: на одном и том же сервере, под той же нагрузкой и с тем же моментом сигнала — 20 оборванных запросов из 20 без дренажа и 0 из 20 с ним. Разница только в обработчике SIGTERM, то есть это свойство кода, а не выкатки. Ноль при этом не гарантирован методом: он получился потому, что вся начатая работа уложилась в льготный срок. Что не уложится — оборвёт SIGKILL.

Утверждение

graceful shutdown — это «дождаться завершения запросов»

На самом деле

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

Утверждение

дренаж стоит весь льготный срок

На самом деле

Он стоит остаток начатой работы. Измерено: 164 мс против 2 при обработке в 300 мс и сигнале на её середине. Льготный срок — это предел ожидания, а не цена: процесс уходит, как только доработал.

Утверждение

если внутри процесса есть дренаж, реплику можно останавливать в любой момент

На самом деле

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

Утверждение

дренаж спасает и долгие запросы, надо только поставить льготный срок побольше

На самом деле

До определённого предела — да, но граница жёсткая: по истечении срока приходит SIGKILL, а его cannot be caught, blocked, or ignored (нельзя перехватить, заблокировать или проигнорировать). Долгую работу делают возобновляемой или выносят из запроса, а не удлиняют ожидание до бесконечности.

Утверждение

код выхода 0 при остановке означает, что всё прошло хорошо

На самом деле

Он означает ровно обратное: процесс не вышел сам, а был убит сигналом. Измерено: при немедленном выходе ожидание вернуло -15, то есть смерть от пятнадцатого сигнала (оболочка показывает это как 128 + 15 = 143), и двадцать оборванных запросов; при дренаже — код 0 и ноль оборванных. Но код ничего не говорит о запросах: он говорит только о том, кто и как завершил процесс.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Двадцать одновременных запросов, каждый обрабатывается 300 мс, SIGTERM приходит на середине обработки. Сервер запускается дважды: с немедленным выходом по сигналу и с дренажем. Печатаются три числа: сколько запросов оборвано в первом случае, сколько во втором и что вернуло ожидание первого сервера. Что напечатает этот код?
abrupt = run("abrupt")
drained = run("drain")
print(abrupt["broken"])
print(drained["broken"])
print(abrupt["exit"])

Практика · оцените

Обработка запроса занимает 300 мс, SIGTERM приходит на её середине. Сколько миллисекунд займёт остановка сервера с дренажем?
мс

Проверка знаний

Вопрос 1 из 6

Сервер обрабатывает двадцать запросов и выходит немедленно по SIGTERM. Сколько запросов оборвётся?

Источники и что читать дальше

2 ИСТОЧНИКА

  1. signal(7), Linux man-pages 6.7Официальная документация. Почему остановка вообще начинается с SIGTERM, а не с SIGKILL: в таблице сигналов у SIGTERM умолчательное действие `Term`, и он перехватывается, а про второй сказано прямо: «SIGKILL and SIGSTOP cannot be caught, blocked, or ignored» (SIGKILL и SIGSTOP нельзя перехватить, заблокировать или проигнорировать). Дренаж возможен ровно потому, что первый сигнал можно обработать.https://man7.org/linux/man-pages/man7/signal.7.html
  2. listen(2) и accept(2), Linux man-pages 6.7Официальная документация. Почему «перестать принимать» и «перестать отвечать» — разные действия: `accept` «extracts the first connection request on the queue of pending connections for the listening socket» (извлекает первый запрос на соединение из очереди ожидающих соединений для слушающего сокета), то есть соединения копятся в очереди ядра независимо от того, читает ли их приложение. Отсюда порядок дренажа: сначала закрыть слушающий сокет, потом доработать уже принятое.https://man7.org/linux/man-pages/man2/accept.2.html