Содержание урока · 10 разделов
У файла из прошлого урока есть свойство, которое не заметно в первый месяц: он растёт. Сервис с ровным потоком в пару мегабайт в час за год отложит на диск пятнадцать-двадцать гигабайт, и закончиться это может в самый неподходящий момент — в ночь на чёрную пятницу, когда логи как раз нужны. Классическое решение на уровне системы — демон logrotate: раз в сутки (или по размеру) он переименовывает файл, сжимает старые копии и просит сервис переоткрыть журнал. Работает, но требует конфига вне кода, прав на перезапуск сервисов и аккуратности с переоткрытием. Есть решение проще — держать ротацию в руках самого процесса: библиотека lumberjack встраивается в Go-программу и превращает файл логов в самоподдерживающееся кольцо: переполнился — переименовался, открылся заново, старые куски сжал и почистил. Для zerolog интеграции нет, потому что она не нужна: lumberjack — это io.Writer, и передаётся он в zerolog.New как обычный файл — прямое продолжение приёма из урока про запись в файл. Ни одной строки кода логирования менять не придётся — меняется только writer при старте, и диск перестаёт быть расходником.
lumberjack zerolog: writer с ротацией на лету#
Библиотека живёт по пути gopkg.in/natefinch/lumberjack.v2 и выставляет наружу одну структуру — lumberjack.Logger с полями-настройками. В связке lumberjack zerolog она играет роль writer-а: logger := zerolog.New(lumber).With().Timestamp().Logger() — и всё, дальше обычный код событий. Внутри lumberjack.Logger — мьютекс, открытый файл и текущий размер. Каждый Write, который приходит от zerolog, проверяет: влезет ли эта строка в лимит MaxSize; если нет — файл закрывается, переименовывается с меткой времени в имени, открывается новый с прежним именем, и только затем запись дописывается уже в новый файл. Ротация происходит прямо между записями, на лету, без остановки сервиса и без сигналов. Текущий файл всегда носит имя из Filename — logs/app.log, — а бэкапы получают суффикс с датой и временем. Для системы сбора это значит: хвостить нужно одно и то же имя, а появившиеся рядом файлы с метками — архив, который можно читать, не боясь, что в него пишут. Ровно на этом контракте строятся filebeat, promtail и простой scp-скрипт дежурного.
Zerolog с lumberjack: вся настройка
GOpackage main
import (
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
func main() {
lumber := &lumberjack.Logger{
Filename: "logs/app.log",
MaxSize: 10, // мегабайты
MaxBackups: 5,
MaxAge: 30, // дней
Compress: true,
}
defer lumber.Close()
logger := zerolog.New(lumber).With().Timestamp().Logger()
logger.Info().Str("user", "ann").Msg("job done")
}
// logs/app.log — текущий файл, имя не меняется
// logs/app-2024-04-06T12-00-00.000.log — переименован при ротации
// logs/app-2024-04-06T12-00-00.000.log.gz — сжат фоновой горутинойlumberjack.Logger создаётся с адресом: Write у него на указателе, потому что внутри мьютекс и mutable-размер. Пять полей задают весь жизненный цикл журнала; всё остальное lumberjack решает сам при записи. Логгер zerolog получает lumber как обычный io.Writer и не отличает его от *os.File из прошлого урока.
MaxSize, MaxBackups, MaxAge: дефолты и смысл#
Пять полей структуры делятся на два типа: пороги и уборка. MaxSize — максимальный размер текущего файла в мегабайтах перед ротацией; дефолт 100 МБ. Внимание к единицам: поле целое и измеряется именно мегабайтами — MaxSize: 10 это десять мегабайт, и наивное «10 для десяти килобайт» сделает файл в тысячу раз больше задуманного. MaxBackups — сколько старых файлов хранить; дефолт 0 означает «хранить все», что вместе с нулевым MaxAge ведёт к бесконечному росту архива — именно поэтому число стоит задавать всегда. MaxAge — предельный возраст бэкапа в днях; дефолт 0 — не удалять по возрасту вовсе. Compress включает gzip для переименованных файлов; дефолт false — бэкапы лежат сырыми. LocalTime определяет, в какой зоне штампуется имя бэкапа: дефолт — UTC, и это разумный дефолт для серверов, но при сверке с локальными журналами удобно LocalTime: true. Нюанс уборки: удаление старых и сжатие выполняются фоново, после ротации — не блокируя запись; поэтому сжатый .gz может появиться чуть позже самого переименования.
Механика внутри полезна для понимания границ. Write блокируется мьютексом — параллельные записи из горутин сериализуются, и размер не врёт. Если одна запись сама больше MaxSize, lumberjack вернёт ошибку вида write length 2000000 exceeds maximum file size 1000000 — строки логов обычно крошечные, но протечёт это место, только если кто-то логирует гигантский payload одним куском; отдельная тема — гигантские поля, у zerolog они разбираются в уроке про поля и секреты. Каталог под журнал создаётся автоматически с правами 0755, сам файл — 0600: только владелец процесса пишет и читает, что резко снижает интерес посторонних к вашим логам. Права нового файла lumberjack копирует с прежнего журнала, если тот уже существовал, — ручной chmod переживает ротации. Имена бэкапов формируются из текущего времени в формате с точностью до миллисекунд: app-2024-04-06T12-00-00.000.log — сортировка по имени равна сортировке по времени, и любые скрипты очистки работают без парсинга; метка в имени — это момент самой ротации, а не время первой записи, по именам видно, когда именно журнал делился на куски. Всё это происходит синхронно с записью; уборка — сжатие и удаление лишних — уезжает в отдельную горутину и догоняет по мере сил, так что всплеск ротации не тормозит горячий путь.
Правила ротации одним списком#
- MaxSize измеряется мегабайтами: 10 — это 10 МБ; проверьте единицы, прежде чем удивляться размеру файлов
- MaxBackups: 0 хранит все бэкапы — задайте число, диск конечен
- MaxAge: 0 не удаляет по возрасту; для журналов с ретеншеном задайте оба порога
- Compress жмёт только переименованные бэкапы — текущий файл всегда сырой
- имена бэкапов штампуются в UTC; для сверки с локальными журналами — LocalTime: true
- бюджет места примерно MaxSize × (MaxBackups + 1) — считайте его до инцидента, а не после
- одна запись больше MaxSize вернёт ошибку: не опускайте лимит ниже самой большой строки лога
Арифметика диска: бюджет до инцидента#
Ротация превращает неограниченный рост в константу, и эту константу стоит назвать до того, как диск назовёт её сам. Верхняя граница занятого места — MaxSize, умноженный на количество текущих и резервных файлов: MaxSize: 100 при MaxBackups: 5 — это до шестисот мегабайт в худшем случае, Compress отыгрывает ещё в разы на текстовых JSON-логах. Пороги работают независимо: по документации lumberjack файлы старше MaxAge дней удаляются независимо от MaxBackups, поэтому мягкий ретеншен задаёт MaxAge, а жёсткий потолок места — именно MaxBackups. Дальше обратная задача: сколько пишет ваш сервис? Оцените средний размер записи — у zerolog со структурными полями это обычно двести-четыреста байт, — умножьте на события в час и получите мегабайты в сутки; от них вверх возьмите MaxSize. Если сервис на пике пишет гигабайты в час, сначала уберите объём, а не наращивайте диск: прореживание потока из урока про sampling режет повторяющиеся записи до того, как они доедут до writer-а, и ротация становится техникой, а не спасением. Худшее, что может случиться без этой арифметики, — не переполнение диска само по себе, а то, что вместе с логами место кончится у базы данных, живущей на той же машине.
Дежурный следит за логами командой tail -f app.log. Ночью происходит ротация: lumberjack переименовывает файл в app-2024-04-06T12-00-00.000.log и открывает новый app.log, а tail продолжает читать переименованный старый — по inode он-то привязан к нему. Дежурный смотрит в замёрзший терминал и делает вывод «сервис молчит», хотя сервис пишет вовсю. Лечится одной буквой: tail -F переоткрывает файл по имени и переживает ротацию. Collector-ы — filebeat, promtail, vector — умеют это из коробки, но любой самописный скрипт с -f ловится на ту же граблю, и на разборе инцидента это выглядит как загадочное исчезновение логов.
Принудительная ротация по SIGHUP
GOpackage main
import (
"os"
"os/signal"
"syscall"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
func main() {
lumber := &lumberjack.Logger{Filename: "app.log", MaxSize: 50}
defer lumber.Close()
logger := zerolog.New(lumber).With().Timestamp().Logger()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP)
go func() {
for range sig {
if err := lumber.Rotate(); err != nil {
logger.Error().Err(err).Msg("rotate failed")
}
}
}()
logger.Info().Msg("ready: SIGHUP rotates the log")
select {}
}Rotate() закрывает текущий файл, переименовывает его и открывает новый — та же механика, что и автоматическая ротация, но по требованию. Классический повод — SIGHUP от внешнего logrotate или скрипта завершения смены. Close в defer закрывает журнал при остановке; ошибка ротации логируется тем же zerolog с Err.
Чего lumberjack не делает#
Границы библиотеки важнее её возможностей, потому что их узнают по факту. Lumberjack не пишет по сети: это файловый writer, и для Loki или Kafka нужен сборщик-агент, читающий каталог. Он не ротирует по дням автоматически: порог у него один — размер, а календарную разбивку делают своим кодом — меняйте Filename на dated-имя и вызывайте Rotate() в полночь, если формат дневных журналов обязателен. Он ничего не знает о содержимом: не режет спам, не разметит поля, не пришлёт алерт — всё это уровни логгера и сэмплеры zerolog. Наконец, он не координируется с соседними процессами: два сервиса на один Filename сообща будут устраивать гонку за файл — каждому сервису свой путь, это дешёвая дисциплина. Для типового сервиса всё перечисленное не нужно: размерный порог, уборка, сжатие покрывают девять случаев из десяти, а остальное — работа агента на стороне читателя. Честный вывод: lumberjack решает ровно одну задачу — диск не переполнится, — и решает её без движения со стороны zerolog. Проверить связку руками можно за пять минут, и это стоит сделать локально: песочница сайта — тематическая программа на чистом Go, файловые операции и сигналы в ней недоступны. Соберите программу из первого примера с намеренно крошечным лимитом — MaxSize: 1, — закиньте в цикл тысячу записей и посмотрите ls logs/: увидите свежие бэкапы с метками в именах и одиночный app.log, в который пишется прямо сейчас. Затем выполните gzip -t на бэкапе, чтобы убедиться в валидности сжатия, и подождите пару секунд с MaxBackups: 2 — лишние куски исчезнут фоном. На этой же программе проверьте tail -F против tail -f: запустите хвост, сгенерируйте ротацию и посмотрите, кто из них продолжит показывать новые записи. Пятиминутный прогон надолго снимает вопросы «а как оно выглядит».
Две метрики, которые предупредят раньше диска: свободное место в каталоге логов и возраст последней записи в журнале. Добавьте к ним счётчик файлов с меткой времени в имени — всплеск числа бэкапов за час расскажет о внезапном росте потока записей раньше, чем график CPU. Alert ставьте на заполнение каталога процентов на восемьдесят: до полного диска останется запас на реакцию.
Поля lumberjack.Logger: дефолты и смысл#
| Поле | Тип | Дефолт | Что делает |
|---|---|---|---|
| Filename | string | temp-lumberjack.log в os.TempDir() | имя текущего файла журнала |
| MaxSize | int | 100 | порог ротации в мегабайтах |
| MaxBackups | int | 0 — хранить все | сколько старых файлов держать |
| MaxAge | int | 0 — не удалять | предельный возраст бэкапа в днях |
| Compress | bool | false | gzip для переименованных бэкапов |
| LocalTime | bool | false — UTC | зона временной метки в имени бэкапа |
Что дальше по курсу#
Ротация — последний кирпич локального хранения: в уроке про запись в файл лежит базовая связка с os.OpenFile, а здесь она обросла жизненным циклом. Дальше два пути: продовая эксплуатация — что мониторить и куда отдавать записи, разберёт урок про логи на проде, — и экономия потока до writer-а: сэмплеры из урока о прореживании убирают повторяющийся спам так, что ротация успевает дышать. Практический тест на сегодня: соберите сервис из урока с MaxSize: 1 и MaxBackups: 2, прогоните пару тысяч записей и посчитайте занятое место в каталоге. Совпало с формулой MaxSize × (MaxBackups + 1)? Диск теперь под контролем, и это главный результат урока.
Проверка: место lumberjack в zerolog
Как lumberjack подключается к zerolog?
Проверка: MaxBackups: 0
Что означает MaxBackups: 0 в lumberjack.Logger?
Какое имя получит бэкап после ротации?
Прочитайте код и выберите, что он выведет:
lumber := &lumberjack.Logger{
Filename: "app.log",
MaxSize: 1,
}
// файл дописался до порога — запись вызвала ротациюЧто вернёт Write, если запись больше MaxSize?
Прочитайте код и выберите, что он выведет:
lumber := &lumberjack.Logger{Filename: "app.log", MaxSize: 1}
big := make([]byte, 2<<20) // 2 МБ одной записью
_, err := lumber.Write(big)Соберите связку zerolog с lumberjack
Восстановите программу: пакет-путь, структура, пороги и закрытие.
Программа с ротацией
Расставьте строки в правильном порядке: импорты, writer с ротацией, логгер и запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ротация
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Lumberjack — io.Writer с ротацией: передаётся в zerolog.New без единой строки интеграции.
- Ротация происходит на лету при записи: превышение MaxSize — переименовать, открыть новый, писать дальше.
- Дефолты опасны для долгоживущего сервиса: MaxBackups: 0 и MaxAge: 0 хранят архив вечно.
- Бюджет места — примерно MaxSize × (MaxBackups + 1); Compress отыгрывает в разы на текстовых логах.
- Сжатие и удаление бэкапов идут фоново после ротации и не тормозят запись.
- Rotate() принудительно делит журнал — классика SIGHUP; Close закрывает файл при остановке.
- Tail -f отрывается после ротации: следите командой tail -F или агентом, знающим переоткрытие.
- Чего нет: сетевой отправки, дневной ротации и фильтрации спама — это работа агентов и сэмплеров zerolog.
Домашнее задание
Соберите программу урока с MaxSize: 1 и MaxBackups: 2, прогоните цикл из пары тысяч записей и разберите каталог logs: имена бэкапов, число файлов, занятое место против формулы. Включите Compress и повторите — сравните размеры. Затем подайте процессу SIGHUP и убедитесь, что Rotate создал свежий кусок без записей-двойников. Наконец, запустите tail -f и tail -F рядом с ротацией и запишите для себя, какой из них пережил переименование.
Личные заметкиПусто
Вопросы и ответы по уроку
Как настроить ротацию логов zerolog?
Передайте в zerolog.New писателя lumberjack: lumber := &lumberjack.Logger{Filename: "logs/app.log", MaxSize: 10, MaxBackups: 5, MaxAge: 30, Compress: true}. Файл будет делиться на куски по 10 МБ, останутся пять последних, бэкапы старше 30 дней удалятся, а свежие — сожмутся gzip.
Lumberjack или внешний logrotate — что выбрать?
Lumberjack проще: ротация внутри процесса, без конфигов и сигналов, и работает везде, где живёт бинарник. Внешний logrotate берёт верх, когда политика единая для многих сервисов и языков, когда нужен дневной формат журналов или постобработка после ротации — postrotate-скрипты он умеет, lumberjack о внешних сигналах не знает. Их можно совмещать: lumberjack ведёт размер, logrotate остаётся для остальной системы.
Почему после ротации tail -f перестал показывать новые записи?
Tail -f привязан к inode открытого файла, а lumberjack переименовал его и открыл новый под старым именем — хвост остался читать архив. Tail -F переоткрывает файл по имени и переживает ротацию; агенты вроде filebeat и promtail делают то же самое из коробки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила