---
title: "Куда пишем: stdout, файлы и мультиплексирование"
description: "zap запись в файл и в stdout: WriteSyncer, AddSync, os.OpenFile, NewMultiWriteSyncer, ErrorOutputPaths и буферизация — с примерами и разбором флагов."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/08-vyvody-vhodnye/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Куда пишем: stdout, файлы и мультиплексирование

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

zap запись в файл — первое, что стоит настроить после ночной смены, на которой выяснилось, что kubectl logs помнит только текущий запуск контейнера: контейнер пересоздали, и половина улик ушла вместе с его stdout. Разберём, к чему zap пишет на самом деле, как подключить файл правильными флагами, как раздавать записи двум приёмникам сразу и где теряется хвост лога.

Начну с этой ночи, потому что она объясняет весь урок. Платёжный сервис отдаёт пятисотые, вы открываете логи пода — видите только то, что написано с последнего рестарта. Оркестратор пересоздал контейнер полчаса назад, stdout умер вместе с контейнером, и всё, что сервис писал неделю до этого, для вас больше не существует. Пока логи живут только в потоке, их судьба совпадает с судьбой процесса: умер процесс — умерли записи. Лечение двухступенчатое: zap запись в файл, который переживает рестарт, и по возможности дублирование в stdout, чтобы не сломать систему сбора. Это не история про большие кластеры: docker restart на голой машине даёт тот же эффект — docker logs после рестарта начинает с нуля. В этом уроке спустимся на уровень ниже пресетов и соберём приёмники руками: от os.OpenFile до мультиплексора и буфера с его ловушкой.

### zapcore.WriteSyncer: к чему zap пишет на самом деле

Пресеты из прошлых уроков прятали одну деталь, о которой теперь пора поговорить: под NewProduction лежит пакет zapcore, и место назначения логов там описано интерфейсом zapcore.WriteSyncer. Интерфейс крошечный: метод Write, унаследованный от io.Writer, плюс собственный Sync — сброс буферов на диск. Из этого следует простое правило: всё, что умеет Write и Sync, годится в приёмник логов — os.Stdout, открытый файл, сетевое соединение, тестовый буфер в памяти. Когда вы пишете в zap.Config OutputPaths: ["stdout"], zap сам открывает поток и оборачивает его в WriteSyncer; когда нужен контроль — конкретные флаги файла, два приёмника сразу, буфер, — вы собираете приёмник вручную и передаёте его в zapcore.NewCore. Для обычного io.Writer, у которого Sync нет, есть обёртка zapcore.AddSync: она добавит пустой Sync, и любой writer станет годным приёмником. Эта связка — входная дверь во всё остальное из урока.

### Файл как приёмник: os.OpenFile и zapcore.AddSync

```go
package main

import (
        "log"
        "os"

        "go.uber.org/zap"
        "go.uber.org/zap/zapcore"
)

func main() {
        file, err := os.OpenFile(
                "app.log",
                os.O_APPEND|os.O_CREATE|os.O_WRONLY,
                0o644,
        )
        if err != nil {
                log.Fatal(err)
        }

        sink := zapcore.AddSync(file)

        core := zapcore.NewCore(
                zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
                sink,
                zapcore.InfoLevel,
        )
        logger := zap.New(core)
        defer logger.Sync()

        logger.Info("started", zap.Int("port", 8080))
}
```

os.OpenFile открывает app.log в режиме дозаписи; 0o644 — права: владелец читает и пишет, остальные только читают. zapcore.AddSync оборачивает файл в WriteSyncer. Дальше — сборка руками: JSON-энкодер с production-настройками, приёмник, уровень Info — ровно то, что пресет делает за вас, только теперь каждый винтик ваш. Каждый запуск дописывает строки в конец: перезапускали сервис десять раз — лог хранит все десять запусков. Запускать примеры этого урока нужно локально: в песочнице сайта сторонних модулей нет.

### Флаги os.OpenFile и права на файл

Тройка флагов из примера — де-факто стандарт для логов, и каждый флаг там по делу. O_CREATE создаёт файл, если его ещё нет: при первом старте сервиса app.log появится сам. O_WRONLY открывает только на запись: логгер читать из файла не обязан, лишние права процессу не нужны. O_APPEND — главный флаг: перед каждой записью курсор ставится в конец файла, поэтому дозапись не перетирает историю, а при параллельных писателях — двух процессах, пишущих в один лог, — строки не смешиваются в байтовом месиве. Права 0o644 означают: владелец читает и пишет, группа и остальные — только читают. На практике OpenFile передают 0o666, а umask системы сама срезает лишнее до привычных 0644 — обе формы встречаются в коде и обе годны. Две грабли рядом: os.OpenFile не создаёт каталоги — если app/logs не существует, получите ошибку на ровном месте, так что MkdirAll делает запускающий скрипт или сама программа заранее; и владелец файла — тот пользователь, под которым живёт сервис, что в контейнерах с непривилегированным юзером стоит проверять до первого инцидента.

> **O_TRUNC стирает лог при каждом старте.** Поставьте os.O_TRUNC вместо O_APPEND — и каждый рестарт сервиса обнулит app.log: «чистка логов» станет автоматической, а расследование — невозможным. Хуже вариант вообще без O_APPEND и O_TRUNC: запись пойдёт с нулевой позиции, поверх старых байтов, и вы получите кашу из двух запусков. Для логов осознанный минимум — O_APPEND|O_CREATE|O_WRONLY; O_TRUNC и O_RDWR в этой компании не нужны.

### zapcore.NewMultiWriteSyncer: запись zap в файл и stdout

Чаще всего файл нужен не вместо stdout, а вместе с ним: в Kubernetes агрегатор собирает логи именно с потока контейнера, а файл полезен как локальная страховка и для утилит вне кластера. Решение — zapcore.NewMultiWriteSyncer: он принимает любое число WriteSyncer-ов и возвращает один составной приёмник, который раздаёт каждую запись всем перечисленным сразу. Это и есть multiplex вывод логов: одна строка кода превращает логгер из пишущей машинки в вещатель. Записи уходят приёмникам по очереди в том порядке, как вы их перечислили; сбой одного не отменяет остальные — наверх вернётся объединённый результат, а zap доложит о проблеме в ErrorOutputPaths, о которых ниже. Один нюанс стоит запомнить: приёмники пишут последовательно, и если файл живёт на медленном диске, притормаживать будет и stdout-ветка — общий путь записи делит общую судьбу.

### Логгер в stdout и файл одновременно

```go
package main

import (
        "log"
        "os"

        "go.uber.org/zap"
        "go.uber.org/zap/zapcore"
)

func main() {
        file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
        if err != nil {
                log.Fatal(err)
        }

        multi := zapcore.NewMultiWriteSyncer(
                zapcore.AddSync(os.Stdout),
                zapcore.AddSync(file),
        )

        core := zapcore.NewCore(
                zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
                multi,
                zapcore.InfoLevel,
        )
        logger := zap.New(core)
        defer logger.Sync()

        logger.Info("payment processed", zap.String("order_id", "A-1024"))
}
```

Одна запись уходит в оба места: NewMultiWriteSyncer раздаёт её stdout и файлу. Терминал живой при локальной отладке, файл копится для архива или системы сбора. Ошибка записи в один приёмник не отменяет запись в другой — zap вернёт объединённую ошибку и напишет жалобу в ErrorOutputPaths. Для запуска нужен локальный Go с установленным go.uber.org/zap.

### Куда какой поток: расклад по приёмникам

| Поток | Что туда пишем | Кто обычно читает |
| --- | --- | --- |
| stdout | штатные события Info и Warn — полный поток записей | агрегатор в кластере, docker logs, ваш терминал |
| stderr | ошибки приложения по конвенции Unix: stdout — данные, stderr — диагностика | дежурный и алерты; supervisor и systemd собирают его отдельно |
| файл app.log | полный поток или важные уровни — архив, который переживёт рестарт | система сбора с файлового хвоста, инженер задним числом |
| ErrorOutputPaths | жалобы самого zap: приёмник не открылся, диск переполнен | вы — когда разбираетесь, почему логи внезапно кончились |

### ErrorOutputPaths: куда zap жалуется сам на себя

