Вопрос о выводе выглядит хозяйственным, а на деле определяет инциденты за полгода вперёд: лог в stdout живёт, пока жив контейнер, файл в приложении растёт без спроса, диск, отведённый под записи, кончается в самый неподходящий момент. Обработчики slog абстрагируют место назначения: NewJSONHandler и NewTextHandler принимают любой io.Writer, и вся разница между stdout, файлом и ротатором умещается в первом аргументе. Но за этим первым аргументом стоят права доступа, append-режим, поведение при переполнении и договорённость с окружением, кто собирает строки. В этом уроке пройдём оба пути целиком, посмотрим на ошибки, которые случаются при перезапусках, и соберём чек-лист, по которому выбор вывода делается за минуту, а не за вечер разбора.
Вывод логов go: stdout, stderr или файл#
Выбор вывода — это выбор владельца записи. Пишите в os.Stdout — и владельцем становится окружение: Docker складывает поток в свой драйвер логов, systemd отправляет его в journald, Kubernetes поднимает сбор на своей стороне. Приложение при этом не знает ни про диск, ни про ротацию, ни про место: его задача — печатать строки и жить дальше. Такой путь закреплён в двенадцати факторах приложений и потому стал дефолтом контейнерных платформ. Пишите в файл — и владельцем становится само приложение: оно открывает файл с правами, следит за смещением, закрывается с ростом размера, убирает старые копии. Это больше контроля: файл можно вычитать локально, приложить к тикету, просмотреть прямо на машине без конвейера. И больше ответственности: права, владельцы, место на диске, ротация — всё это теперь вопрос приложения, а не платформы. Третий вариант — os.Stderr: им пользуются, когда записи логов нужно отделить от обычного вывода процесса, но для сервиса на slog различие сводится к маршруту сбора — платформы собирают оба потока. Практическое правило: контейнер или systemd — stdout, standalone-машина без конвейера — файл; подробный разбор сбора окружением ждёт в двадцать четвёртом уроке.
Логгер в файл: os.OpenFile с правами
GOpackage main
import (
"log/slog"
"os"
)
func main() {
f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
panic(err)
}
defer f.Close()
logger := slog.New(slog.NewJSONHandler(f, nil))
logger.Info("сервис стартовал", "pid", os.Getpid())
}Флаги задают поведение файла: O_CREATE создаёт его при отсутствии, O_WRONLY открывает только на запись, O_APPEND добавляет каждую запись в конец — это критично, если файл уже содержит записи прошлых запусков. Число 0644 — права: владелец читает и пишет, группа и остальные только читают. Дефолтный umask 0022 оставит их как есть, более строгий 0077 сожмёт до 0600 — фактические права после создания проверяют ls -l. Handler принимает любой io.Writer, поэтому NewJSONHandler берёт файл так же, как stdout, а defer f.Close() закрывает дескриптор при выходе из main.
Права, владельцы и место на диске#
Файловый путь приносит три хозяйственных вопроса. Первый — права: сервис, работающий не от root, должен уметь писать в целевой каталог; классическая ошибка — файл создаётся первым запуском от root, а после смены пользователя процесса запись падает с permission denied. Права задаются в os.OpenFile, но фактический результат сужается umask процесса: заявленные 0644 могут превратиться в 0600, и коллектор под другим пользователем не прочитает записи. Второй вопрос — место: файл растёт со скоростью записей, и лог при плотном Debug на горячем пути занимает гигабайты за сутки. Механику контроля объёма — уровни и проверку Enabled до сборки полей — разбирает четырнадцатый урок, здесь достаточно помнить, что файл без лимитов — диск без страховки. Третий вопрос — поведение при переполнении: когда место кончилось, Write возвращает ошибку, а встроенные Handler slog её молча проглатывают и продолжают работу. Сервис выглядит здоровым, метрики зелёные, а записи не пишутся — такие инциденты находят по жалобам пользователей, а не по логам, потому что логов нет. Отсюда правило файлового пути: мониторинг места на диске и алерт на ошибки записи обязательны.
Кейс: бэкенд писал JSON-логи в /var/log/app/app.log без ротации — на старте проекта записей было мало. Через полгода файл перевалил за объём раздела, Write начал возвращать no space left on device, а JSONHandler эти ошибки игнорирует: записи перестали появляться, сервис продолжал отвечать. Дежурный нашёл проблему, когда пользователь прислал скриншот страницы сбоя: строк в хранилище логов не было уже три дня. Разбор дал два фикса: lumberjack с MaxSize 100 мегабайт вместо сырого файла и алерт на заполнение раздела с логами плюс счётчик неудачных записей через обёртку io.Writer, которая на ошибке инкрементирует метрику. Похожая история с правами случается при смене пользователя процесса — проверяйте и то и другое на стенде, а не на проде.
Ротация логов go: logrotate и lumberjack#
Ротация — это замена одного растущего файла на серию ограниченных: текущий файл переименовывается с меткой времени, открывается свежий, старые сжимаются и удаляются по возрасту или количеству. Решений два, и живут они по разные стороны приложения. Logrotate — системный инструмент: конфиг с путями, размерами и расписанием, по которому он перемещает файлы; приложению нужно либо уметь переоткрывать файл по сигналу, либо использовать copytruncate — копирование с обнулением на месте, при котором записи между копированием и обнулением могут потеряться. Lumberjack — библиотека для приложения: объект gopkg.in/natefinch/lumberjack.v2 реализует io.Writer и сам следит за размером: каждая запись проходит через него, при превышении MaxSize файл переименовывается и открывается новый. Поля конфигурации прямые: MaxSize в мегабайтах, MaxBackups — сколько старых файлов хранить, MaxAge — сколько дней, Compress — сжимать ли архивы gzip. Ноль в MaxBackups означает хранить всё без чистки — удобная опция для отладки и дорогая для продакшена. Оба подхода законны: logrotate централизует ротацию всех сервисов машины, lumberjack держит настройку рядом с кодом и не требует прав на системные каталоги.
Lumberjack как io.Writer для обработчика
GOpackage main
import (
"log/slog"
"os"
"gopkg.in/natefinch/lumberjack.v2"
)
func main() {
rotator := &lumberjack.Logger{
Filename: "/var/log/myapp/app.log",
MaxSize: 100, // мегабайты
MaxBackups: 7,
MaxAge: 28, // дни
Compress: true,
}
logger := slog.New(slog.NewJSONHandler(rotator, nil))
logger.Info("ротация настроена", "max_mb", rotator.MaxSize)
}Lumberjack — сторонний пакет: он ставится через go get gopkg.in/natefinch/lumberjack.v2 и появляется в зависимостях проекта. Объект подключается обработчику как обычный io.Writer — NewJSONHandler не отличает его от файла и stdout. При превышении 100 мегабайт текущий файл переезжает в имя с датой вроде app-2025-03-14T09-30-00.000.log, на его месте открывается пустой; хранятся последние семь копий не старше 28 дней, архивы сжимаются. Деталь, о которую спотыкаются: утилиты, следящие за файлом по иноде — например, tail -f, запущенный до ротации, — продолжают читать старый переименованный файл; следите по имени или настраивайте сбор так, чтобы он переоткрывал файл.
Спорное: ротация логов go внутри приложения#
Мнение, которое стоит оспорить на ретро: ротация — дело приложения, и lumberjack должен стоять в каждом сервисе с файловым выводом. За: настройка живёт в коде и в ревью, поведение одинаково на всех машинах, не нужны права на /etc/logrotate.d и согласование с системными инженерами. Против: приложение перестаёт быть простым писателем и берёт на себя файловую политику, которую на остальных сервисах машины решает один общий конфиг; тестировать собственную ротацию дольше, чем перепроверить logrotate; и главный аргумент — на проде с контейнерами файлового пути часто нет вовсе, сбор идёт из stdout, и ротация приложения не нужна ни в каком виде. Компромисс, который прижился: файловый вывод с lumberjack — для standalone-машин и стендов без конвейера сбора, stdout — для контейнеров и systemd. Граница по владельцу: если логами управляет общий конфиг инфраструктуры, приложение ротацию не дублирует. Ошибка обратного рода — держать файл без ротации, пока объём маленький: объём растёт незаметно, и история с закончившимся диском повторяется слово в слово.
Конвейер сбора — promtail, Vector, Fluent Bit, драйверы Docker — устроен так: он читает поток или файл и пересылает записи в хранилище вроде Loki или Elasticsearch. Потоку stdout конвейер рад: источник живёт рядом с контейнером, переоткрытие не требуется, записи не теряются между ротациями. Файлу конвейер тоже рад, но спрашивает за подробности: как переоткрывать после ротации, что делать с инодой, как не прочитать половину записи. Когда приложение пишет в stdout, эти вопросы исчезают — платформа решает их один раз для всех сервисов. Именно поэтому двадцать четвёртый урок про сбор разбирает связку journald, docker logs и драйверы, а этот урок лишь ставит приложение в правильное место конвейера — источника строк.
Чек-лист выбора вывода#
- Контейнер или systemd: os.Stdout — сбором ведает платформа, приложение про диск не думает.
- Standalone без конвейера: файл с O_CREATE|O_WRONLY|O_APPEND и правами 0644.
- Ротация в приложении: lumberjack с MaxSize, MaxBackups, MaxAge и Compress по объёму.
- Ротация на машине: logrotate с copytruncate или переоткрытием по сигналу.
- Права после umask проверяются ls -l: заявленные 0644 могут стать 0600.
- Мониторинг места на диске и счётчик ошибок записи: встроенные Handler проглатывают сбои молча.
Пакета log/slog в песочнице сайта нет — там работают fmt, strings, strconv, math, sort, errors и time, а файловых дескрипторов нет вовсе. Механику писателя она разминает честно: соберите функцию, принимающую *strings.Builder и строку записи, и дописывайте в буфер пары ключ=значение через fmt.Fprintf — контракт совпадает с io.Writer файла и ротатора, а растущая длина буфера показывает скорость заполнения диска. Подсчёт через builder.Len() моделирует MaxSize: при превышении буфер обнуляется с сохранением счётчика копий — это и есть модель ротации. Полный файловый лог с правами и lumberjack запускается локально через go run; интерактивы ниже проверят флаги открытия и поля ротации.
Проверка: вывод в контейнере
Сервис работает в Docker-контейнере под Kubernetes. Куда направить вывод логов go, чтобы собрать их стандартным конвейером платформы?
Проверка: переполнение диска
Раздел с файлом логов заполнился. Что произойдёт со следующими вызовами logger.Info во встроенном JSONHandler?
Что окажется в app.log?
Прочитайте код и выберите, что он выведет:
// app.log уже содержит 200 байтов записей прошлых запусков
f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY, 0644)
logger := slog.New(slog.NewJSONHandler(f, nil))
logger.Info("сервис перезапущен")Откройте файл правильно
Допишите: режим записи в конец, проверку ошибки и формат записей.
Логгер в файл по строкам
Расставьте по порядку: открытие файла с append-режимом, проверка ошибки, обработчик, назначение дефолтным, запись, закрытие.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: вывод и ротация
Вывод настроен: stdout для конвейера платформы, файл с правами и ротацией для машин без него, хозяйственные риски закрыты мониторингом. Дальше по курсу: пятый урок про TextHandler и JSONHandler остаётся основой формата, семнадцатый урок показывает, что конвейер ждёт от полей записей, двадцать четвёртый разбирает сбор journald и Docker, а четырнадцатый — как не разогнать объём записей на горячем пути. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Любой Handler пишет в io.Writer: stdout, файл или ротатор выбираются при создании логгера одной строкой.
- stdout в контейнере и systemd: сбором ведает платформа, приложение не управляет диском и ротацией.
- Файл открывается с O_CREATE|O_WRONLY|O_APPEND и правами 0644; umask сужает фактические права.
- Ротация: logrotate на стороне машины или lumberjack в приложении с MaxSize, MaxBackups, MaxAge.
- Встроенные Handler проглатывают ошибки записи: без алерта на место инцидент виден по жалобам.
- Граница путей: есть конвейер сбора — stdout; standalone-машина — файл с ротацией.
Домашнее задание
Поднимите локально оба пути: логгер с NewJSONHandler в os.Stdout и логгер в файл app.log с O_APPEND и правами 0644. Перезапустите процесс дважды и убедитесь, что записи дописываются в конец, а не поверх старых. Затем подключите lumberjack с MaxSize 1, напишите больше мегабайта и посмотрите на имена ротированных файлов и их права после сжатия.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужно ли закрывать файл после каждой записи?
Нет: файл открывают один раз при старте и закрывают при остановке сервиса через defer или в shutdown-пути. Открытие и закрытие на каждую запись — лишние системные вызовы, а Handler рассчитан на долгоживущий io.Writer.
lumberjack или logrotate — что выбрать?
Если сервисы на машине управляются общим конфигом инфраструктуры, logrotate централизует ротацию, а приложение просто пишет в файл. Lumberjack уместен, когда настройка должна жить в коде и ревью или прав на системные каталоги нет. На контейнерном проде с stdout вопрос снимается сам: там файлового пути нет.
Как заметить ошибки записи, если Handler их проглатывает?
Заверните io.Writer в обёртку, которая при ошибке инкрементирует счётчик метрики и дублирует сообщение в stderr. Метрика попадает в алерты, и тихая потеря записей становится видимым инцидентом со временем начала.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Логи для сбора: поля для Loki и ELK
- Уровни по окружению: debug в dev, info в прод
- Секреты в логах: токены, пароли и ПДн
- Тестирование логов: capture и утверждения
- Сквозные поля: request ID через слои
- Производительность: Enabled и горячий путь
- Конкурентность: один логгер на сервис
- Свой Handler: интерфейс, Record и WithAttrs
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY