Site icon Заметки разработчика

FileBackend в production: ротация логов C++ в logme

FileBackend в production и ротация логов C++ в logme

Записать сообщения в файл несложно. Гораздо сложнее сделать так, чтобы через несколько месяцев работы сервиса файловое логирование оставалось предсказуемым.

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

Поэтому ротация логов C++ в production — это не просто переименование app.log в app.old.log. Нужен полный жизненный цикл файла.

Именно этим занимается FileBackend в logme. Он умеет асинхронно писать данные, ротировать файлы по размеру и времени, создавать архивы, применять retention и отправлять завершённые файлы на gzip-сжатие.

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

Что происходит после вызова LogmeI()

По умолчанию FileBackend работает асинхронно.

Когда канал передаёт ему готовую запись, она не обязательно сразу вызывает физическую запись в файл. Данные помещаются во внутреннюю очередь. Затем общий file manager записывает накопившиеся буферы пачками.

Это важная особенность production-поведения.

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

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

Явный Flush() работает иначе. Для асинхронного FileBackend он публикует оставшиеся данные, запрашивает немедленный flush и ждёт, пока QueuedBytes не станет равен нулю.

Поэтому Flush() полезен как точка синхронизации. Однако вызывать его после каждой записи не стоит: это уничтожило бы значительную часть преимуществ асинхронного backend.

Production-конфигурация FileBackend

Практическая конфигурация может выглядеть так:

{
  "type": "FileBackend",
  "file": "logs/app.log",
  "async": true,
  "append": true,

  "rotation": "daily",

  "max-size": "100Mb",
  "on-size-limit": "rotate",

  "archive": "logs/archive/app.{date}.{index}.log",
  "compression": "gz",

  "retention": {
    "max-files": 30,
    "max-age": "30d",
    "max-total-size": "2Gb",
    "clean-on-start": true
  }
}

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

Завершённый файл получает архивное имя. После этого к архивам применяется retention, а при включённом gzip файл передаётся менеджеру сжатия.

Разберём, почему каждая часть этой конфигурации имеет значение.

Активный файл и append

file задаёт имя текущего файла:

"file": "logs/app.log"

Если путь относительный, logme разрешает его относительно home directory логгера.

Если родительского каталога ещё нет, FileBackend пытается создать его автоматически. Поэтому logs/app.log не требует заранее вручную создавать logs.

По умолчанию используется:

"append": true

При запуске существующий файл открывается с продолжением записи. Текущий размер определяется по фактической позиции конца файла.

Для production это обычно ожидаемое поведение. Перезапуск сервиса сам по себе не должен уничтожать журнал предыдущего запуска.

Есть ещё одна деталь. Если включена временная rotation, logme принудительно использует append независимо от значения append. Это защищает уже существующий файл текущего временного периода от ненужного перезаписывания.

Что происходит при достижении max-size

У FileBackend есть встроенное ограничение размера активного файла:

"max-size": "100Mb"

Без явной настройки текущий default равен 8 MiB.

Однако само наличие max-size ещё не означает классическую ротацию файлов. Поведение определяет параметр on-size-limit.

По умолчанию используется:

"on-size-limit": "truncate"

Это исторический режим logme. Активный файл остаётся тем же файлом, а старое содержимое сокращается. Реализация сохраняет более свежую часть журнала и добавляет маркер о количестве отброшенных символов.

Такой режим удобен, если требуется просто ограниченный по размеру текущий диагностический лог.

Однако для server-side production чаще нужна история. Тогда лучше использовать:

"on-size-limit": "rotate"

В этом режиме достигший лимита файл завершается и переносится в архив.

Для size rotation нужен archive

Если используется:

"on-size-limit": "rotate"

конфигурация обязана содержать archive, причём шаблон должен включать {index}:

"archive": "logs/archive/app.{index}.log"

Это проверяется при разборе конфигурации. logme не запускает size rotation с неоднозначной схемой именования.

Причина понятна. За один день или даже за одну минуту приложение может создать несколько файлов одинакового размера. Им нужны разные имена.

Например:

app.1.log
app.2.log
app.3.log

Причём logme не начинает слепо с единицы при каждом запуске.

FileArchivePolicy проверяет существующие архивы и восстанавливает индекс. Уже существующий .log не будет затёрт. То же самое относится к архиву с соответствующим именем и суффиксом .gz.

Это важно после restart. Новая копия процесса продолжит нумерацию, а не перезапишет архивы предыдущей.

Ротация по времени

Кроме размера, FileBackend умеет завершать файл по времени.

Поддерживаются:

hourly
daily
weekly
monthly

Для отключения можно использовать none, off или disabled.

Например:

"rotation": "daily"

Дневная ротация привязана к локальному календарному времени. hourly начинает новый период с начала часа. Для weekly началом недели считается понедельник, а monthly использует первый день месяца.

