---
title: "Куда пишут логи: stdout, файл и ротация"
description: "Куда направить вывод логов go: stdout или файл, права доступа и место на диске, ротация логов go через lumberjack и logrotate без сюрпризов на проде."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/16-kuda-pishut-logi/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

*Курс «Логирование в Go: структурные логи на slog», урок 16 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/16-kuda-pishut-logi/*

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

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

> **Диск кончился, а сервис жив.** Кейс: бэкенд писал 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 для обработчика

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

> **Почему конвейер сбора предпочитает stdout.** Конвейер сбора — 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 проглатывают сбои молча.

> **Песочница: модель писателя на strings.Builder.** Пакета log/slog в песочнице сайта нет — там работают fmt, strings, strconv, math, sort, errors и time, а файловых дескрипторов нет вовсе. Механику писателя она разминает честно: соберите функцию, принимающую *strings.Builder и строку записи, и дописывайте в буфер пары ключ=значение через fmt.Fprintf — контракт совпадает с io.Writer файла и ротатора, а растущая длина буфера показывает скорость заполнения диска. Подсчёт через builder.Len() моделирует MaxSize: при превышении буфер обнуляется с сохранением счётчика копий — это и есть модель ротации. Полный файловый лог с правами и lumberjack запускается локально через go run; интерактивы ниже проверят флаги открытия и поля ротации.

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

**Вопрос:** Сервис работает в Docker-контейнере под Kubernetes. Куда направить вывод логов go, чтобы собрать их стандартным конвейером платформы?

- ✅ В stdout: драйвер логов Docker заберёт поток, приложение не управляет файлами
- В файл /var/log внутри контейнера: так записи переживают перезапуск пода
- В файл на хосте через volume и в ротацию внутри приложения
- В stderr вместо stdout: драйвер Docker собирает только stderr

Платформа собирает и stdout, и stderr, поэтому приложение остаётся источником строк: файлы и ротация на стороне приложения добавляют код и проблемы с volume'ами. Файл в контейнере живёт до перезапуска пода — записи теряются вместе с ним.

### Проверка: переполнение диска

**Вопрос:** Раздел с файлом логов заполнился. Что произойдёт со следующими вызовами logger.Info во встроенном JSONHandler?

- ✅ Write вернёт ошибку, Handler её проглотит: записи молча не запишутся, сервис продолжит работу
- slog.Error напечатает ошибку записи в тот же файл, и процесс завершится
- Handler заблокируется и подождёт, пока место освободится
- Записи начнут уходить в stderr как запасной канал

Встроенные Handler не репортят ошибки записи наружу — потеря записей беззвучна. Отсюда мониторинг места на диске и счётчик неудачных записей через собственную обёртку io.Writer, которая на ошибке инкрементирует метрику.

### Что окажется в app.log?

```go
// 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("сервис перезапущен")
```

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

- ✅ Новая запись ляжет с начала файла поверх старых байтов, хвост старых строк останется после неё
- Запись допишется в конец файла, как при O_APPEND
- Файл обнулится: открытие на запись стирает содержимое
- Ошибка записи: файл занят прошлым запуском процесса

Без O_APPEND запись начинается с текущего смещения, а у свежего открытого файла это ноль: новая строка ляжет поверх первых байтов старых записей, и хвост старых строк останется после неё. Без O_TRUNC файл не обнуляется, а флага O_APPEND в вызове нет — получился микс старого и нового в одних строках, который ломает парсер. Append-only лог открывают с O_APPEND.

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

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

```go
f, err := os.OpenFile("app.log", os.O_CREATE|os.O___|os.O_APPEND, 0644)
if ___ != nil {
        panic(err)
}

logger := slog.New(slog.New___Handler(f, nil))
```

Подсказка: Запись в конец даёт O_WRONLY, ошибку возвращает OpenFile в err, записи в формате JSON собирает NewJSONHandler.

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

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

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

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

1. `f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)`
2. `if err != nil { panic(err) }`
3. `logger := slog.New(slog.NewJSONHandler(f, nil))`
4. `slog.SetDefault(logger)`
5. `slog.Info("запись уходит в файл", "pid", os.Getpid())`
6. `defer f.Close()`

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

- **O_APPEND** — Флаг открытия: каждая запись добавляется в конец файла. Без него запись идёт с текущего смещения и портит начало лога.
- **0644 и umask** — Права в os.OpenFile: владелец читает и пишет, остальные — читают. Umask сужает их; фактические права проверяют ls -l.
- **lumberjack.Logger** — io.Writer с ротацией: MaxSize в мегабайтах, MaxBackups — число копий, MaxAge в днях, Compress для gzip-архивов.
- **MaxBackups: 0** — Хранить все старые файлы без чистки. Удобно на стенде, дорого на проде: раздел закончится.
- **stdout в контейнере** — Дефолт платформ: драйвер Docker или journald забирает поток, приложение не управляет диском и ротацией.
- **ошибки записи** — Встроенные Handler проглатывают ошибку Write: записи теряются молча. Нужны алерт на место и счётчик сбоев через обёртку Writer.

Вывод настроен: stdout для конвейера платформы, файл с правами и ротацией для машин без него, хозяйственные риски закрыты мониторингом. Дальше по курсу: [пятый урок про TextHandler и JSONHandler](/courses/slog/lessons/05-text-vs-json/) остаётся основой формата, [семнадцатый урок](/courses/slog/lessons/17-logi-dlya-sbora/) показывает, что конвейер ждёт от полей записей, [двадцать четвёртый](/courses/slog/lessons/24-logi-na-prode/) разбирает сбор journald и Docker, а [четырнадцатый](/courses/slog/lessons/14-proizvoditelnost/) — как не разогнать объём записей на горячем пути. Карта курса — на [странице курса](/courses/slog/).

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

- Любой 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. Метрика попадает в алерты, и тихая потеря записей становится видимым инцидентом со временем начала.
