Логи, по которым можно чинить; здоровье и мягкая остановка
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Через месяц после выката приложение разбудит вас не отладчиком, а строкой в журнале — и остановит его не Ctrl+C, а сигнал от docker, пришедший посреди запроса, который кто-то ждёт. Обе вещи выглядят мелочью рядом с доменной логикой, и обе решают, чинится сервис или нет: по журналу, где каждая строка — отдельная фраза, картину падения не собрать, а процесс, который умирает молча и сразу, теряет ответы и оставляет за собой недописанные данные. В этой статье — запись лога, по которой инцидент восстанавливается, две ручки здоровья и остановка, которая дослуживает начатое.
В разработке console.log("пользователь " + id + " не смог войти") выглядит достаточно: строка читается, а если что — рядом отладчик. В проде отладчика нет, зато есть десятки тысяч таких строк от нескольких процессов, и вопросы к ним другие: сколько таких отказов было за час, всем пользователям или одному, что стояло в журнале за секунду до. Склеенная фраза ни на один из них не отвечает — по ней можно только искать подстроку, и первая же правка формулировки ломает поиск.
Поэтому строка лога пишется как запись: время, уровень, сообщение и поля. Сообщение при этом остаётся константой — «вход не удался», а не «вход не удался у 42». Всё переменное уезжает в поля, и тогда записи группируются по сообщению, отбираются по полю и считаются. Ещё два поля кладутся в каждую строку не думая: кто пишет (сервис, а лучше и версия) и по какому запросу — про второе будет следующий раздел.
Соберём такой логгер: функция печатает одну строку JSON на событие, а ветка делает из него дочерний логгер с дописанными полями. Прогноз: сколько строк выйдет и какие поля окажутся в последней?
Три строки, и каждая — самостоятельная запись. Общее поле svc проставлено один раз при создании логгера, requestId и userId — один раз на ветку, а в самих вызовах остались только те поля, которые относятся к этому событию. Это и есть дочерний логгер — приём, который есть у любой серьёзной библиотеки логов (у pino 10.3.1 это метод child, проверено) и придуман везде для одного: чтобы не протаскивать одни и те же поля в каждый вызов руками.
Сообщение — константа. «Пользователь 42 не найден» и «Пользователь 77 не найден» для любого сборщика логов два разных сообщения, и посчитать их вместе уже нельзя.
Обратная сторона полей — в них слишком легко положить лишнее. Самый частый способ — отдать в лог целый объект, который был под рукой. Прогноз: что окажется в журнале в каждой из трёх строк?
В первой строке в журнал уехали хеш пароля, токен и почта — а журнал живёт дольше, чем инцидент, и лежит там, куда доступ шире, чем к базе. Вторая строка показывает, что отбор по списку разрешённых имён спасает не полностью: он выбросил user целиком, вместе с идентификатором, который как раз был нужен. Поэтому фильтр — это страховка от случайности, а не замена правилу: поля для журнала перечисляются руками, как в третьей строке.
Отдельный случай — ошибка. У неё в журнал кладут name, message и, если он есть, код первопричины из cause. Стек — по решению: он длинный и в JSON-строке нечитаем, но без него неизвестную ошибку не найти в коде. Разумный компромисс — стек только у уровня error и выше.
Уровней обычно пять: debug, info, warn, error, fatal. Работают из них три. debug в проде выключен — иначе он утопит остальные; fatal отличается от error только тем, что после него процесс не живёт, и на практике заменяется обычной ошибкой плюс выходом.
Разница между тремя оставшимися — не в эмоции, а в том, кто и когда это читает. info — «так и задумано» (принят запрос, выполнена миграция, закрыт пул). warn — «странно, но обработано»: ретрай удался со второй попытки, поле пришло пустым и подставлено умолчание. error — «запрос не выполнен», то есть кто-то снаружи получил отказ. Если по warn никто никогда не приходит, это не уровень, а info с тревожной интонацией.
Один запрос проходит через контроллер, сервис, репозиторий и внешний вызов, и каждый из них пишет свои строки. В журнале они лежат вперемешку со строками соседних запросов — а на боевом сервере соседних запросов десятки в секунду. Сшивает их одно поле: идентификатор запроса, заведённый на входе и попавший во все строки, которые этот запрос породил.
Берётся он либо из заголовка, который дописал обратный прокси, либо создаётся самим приложением, если заголовка нет. И тем же значением полезно отвечать клиенту — заголовком ответа: тогда в жалобе «у меня не открылось» будет номер, по которому строки находятся за одну команду.
Вот кусок журнала с настоящего инцидента — восемь строк, четыре запроса. Задача — собрать из них картину. Прогноз: сколько запросов упало и по какой причине?
Картина собралась из последней строки. Отказов три из четырёх, причина у всех одна, а маршрута два — значит, сломалась не ручка, а то, чем они обе пользуются. Отдельно стоит посмотреть на длительности: успевший запрос уложился в 12 мс, упавшие висели по 0,7–0,8 секунды и упали почти одновременно. Это подпись отказа общей зависимости: все ждали её таймаута, а не своей работы.
Собранная таким разбором сводка — то, с чего начинается отчёт об инциденте: что сломалось, когда, сколько запросов задело и по какому признаку они схожи. Дальше идёт состояние контейнеров и история выкатов — это уже разбор падения целиком, и там тот же журнал читается вместе с ними.
Время в журнале пишется в UTC и с миллисекундами. Локальный часовой пояс превращает сравнение строк от двух машин в задачу с подвохом, а секундной точности не хватает уже на десятке запросов в секунду.
Протаскивать идентификатор параметром через все слои — работа, которую никто не доводит до конца: он обязательно потеряется в третьей вложенной функции. Хранилище на цепочку асинхронных вызовов вы уже заводили в главе о настоящем Nest — там же названа и плата за него. Журналу от этого хранилища нужно одно: положить номер на входе, а читать его будет сам логгер.
Логгер больше ничего не принимает сверх своего сообщения, а номер всё равно попадает в каждую строку — в том числе из функции, до которой от границы три вызова. В Nest такой контекст заводят прослойкой или перехватчиком на входе, и в логгер он приезжает уже готовым.
Писать логи приложение должно в stdout, и на этом его обязанности кончаются. Файл, ротация, сжатие, отправка в хранилище — дело того, кто запустил процесс: docker подбирает вывод сам, systemd отдаёт его журналу. Приложение, которое само открывает файл и само его вращает, придётся учить этому заново при каждом переезде, а при двух репликах ещё и выяснять, кто из них пишет в тот же файл.
У Nest логгер встроен, и он же отвечает за машинный формат. Посмотрим на один и тот же вызов при четырёх наборах настроек ConsoleLogger. Прогноз: сколько строк напечатает последний логгер и что в них будет?
import { ConsoleLogger } from '@nestjs/common'; // Логгер каркаса, каким он стоит по умолчанию: строка для человека. const поумолчанию = new ConsoleLogger('Заказы'); поумолчанию.log('заказ принят', { orderId: 'A-17', userId: 42 }); // Тот же логгер в машинном виде — одна строка JSON на событие. const машинный = new ConsoleLogger('Заказы', { json: true }); машинный.log('заказ принят', { orderId: 'A-17', userId: 42 }); машинный.error('база недоступна', { orderId: 'A-17', code: 'ECONNREFUSED' }); // Те же поля, но не отдельным объектом, а прямо в корне записи. const плоский = new ConsoleLogger('Заказы', { json: true, flattenParams: true }); плоский.log('заказ принят', { orderId: 'A-17', userId: 42 }); // Как логгер вёл себя до Nest 12: разбор объектов-параметров выключен. const безПараметров = new ConsoleLogger('Заказы', { json: true, structuredParams: false }); безПараметров.log('заказ принят', { orderId: 'A-17', userId: 42 });
[Nest] <pid> - <дата> LOG [Заказы] заказ принят { orderId: 'A-17', userId: 42 }
{"level":"log","pid":<pid>,"timestamp":<время>,"message":"заказ принят","context":"Заказы","params":{"orderId":"A-17","userId":42}}
{"level":"error","pid":<pid>,"timestamp":<время>,"message":"база недоступна","context":"Заказы","params":{"orderId":"A-17","code":"ECONNREFUSED"}}
{"level":"log","pid":<pid>,"timestamp":<время>,"message":"заказ принят","context":"Заказы","orderId":"A-17","userId":42}
{"level":"log","pid":<pid>,"timestamp":<время>,"message":"заказ принят","context":"Заказы"}
{"level":"log","pid":<pid>,"timestamp":<время>,"message":{"orderId":"A-17","userId":42},"context":"Заказы"}Первая строка — вид по умолчанию: метка, pid, дата, уровень, контекст и объект, распечатанный util.inspect. Читать её человеку удобно, отбирать по полю — нечем. Вторая — тот же вызов с json: true: одна строка JSON, в ней level, pid, timestamp, message, context и объект-параметры отдельным полем params. Третья — то же для уровня error: такие записи ConsoleLogger пишет в stderr, а прогон складывает оба потока в один вывод. Четвёртая показывает, что делает flattenParams: поля переезжают в корень записи, и путь к ним становится короче на одно колено.
А последние две строки — одна запись. Так ведёт себя логгер с structuredParams: false, то есть ровно так он вёл себя до Nest 12: сообщение печатается без данных, а объект уезжает отдельной записью, у которой в поле message лежит сам объект. Сшить эти две строки нечем — ни номера запроса, ни признака, что они пара. В Nest 12 разбор параметров включён по умолчанию, и это едва ли не главная практическая новость релиза для тех, кто читает свои логи.
Цвета в первой строке появляются, когда json выключен, и пропадают, когда включён: раскрашенная строка перестала бы быть разбираемым JSON. Переменная NO_COLOR в окружении гасит их в любом случае — проверено, в трубу Nest по умолчанию всё равно пишет с кодами цвета.
Когда встроенного мало — берут pino. Дочерние логгеры у него из коробки, а вычищение секретных полей задаётся путями при создании: проверено на 10.3.1, redact: ["user.passwordHash"] подменяет значение на [Redacted] — ровно та страховка, о которой шла речь в первом разделе. Для одного сервиса это всё ещё оверкилл, и выбор между «хватает своего» и «пора брать библиотеку» разбирается на полке сервера.
«Приложение здорово» — два разных вопроса, и путать их дорого. Первый: жив ли процесс, отвечает ли он вообще. Второй: можно ли давать ему работу прямо сейчас. Ответы расходятся сплошь и рядом: процесс запустился, но ещё не прогрел соединения; база моргнула, а процесс цел; пришёл сигнал остановки — процесс жив и дослуживает начатое, но новых запросов ему давать уже нельзя.
Поэтому ручек две. Живость (/health) отвечает за сам процесс и не ходит никуда: если она начнёт спрашивать базу, одна медленная зависимость превратит проверку в приговор — оркестратор убьёт процесс ровно тогда, когда он и так не справляется. Готовность (/ready) отвечает за работу и зависимости спрашивает, но бережно: с кешем и коротким таймаутом, потому что стучатся в неё несколько источников раз в несколько секунд.
Соберём обе на модели с часами вместо времени. Прогноз: какой код вернёт /ready сразу после того, как база перестала отвечать?
Сразу после отказа готовность ещё отвечает 200 — ответ пришёл из кеша, снятого до отказа. Это не баг, а цена: проба не ходит в базу на каждый запрос, и её картина отстаёт на срок кеша. Срок и выбирают из этого: две секунды — это до двух секунд запоздалого «готов», зато база не получает лишней нагрузки от проверок. А на сигнале остановки готовность переключается сразу и без всяких проб: тут спрашивать нечего, решение уже принято.
Этот порядок и есть главная польза от второй ручки: /ready отвечает 503 раньше, чем процесс начнёт умирать. Балансировщик или прокси успевает увести трафик, и остановка застаёт сервер без новых запросов. Без готовности снятие с ротации происходит по факту отказа соединения — то есть уже на чьих-то запросах.
Снаружи ручку здоровья опрашивает проба контейнера. В боевом репозитории автора она лежит не в compose, а в образе — и едет вместе с ним:
Проба написана на node, потому что в образе на node:24-slim нет ни curl, ни wget; решает не её вывод, а код выхода. Остальное — расписание: как часто спрашивать, сколько ждать ответа, сколько отказов подряд делают контейнер больным и сколько времени в начале отказы не считать. Что docker делает с больным контейнером (и чего не делает), разобрано в разделе про пробу, которая меряет не то; про последнюю стадию образа — там же в главе о слоях.
Ручка здоровья обязана отвечать и когда база лежит: на то она и живость. Приложение, которое не дождалось конфигурации, — другое дело: ему честнее упасть на старте, до первого запроса.
Последнее уточнение про адреса: обе ручки должны отвечать быстро и без авторизации — пробе негде взять токен. Наружу их не публикуют: проба ходит изнутри сети контейнеров, а через прокси адрес здоровья становится первым, что находит сканер. Строгая проверка конфигурации перед стартом — отдельная тема первой статьи про загрузку.
Выкат новой версии выглядит так: docker посылает старому контейнеру SIGTERM, ждёт stop_grace_period и, если процесс ещё жив, посылает SIGKILL — сигнал, который не перехватывается. Всё, что приложение успеет сделать, оно сделает в этом окне. Не успеет — оборванные ответы, незакрытые транзакции и запись, которая была на середине.
Первая же деталь меняет поведение процесса целиком. У SIGTERM в Node есть действие по умолчанию — завершиться с кодом 143; но стоит повесить на сигнал слушателя, и действие по умолчанию отменяется: процесс остаётся жить, и выход теперь ваша забота. Проверено на node 26.5.0: без слушателя процесс уходит немедленно с кодом 143, со слушателем — продолжает работать и печатать. Слушатель, который забыл выйти, превращает остановку в ожидание SIGKILL.
Порядок действий внутри окна всегда один: снять себя с ротации (готовность отвечает 503), закрыть приём новых запросов, дослужить те, что в полёте, закрыть пул и остальные ресурсы, выйти. Соберём это на модели: сигналы разносит EventTarget — родственник излучателя, который мы собирали сами, только штатный; запросы — множество имён, пул — счётчик занятых соединений. Прогноз: что напечатает запрос, пришедший после сигнала, и когда закроется пул?
Запрос после сигнала получает 503, а не обрыв соединения: это важная разница — клиент видит понятный отказ и может повторить его на другой реплике. Два запроса в полёте дослуживаются как обычно, и пул закрывается сам, когда счётчик в полёте дошёл до нуля. Обратите внимание, что закрывает его не обработчик сигнала, а событие «пусто»: обработчику в момент сигнала закрывать нечего, он только переводит сервер в режим остановки.
Чего в модели нет — крайнего срока. Если один запрос завис на внешнем вызове без таймаута (та самая почта, которая приняла соединение и молчит), счётчик до нуля не дойдёт никогда, и процесс будет ждать SIGKILL, ничего не закрыв. Поэтому вместе с сигналом заводят таймер на срок заведомо меньше stop_grace_period: вышло время — закрываем пул и выходим, записав в журнал, сколько запросов бросили. Это первая задача статьи.
Таймер крайнего срока в Node ставят с unref: без него сам таймер держит процесс живым и мешает ему выйти раньше, если все запросы дослужили быстро.
В Nest всё это уже написано, но включается одной строкой. Без неё каркас на сигналы не подписывается, и хуки завершения не вызываются вовсе:
Проверено 22.09.2026 на Nest 12.0.3: с enableShutdownHooks сигнал доходит до хука, асинхронный хук дожидаются, и только потом процесс выходит; без неё хук не зовут совсем. Код выхода в обоих случаях один и тот же — 143, то есть по коду выхода не отличить процесс, который аккуратно закрылся, от процесса, который просто умер. Отличить их можно только по строке в журнале, которую пишет хук, — поэтому её и пишут.
Перенесём модель на настоящий node:http — тот самый разбор запросов, который мы писали руками, только готовый. У него для остановки есть server.close(): он перестаёт принимать новые соединения и зовёт колбэк, когда закрылось последнее. Звучит как готовая мягкая остановка, и в этом месте прячется сюрприз.
В прогоне два одинаковых сервера. Оба получают один SIGTERM, посланный процессом самому себе, у обоих в этот момент по запросу в полёте. Расходятся они одной строкой: сервер B, ответив последнему запросу, ещё раз убирает простаивающие соединения, сервер A — нет. Прогноз: что получит запрос, пришедший после сигнала, что получит запрос в полёте и кто из серверов закроется быстрее секунды?
import http from 'node:http'; import { once } from 'node:events'; import { setTimeout as sleep } from 'node:timers/promises'; // Два одинаковых сервера. Расходятся одной строкой: B убирает простаивающие // соединения ещё раз — когда отвечен последний запрос. function поднять(имя, убиратьПослеОтвета) { const счёт = { останавливаемся: false, вПолёте: 0, последнийОтвет: 0 }; const сервер = http.createServer(async (запрос, ответ) => { счёт.вПолёте += 1; await sleep(600); ответ.end(`ответ ${имя} на ${запрос.url}\n`); await once(ответ, 'finish'); счёт.вПолёте -= 1; счёт.последнийОтвет = Date.now(); if (убиратьПослеОтвета && счёт.останавливаемся && счёт.вПолёте === 0) сервер.closeIdleConnections(); }); сервер.listen(0, '127.0.0.1'); // срок считается прямо в событии close: до печати итога очередь доходит своим // чередом, и замер по ней был бы не о том const быстро = once(сервер, 'close').then(() => Date.now() - счёт.последнийОтвет < 1000); return { счёт, адрес: once(сервер, 'listening').then(() => сервер.address()), остановить() { счёт.останавливаемся = true; сервер.close(); }, async итог() { return `${имя}: закрылся быстрее секунды — ${(await быстро) ? 'да' : 'нет'}`; }, }; } const a = поднять('A', false); const b = поднять('B', true); const адресA = await a.адрес; const адресB = await b.адрес; const url = ({ address, port }, путь) => `http://${address}:${port}${путь}`; console.log(`A слушает ${адресA.address}:${адресA.port}, B слушает ${адресB.address}:${адресB.port}`); process.on('SIGTERM', () => { console.log(`SIGTERM: в полёте запросов — A ${a.счёт.вПолёте}, B ${b.счёт.вПолёте}`); a.остановить(); b.остановить(); }); const вПолёте = [fetch(url(адресA, '/slow')), fetch(url(адресB, '/slow'))]; await sleep(100); process.kill(process.pid, 'SIGTERM'); await sleep(100); try { const ответ = await fetch(url(адресA, '/fast')); console.log(`запрос после сигнала: ${ответ.status}`); } catch (ошибка) { console.log(`запрос после сигнала: ${ошибка.cause?.code ?? ошибка.message}`); } for (const запрос of вПолёте) { const ответ = await запрос; console.log(`запрос в полёте: ${ответ.status} ${(await ответ.text()).trim()}`); } console.log(await a.итог()); console.log(await b.итог());
A слушает 127.0.0.1:<порт>, B слушает 127.0.0.1:<порт> SIGTERM: в полёте запросов — A 1, B 1 запрос после сигнала: ECONNREFUSED запрос в полёте: 200 ответ A на /slow запрос в полёте: 200 ответ B на /slow A: закрылся быстрее секунды — нет B: закрылся быстрее секунды — да
Первые две ожидаемые части сошлись: запрос, пришедший после сигнала, получил ECONNREFUSED — слушающий сокет уже закрыт, соединение некому принять; запросы в полёте дослужились и получили свои 200. Процесс вышел с нулём. А вот сроки разошлись: B закрылся быстрее секунды, A — нет. Отдельный замер тем же кодом: событие close у A приходило через 3008 мс после последнего ответа, при повторе — через 3013.
Причина в том, что делает close() на самом деле. Он закрывает слушающий сокет и те соединения, которые простаивают в момент вызова. Соединение, по которому шёл запрос, в этот момент занято — его он не трогает; а когда через полсекунды запрос дослужился и соединение стало простаивающим, закрывать его уже некому: разовая уборка прошла. Соединение живо по keep-alive, и close ждёт, пока кто-нибудь повесит трубку.
Три секунды — это клиент. Замер на node 26.5.0: при keepAliveTimeout сервера 3, 5, 7, 10 и 20 секунд ожидание вышло 1, 3, 5, 8 и 18 секунд — ровно на две меньше объявленного. Сервер сообщает свой срок заголовком Keep-Alive: timeout=5, а fetch вешает трубку с запасом в две секунды. А если на том конце клиент, который сам не уходит, соединение закрывает уже сервер — по своему keepAliveTimeout из таблицы сроков: в замере с сырым сокетом это шесть секунд после ответа при сроке в пять.
Лечится это одной строкой, и вариантов два. Первый — сервер B: после последнего ответа позвать closeIdleConnections() ещё раз, когда соединение уже освободилось. Второй — на время остановки отвечать с заголовком Connection: close: тогда клиент закроет соединение сам, получив ответ. Оба честнее, чем просто уменьшить keepAliveTimeout: это срок для всех соединений и на всё время работы, а проблема — на секунды остановки.
| Шаг остановки | Чем делается | Чем грозит пропуск |
|---|---|---|
| Снять с ротации | готовность отвечает 503 | балансировщик шлёт запросы в умирающий процесс |
| Закрыть приём | server.close() | новые соединения принимаются до последнего мига |
| Дослужить в полёте | счётчик запросов, выход по нулю | оборванные ответы у тех, кто уже ждёт |
| Отпустить соединения | closeIdleConnections() после последнего ответа | остановка растягивается на keep-alive |
| Закрыть ресурсы | пул, очереди, SMTP — в хуках | незакрытые транзакции и висящие соединения базы |
| Крайний срок | таймер короче stop_grace_period | зависший запрос доводит дело до SIGKILL |
server.close не обрывает запрос в полёте — это единственное, что он обещает. Всё остальное в остановке приходится дописывать самому.
- Строка лога — запись, а не фраза: время, уровень, сообщение-константа и поля. Всё переменное уезжает в поля, иначе одинаковые события нельзя ни сгруппировать, ни посчитать.
- Сквозной идентификатор запроса сшивает строки, написанные в разных слоях. По ним восстанавливается картина: сколько запросов упало, с какой причиной и на каких маршрутах — а разные маршруты с одной причиной значат, что сломалась общая зависимость.
- В журнал не кладут секреты и целые объекты «на всякий случай»: фильтр по списку разрешённых полей — страховка, а не замена правилу перечислять поля руками.
- Приложение пишет в stdout; файлы и ротация — дело окружения. У
ConsoleLoggerNest 12 естьjsonи разбор объектов-параметров, включённый по умолчанию: до 12-й версии такой объект уезжал в журнал отдельной несшиваемой записью. - Ручек здоровья две: живость отвечает за процесс и никуда не ходит, готовность отвечает за работу, спрашивает зависимости с кешем и первой переключается в 503 на остановке — чтобы трафик ушёл до того, как процесс начнёт умирать.
- Мягкая остановка — это снять с ротации, закрыть приём, дослужить в полёте, отпустить простаивающие соединения, закрыть ресурсы и выйти по крайнему сроку.
server.close()сам по себе делает только часть: соединение, занятое в момент вызова, после ответа остаётся жить по keep-alive.
1/4Что делает server.close() у node:http?
2/4Почему на сигнале остановки /ready отвечает 503, а /health остаётся 200?
3/4Что случится, если повесить на SIGTERM слушателя и забыть в нём выйти?
4/4Зачем в записи лога сообщение держат константой, а переменное уносят в поля?
Крайний срок остановки
Модель остановки из раздела, слегка урезанная: SIGTERM закрывает приём, событие «пусто» закрывает пул, когда все ответили. В сценарии ниже отвечает только r-1, а r-2 завис — и пул остаётся открытым навсегда. Допишите обработчик события «срок»: он закрывает пул, не дожидаясь зависших, и в строке журнала видно, сколько запросов бросили. Метод закрыть уже умеет и то и другое.
Обработчику остаётся позвать закрыть с честной причиной — повторный вызов метод отсекает сам:
Теперь перед последней строкой встаёт пул закрыт (время вышло), брошено запросов 1, и сама последняя меняется на пул закрыт: true. Брошенный запрос в журнале назван числом, а не забыт: по нему потом и выясняют, что именно висло на остановке.
В настоящем сервере это не событие, а таймер, поставленный в обработчике сигнала: const срок = setTimeout(() => пул.закрыть("время вышло"), 15000); срок.unref();. Два условия: срок заведомо меньше stop_grace_period контейнера — иначе до него дойдёт SIGKILL, и unref, чтобы сам таймер не держал процесс, когда все успели ответить.
Кто был в полёте в момент отказа
Тот же журнал инцидента. Первый вопрос при разборе — что происходило в момент первого отказа: если в полёте висело полдюжины запросов, отказ похож на исчерпанный пул соединений, а если один — на его собственную ошибку. Допишите вПолётеНа: она принимает момент в миллисекундах и возвращает имена запросов, у которых этот момент попал между первой и последней строкой включительно.
Строки одного запроса лежат в журнале по порядку, поэтому первая и последняя строки группы — и есть границы. Группировка выносится наружу, чтобы не пересобирать её на каждый вопрос:
Ответ — r-2, r-3, r-4: в момент первого отказа в полёте висели все три упавших запроса, а успевший r-1 закончился на секунду раньше. Вместе с картиной из раздела (одна причина, два маршрута) это и есть диагноз: отказала общая зависимость, и все, кто в неё пошёл, упали разом.
Границы взяты по включению с обеих сторон намеренно. Запрос, который сам в этот момент записывает отказ, тоже был в полёте — исключив его, вы потеряете как раз того, с кого начали разбор.
Готовность, которая не дрожит
Готовность, отвечающая по последней пробе, дрожит: база моргнула на секунду — процесс вылетел из ротации, вернулась — вернулся, и так по кругу. Лечится это требованием устойчивости: 200 только после двух удачных проб подряд, любой отказ сбрасывает счётчик. Перепишите готовность, переменная подряд уже заведена.
Счётчик растёт на удаче и обнуляется на отказе, а ответ читается по счётчику:
Теперь после отказа процесс возвращается в ротацию не с первой удачной пробы, а со второй — на строке «вторая удачная проба подряд». Заодно 503 отвечает и самый старт: пока проб не было двух, готовность честно говорит «ещё нет».
Цена та же, что у кеша: возврат в строй запаздывает на лишний интервал пробы. Поэтому порог и держат на двух — этого хватает, чтобы пережить моргание, и не хватает, чтобы заметно задержать возвращение.