Однако здесь есть важный практический нюанс.

FileBackend не запускает отдельный таймер только ради переименования файла ровно в полночь. Проверка временной границы выполняется при поступлении следующей записи.

Поэтому тихий сервис может не создать новый файл в 00:00:00. Если следующая запись появится в 03:17, предыдущий период будет завершён именно тогда.

При этом архив получает время завершившегося логического периода, а не случайное время первой записи нового периода.

Для журналирования это обычно именно то поведение, которое нужно.

Размер и время можно использовать одновременно

Для production редко приходится выбирать только один механизм.

Например:

{
  "rotation": "daily",
  "max-size": "100Mb",
  "on-size-limit": "rotate",
  "archive": "logs/archive/app.{date}.{index}.log"
}

Теперь файл завершится в двух случаях.

Первый — начался новый день. Второй — текущий файл достиг 100 MB.

Оба события проходят через общий механизм завершения файла. Поэтому naming, retention и compression работают одинаково независимо от причины rotation.

Шаблон:

app.{date}.{index}.log

особенно удобен в таком режиме.

В спокойный день может появиться только:

app.2026-08-18.1.log

а под высокой нагрузкой:

app.2026-08-18.1.log
app.2026-08-18.2.log
app.2026-08-18.3.log

Таким образом, дата показывает период, а индекс различает части внутри него.

{date}, {datetime} и {index}

В архивном имени FileBackend отдельно обрабатывает несколько lifecycle placeholders.

{date} превращается в дату вида:

2026-08-18

{datetime} содержит также время:

2026-08-18-14-35-00

А {index} задаёт номер части.

Например:

"archive": "archive/server.{date}.{index}.log"

или:

"archive": "archive/server.{datetime}.{index}.log"

При переходе в новый временной период индекс восстанавливается именно для соответствующего набора имён. Архивы предыдущего дня не заставляют новый день продолжать их нумерацию.

Это небольшая деталь, но она делает каталог логов намного понятнее.

Retention: ротация без уборки проблемы не решает

Самая распространённая ошибка при настройке файловых логов — настроить rotation и на этом остановиться.

В результате один огромный app.log превращается в тысячи маленьких архивов. Диск всё равно когда-нибудь заканчивается.

В logme эту задачу решает retention.

Например:

"retention": {
  "max-files": 30,
  "max-age": "30d",
  "max-total-size": "2Gb",
  "clean-on-start": true
}

Здесь используются три независимых ограничения.

max-files ограничивает количество подходящих файлов. max-age удаляет слишком старые архивы. max-total-size удаляет старейшие файлы, пока общий размер не опустится ниже лимита.

Правила применяются последовательно. Поэтому фактически действует наиболее строгая комбинация ограничений.

Например, max-files: 30 не означает «хранить 30 дней». Если приложение создаёт десять файлов в день, лимит в 30 файлов может оставить только три дня истории.

Поэтому в production полезно задавать max-age отдельно. Он описывает уже другую политику: насколько старая история вообще имеет смысл.

max-total-size защищает диск от неожиданной нагрузки

Количество файлов не всегда хорошо отражает реальный объём логов.

Обычно сервис пишет 50 MB в сутки, но после включения DEBUG или при аварийном цикле может внезапно начать писать гигабайты.

Для этого существует:

"max-total-size": "2Gb"

Если соответствующие файлы превышают лимит, cleaner начинает с самых старых.

Это делает max-total-size хорошей второй линией защиты. Даже если количество файлов выглядит нормальным, один необычно шумный период не должен бесконтрольно занять диск.

Нулевое значение отключает соответствующее правило. Это относится к max-files, max-age и max-total-size.

Старый параметр max-parts тоже поддерживается. Сейчас он является legacy-эквивалентом retention.max-files.

Если заданы оба значения, они должны совпадать. Иначе конфигурация считается ошибочной.

clean-on-start убирает старые архивы после простоя

По умолчанию:

"clean-on-start": true

При применении конфигурации FileBackend запускает retention.

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

После запуска backend приводит историю к заданным ограничениям.

Далее retention выполняется и при завершении очередного файла. При этом очистка не запускается для каждой обычной записи.

Именно поэтому retention не должен превращаться в filesystem scan на hot path логирования.

Cleaner не удаляет всё подряд

Retention строит шаблон файлов, относящихся к конкретному FileBackend.

Посторонние файлы в каталоге не должны удаляться только потому, что находятся рядом.

Кроме того, активный файл передаётся cleaner как защищённый. Даже если его имя подходит под шаблон очистки, удалить текущий открытый лог retention не должен.

Это позволяет хранить активный файл и архивы в одном каталоге.

Однако на практике я бы всё равно разделял их:

logs/app.log
logs/archive/app.2026-08-18.1.log
logs/archive/app.2026-08-18.2.log

Так каталог проще просматривать человеку, а операции над архивами логически отделены от текущего файла.

Gzip применяется только к завершённым файлам

Compression включается очень просто:

"compression": "gz"

или:

"compression": "gzip"

Активный app.log при этом не сжимается.

После завершения archive-файл передаётся CompressionManager. У него есть собственная очередь и worker, поэтому gzip не выполняется непосредственно внутри обычного вызова логирования.

Это важное свойство для production. Сжатие большого файла может занять заметное время и CPU. Нет смысла заставлять поток, вызвавший LogmeI(), ждать эту работу.

После успешного gzip архив получает суффикс .gz.

При этом logme учитывает и несжатые, и уже сжатые варианты при восстановлении архивных индексов. Поэтому существующий:

app.2026-08-18.4.log.gz

не позволит новой rotation случайно создать и затем перезаписать ту же четвёртую часть.

Есть только одно условие: logme должен быть собран с поддержкой zlib. Если USE_ZLIB отключён, compression: "gz" принимается конфигурацией, но фактического gzip не происходит.

Что происходит, если rotation не удалась

Production-код обязан учитывать ошибки файловой системы.

Например, каталог архива может стать недоступен. Может завершиться свободное место. Наконец, rename() может вернуть ошибку.

При завершении текущего файла FileBackend сначала пытается подготовить archive directory. Затем активный файл переименовывается в выбранное архивное имя.

Если создать archive directory не удалось, backend пишет внутреннюю ошибку и пытается снова открыть текущий лог в append mode.

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

То есть неудачная ротация сама по себе не должна означать немедленную потерю уже накопленного app.log.

Однако это best-effort recovery. Если сама файловая система больше не позволяет открыть или записать файл, backend не может сделать невозможное.

Поэтому проблемы файлового логирования всё равно нужно наблюдать.

Async не означает «данные уже на диске»

Это ещё один важный production-момент.

Если LogmeI() успешно добавил сообщение в асинхронный FileBackend, это не означает, что байты уже физически записаны в файл в этот же момент.

Они могут находиться в очереди до ближайшего flush.

При нормальном shutdown backend вычищает оставшуюся очередь. Явный Flush() также позволяет дождаться её опустошения.

Однако аварийное уничтожение процесса принципиально отличается от штатной остановки. Например, SIGKILL не даёт библиотеке возможности выполнить shutdown-код.

Поэтому обычный асинхронный файл не следует воспринимать как crash-safe журнал последней инструкции программы. Для аварийных путей у logme существует отдельный crash logging API.

Для обычных application logs асинхронный режим, напротив, является разумным production default.

Truncate или rotate

У этих режимов разные задачи.

truncate полезен для локального технического журнала с жёстко ограниченным размером. История здесь вторична, зато файл не размножается.

rotate нужен, когда прошлые события имеют диагностическую ценность. Именно он позволяет применить архивирование, retention и compression.

Для серверов я бы в большинстве случаев выбирал rotate.

Например:

"max-size": "100Mb",
"on-size-limit": "rotate"

Вместе с дневной rotation это защищает сразу от двух крайностей. Спокойный сервис получает удобное разделение по дням. Очень шумный сервис не создаёт гигантский файл внутри одного дня.

FileBackend retention и DirectorySizeWatchdog — не одно и то же

В logme есть ещё один механизм ограничения диска — DirectorySizeWatchdog.

Он решает более широкую задачу.

retention относится к lifecycle конкретного FileBackend и его шаблону файлов. Watchdog контролирует общий размер logging directory.

Например, в одном каталоге могут работать пять разных backend. Каждый из них соблюдает собственный retention, но суммарно они всё равно могут использовать слишком много места.

В таком случае watchdog выступает дополнительным глобальным ограничителем.

Эти механизмы не конкурируют. На production-системе вполне разумно использовать оба.

Конфигурация, с которой имеет смысл начинать

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

{
  "type": "FileBackend",
  "file": "logs/app.log",
  "async": true,
  "append": true,

  "rotation": "daily",

  "max-size": "100Mb",
  "on-size-limit": "rotate",

  "archive": "logs/archive/app.{date}.{index}.log",
  "compression": "gz",

  "retention": {
    "max-files": 50,
    "max-age": "30d",
    "max-total-size": "2Gb",
    "clean-on-start": true
  }
}

Конкретные числа, конечно, зависят от приложения.

Но сама схема универсальна: один понятный активный файл, ограничение размера, временная rotation, отдельный каталог архивов, gzip и несколько независимых ограничений retention.

Такой FileBackend уже не просто «пишет лог в файл». Он управляет полным жизненным циклом журналов.

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

Exit mobile version