Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 8 из 25 ~18 мин +50 XP

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

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

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

  • Приёмник 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 разделов

Начну с этой ночи, потому что она объясняет весь урок. Платёжный сервис отдаёт пятисотые, вы открываете логи пода — видите только то, что написано с последнего рестарта. Оркестратор пересоздал контейнер полчаса назад, 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 делает запускающий скрипт или сама программа заранее; и владелец файла — тот пользователь, под которым живёт сервис, что в контейнерах с непривилегированным юзером стоит проверять до первого инцидента.

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

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 после открытия?

Прочитайте код и выберите, что он выведет:

// в 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: открытие файла с флагом только-на-запись, обёртка и сборка двух приёмников в один.

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)) }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

zapcore.WriteSyncer

нажмите, чтобы перевернуть
Определение

Интерфейс приёмника zap: Write из io.Writer плюс Sync. Всё, что умеет эти два метода, годится как место назначения логов.

1 / 6

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

Файл без присмотра растёт бесконечно: через полгода работы сервиса 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 · Куда пишем: stdout, файлы и мультиплексирование)

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

Как настроить запись 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. Для записей-улик в проде я предпочитаю честную запись без буфера.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Colly25 уроков~9 чпрактика25 уроков уже доступны

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси