Содержание урока · 10 разделов
У вывода в терминал есть врождённый изъян: он исчезает. Рестарт процесса — и буфер терминала пуст; на сервере без сборщика логов записи уезжают в journald, в контейнере — в docker json-файл, а при локальной отладке демона вы хотите tail -f по своему файлу, а не курение journalctl с фильтрами. Файл логов нужен в трёх типовых сценариях: утилита или демон на машине, где нет системы сбора; локальная разработка, когда записи должны переживать перезапуск; и отдельные журналы со своим жизненным циклом — аудит, доступ, отладочный след. При этом zerolog ничего не знает о файлах: он пишет байты в любой io.Writer, и файл — просто один из приёмников наравне со stdout, сетевым соединением или буфером в тесте. Из этого следует удобное правило: вся специфичность «логов в файл» — это вопросы к os.OpenFile, правам и ротации, а не к самому логгеру. Логгер, собранный в прошлых уроках, не изменится ни на одну строку кода событий — поменяется только writer, который вы ему отдадите при старте.
zerolog в файл: New принимает любой io.Writer#
Контракт у zerolog максимально прост: zerolog.New(w io.Writer) строит логгер, который на каждое событие дописывает в w одну JSON-строку с завершающим переводом строки. Глобальный логгер пакета log по умолчанию собран именно так — zerolog.New(os.Stderr).With().Timestamp().Logger() — и все прошлые уроки писали в stderr, хотя вы могли этого не замечать: терминал показывает оба потока вперемешку. Запись zerolog в файл отличается только первым аргументом: вместо os.Stdout — *os.File из os.OpenFile. Смена приёмника не требует перекомпиляции событий и даже не меняет порядок полей: уровень, время, контекст, поля, message — та же схема, что разбирали в уроке про первый лог и в теме контекстных полей. Есть и способ заменить приёмник у уже собранного логгера: метод Output(w) возвращает копию с новым writer-ом — удобно для тестов и для конфигурации по окружению, о которой пойдёт речь в уроке про конфигурацию. Главное правило выбора писателя: writer выбирается один раз при старте процесса, и дальше весь код логирования не знает, куда физически уезжают записи.
Первый лог в файл
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
f, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
defer f.Close()
logger := zerolog.New(f).With().Timestamp().Logger()
logger.Info().Str("user", "ann").Msg("backup finished")
}
// app.log:
// {"level":"info","time":"2024-04-06T12:00:00+03:00","user":"ann","message":"backup finished"}os.OpenFile открывает app.log на дописывание: O_APPEND ставит каждую запись в конец, O_CREATE создаёт файл при первом запуске, O_WRONLY — только запись. Права 0o644 — классика для журналов. Логгер получает *os.File как обычный io.Writer; каждая JSON-строка уходит в файл отдельным write-вызовом сразу, без буфера между ними.
Флаги OpenFile: O_APPEND, O_CREATE и права#
Тройка флагов из примера выбрана не случайно. O_APPEND — самый важный: на POSIX каждая запись атомарно дописывается в конец файла даже при параллельных писателях, поэтому два процесса или горутины не порвут строки друг друга; без него писатели перезаписывают друг друга с текущего смещения. O_CREATE избавляет от отдельного шага «сначала создай файл»: при первом запуске файл появится сам — но не каталог, и это классическая ошибка первых дней, поэтому каталог под логи создают заранее os.MkdirAll. O_WRONLY достаточно, потому что логгер только пишет; читать файл будет tail или сборщик. Права 0o644 — владелец пишет, остальные читают; если в логах персональные данные, каталог с логами стоит закрыть сильнее — 0o750, чтобы посторонние пользователи не читали дампы событий. Учтите и umask процесса: права при создании режутся им, и на паранойе о доступе это место проверяют первыми. Для сравнения: O_TRUNC перезаписал бы файл с нуля при каждом запуске, а O_EXCL вместе с O_CREATE превратил бы повторный запуск в ошибку «файл уже существует» — оба флага журналу не нужны, но знать их стоит, чтобы не подставить случайно. Отдельный вопрос — устойчивость к падению питания: обычный write переносит данные в кэш ядра, и процесс их уже не потеряет, но при обесточивании машины хвост может пропасть; флаг os.O_SYNC заставляет ядро доводить каждую запись до физического завершения — ценой кратного замедления записи, для бизнес-логов это почти всегда избыточно. И проверяйте ошибку OpenFile без вариантов: несуществующий каталог, занятый файл, испорченные права — всё это вернётся ошибкой при старте, и честнее упасть сразу, чем молчать без логов весь сезон. Ошибка записи уже после старта — отдельная и более коварная история, до неё дойдём ниже.
Кто закрывает файл — вопрос с двумя ответами. Штатный: defer f.Close() в main — файл закроется при нормальном завершении. Аварийный интереснее: zerolog.Fatal на записи вызывает os.Exit(1), а defer при выходе из процесса не срабатывает — но zerolog подстраховался: перед выходом он закрывает writer, если тот реализует io.Closer, и os.File реализует. Это гарантия из исходников: сообщение фатальной записи сначала уедет в файл, потом файл закроется, потом процесс умрёт. Про буферизацию знать обязательно: между буфером события и файлом в zerolog ничего нет — каждая запись завершается системным вызовом write, поэтому хвост лога виден в tail -f мгновенно, а цена записи включает syscall. Если на больших объёмах захочется буфер, помните цену: буфер добавляет окно потерь при падении процесса. В экосистеме zerolog для этого есть diode.Writer и связка с буферизацией, но для типового сервиса прямой write в файл — нормальная точка отсчёта, а скорость записи логов мы считаем в уроке про производительность. Бонус к спокойствию: файл сам по себе уже потокобезопасен — системный вызов write на POSIX и Windows сериализует параллельные вызовы, поэтому SyncWriter для os.File не нужен: это оговорка из исходников zerolog, и она снимает вопрос, который иначе всплывёт на первом же ревью горутин. Любопытная деталь того же API: сам zerolog.Logger реализует io.Writer — метод Write обрезает хвостовой перевод строки и заворачивает полученное в одно событие без уровня. В исходниках это названо подсказкой для стандартного пакета log: log.SetOutput(logger) превращает вывод обычного log в JSON-события zerolog — приём для легаси-кодовой базы, где log.Printf стоит на каждом шагу.
Чек-лист записи в файл#
- флаги O_APPEND|O_CREATE|O_WRONLY и права 0o644 — стартовый набор для журнала
- ошибку OpenFile проверяйте всегда: нет каталога — нет логов, и узнаете вы это через месяц
- каталог под логи создавайте os.MkdirAll при старте, до первого OpenFile
- для журналов с персональными данными закрывайте каталог правами 0o750
- defer f.Close() в main; Fatal zerolog закроет io.Closer сам перед os.Exit(1)
- без ротации файл растёт вечно — бюджет диска считают заранее, до инцидента
- в контейнере сначала оцените stdout: сборщик может читать его из коробки, и файл не нужен
io.MultiWriter: stdout и файл одновременно#
Переходные периоды почти всегда требуют дублировать: сервис едет в контейнер, где stdout собирает платформа, а старая схема дежурства смотрит в файл на диске. Для этого в стандартной библиотеке есть io.MultiWriter(os.Stdout, f): обёртка, которая каждый Write копирует во все приёмники по очереди. Логгеру всё равно — MultiWriter реализует io.Writer, — и каждая JSON-строка появится и в терминале, и в файле. Две детали поведения. Первая: записи идут последовательно в каждом writer-е, поэтому цена удваивается вместе с write-вызовами — для постоянной работы это лишний syscall, для перехода — норм. Вторая: MultiWriter возвращает первую встреченную ошибку и признак короткой записи, так что падение одного приёмника видно логгеру — правда, zerolog отвечает на неё только строкой диагностики в stderr, и рассчитывать на неё как на полноценный сигнал не стоит. Дублирование с фильтром по уровню — задача посложнее, и там начинается zerolog-овская надстройка над tee: MultiLevelWriter.
Дублируем всё, ошибки — ещё и в stderr
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
f, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
defer f.Close()
errToStderr := &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{os.Stderr},
Level: zerolog.ErrorLevel,
}
writer := zerolog.MultiLevelWriter(os.Stdout, f, errToStderr)
logger := zerolog.New(writer).With().Timestamp().Logger()
logger.Info().Msg("tick")
logger.Error().Msg("disk pressure")
}
// stdout и app.log:
// {"level":"info","time":"...","message":"tick"}
// {"level":"error","time":"...","message":"disk pressure"}
// stderr — только error и выше:
// {"level":"error","time":"...","message":"disk pressure"}MultiLevelWriter дублирует каждую запись всем приёмникам, как tee. Ошибки маршрутизирует FilteredLevelWriter: у него свой Writer и порог Level, и WriteLevel пропускает только события уровня и выше — здесь error, fatal, panic едут ещё и в stderr. Адаптер LevelWriterAdapter превращает обычный io.Writer в приёмник, принимающий уровень.
MultiLevelWriter: ошибки ещё и в stderr#
zerolog.MultiLevelWriter(writers...) — это tee из экосистемы zerolog: дублирует записи всем, кто передан, и для приёмников, реализующих интерфейс LevelWriter, вызывает метод WriteLevel — writer получает событие вместе с уровнем и может решать сам, печатать или нет. На этой паре строится типовой сценарий «stderr для ошибок»: os.Stderr обёрнут в FilteredLevelWriter с порогом ErrorLevel и добавлен к основному приёмнику — теперь каждая ошибка сервиса видна в journalctl и в платформенном сборщике stderr, без grep по файлу. Зачем это нужно: системы, которые ловят stderr отдельно от файлов, живут повсюду — systemd, supervisor, docker — и дежурный смотрит именно туда. Практическое правило из документации: уровень самого логгера ставьте на минимум из порогов приёмников, иначе события отфильтруются до того, как доберутся до писателей, и маршрутизация не сработает. Тот же контракт используют ConsoleWriter и lumberjack: приёмники с WriteLevel — единый язык, на котором говорит вся карта вывода zerolog.
Худшая часть истории записи в файл: ошибка записи не возвращает вам событие. Файл упёрся в квоту, диск забил соседний сервис, NFS отвалился — write вернул ошибку, zerolog потерял запись, а в stderr уйдёт одна строка «zerolog: could not write event: ...» — по одной на каждое погибшее событие, и это максимум реакции библиотеки (если задан zerolog.ErrorHandler, вместо строки вызовется он). Реальный кейс: сервис два месяца писал на диск, заполненный на 100%, и никто не заметил — графики бизнес-метрик были зелёными. Лечится мониторингом места и возраста лог-файла, а не надеждой на диагностику логгера: следите за размером каталога и свежестью последней записи, это две метрики вместо расследования.
Браузерная песочница не умеет файловый ввод-вывод, поэтому OpenFile в ней не исполнить: там ждёт тематическая программа на чистом Go, которая собирает событие в буфер — механику writer-а видно и на ней. Настоящий файл проверяйте локально: go mod init demo, go get github.com/rs/zerolog/log, go run — и tail -f app.log во втором терминале, чтобы видеть строки живьём.
Приёмники логов zerolog: что выбрать#
| Приёмник | Что получает | Сценарий |
|---|---|---|
| os.Stdout | все события потоком | контейнеры и системы, читающие stdout |
| os.Stderr | все события потоком | дефолт глобального логгера zerolog/log |
| *os.File через OpenFile | все события в файл | сервер без сборщика, локальная отладка |
| io.MultiWriter | копия каждой записи всем | переходный период: stdout и файл |
| MultiLevelWriter + FilteredLevelWriter | дублирование плюс порог по уровню | ошибки ещё и в stderr для journald |
| lumberjack.Logger | файл с авто-ротацией | долгоживущий сервис на диске |
Что дальше по курсу#
Файл без ротации — это отложенный инцидент: в уроке про lumberjack файл начинает делиться на куски по MaxSize, и диск перестаёт быть одноразовым. Выбор writer-а по окружению — dev терминал, prod файл или stdout — собирается при старте и разбирается в уроке про конфигурацию. А когда всё настроено и сервис едет в эксплуатацию, чек-лист прода подхватит урок про логи на проде. Практическое задание на сейчас: соберите программу из урока, запустите её дважды подряд и убедитесь по содержимому app.log, что O_APPEND дописал вторую запись в конец, а не перезаписал файл — на этом флаге держится вся параллельная запись.
Проверка: дефолт глобального логгера
Куда пишет глобальный логгер zerolog/log по умолчанию?
Проверка: поведение MultiLevelWriter
Что делает zerolog.MultiLevelWriter(os.Stdout, f)?
Закроется ли файл при Fatal?
Прочитайте код и выберите, что он выведет:
f, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
defer f.Close()
logger := zerolog.New(f).With().Timestamp().Logger()
logger.Fatal().Msg("unrecoverable")Что будет с app.log после второго запуска?
Прочитайте код и выберите, что он выведет:
// Первый запуск записал 3 события.
// Второй запуск — та же команда OpenFile:
f, err := os.OpenFile("app.log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)Соберите запись в файл
Восстановите программу: открытие файла с флагами, закрытие, конструктор логгера и событие.
Программа с tee-писателем
Расставьте строки в правильном порядке: импорты, файл, tee-писатель и запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: файл и io.Writer
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Zerolog пишет в любой io.Writer: файл — это os.OpenFile, отданный в zerolog.New.
- Стартовый набор флагов: O_APPEND|O_CREATE|O_WRONLY и права 0o644; каталог создаёт MkdirAll.
- O_APPEND даёт атомарное дописывание в конец — параллельные писатели не рвут строки.
- Буфера между событием и файлом нет: каждая запись — write-вызов, tail -f видит строки мгновенно.
- io.MultiWriter дублирует записи stdout и файлу — приём для переходных периодов.
- MultiLevelWriter с FilteredLevelWriter{Level: ErrorLevel} отправляет ошибки ещё и в stderr.
- Ошибки записи zerolog не репортит: место на диске и свежесть лога мониторят снаружи.
- Fatal закрывает io.Closer-писатель перед os.Exit(1) — фатальная запись не теряется.
Домашнее задание
Соберите программу урока и запустите дважды: убедитесь, что обе записи в app.log, вторая дописана в конец. Затем замените writer на io.MultiWriter(os.Stdout, f) и проверьте дубль в терминале. Добавьте третий приёмник — FilteredLevelWriter с порогом WarnLevel на stderr — и подтвердите, что warn и error видны в stderr, а info нет. Наконец, уберите O_APPEND и запустите два раза: сравните файл и объясните разницу своими словами.
Личные заметкиПусто
Вопросы и ответы по уроку
Как записать логи zerolog в файл?
Откройте файл os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) и передайте его в zerolog.New(f).With().Timestamp().Logger(). Каждая JSON-строка будет дописываться в конец сразу, без буфера. Для долгоживущих сервисов добавьте ротацию lumberjack.
Можно ли писать и в stdout, и в файл одновременно?
Да: io.MultiWriter(os.Stdout, f) реализует io.Writer и дублирует каждую запись обоим приёмникам. Если нужно дублировать выборочно — только ошибки в stderr — берите zerolog.MultiLevelWriter вместе с FilteredLevelWriter, у которого есть порог по уровню.
Почему zerolog не буферизует запись в файл?
Прямой write после каждого события даёт мгновенную видимость в tail -f и ноль потерь при падении процесса: запись либо уехала в файл, либо её не было. Буфер экономит syscalls, но добавляет окно, в котором хвост логов теряется при крахе, — для сервисов это обычно худший размен, а скорость разберите в бенчмарках курса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила