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

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

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

Урок 16 из 25 ~16 мин +20 XP

Куда пишут логи: stdout, файл и ротация

Вывод логов go устроен проще, чем кажется: любой Handler пишет в io.Writer, а конкретный писатель — stdout, файл или ротатор — выбирается одной строкой при создании логгера. Простота обманчива: от выбора зависит, кто забирает записи, кто отвечает за место на диске и что происходит с логами при ротации. Разберём оба пути — поток в stdout под конвейер сбора и файл в приложении с правами, append-режимом и ротацией через lumberjack, — и договоримся, где какой путь уместен.

Содержание урока · 8 разделов

Вопрос о выводе выглядит хозяйственным, а на деле определяет инциденты за полгода вперёд: лог в 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 с правами

GO
package 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 её молча проглатывают и продолжают работу. Сервис выглядит здоровым, метрики зелёные, а записи не пишутся — такие инциденты находят по жалобам пользователей, а не по логам, потому что логов нет. Отсюда правило файлового пути: мониторинг места на диске и алерт на ошибки записи обязательны.

Ротация логов 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 для обработчика

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

Чек-лист выбора вывода#

  • Контейнер или systemd: os.Stdout — сбором ведает платформа, приложение про диск не думает.
  • Standalone без конвейера: файл с O_CREATE|O_WRONLY|O_APPEND и правами 0644.
  • Ротация в приложении: lumberjack с MaxSize, MaxBackups, MaxAge и Compress по объёму.
  • Ротация на машине: logrotate с copytruncate или переоткрытием по сигналу.
  • Права после umask проверяются ls -l: заявленные 0644 могут стать 0600.
  • Мониторинг места на диске и счётчик ошибок записи: встроенные Handler проглатывают сбои молча.

Проверка: вывод в контейнере

Сервис работает в 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("сервис перезапущен")

Откройте файл правильно

Допишите: режим записи в конец, проверку ошибки и формат записей.

f, err := os.OpenFile("app.log", os.O_CREATE|os.O|os.O_APPEND, 0644) if != nil { panic(err) } logger := slog.New(slog.NewHandler(f, nil))

Логгер в файл по строкам

Расставьте по порядку: открытие файла с append-режимом, проверка ошибки, обработчик, назначение дефолтным, запись, закрытие.

Строки

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

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

Карточки: вывод и ротация

Термин

O_APPEND

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

Флаг открытия: каждая запись добавляется в конец файла. Без него запись идёт с текущего смещения и портит начало лога.

1 / 6

Вывод настроен: 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, напишите больше мегабайта и посмотрите на имена ротированных файлов и их права после сжатия.

Личные заметкиПусто
Хранятся только в вашем браузере (Slog · Куда пишут логи: stdout, файл и ротация)

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

Нужно ли закрывать файл после каждой записи?

Нет: файл открывают один раз при старте и закрывают при остановке сервиса через defer или в shutdown-пути. Открытие и закрытие на каждую запись — лишние системные вызовы, а Handler рассчитан на долгоживущий io.Writer.

lumberjack или logrotate — что выбрать?

Если сервисы на машине управляются общим конфигом инфраструктуры, logrotate централизует ротацию, а приложение просто пишет в файл. Lumberjack уместен, когда настройка должна жить в коде и ревью или прав на системные каталоги нет. На контейнерном проде с stdout вопрос снимается сам: там файлового пути нет.

Как заметить ошибки записи, если Handler их проглатывает?

Заверните io.Writer в обёртку, которая при ошибке инкрементирует счётчик метрики и дублирует сообщение в stderr. Метрика попадает в алерты, и тихая потеря записей становится видимым инцидентом со временем начала.

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

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

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

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

PostgreSQL в Go: драйвер pgx с нуля до продакшена

Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.

  • Подключаться к PostgreSQL через pgx и pgxpool
  • Сканировать строки в структуры и справляться с NULL
  • Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
  • Проводить транзакции и массовые вставки через Batch и COPY