---
title: "На проде: journald, Docker, сбор"
description: "Логи zerolog на проде: stdout в контейнере, драйвер json-file и ротация max-size, docker logs и journalctl, rate limit journald, logrotate и рамки сбора."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/24-logi-na-prode/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# На проде: journald, Docker, сбор

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 24 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/24-logi-na-prode/*

На проде zerolog делает ровно одно: пишет JSON в io.Writer, обычно в os.Stdout. Всё остальное — контейнер, драйвер, journald, агент, хранилище — инфраструктура, и это честная рамка урока: связка zerolog и продакшена не содержит ни одной строки интеграции с Docker или systemd. Знать механизм должен разработчик: куда уедет лента из контейнера, почему json-file без настроек растёт без ограничения, как читать ленту командами docker logs и journalctl, чем грозит rate limit journald «болтливому» сервису и при чём тут logrotate рядом с lumberjack. Почему stdout, а не файл: файл внутри контейнера живёт в слое и умирает вместе с ним, а stdout демон забирает сам; логгер собирается по окружению, как разобрано в [уроке про конфигурацию](/courses/zerolog/lessons/16-konfiguraciya-okrug/) — в контейнере writer это os.Stdout, на bare-metal тот же stdout, но собирает его systemd. Один процесс — одна лента: два процесса в одном контейнере перемешают записи без шанса на разбор. Дальше — цепочка доставки: контейнер, драйвер, чтение, journald, ротация на хосте.

### Контейнер и stdout: одна лента на процесс

Контейнер пишет JSON в stdout и stderr — это весь вклад приложения в логи docker go-инфраструктуры. Дальше работает Docker: демон читает поток процесса и складывает записи согласно драйверу логирования. Драйвер по умолчанию — json-file: записи ложатся файлом на диск хоста, в каталог контейнера, и docker logs читает именно их. Важное свойство: zerolog выдаёт поток JSON-строк, и драйвер их не разбирает — он сохраняет как есть, поэтому лента в файле остаётся вашей: те же ключи level и message, что и в любом другом writer-е. STDERR уходит в ту же ленту с маркером потока, и docker logs печатает обе части; zerolog по умолчанию пишет всё в один writer из New. Если сервис ведёт и stdout, и файл — например, в переходной схеме — держите одно содержание в обоих местах: расхождение лент хуже каждой поодиночке. Файлы json-file живут на хосте и переживают рестарт контейнера, но стираются при его удалении — для истории дольше жизни контейнера нужен агент. Частая ошибка — вести логи в файл внутри контейнера: файл попадает в слой, теряется при пересоздании и не виден docker logs, а сборщикам нужен доступ в файловую систему конкретного контейнера. Контракт один: процесс — поток — драйвер — лента. Всё, что ушло в stdout, доедет; всё, что спрятано в файлах контейнера, исчезнет вместе с подом.

### Минимальный Dockerfile для логирующего сервиса

```go
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/app

FROM alpine:3.19
COPY --from=build /bin/app /bin/app
STOPSIGNAL SIGTERM
CMD ["/bin/app"]
```

Многоступенчатая сборка: компилятор остаётся на build-этапе, в рантайм уезжает один статический бинарник. Zerolog внутри настраивается по окружению и пишет в os.Stdout — CMD не перенаправляет потоки, лента уходит демону как есть. STOPSIGNAL SIGTERM фиксирует сигнал остановки: сервис штатно дописывает события и выходит.

### Драйвер json-file: ротация через max-size

По умолчанию у json-file нет ни ограничения размера, ни ротации: max-size по документации Docker равен unlimited, max-file — единице, и лента одного болтливого контейнера способна съесть диск целиком. Ротация включается опциями драйвера: max-size задаёт порог файла, max-file — количество кусков; старший стирается при переполнении. Рабочий пример: max-size=10m и max-file=3 — потолок тридцать мегабайт на контейнер, три файла по десять. Те же опции ставятся глобально в daemon.json — тогда применяются ко всем контейнерам хоста; флаги docker run перекрывают глобальные и удобны точечно. Нюанс, о котором узнают на инциденте: docker logs читает только текущий набор файлов, и стёртые ротацией куски не вернуть — если записи нужны дольше потолка, это задача системы сбора, а не драйвера. Рядом с json-file есть драйвер local — тоже файловый, со сжатием и собственными лимитами по умолчанию; выбирается теми же флагами, а ленты читаются демоном одинаково.

### Запуск с лимитами и daemon.json

```go
# запуск: драйвер и лимиты на один контейнер
docker run -d --name app --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 registry.local/app:1.4

# daemon.json: лимиты для всех контейнеров хоста
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

# чтение ленты
docker logs app --tail 100
docker logs -f --since 10m app
```

--log-driver=json-file выбирает драйвер, каждая --log-opt добавляет опцию: файл по десять мегабайт, максимум три — потолок тридцать мегабайт на контейнер. daemon.json повторяет те же опции глобально и требует перезапуска демона; флаги запуска сильнее и работают без него. docker logs показывает хвост, -f следит, --since режет окно времени.

> **Живое место: тихое стирание при ротации.** Если сервис пишет миллион записей в час, потолок «десять мегабайт, три файла» стирает ленту за минуты: docker logs --since 1h вернёт пустоту, и на разборе это выглядит как сбой логирования, хотя сервис честно писал. Если прикинуть объём заранее — средний размер записи zerolog двести-четыреста байт, умножьте на поток, — то max-size и max-file подбираются так, чтобы окно держало хотя бы сутки. Когда записи нужны дольше потолка, подключайте сбор агентом: драйвер не архив и не хранилище.

### Чтение лент: docker logs и journalctl

Чтение — то, что делают чаще всего, и обе утилиты дают минимум, за который не стыдно. docker logs app печатает ленту контейнера целиком, --tail 100 обрезает до хвоста, -f следит в реальном времени, --since 10m или --since с меткой времени режет окно — записи zerolog при этом печатаются как есть, JSON-строками, поэтому по ним удобно ходить jq-фильтрами прямо в терминале. Для bare-metal сервиса под systemd ленту собирает journald: процесс пишет в stdout, юнит подключает его к журналу, и journalctl -u app показывает записи именно этого сервиса. Флаги те же по смыслу: -f следит, --since режет окно, -n 100 ограничивает число строк. Разница глубже утилит: journald добавляет к каждой записи свои поля — юнит, hostname, момент приёма, — но JSON zerolog доезжает внутри без изменений, и поля событий остаются ваши. Практичный порядок чтения при разборе одинаковый у обеих утилит: сначала хвост через --tail, затем окно --since вокруг инцидента, затем -f после воспроизведения; формат --since понимает и абсолютные метки, и относительные значения вроде 10m. Смешивать потоки двух сервисов в одну ленту не нужно никогда: у каждого юнита свой журнал, и разделение уже сделано за вас.

Драйверы делятся на локальные и пересылающие. json-file и local пишут на диск хоста: записи лежат рядом с контейнером, а сборщик-агент — filebeat, promtail, vector — читает каталог и увозит в хранилище; схема предсказуема, потому что сетевые проблемы роняют агента, а не демон Docker. Пересылающие драйверы — fluentd, gelf, syslog — шлют записи по сети прямо из демона: минус агент, плюс сетевой сбой внутри самого демона, и лента теряется вместе с ним. Для старта честнее локальный драйвер плюс агент: тот же принцип, что у файловой схемы из урока про ротацию. Агент делает ещё одну услугу: переживает ротацию json-file — следит за файлами и переходит к новым кускам, не теряя хвост, — и буферизует записи на случай недоступности хранилища. Рядом в кластере живут соседи: Envoy или nginx-прокси пишут свои access-логи в собственный stdout — отдельный контейнер, отдельная лента. Общий сборщик складывает обе ленты в одном хранилище, а склеивает их не время, а request_id из middleware: тот же идентификатор, что ставит zerolog в свои записи, и тот же принцип корреляции, что в [уроке про трейсы](/courses/zerolog/lessons/22-logi-trejsy/).

### Путь записи на проде

| Сценарий | Кто пишет | Кто собирает | Чем читать |
| --- | --- | --- | --- |
| Контейнер | zerolog в stdout | json-file на диске хоста | docker logs --tail, -f |
| Контейнер в кластере | stdout пода | агент filebeat или promtail | хранилище Loki |
| Bare-metal systemd | zerolog в stdout | journald | journalctl -u app -f |
| Файл из урока 09 | zerolog в lumberjack | агент по каталогу | tail -F, каталог бэкапов |
| Прокси рядом | Envoy в своём stdout | тот же сборщик | общее хранилище лент |

### journald и rate limit: болтливый сервис

journald ведёт счёт на каждый сервис: RateLimitIntervalSec задаёт окно, RateLimitBurst — число сообщений на окно; в типовой конфигурации это тридцать секунд и десять тысяч сообщений на юнит, директивы живут в journald.conf. Пока сервис в лимите, всё доезжает; как только всплеск превысил burst, новые записи отбрасываются до конца окна — молча, без предупреждения в самой ленте. Эффект на «болтливый» сервис ровно такой: в худший момент, когда записей больше всего, лента исчезает, и на разборе видна дыра вместо истории. Лечится не поднятием лимита до бесконечности, а объёмом: сводки вместо построчных логов из урока про антипаттерны, прореживание семплером, дисциплина уровней. Проверка простая: отправьте всплеск из теста и посчитайте записи journalctl-ом — часть пропадёт ровно на burst. Юнит-файл сервиса тоже участвует в схеме: StandardOutput по умолчанию уходит в журнал, и переопределять его стоит осознанно — вывод в файл силами systemd возвращает проблему ротации, только теперь в конфиге юнита. Отдельная настройка — Storage в journald.conf: значение auto хранит журнал на диске только при существующем каталоге /var/log/journal, иначе в памяти — это первый вопрос, когда после рестарта journalctl оказывается пуст.

Файловая схема с lumberjack из [урока про ротацию](/courses/zerolog/lessons/09-rotaciya-lumberjack/) в logrotate не нуждается: библиотека сама делит файл по размеру, сжимает бэкапы и чистит лишнее — вторая ротация поверх её работы только путает каталог и может стереть свежие куски. Logrotate нужен для файлов, которые пишет не ваш процесс: чужие демоны, приложения без собственной ротации, журналы старой схемы. Классическая ловушка — работа с живым файлом. Утилита копирует и переименовывает журнал, а процесс держит открытый файловый дескриптор на inode: после переименования он продолжает писать в старый файл, и «текущий» журнал пустеет, хотя место продолжает расти. Лечение — copytruncate: копия делается, затем файл усекается на месте, и дескриптор остаётся валидным; цена — окно между копированием и усечением, когда записи могут потеряться. На практике окно короткое, но существует: для медленного ночного журнала оно незаметно, для болтливого сервиса — реальная потеря, и тогда сигнал переоткрытия честнее. Альтернатива — сигнал: процесс ловит SIGHUP и переоткрывает файл, как умеет lumberjack методом Rotate. Для zerolog-вывода в stdout ни logrotate, ни copytruncate не существуют: ленту режет драйвер, а не утилита хоста.

> **Про песочницу.** Docker и systemd в песочнице не заводятся: она показывает тематическую программу на чистом Go — логгер печатает JSON в stdout, и лента выглядит ровно так, как её увидит docker logs. Полный цикл проверяется локально: соберите образ из урока, запустите с --log-driver=json-file и max-size=1m, насыпьте записей и посчитайте куски ленты в каталоге контейнера.

### Чек-лист прод-логов одним списком

- контракт: процесс пишет JSON в stdout; файлы внутри контейнера — потерянные логи
- json-file по умолчанию без ротации: max-size и max-file задают потолок ленты
- daemon.json — лимиты на весь хост, флаги docker run — точечно на контейнер
- docker logs --tail, -f, --since: чтение текущих файлов; стёртые куски не вернуть
- bare-metal: stdout собирает journald; journalctl -u app -f — лента юнита
- rate limit journald: всплеск сверх burst отбрасывается молча до конца окна
- локальный драйвер плюс агент предсказуемее пересылающего драйвера внутри демона
- lumberjack ротирует сам; logrotate и copytruncate — для чужих файлов и живых дескрипторов

### Проверка: ротация json-file

**Вопрос:** Контейнер с json-file без настроек пишет год — что с логами?

- ✅ Один файл без ограничения размера: ротация не включена по умолчанию
- Файлы по десять мегабайт: лимит встроен в драйвер
- Ротация раз в сутки по умолчанию
- Docker остановит контейнер при переполнении

Драйвер json-file по документации Docker не ограничивает размер: max-size равен unlimited, max-file — единице. Лимиты задаются только опциями max-size и max-file или в daemon.json — иначе лента растёт, пока есть диск.

### Проверка: logrotate и дескриптор

**Вопрос:** После переименования журнала logrotate-ом процесс пишет в старый файл — почему?

- ✅ Файловый дескриптор привязан к inode: переименование его не меняет
- Процесс кэширует имя файла при старте
- Logrotate обязан послать SIGHUP, но забыл
- Файл был открыт в режиме добавления

Переименование меняет имя, а не inode: открытый дескриптор продолжает указывать на тот же файл, и записи уходят в переименованный. Лечится copytruncate — усечением на месте — или сигналом переоткрытия файла.

### Сколько записей доедет до journald?

```go
# journald.conf: RateLimitIntervalSec=30s, RateLimitBurst=10000
# сервис печатает 12000 Info-записей за 10 секунд
journalctl -u app --since "09:00:00" | wc -l
```

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

- ✅ 10000: всплеск сверх burst отброшен до конца окна
- 12000: journald не теряет записи никогда
- 300: три записи в секунду по лимиту
- 0: сервис заблокирован за спам

Лимит считается на сервис за окно: первые десять тысяч сообщений пройдут, остальные отбросятся до истечения тридцати секунд. На разборе это выглядит как дыра в ленте — типичный след rate limit.

### Соберите чтение лент

Восстановите команды чтения: хвост контейнера и слежение за юнитом.

```go
# последние 100 строк ленты контейнера
docker logs app ___ 100

# следить за сервисом на bare-metal systemd
journalctl ___ app -f
```

Подсказка: Хвост обрезает --tail, юнит выбирает -u.

Правильные ответы в пропусках: `--tail`, `-u`

### Dockerfile по шагам

Расставьте строки Dockerfile в правильном порядке: от build-этапа до команды запуска.

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

1. `FROM golang:1.22-alpine AS build`
2. `WORKDIR /src`
3. `COPY . .`
4. `RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/app`
5. `FROM alpine:3.19`
6. `COPY --from=build /bin/app /bin/app`
7. `STOPSIGNAL SIGTERM`
8. `CMD ["/bin/app"]`

### Карточки: логи на проде

- **Контракт stdout** — zerolog ничего не знает про Docker: он пишет JSON в stdout, собирает инфраструктура. Файл внутри контейнера теряется вместе с подом.
- **json-file по умолчанию** — Без ротации: max-size unlimited, max-file 1. Лента растёт, пока есть диск; лимиты задают max-size и max-file.
- **max-size и max-file** — Порог файла и число кусков: 10m и 3 — тридцать мегабайт на контейнер. docker logs читает только текущие файлы.
- **Локальные и пересылающие драйверы** — json-file и local пишут на диск хоста, агент увозит. fluentd, gelf и syslog шлют по сети прямо из демона.
- **Rate limit journald** — RateLimitIntervalSec и RateLimitBurst — окно и burst на сервис: сверх лимита записи отбрасываются молча до конца окна.
- **Logrotate и copytruncate** — Переименование не ломает fd: процесс пишет в старый файл. Copytruncate усекает на месте, ценой окна потери записей.

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

Инфраструктура проверяет принципы курса с удвоенной ставкой: спам из урока про антипаттерны доезжает до драйвера, потерянные единицы — до хранилища, двойная запись — до алертов. Дальше — [итоговый проект](/courses/zerolog/lessons/25-itogovyj-proekt/): логгер для CLI-сервиса, где конфиг, уровни, файл и stdout собираются в один каркас и проверяются сборкой. Справочное закрепление: соберите Dockerfile из урока, запустите контейнер с лимитами и посчитайте куски ленты; затем поднимите тот же сервис systemd-юнитом на виртуалке и сравните journalctl -u с docker logs — записи должны совпасть, потому что writer один и тот же. Разница, если найдётся, обычно в обёртке: journald добавляет свои поля, docker — маркер потока, но JSON событий остаётся тем же артефактом. Если разница есть, она в инфраструктуре, и теперь вы знаете, где искать.

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

- zerolog ничего не знает про Docker и journald: контракт один — JSON в stdout, сбор — инфраструктура.
- Контейнер: один процесс — одна лента; файлы внутри контейнера теряются с подом.
- json-file по умолчанию без ротации: max-size и max-file включают лимит, daemon.json — на весь хост.
- docker logs --tail, -f, --since читает текущие файлы: стёртые ротацией куски не вернуть.
- Локальные драйверы пишут на диск хоста, пересылающие шлют по сети из демона.
- Bare-metal: stdout собирает journald; journalctl -u app -f — лента юнита.
- Rate limit journald отбрасывает всплеск сверх burst молча — объём лечится сводками и семплерами.
- Lumberjack ротирует сам; logrotate и copytruncate — для чужих файлов и живых дескрипторов.

## Домашнее задание

Соберите Dockerfile из урока для своего сервиса и запустите контейнер с max-size=1m и max-file=3: насыпьте записей в цикле и посчитайте куски ленты в каталоге контейнера, затем docker logs --tail 50 — свежие записи должны остаться на месте. Повторите запуск без max-size и убедитесь, что файл растёт без ограничения. На машине с systemd соберите тот же сервис юнитом: journalctl -u покажет те же JSON-строки. Смоделируйте rate limit: всплеск из пары тысяч записей и подсчёт journalctl-ом — найдите границу. Разберите файловую схему с lumberjack: убедитесь, что logrotate к ней не подключён, и объясните, что случится при двойной ротации.

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

### Куда писать логи zerolog в контейнере — stdout или файл?

В stdout: контракт контейнера — процесс пишет в поток, демон Docker собирает ленту по драйверу. Файл внутри контейнера живёт в слое, теряется при пересоздании и не виден docker logs. Если нужна файловая схема — монтируйте каталог и настраивайте lumberjack, но для контейнера stdout и json-file покрывают типовой случай.

### Почему docker logs пуст, хотя сервис работал?

Первая причина — ротация: docker logs читает только текущий набор файлов, а стёртые max-file-ом куски не возвращаются. Вторая — процесс пишет в файл внутри контейнера вместо stdout. Третья — rate limit journald на bare-metal: записи отброшены сверх burst. Считайте объём до настройки: потолок в тридцать мегабайт может стираться за минуты.

### Нужен ли logrotate рядом с lumberjack?

Нет: lumberjack из урока про ротацию сам делит файл по размеру, сжимает бэкапы и чистит лишнее — вторая ротация путает каталог. Logrotate нужен для файлов, которые пишет не ваш процесс. Если всё же запускаете его на живом файле — включайте copytruncate: без него процесс продолжает писать в переименованный файл по открытому дескриптору.