В zap.Config, которую мы разбирали в уроке про [конфигурацию логгера](/courses/zap/lessons/07-zap-config/), есть два путевых поля, и их путают регулярно. OutputPaths — куда идут записи логов: stdout, файл, сокет. ErrorOutputPaths — куда zap сообщает о собственных проблемах: приёмник не открылся при старте, диск переполнился на середине записи, энкодер не справился со значением. По умолчанию это stderr — и это не случайность, а та самая конвенция Unix, по которой штатный поток и поток диагностических сообщений разделяются: сборщик логов не должен захлёбываться жалобами логгера. Логика zap такая: собственный сбой записи не должен ронять сервис — процесс не паникует, продолжает работать и честно докладывает в отдельный канал. Практика из жизни: когда логи «внезапно кончились», первым делом заглядывайте в ErrorOutputPaths — часто выясняется, что записи шли в никуда уже час, потому что файл отвалился, а никто не смотрел stderr. Совет вдогонку: менять умолчание стоит только осознанно — направив жалобы zap в отдельный файл, вы получите журнал отказов приёмника, по которому видна история диска, а не только текущая беда.

### Проверка: WriteSyncer

**Вопрос:** Что требует от приёмника интерфейс zapcore.WriteSyncer?

- Только метод Write — как io.Writer
- ✅ Write и Sync: писать и уметь сбрасывать буферы
- Open, Write и Close — полный набор файловой операции
- Только Sync — записи идут через поля

WriteSyncer — это io.Writer плюс Sync. У обычного io.Writer метода Sync нет, поэтому его оборачивают в zapcore.AddSync, добавляющий пустой сброс.

### Что в stdout, что в файл: осознанный выбор

Дефолт облачной эпохи — писать всё в stdout и отдавать сбор на откуп платформе: так советуют двенадцать факторов, так устроены сборщики в кластерах, и для сервиса в Kubernetes это правильный ответ по умолчанию. Файл берут, когда системы сбора нет: голый VPS, утилита на машине заказчика, локальный запуск, где нужен хвост для tail -f. Мультиплексирование обоих приёмников — типичный костыль переходного периода: пока агрегатор не проверен на прочность, файл остаётся страховкой. Спорное мнение, которое я защищаю на ревью: не размножайте записи «на всякий случай». Дублирование всего потока во все приёмники удваивает объём, удваивает цену и создаёт два источника правды, которые обязательно разойдутся в часах при разборе. Лучше выбрать основное место назначения и осознанно ограничить второй: например, в файл идёт только Warn и выше. Разводить потоки по уровням умеет связка нескольких ядер — это уже территория zapcore, [разберём её отдельно](/courses/zap/lessons/10-zapcore/).

### Проверка: ErrorOutputPaths

**Вопрос:** Куда по умолчанию zap сообщает о собственных сбоях записи — например, о переполненном диске?

- В основной поток логов — следующей записью
- Никуда: молча пропускает запись и живёт дальше
- ✅ В ErrorOutputPaths — по умолчанию это stderr
- Паникует и завершает процесс

Собственные сбои zap докладывает в ErrorOutputPaths, по умолчанию — stderr. Процесс при этом не падает: запись в основной приёмник не удалась, но логгер жив.

### Буферизация: скорость против потерянного хвоста

Запись в файл на каждой строке — системный вызов на каждое событие, и на тысячах записей в секунду эта цена видна в профиле. Лекарство известно давно: bufio.Writer между zap и файлом — записи копятся в памяти и уходят на диск пачками, число системных вызовов падает на порядки. Обратная сторона, о которой туториалы молчат: всё, что оставалось в буфере на момент краха, на диске не появится. Graceful shutdown спасает — defer с Flush сработает; kill -9, OOM-killer или забытый os.Exit — нет: буфер умирает вместе с процессом, и последние записи не дойдут никуда. Для сервисов, где каждая запись — улика, я эту игру не играю: честная запись без буфера, а ускорение — только после профилирования, которое покажет, что файловая запись вообще узкое место. Буфер — осознанный компромисс с известной ценой, а не дефолт из туториала. Случай из практики: в одном сервисе буфер на 256 килобайт приехал из чужого шаблона, а Flush звали по таймеру раз в тридцать секунд; после каждого краша команда теряла последние полминуты логов — ровно тот отрезок, ради которого логи и открывают. Размер буфера, частоту Flush и цену потери нужно назвать вслух до того, как bufio появится в коде.

### Буферизованный приёмник — с обязательным Flush

```go
package main

import (
        "bufio"
        "log"
        "os"

        "go.uber.org/zap"
        "go.uber.org/zap/zapcore"
)

func main() {
        file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
        if err != nil {
                log.Fatal(err)
        }

        buffered := bufio.NewWriterSize(file, 64*1024)
        core := zapcore.NewCore(
                zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
                zapcore.AddSync(buffered),
                zapcore.InfoLevel,
        )
        logger := zap.New(core)

        defer func() {
                if err := buffered.Flush(); err != nil {
                        log.Printf("flush: %v", err)
                }
        }()

        logger.Info("buffered writer attached")
}
```

bufio.NewWriterSize прячет файл за буфером 64 КБ: записи копятся в памяти и уезжают на диск одной пачкой — системных вызовов на порядок меньше. Цена — окно потери: всё, что ещё в буфере, пропадёт при крахе. Flush обязателен: defer с явным вызовом и проверкой ошибки — минимум, без которого затея не имеет смысла.

### Что окажется в app.log после открытия?

```go
// в app.log уже 100 строк прошлого запуска
file, err := os.OpenFile("app.log",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
```

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

- Пустой файл — флаги стёрли содержимое
- ✅ Прежние 100 строк на месте: O_APPEND дозапишет новые в конец
- Ошибка: файл уже существует, O_CREATE не нужен
- Файл откроется только на чтение

Ничего не стирается: O_TRUNC в наборе флагов нет, O_CREATE срабатывает только при отсутствии файла, а O_APPEND ставит курсор в конец перед каждой записью. История прошлого запуска сохраняется.

### Сколько записей дойдёт до файла?

```go
buffered := bufio.NewWriterSize(file, 64*1024)
logger := zap.New(zapcore.NewCore(enc, zapcore.AddSync(buffered), zapcore.InfoLevel))
logger.Info("first")
logger.Info("second")
os.Exit(1)
```

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

- Обе записи: буфер сам сбрасывается на выходе
- Только first: second осталась в буфере
- ✅ Ни одной: обе остались в буфере, os.Exit обходит defer и Flush
- Программа упадёт с паникой на Flush

Буфер на 64 КБ ещё далёк от заполнения, Flush никто не вызывал, а os.Exit завершает процесс немедленно, минуя defer. На диске — пусто: обе записи остались в памяти буфера и умерли вместе с процессом.

### Заполните пропуски в монтаже приёмников

Восстановите фрагмент внутри func main: открытие файла с флагом только-на-запись, обёртка и сборка двух приёмников в один.

```go
package main

import (
        "log"
        "os"

        "go.uber.org/zap"
        "go.uber.org/zap/zapcore"
)

func main() {
        file, err := os.___("app.log", os.O_APPEND|os.O_CREATE|os.O___, 0o644)
        if err != nil {
                log.Fatal(err)
        }

        multi := zapcore.___(
                zapcore.AddSync(os.Stdout),
                zapcore.___(file),
        )

        core := zapcore.NewCore(
                zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
                multi,
                zapcore.InfoLevel,
        )
        logger := zap.New(core)
        defer logger.Sync()

        logger.Info("started", zap.Int("port", 8080))
}
```

Подсказка: Файл открывает os.OpenFile; флаг только-на-запись — O_WRONLY; составной приёмник собирает NewMultiWriteSyncer; обычный io.Writer в WriteSyncer оборачивает AddSync.

Правильные ответы в пропусках: `OpenFile`, `WRONLY`, `NewMultiWriteSyncer`, `AddSync`

### Соберите монтаж приёмников

Расставьте строки фрагмента внутри func main — от открытия файла до готового логгера.

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

1. `file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)`
2. `sink := zapcore.AddSync(file)`
3. `stdout := zapcore.AddSync(os.Stdout)`
4. `multi := zapcore.NewMultiWriteSyncer(stdout, sink)`
5. `core := zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), multi, zapcore.InfoLevel)`
6. `logger := zap.New(core)`

### Карточки: приёмники zap

- **zapcore.WriteSyncer** — Интерфейс приёмника zap: Write из io.Writer плюс Sync. Всё, что умеет эти два метода, годится как место назначения логов.
- **zapcore.AddSync** — Обёртка io.Writer в WriteSyncer: добавляет пустой Sync, чтобы файл, stdout или буфер подошли ядру zapcore.
- **zapcore.NewMultiWriteSyncer** — Составной приёмник: раздаёт каждую запись всем перечисленным WriteSyncer-ам — stdout и файл одновременно.
- **O_APPEND|O_CREATE|O_WRONLY** — Стандартная тройка флагов os.OpenFile для логов: дозапись в конец, создать при отсутствии, только запись. Без O_TRUNC.
- **ErrorOutputPaths** — Поле zap.Config: куда zap сообщает о собственных сбоях записи. По умолчанию — stderr, процесс при сбое не падает.
- **Буфер между zap и файлом** — bufio.Writer режет число системных вызовов на порядки, но хвост в буфере теряется при крахе. Без Flush и Sync затея теряет смысл.

### Что дальше по курсу

Файл без присмотра растёт бесконечно: через полгода работы сервиса app.log догонит диск, и следующий урок — [ротация с lumberjack](/courses/zap/lessons/09-rotaciya-lumberjack/): максимальный размер, возраст файлов, сжатие старых поколений. Потом — [zapcore целиком](/courses/zap/lessons/10-zapcore/): Core, энкодеры и свои приёмники, из которых собраны все примеры этого урока. Если вы пришли сюда от [zap.Config](/courses/zap/lessons/07-zap-config/), вернитесь и перечитайте раздел про OutputPaths и ErrorOutputPaths — теперь за обоими полями стоят живые приёмники. Карта всех тем — на [странице курса](/courses/zap/), а пример из этого урока — готовый шаблон «stdout плюс файл» для ваших сервисов.

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

- Приёмник zap — zapcore.WriteSyncer: io.Writer плюс Sync; пресеты прячут его за OutputPaths.
- Файл открывают os.OpenFile с O_APPEND|O_CREATE|O_WRONLY и правами 0o644 — дозапись в конец, история не стирается.
- zapcore.NewMultiWriteSyncer(stdout, file) раздаёт каждую запись обоим приёмникам — это и есть multiplex вывод логов.
- ErrorOutputPaths — канал жалоб самого zap; по умолчанию stderr, и сбой записи не роняет процесс.
- Штатный поток — в stdout под агрегатор; файл — среда без системы сбора и страховка на переходный период.
- Буфер режет системные вызовы, но теряет хвост при крахе: без Flush и Sync затея теряет смысл.

## Домашнее задание

Соберите логгер урока: файл app.log через os.OpenFile с тройкой флагов и NewMultiWriteSyncer с stdout. Запустите программу три раза и убедитесь, что строки дописываются, а не затираются. Потом добавьте bufio.NewWriterSize на 4 килобайта, уберите Flush и завершите main через os.Exit(1) — объясните себе, почему файл остался пустым, и верните Flush на место.

## Вопросы и ответы по уроку

### Как настроить запись zap в файл в Go?

Откройте файл os.OpenFile с O_APPEND|O_CREATE|O_WRONLY и правами 0o644, оберните zapcore.AddSync и передайте в zapcore.NewCore как приёмник. Для stdout и файла одновременно соберите zapcore.NewMultiWriteSyncer.

### Куда пишет zap, если файл недоступен или диск переполнен?

Процесс не падает: zap докладывает о сбоях записи в ErrorOutputPaths — по умолчанию stderr. Проверяйте этот канал, когда записи «пропали»: приёмник часто отваливается задолго до инцидента.

### Нужна ли буферизация при записи логов в файл?

Буфер уменьшает число системных вызовов на порядки, но хвост в буфере пропадает при крахе: kill -9 и os.Exit минуют Flush. Для записей-улик в проде я предпочитаю честную запись без буфера.
