Коротко: что запомнить
- Приёмник 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 затея теряет смысл.
Содержание урока · 11 разделов
- Коротко: что запомнить
- zapcore.WriteSyncer: к чему zap пишет на самом деле
- Флаги os.OpenFile и права на файл
- zapcore.NewMultiWriteSyncer: запись zap в файл и stdout
- Куда какой поток: расклад по приёмникам
- ErrorOutputPaths: куда zap жалуется сам на себя
- Что в stdout, что в файл: осознанный выбор
- Буферизация: скорость против потерянного хвоста
- Что дальше по курсу
- Песочница урока
- Вопросы и ответы
Начну с этой ночи, потому что она объясняет весь урок. Платёжный сервис отдаёт пятисотые, вы открываете логи пода — видите только то, что написано с последнего рестарта. Оркестратор пересоздал контейнер полчаса назад, 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
GOpackage 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 делает запускающий скрипт или сама программа заранее; и владелец файла — тот пользователь, под которым живёт сервис, что в контейнерах с непривилегированным юзером стоит проверять до первого инцидента.
Поставьте 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 и файл одновременно
GOpackage 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, которую мы разбирали в уроке про конфигурацию логгера, есть два путевых поля, и их путают регулярно. OutputPaths — куда идут записи логов: stdout, файл, сокет. ErrorOutputPaths — куда zap сообщает о собственных проблемах: приёмник не открылся при старте, диск переполнился на середине записи, энкодер не справился со значением. По умолчанию это stderr — и это не случайность, а та самая конвенция Unix, по которой штатный поток и поток диагностических сообщений разделяются: сборщик логов не должен захлёбываться жалобами логгера. Логика zap такая: собственный сбой записи не должен ронять сервис — процесс не паникует, продолжает работать и честно докладывает в отдельный канал. Практика из жизни: когда логи «внезапно кончились», первым делом заглядывайте в ErrorOutputPaths — часто выясняется, что записи шли в никуда уже час, потому что файл отвалился, а никто не смотрел stderr. Совет вдогонку: менять умолчание стоит только осознанно — направив жалобы zap в отдельный файл, вы получите журнал отказов приёмника, по которому видна история диска, а не только текущая беда.
Проверка: WriteSyncer
Что требует от приёмника интерфейс zapcore.WriteSyncer?
Что в stdout, что в файл: осознанный выбор#
Дефолт облачной эпохи — писать всё в stdout и отдавать сбор на откуп платформе: так советуют двенадцать факторов, так устроены сборщики в кластерах, и для сервиса в Kubernetes это правильный ответ по умолчанию. Файл берут, когда системы сбора нет: голый VPS, утилита на машине заказчика, локальный запуск, где нужен хвост для tail -f. Мультиплексирование обоих приёмников — типичный костыль переходного периода: пока агрегатор не проверен на прочность, файл остаётся страховкой. Спорное мнение, которое я защищаю на ревью: не размножайте записи «на всякий случай». Дублирование всего потока во все приёмники удваивает объём, удваивает цену и создаёт два источника правды, которые обязательно разойдутся в часах при разборе. Лучше выбрать основное место назначения и осознанно ограничить второй: например, в файл идёт только Warn и выше. Разводить потоки по уровням умеет связка нескольких ядер — это уже территория zapcore, разберём её отдельно.
Проверка: ErrorOutputPaths
Куда по умолчанию zap сообщает о собственных сбоях записи — например, о переполненном диске?
Буферизация: скорость против потерянного хвоста#
Запись в файл на каждой строке — системный вызов на каждое событие, и на тысячах записей в секунду эта цена видна в профиле. Лекарство известно давно: bufio.Writer между zap и файлом — записи копятся в памяти и уходят на диск пачками, число системных вызовов падает на порядки. Обратная сторона, о которой туториалы молчат: всё, что оставалось в буфере на момент краха, на диске не появится. Graceful shutdown спасает — defer с Flush сработает; kill -9, OOM-killer или забытый os.Exit — нет: буфер умирает вместе с процессом, и последние записи не дойдут никуда. Для сервисов, где каждая запись — улика, я эту игру не играю: честная запись без буфера, а ускорение — только после профилирования, которое покажет, что файловая запись вообще узкое место. Буфер — осознанный компромисс с известной ценой, а не дефолт из туториала. Случай из практики: в одном сервисе буфер на 256 килобайт приехал из чужого шаблона, а Flush звали по таймеру раз в тридцать секунд; после каждого краша команда теряла последние полминуты логов — ровно тот отрезок, ради которого логи и открывают. Размер буфера, частоту Flush и цену потери нужно назвать вслух до того, как bufio появится в коде.
Буферизованный приёмник — с обязательным Flush
GOpackage 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 после открытия?
Прочитайте код и выберите, что он выведет:
// в app.log уже 100 строк прошлого запуска
file, err := os.OpenFile("app.log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)Сколько записей дойдёт до файла?
Прочитайте код и выберите, что он выведет:
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)Заполните пропуски в монтаже приёмников
Восстановите фрагмент внутри func main: открытие файла с флагом только-на-запись, обёртка и сборка двух приёмников в один.
Соберите монтаж приёмников
Расставьте строки фрагмента внутри func main — от открытия файла до готового логгера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: приёмники zap
Что дальше по курсу#
Файл без присмотра растёт бесконечно: через полгода работы сервиса app.log догонит диск, и следующий урок — ротация с lumberjack: максимальный размер, возраст файлов, сжатие старых поколений. Потом — zapcore целиком: Core, энкодеры и свои приёмники, из которых собраны все примеры этого урока. Если вы пришли сюда от zap.Config, вернитесь и перечитайте раздел про OutputPaths и ErrorOutputPaths — теперь за обоими полями стоят живые приёмники. Карта всех тем — на странице курса, а пример из этого урока — готовый шаблон «stdout плюс файл» для ваших сервисов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Домашнее задание
Соберите логгер урока: файл 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. Для записей-улик в проде я предпочитаю честную запись без буфера.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси