open atlas
↑ К треку
Node.js с нуля до senior NODE · 03 · 03

Диагностика: каналы, async-контекст и профили

diagnostics_channel — это in-process pub/sub, который ничего не стоит без подписчиков, AsyncLocalStorage несёт request id сквозь await без проброса аргументов, а --cpu-prof пишет профиль на выходе — инструментируй без monkey-patching.

NODE Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

У сервиса оплаты были чистые структурные логи, но каждая строка была островом: level=error msg="charge failed" без всякой возможности связать её с запросом, который её вызвал. Команда пробросила аргумент traceId через HTTP-обработчик и сервис заказов, но двумя слоями ниже колбэк библиотеки его потерял, и оттуда каждая строка лога стала анонимной. Во время частичного сбоя у них были тысячи строк об ошибках и никакого способа восстановить путь одного пользователя. Фикс был не в ещё одном поле лога вручную — это AsyncLocalStorage, который несёт id сквозь каждый await и колбэк автоматически, так что глубокий логгер читает его, и никто его не передаёт.

diagnostics_channel: инструментировать без monkey-patching

Старый способ добавить трейсинг или метрики в библиотеку — это monkey-patch её прототипа: обернуть http.request, подменить метод драйвера, — что молча ломается на следующем апгрейде и привязывает твой код наблюдаемости к внутренностям. node:diagnostics_channel — встроенная in-process шина pub/sub, появившаяся в Node 15, — заменяет этот подход. Продюсер публикует именованные сообщения; подписчики получают их синхронно. Core, undici и http уже выставляют каналы, так что ты инструментируешь их, не трогая их код.

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

import diagnostics_channel from "node:diagnostics_channel";

const channel = diagnostics_channel.channel("app:db:query");

// продюсер (в обёртке БД) — платим за сборку payload только если наблюдают
function runQuery(sql, params) {
  if (channel.hasSubscribers) {
    channel.publish({ sql, params, startedAt: performance.now() });
  }
  return driver.query(sql, params);
}

// подписчик (в твоей настройке трейсинга) — подключаемся с нулевой связностью с обёрткой
channel.subscribe((message) => {
  metrics.increment("db.query", { sql: message.sql });
});

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

AsyncLocalStorage: контекст запроса, переживающий await

Баг из хука — traceId, потерянный двумя слоями ниже, — это канонический случай для AsyncLocalStorage (из node:async_hooks — хранилище, автоматически привязанное к текущему async-контексту, без передачи аргументов). Он даёт тебе хранилище, заскоупленное на асинхронное дерево вызовов: ты вызываешь als.run(store, fn) один раз на границе запроса, и любой код, достижимый из fn — сквозь await, setTimeout, цепочки промисов, колбэки библиотек, — может вызвать als.getStore() и прочитать тот же store. Ты больше никогда не передаёшь id аргументом.

import { AsyncLocalStorage } from "node:async_hooks";

const als = new AsyncLocalStorage();

// на границе: открыть контекст для этого запроса
app.use((req, res, next) => {
  const store = { traceId: req.headers["x-trace-id"] ?? crypto.randomUUID() };
  als.run(store, () => next());
});

// где угодно ниже по потоку, как угодно глубоко, без всякой проводки:
function log(msg) {
  const { traceId } = als.getStore() ?? {};
  logger.info({ traceId, msg });
}

Сравни это с переменной уровня модуля: глобал разделяется между всеми конкурентными запросами, так что под нагрузкой запрос B перезаписывает id запроса A, и твои трассы перемешиваются в бессмыслицу. AsyncLocalStorage держит отдельный store на каждый async-контекст, что ровно та изоляция, которая нужна серверу. Стоимость реальна, но ограничена — он едет на async_hooks, так что есть накладной расход на каждую async-операцию; держи store маленьким (id и флаги, а не большие объекты) — и ты платишь однозначные проценты в типичных обработчиках запросов.

ИнструментЧто даётСтоимость / когда брать
diagnostics_channelIn-process pub/sub: инструментировать core/undici/http без патчинга~бесплатно без подписчиков (hasSubscribers); всегда включённые хуки трейсинга/метрик
AsyncLocalStorageStore, заскоупленный на запрос, читаемый сквозь await/колбэкиОграниченный расход на операцию; неси trace/request id и флаги тенанта
async_hooksСырой жизненный цикл init/before/after/destroy async-ресурсовРеальный CPU на ресурс; используй экономно, для контекста предпочитай ALS
—cpu-prof / —heap-prof.cpuprofile/.heapprofile, записанный на выходе, открывается в DevToolsРасход профилирования, пока включено; CPU/heap-разбор по запросу
сигналы (SIGTERM/SIGINT/SIGUSR1)Хуки жизненного цикла: graceful shutdown, Ctrl-C, открыть инспекторБесплатно; обрабатывай SIGTERM, чтобы слить до того, как оркестратор тебя убьёт
Почему это работает

AsyncLocalStorage построен на async_hooks, но это не один и тот же инструмент. async_hooks выставляет сырой жизненный цикл каждого async-ресурса — init, before, after, destroy — и занятый хук срабатывает на каждом таймере, сокете и промисе, поэтому наивное использование может стоить двузначные проценты CPU. AsyncLocalStorage — это один хорошо оптимизированный потребитель этой машинерии, который тебе на самом деле нужен для контекста. Правило: бери AsyncLocalStorage для данных, заскоупленных на запрос, и опускайся к сырому async_hooks только ради глубокого отслеживания ресурсов, которое иначе не получить, — и измеряй, когда делаешь это.

Сигналы и профилирование по запросу

Когда контейнер попадает под rolling-обновление или процесс теряет память на staging, нужно действовать на живом процессе без рестарта — именно для этого и существуют сигналы и профилирование по запросу. Долгоживущий процесс общается со своим оператором через сигналы. Оркестраторы шлют SIGTERM, чтобы попросить graceful shutdown — ты получаешь окно, чтобы перестать принимать новую работу, слить запросы в полёте, закрыть пулы БД и выйти; если ты его игнорируешь, Kubernetes следом шлёт SIGKILL после grace-периода (30с по умолчанию) и обрубает тебя посреди запроса. SIGINT — это Ctrl-C в терминале. SIGUSR1 особый: Node по умолчанию открывает инспектор при его получении, так что ты можешь подключить дебаггер к живому процессу без рестарта.

// graceful shutdown: перестать принимать → слить → закрыть зависимости → выйти
process.on("SIGTERM", async () => {
  server.close();             // перестать принимать новые соединения
  await drainInFlight();      // дать активным запросам завершиться
  await db.end();             // закрыть пулы / сбросить
  process.exit(0);
});

Когда нужно увидеть, почему процесс медленный или течёт, профилируй его по запросу. Запусти с --cpu-prof (или --heap-prof), и Node запишет .cpuprofile (или .heapprofile) на выходе, который ты открываешь в панели Performance/Memory Chrome DevTools; --diagnostic-dir управляет тем, куда падают файлы. Для жёсткого краша включи core dumps (ulimit -c unlimited и --abort-on-uncaught-exception, чтобы дампить на непойманном throw) и разбирай дамп посмертно с llnode/lldb. Сквозная идея: ничто из этого не требует править код приложения, так что это безопасно добавить к плохо ведущему себя production-процессу.

Выбери лучший вариант

Веб-сервис должен прицепить trace id на границе запроса и заставить логгер пятью слоями ниже его эмитить, включая внутри async-колбэков сторонней библиотеки. Как пробрасывать id?

Викторина

Почему переменная-глобал уровня модуля неверна для хранения trace id на запрос, а AsyncLocalStorage верен?

Викторина

Библиотека публикует в diagnostics_channel на каждый запрос к БД, но команда не замечает расхода в проде, где никто не подписан. Почему publish почти бесплатен?

Расставь шаги по порядку

Расставь корректный graceful shutdown по SIGTERM — от получения сигнала до чистого выхода:

  1. 1 Получить SIGTERM от оркестратора (просьбу завершиться)
  2. 2 Перестать принимать новые соединения / работу (server.close, выйти из балансировщика)
  3. 3 Слить запросы в полёте — дать активной работе завершиться в окне grace
  4. 4 Закрыть зависимости: завершить пулы БД, сбросить буферы, закрыть очереди
  5. 5 process.exit(0) до дедлайна SIGKILL оркестратора
Вспомните перед уходом
  1. 01
    Что делает diagnostics_channel достаточно дешёвым, чтобы оставлять его в горячем пути, и в чём его компромисс против monkey-patching библиотеки?
  2. 02
    Почему глобал уровня модуля проваливается в хранении trace id на запрос под конкуренцией и как AsyncLocalStorage чинит это без смены каждой сигнатуры функции?
Итог

Node поставляет первоклассную диагностику, не требующую правки твоего приложения. diagnostics_channel — это in-process шина pub/sub: продюсеры publish-ят именованные сообщения, а подписчики получают их синхронно, и поскольку продюсер ограждает через hasSubscribers, ненаблюдаемый publish по сути бесплатен — так что ты инструментируешь core, undici и http для трейсинга или метрик без monkey-patching прототипов, которые ломаются на апгрейде. Для контекста, заскоупленного на запрос, AsyncLocalStorage (построенный на async_hooks) держит отдельный store на каждое async-дерево вызовов: als.run(store, fn) на границе, als.getStore() где угодно ниже по потоку сквозь каждый await и колбэк, чего общий глобал уровня модуля не может, потому что конкурентные запросы перезаписали бы id друг друга. Сырой async_hooks используй экономно — его жизненный цикл init/before/after/destroy срабатывает на каждом async-ресурсе и стоит реального CPU — и держи store ALS маленьким. Общайся с оператором через сигналы: обрабатывай SIGTERM, чтобы перестать принимать, слить работу в полёте, закрыть зависимости и сделать process.exit(0) до дедлайна SIGKILL оркестратора; SIGINT — это Ctrl-C, а SIGUSR1 открывает инспектор. И разбирайся по запросу: --cpu-prof/--heap-prof пишут профиль на выходе для DevTools, --diagnostic-dir размещает файлы, а core dumps с llnode/lldb покрывают жёсткие краши — всё без касания кода приложения. Теперь, когда встретишь анонимные строки лога без трассы, процесс, убитый SIGKILL посреди запроса, или необъяснимый всплеск CPU, — ты знаешь, к какому инструменту тянуться.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.