---
title: "Запись в файл и io.Writer"
description: "Запись логов zerolog в файл: os.OpenFile и флаги O_APPEND, io.MultiWriter для stdout и файла, ошибки в stderr через MultiLevelWriter, чек-лист."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/08-log-fail/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Запись в файл и io.Writer

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

Записать логи в файл на Go с zerolog — значит передать в zerolog.New любой io.Writer: os.OpenFile с O_APPEND и O_CREATE уже готовый приёмник. Разберём флаги и права, io.MultiWriter для stdout и файла одновременно и MultiLevelWriter, чтобы ошибки дублировать в stderr.

У вывода в терминал есть врождённый изъян: он исчезает. Рестарт процесса — и буфер терминала пуст; на сервере без сборщика логов записи уезжают в 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 — та же схема, что разбирали в [уроке про первый лог](/courses/zerolog/lessons/02-pervyj-log/) и в теме контекстных полей. Есть и способ заменить приёмник у уже собранного логгера: метод Output(w) возвращает копию с новым writer-ом — удобно для тестов и для конфигурации по окружению, о которой пойдёт речь в уроке про конфигурацию. Главное правило выбора писателя: writer выбирается один раз при старте процесса, и дальше весь код логирования не знает, куда физически уезжают записи.

### Первый лог в файл

```go
package 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

```go
package 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](/courses/zerolog/lessons/09-rotaciya-lumberjack/) файл начинает делиться на куски по MaxSize, и диск перестаёт быть одноразовым. Выбор writer-а по окружению — dev терминал, prod файл или stdout — собирается при старте и разбирается в [уроке про конфигурацию](/courses/zerolog/lessons/16-konfiguraciya-okrug/). А когда всё настроено и сервис едет в эксплуатацию, чек-лист прода подхватит [урок про логи на проде](/courses/zerolog/lessons/24-logi-na-prode/). Практическое задание на сейчас: соберите программу из урока, запустите её дважды подряд и убедитесь по содержимому app.log, что O_APPEND дописал вторую запись в конец, а не перезаписал файл — на этом флаге держится вся параллельная запись.

### Проверка: дефолт глобального логгера

**Вопрос:** Куда пишет глобальный логгер zerolog/log по умолчанию?

- В os.Stdout
- ✅ В os.Stderr
- В файл app.log рядом с программой
- Никуда, пока не вызван SetOutput

Глобальный логгер собран как zerolog.New(os.Stderr).With().Timestamp().Logger(): stderr — конвенция для диагностического вывода, stdout остаётся чистым для данных программы. Свой приёмник задаётся zerolog.New(writer) или методом Output.

### Проверка: поведение MultiLevelWriter

**Вопрос:** Что делает zerolog.MultiLevelWriter(os.Stdout, f)?

- Пишет info в stdout, а error в файл
- ✅ Дублирует каждую запись обоим приёмникам, как tee
- Пишет в первый приёмник, а при ошибке — во второй
- Чередует приёмники, чтобы делить нагрузку

MultiLevelWriter копирует каждую запись всем переданным приёмникам. Маршрутизацию по уровням делает не он, а приёмники с интерфейсом LevelWriter — например, FilteredLevelWriter с порогом Level.

### Закроется ли файл при Fatal?

```go
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")
```

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

- ✅ Да: zerolog закроет io.Closer перед os.Exit(1)
- Нет: os.Exit пропускает defer, файл останется открытым
- Нет: Fatal паникует вместо выхода
- Да: defer успевает выполниться до os.Exit

defer при os.Exit не срабатывает — но zerolog подстраховался: перед выходом он закрывает writer, если тот реализует io.Closer, и *os.File его реализует. Фатальная запись сначала дописывается в файл, потом файл закрывается.

### Что будет с app.log после второго запуска?

```go
// Первый запуск записал 3 события.
// Второй запуск — та же команда OpenFile:
f, err := os.OpenFile("app.log",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
```

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

- ✅ Дописывает новые записи в конец файла
- Перезаписывает файл с нуля
- Возвращает ошибку: файл уже существует
- Создаёт копию app.log.1 и пишет в неё

O_APPEND ставит каждую запись в конец, O_CREATE ничего не делает, если файл существует, а ошибки «файл уже есть» нет — она появляется только с флагом O_EXCL. Перезапись с нуля случилась бы с O_TRUNC.

### Соберите запись в файл

Восстановите программу: открытие файла с флагами, закрытие, конструктор логгера и событие.

```go
package ___

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        f, err := os.___("app.log", os.O_APPEND|os.O___|os.O_WRONLY, 0o644)
        if err != nil {
                panic(err)
        }
        defer f.___()

        logger := zerolog.___(f).With().Timestamp().Logger()

        logger.___().Msg("backup finished")
}
```

Подсказка: Пакет — main; файл открывает os.OpenFile; из флагов пропущен O_CREATE; файл закрывает Close; логгер строит zerolog.New; штатное событие начинает Info.

Правильные ответы в пропусках: `main`, `OpenFile`, `CREATE`, `Close`, `New`, `Info`

### Программа с tee-писателем

Расставьте строки в правильном порядке: импорты, файл, tee-писатель и запись.

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

1. `package main`
2. `import (`
3. `  "os"`
4. `  "github.com/rs/zerolog"`
5. `)`
6. `func main() {`
7. `  f, _ := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)`
8. `  defer f.Close()`
9. `  writer := zerolog.MultiLevelWriter(os.Stdout, f)`
10. `  logger := zerolog.New(writer).With().Timestamp().Logger()`
11. `  logger.Info().Msg("backup finished")`
12. `}`

### Карточки: файл и io.Writer

- **zerolog.New(w io.Writer)** — Конструктор логгера: каждое событие уходит в w одной JSON-строкой с переводом строки. Файл, stdout, сеть — любой io.Writer.
- **O_APPEND** — Флаг OpenFile: атомарное дописывание в конец на POSIX. Параллельные писатели не рвут строки; без него записи перезаписывают друг друга.
- **io.MultiWriter** — Обёртка stdlib: каждый Write копируется всем приёмникам по очереди. Возвращает первую ошибку и признак короткой записи.
- **LevelWriter** — Интерфейс zerolog: io.Writer плюс WriteLevel(level, p). Приёмник получает уровень события и решает сам, печатать ли его.
- **FilteredLevelWriter** — Приёмник с порогом: WriteLevel пропускает только события уровня Level и выше. Типовое применение — error-события ещё и в stderr.
- **Fatal и закрытие файла** — Перед os.Exit(1) zerolog закрывает writer, если тот io.Closer. Фатальная запись дописывается в файл, файл закрывается, процесс умирает.

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

- 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, но добавляет окно, в котором хвост логов теряется при крахе, — для сервисов это обычно худший размен, а скорость разберите в бенчмарках курса.
