---
title: "Ротация: lumberjack рядом с zerolog"
description: "Ротация логов в Go: lumberjack рядом с zerolog — MaxSize, MaxBackups, MaxAge, Compress; имена бэкапов в UTC, Rotate по SIGHUP и бюджет диска."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/09-rotaciya-lumberjack/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Ротация: lumberjack рядом с zerolog

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 9 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/09-rotaciya-lumberjack/*

Ротация логов на Go рядом с zerolog не требует интеграции: lumberjack — это io.Writer, который сам следит за размером файла и делит его на куски. Разберём связку lumberjack zerolog: MaxSize, MaxBackups и MaxAge, фоновое сжатие, принудительную ротацию по SIGHUP и бюджет диска до инцидента.

У файла из прошлого урока есть свойство, которое не заметно в первый месяц: он растёт. Сервис с ровным потоком в пару мегабайт в час за год отложит на диск пятнадцать-двадцать гигабайт, и закончиться это может в самый неподходящий момент — в ночь на чёрную пятницу, когда логи как раз нужны. Классическое решение на уровне системы — демон logrotate: раз в сутки (или по размеру) он переименовывает файл, сжимает старые копии и просит сервис переоткрыть журнал. Работает, но требует конфига вне кода, прав на перезапуск сервисов и аккуратности с переоткрытием. Есть решение проще — держать ротацию в руках самого процесса: библиотека lumberjack встраивается в Go-программу и превращает файл логов в самоподдерживающееся кольцо: переполнился — переименовался, открылся заново, старые куски сжал и почистил. Для zerolog интеграции нет, потому что она не нужна: lumberjack — это io.Writer, и передаётся он в zerolog.New как обычный файл — прямое продолжение приёма из [урока про запись в файл](/courses/zerolog/lessons/08-log-fail/). Ни одной строки кода логирования менять не придётся — меняется только 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: вся настройка

```go
package 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](/courses/zerolog/lessons/14-sampling/) режет повторяющиеся записи до того, как они доедут до writer-а, и ротация становится техникой, а не спасением. Худшее, что может случиться без этой арифметики, — не переполнение диска само по себе, а то, что вместе с логами место кончится у базы данных, живущей на той же машине.

> **Живое место: tail -f, который оторвался.** Дежурный следит за логами командой 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

```go
package 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, а здесь она обросла жизненным циклом. Дальше два пути: продовая эксплуатация — что мониторить и куда отдавать записи, разберёт [урок про логи на проде](/courses/zerolog/lessons/24-logi-na-prode/), — и экономия потока до writer-а: сэмплеры из урока о прореживании убирают повторяющийся спам так, что ротация успевает дышать. Практический тест на сегодня: соберите сервис из урока с MaxSize: 1 и MaxBackups: 2, прогоните пару тысяч записей и посчитайте занятое место в каталоге. Совпало с формулой MaxSize × (MaxBackups + 1)? Диск теперь под контролем, и это главный результат урока.

### Проверка: место lumberjack в zerolog

**Вопрос:** Как lumberjack подключается к zerolog?

- Через zerolog.SetOutput(lumberjack)
- ✅ Как обычный io.Writer в zerolog.New
- Через отдельный конструктор zerolog.NewFile
- Через плагин в ConsoleWriter

Интеграции как таковой нет: lumberjack.Logger реализует io.Writer, поэтому передаётся в zerolog.New напрямую, как файл из прошлого урока. Zerolog не знает о ротации — он просто пишет байты.

### Проверка: MaxBackups: 0

**Вопрос:** Что означает MaxBackups: 0 в lumberjack.Logger?

- Ротация выключена
- ✅ Старые файлы хранятся все, без ограничения
- Бэкапы удаляются сразу после ротации
- Хранится ровно один бэкап

Дефолтный ноль значит «не ограничивать число бэкапов» — вместе с MaxAge: 0 это бесконечный рост архива. Поэтому для любого долгоживущего сервиса оба порога задают явно.

### Какое имя получит бэкап после ротации?

```go
lumber := &lumberjack.Logger{
        Filename: "app.log",
        MaxSize:  1,
}

// файл дописался до порога — запись вызвала ротацию
```

**Что выведет программа?**

- ✅ app-2024-04-06T12-00-00.000.log
- app.log.1
- app-2024-04-06T12-00-00.000.log.gz
- app.log.bak

Бэкап переименовывается в имя с меткой времени: префикс — прежнее имя без расширения, суффикс — время в UTC. Сжатый вариант с .gz появится позже, когда фоновая горутина догонит, — сразу после ротации файл лежит сырым.

### Что вернёт Write, если запись больше MaxSize?

```go
lumber := &lumberjack.Logger{Filename: "app.log", MaxSize: 1}

big := make([]byte, 2<<20) // 2 МБ одной записью

_, err := lumber.Write(big)
```

**Что выведет программа?**

- ✅ Ошибку: write length exceeds maximum file size
- Ротацию и успешную запись в новый файл
- Молчаливую усечённую запись
- Панику внутри lumberjack

Запись, которая сама больше порога, не влезает ни в один файл, и lumberjack возвращает ошибку, не пиша ничего. Строки zerolog всегда короткие, но один гигантский RawJSON-поле способен поймать эту ошибку.

### Соберите связку zerolog с lumberjack

Восстановите программу: пакет-путь, структура, пороги и закрытие.

```go
package ___

import (
        "github.com/rs/zerolog"
        "gopkg.in/natefinch/___.v2"
)

func main() {
        lumber := &___.___{
                Filename:   "logs/app.log",
                ___:        10,
                MaxBackups: 5,
                ___:        30,
                Compress:   true,
        }
        defer lumber.___()

        logger := zerolog.New(lumber).With().Timestamp().Logger()
        logger.Info().Msg("job done")
}
```

Подсказка: Пакет — main; импортный путь — gopkg.in/natefinch/lumberjack.v2; тип — lumberjack.Logger; размер в мегабайтах задаёт MaxSize, возраст в днях — MaxAge; закрытие — Close.

Правильные ответы в пропусках: `main`, `lumberjack`, `lumberjack`, `Logger`, `MaxSize`, `MaxAge`, `Close`

### Программа с ротацией

Расставьте строки в правильном порядке: импорты, writer с ротацией, логгер и запись.

Правильный порядок строк:

1. `package main`
2. `import (`
3. `  "github.com/rs/zerolog"`
4. `  "gopkg.in/natefinch/lumberjack.v2"`
5. `)`
6. `func main() {`
7. `  lumber := &lumberjack.Logger{Filename: "logs/app.log", MaxSize: 10}`
8. `  defer lumber.Close()`
9. `  logger := zerolog.New(lumber).With().Timestamp().Logger()`
10. `  logger.Info().Msg("job done")`
11. `}`

### Карточки: ротация

- **lumberjack.Logger** — io.Writer с встроенной ротацией: порог размера, число и возраст бэкапов, gzip. Передаётся в zerolog.New как обычный writer.
- **MaxSize** — Порог ротации в мегабайтах, дефолт 100. При превышении файл переименовывается и открывается новый; запись больше порога вернёт ошибку.
- **MaxBackups / MaxAge** — Сколько бэкапов хранить и предельный возраст в днях. Нули — дефолты «хранить всё»: для сервиса задают оба, иначе архив растёт вечно.
- **Compress** — gzip для переименованных бэкапов, дефолт false. Сжатие идёт фоновой горутиной после ротации, текущий файл всегда сырой.
- **Rotate()** — Принудительная ротация: закрыть, переименовать, открыть заново. Типовой повод — SIGHUP от внешнего logrotate или скрипта.
- **Имя бэкапа** — Префикс — имя журнала, суффикс — метка времени: app-2024-04-06T12-00-00.000.log. Зона — UTC по умолчанию, LocalTime: true — локальная.

## Коротко: что запомнить

- 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 делают то же самое из коробки.
