Логи и конфигурация

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

Структурированный лог — это запись в машиночитаемом формате (обычно JSON) с полями вместо вольной строки, которую можно искать, фильтровать и агрегировать.

Локально console.log('user', user) кажется достаточным. Но в продакшене логи читают не глазами в терминале, а системами вроде Loki, Elasticsearch или CloudWatch — и им нужны поля, уровни и контекст, а не «простыня» текста. Параллельно встаёт вопрос конфигурации: один и тот же код должен работать локально, на staging и на проде с разными адресами БД и ключами. Этот урок — про обе задачи сразу, потому что на практике они идут рука об руку.

Зачем это нужно на практике

Представьте ночной инцидент: сервис тормозит. С console.log вы получите разрозненные строки без таймстемпов и без связи между ними. Со структурированными логами вы за секунды отфильтруете записи по requestId, увидите уровень error и поле durationMs, и поймёте, какой запрос завис. Логи — это ваши «глаза» в проде; от их качества напрямую зависит, найдёте вы причину за минуту или за час.

Структурированные логи вместо console.log

У console.log три беды для продакшена: нет уровней, нет машиночитаемого формата, нет единого контекста. Решение — логгер вроде pino или winston, который пишет JSON-строки с полями.

import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
});

// вместо console.log('user logged in', userId)
logger.info({ userId, ip: req.ip }, 'user logged in');
// вместо console.error(err)
logger.error({ err, route: req.path }, 'request failed');

На выходе — строки, которые система сбора логов разберёт на поля:

{"level":"info","time":1718900000000,"userId":42,"ip":"10.0.0.5","msg":"user logged in"}
{"level":"error","time":1718900000050,"route":"/api/orders","msg":"request failed"}

Первым аргументом идёт объект с полями, вторым — человекочитаемое сообщение. Так вы потом ищете «все ошибки на маршруте /api/orders за последний час» одним запросом, а не grep-ом по тексту.

Уровни логирования

Уровни позволяют управлять «громкостью» без правки кода: на проде вы держите info, а при разборе инцидента временно включаете debug через переменную окружения. Типовая лестница:

УровеньКогда использовать
fatalОшибка, после которой процесс завершается
errorОперация провалилась, но сервис жив
warnПодозрительно, но не сломалось (деградация, ретрай)
infoБизнес-события: старт сервиса, вход пользователя
debugПодробности для отладки, на проде обычно выключены

Главное правило: уровень отражает серьёзность для эксплуатации, а не «насколько мне интересно». В error попадает то, на что должен среагировать дежурный; в debug — то, что нужно лишь при глубоком разборе.

Конфигурация через переменные окружения

Настройки, которые меняются от среды к среде (адрес БД, порт, ключи), нельзя зашивать в код. Их место — переменные окружения. Node читает их из process.env.

const config = {
  port: Number(process.env.PORT) || 3000,
  databaseUrl: process.env.DATABASE_URL,
  logLevel: process.env.LOG_LEVEL || 'info',
};

// валидируйте обязательное при старте — лучше упасть сразу
if (!config.databaseUrl) {
  throw new Error('DATABASE_URL is required');
}

Локально удобно держать переменные в файле .env и подгружать их пакетом dotenv. Критично: сам .env добавляют в .gitignore, а в репозиторий кладут .env.example без значений — как шаблон.

# .env (локально, НЕ в git)
PORT=3000
DATABASE_URL=postgres://localhost:5432/app
LOG_LEVEL=debug

# запуск с переопределением переменной
LOG_LEVEL=debug node server.js

Принципы 12-factor

Методология 12-factor app формулирует, как строить сервисы для облака. Для Node особенно важны три фактора:

  • Config (III). Конфигурация — в окружении, а не в коде. Один и тот же артефакт деплоится в любую среду, отличается только набором переменных.
  • Logs (XI). Приложение пишет логи в stdout как поток событий и не заботится о файлах и ротации — этим занимается инфраструктура (Docker, systemd, агрегатор).
  • Dev/prod parity (X). Среды максимально похожи; различия — только в значениях переменных, а не в логике.

Практический вывод: ваш Node-процесс должен просто писать JSON-логи в stdout и читать конфиг из process.env. Куда эти логи поедут и где хранятся секреты — забота окружения, а не кода.

Секреты не в коде

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

  • Секреты передавайте через переменные окружения, а на проде — через хранилище секретов (Docker/K8s secrets, Vault, параметры облака).
  • Добавьте .env в .gitignore до первого коммита.
  • Если секрет всё же утёк в репозиторий — недостаточно удалить строку: его нужно отозвать и перевыпустить, потому что он остаётся в истории git.
  • Не печатайте секреты в логи. Логгеры позволяют настроить «редактирование» чувствительных полей.
// pino умеет вырезать секреты из логов
const logger = pino({
  redact: ['req.headers.authorization', 'password', '*.token'],
});

Как это работает под капотом

Переменные окружения — это пары «ключ-значение», которые ОС передаёт процессу при запуске; Node при старте копирует их в объект process.env, где все значения — строки (поэтому PORT приходится приводить через Number(...)). Дочерние процессы наследуют окружение родителя — на этом и держатся dotenv и менеджеры процессов.

Структурированный логгер вроде pino работает быстро потому, что не форматирует красивый текст в основном потоке: он сериализует объект в JSON и пишет строку в stdout, а тяжёлую обработку (форматирование для человека, отправку в агрегатор) выносит в отдельный процесс-транспорт. Запись в stdout — это просто поток, который ОС или Docker перенаправляет куда нужно: в файл, в журнал systemd или в сетевой сборщик. Именно поэтому 12-factor советует не открывать файлы логов из кода — вы лишаете инфраструктуру возможности управлять этим потоком.

Частые ошибки

  • console.log в проде. Нет уровней и полей — логи невозможно фильтровать и агрегировать.
  • Секреты в коде или в .env под git. Компрометация; забыли .gitignore — секрет навсегда в истории.
  • Логирование чувствительных данных. Токен или пароль попал в лог — это утечка; настройте redaction.
  • Запись логов в файлы из кода. Ломает 12-factor: оставьте поток stdout инфраструктуре.
  • Нет валидации конфига на старте. Пропущенный DATABASE_URL всплывёт не при запуске, а на первом запросе.

Итоги

  • Пишите структурированные JSON-логи с полями и уровнями, а не вольный текст через console.log.
  • Уровень отражает серьёзность для эксплуатации: error — для дежурного, debug — для глубокого разбора.
  • Конфигурацию держите в переменных окружения и валидируйте обязательные значения при старте.
  • Следуйте 12-factor: config в окружении, логи в stdout, паритет сред.
  • Секреты никогда не коммитьте; утёкший секрет отзывайте и перевыпускайте, чувствительные поля вырезайте из логов.
Проверьте себя
1. В чём главное преимущество структурированных (JSON) логов над обычным console.log в продакшене?
AОни занимают меньше места на диске
BИх можно искать, фильтровать и агрегировать по полям (например, по requestId или уровню)
CОни выводятся быстрее в терминал
DОни автоматически шифруются
2. Согласно принципам 12-factor, где должна храниться конфигурация, отличающаяся между средами (адрес БД, ключи)?
AВ переменных окружения, а не в коде
BВ отдельном JSON-файле внутри репозитория
CПрямо в исходном коде как константы
DВ комментариях к коду
3. Что нужно сделать, если секрет (например, токен API) случайно попал в коммит и историю git?
AДостаточно удалить строку с секретом в новом коммите
BНичего, если репозиторий приватный
CОтозвать и перевыпустить секрет, потому что он остаётся в истории git
DПереименовать переменную окружения