5. Логи инфраструктуры
Потоки ввода-вывода, Disk Pressure, containerd и structured slog в Go
В предыдущей главе (4. Мониторинг инфраструктуры) мы научились опрашивать числовые метрики. Метрики — это высокоуровневый компас инженера: они мгновенно показывают «Что именно сломалось и в какую секунду». Но когда графики заливаются красным цветом, метрики бессильны объяснить глубинную причину: «Какая конкретно ошибка привела к падению?». Здесь на сцену выходит журналирование событий — логи.
В старом мире монолитов на физических серверах логирование казалось тривиальной задачей: приложение открывало локальный файл /var/log/app.log, дописывало в него строки, а системный демон logrotate по ночам упаковывал старые файлы в gzip.
В эфемерной вселенной контейнеров и Kubernetes этот подход терпит сокрушительный крах. Поды живут в изолированных слоях файловой системы OverlayFS. Если контейнер упал с паникой и был перезапущен демоном containerd, все файлы, записанные им во внутреннее дисковое пространство, уничтожаются ядром Linux без возможности восстановления.
Инфраструктурное логирование в Kubernetes требует фундаментальной смены парадигмы: отказ от локальных файлов в пользу бесконечных потоков данных (Streaming I/O) и централизованной агрегации.
1. Философия Unix: Всё есть поток (Stream I/O)
Один из основополагающих принципов операционной системы Unix гласит: «Программа должна читать из стандартного ввода и писать в стандартный вывод, ничего не зная о том, куда направлен этот поток».
В Linux любой процесс при старте получает три открытых системных файловых дескриптора:
FD 0:stdin(стандартный ввод);FD 1:stdout(стандартный вывод);FD 2:stderr(стандартный поток ошибок).
Docker и Kubernetes превратили этот принцип в абсолютный стандарт (Двенадцатый фактор методологии 12-Factor App). Ваше Go-приложение больше не создает файлов на диске! Когда вы вызываете fmt.Println, log.Printf или структурированный slog.Info, рантайм Go выполняет системный вызов write(1, data).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
GoApp["Go Application Pod\n(write() в FD 1 stdout)"]:::system -->|"UNIX Pipe"| CRI["Container Runtime\n(containerd / CRI-O)"]
CRI -->|"Обогащение метаданными\n(namespace, pod, container, time)"| HostDisk["Хост-нода Linux\n/var/log/pods/namespace_pod_uid/container/0.log"]
HostDisk -->|"Прямое чтение Kubelet"| Kubelet["Kubelet API\n(kubectl logs)"]
HostDisk -->|"Монтирование каталога"| Collector["DaemonSet Log Collector\n(Fluent Bit / Promtail)"]
Collector -->|"HTTP Batch Push"| LogStorage["Центральное хранилище\n(Grafana Loki / Elasticsearch / ClickHouse)"]
Среда исполнения контейнеров (containerd через CRI) перехватывает этот поток байтов, оборачивает каждую строку в JSON-конверт с метаданными (временная метка ISO 8601, маркер stdout/stderr) и атомарно дописывает в файл на физическом диске хоста.
2. Опасность неограниченных логов: Ловушка Disk Pressure
По умолчанию классический лог-драйвер Docker json-file не имеет встроенных лимитов на размер файлов. Если в вашем Go-коде закрался бесконечный цикл, спамящий ошибками через log.Printf:
for {
log.Printf("failed to connect to database: %v", err)
}
Такой контейнер способен за считанные часы выплюнуть на диск хоста сотни гигабайт текстового мусора.
Когда физический диск сервера заполняется до 85–90%, ядро Linux и Kubernetes включают защитные механизмы, оборачивающиеся катастрофой:
- Состояние
DiskPressure: Планировщик Kubernetes помечает ноду флагомDiskPressureи запрещает запуск новых подов на этом узле. - Падение Kubelet: Демон
kubeletне может создавать временные сокеты и обновлять чекпоинты. Сервер выпадает из кластера в статусNotReady. - Краш базы данных etcd: Если на этом узле развернут контроллер кластера, база метаданных
etcdпадает с фатальной ошибкой: она не может выполнить системный вызовfsyncдля сброса WAL-журнала на переполненный диск.
Решение: Настройка ротации логов в Kubelet
На каждом сервере кластера в файле конфигурации Kubelet (/var/lib/kubelet/config.yaml) обязательно должны быть зафиксированы жесткие квоты ротации:
containerLogMaxSize: "50Mi" # Максимальный размер одного лог-файла до ротации
containerLogMaxFiles: 5 # Сколько архивных копий хранить (всего не более 250 МиБ)
Как только объем логов достигает 50 МБ, containerd мгновенно ротирует файл (0.log -> 0.log.1) и создает чистый файл, защищая диск сервера от переполнения.
3. Mechanical Sympathy: Блокировка на системном вызове write (Blocking Writes)
Это одна из самых коварных ловушек логирования в высоконагруженных Go-сервисах, которую практически невозможно диагностировать обычными профилировщиками CPU.
По умолчанию системный вывод stdout в Unix-системах — это блокирующий анонимный канал (UNIX Pipe). Когда Go-приложение вызывает slog.Info(), рантайм вызывает системный вызов ядра:
write(1, "request processed\n", 18)
В Linux буфер пайпа между процессом Go и процессом containerd имеет фиксированный размер — всего 64 КБ (F_SETPIPE_SZ).
sequenceDiagram
autonumber
participant Go as Go Goroutine (HTTP Handler)
participant Kernel as Linux Pipe Buffer (Лимит 64 КБ)
participant CRI as containerd (Reader)
participant Disk as Physical Host Disk
Note over Go,Disk: Штатный режим: containerd быстро сбрасывает данные на диск
Go->>Kernel: write(1, log_data) -> Мгновенный возврат (0.01 мс)
Kernel->>CRI: Чтение из буфера
CRI->>Disk: Запись в /var/log/pods/
Note over Disk: Диск перегружен! Disk I/O заблокирован (High iowait)
Disk-->>CRI: Задержка fsync на 200 мс!
Note over Kernel: Буфер 64 КБ переполнен!
Go->>Kernel: write(1, log_data)
Note over Go: ЯДРО БЛОКИРУЕТ ГОРУТИНУ! (Wait on pipe buffer)
Kernel-->>Go: Возврат из syscall только через 200 мс!
Note over Go: Всплеск p99 latency у клиентов!
4. Архитектура агрегации логов: DaemonSet vs Sidecar
Чтобы логи попали в Grafana Loki или Elasticsearch, их нужно собрать с нод. В K8s есть два паттерна:
1. DaemonSet (Стандарт индустрии)
На каждой ноде запускается один Pod с агентом сбора (Fluent Bit, Promtail, Filebeat). Он монтирует /var/log/pods хоста и читает логи всех контейнеров напрямую.
- Плюсы: Минимальный оверхед (1 агент на ноду), не требует изменений в ваших Deployment'ах.
- Минусы: Агент не знает метаданных K8s (имя Deployment, неймспейс). Ему приходится обращаться к API Server, чтобы обогатить логи лейблами.
2. Sidecar (Для изоляции)
В каждый Pod вашего приложения добавляется контейнер с логгером (например, fluentd). Ваше Go-приложение пишет логи в локальный файл (shared Volume между контейнерами Пода), а Sidecar их читает и отправляет.
- Плюсы: Идеальная изоляция (можно парсить логи специфичным для сервиса образом), логи не теряются при падении сети на ноде.
- Минусы: Колоссальный расход ресурсов (CPU/RAM). Если у вас 100 Подов, у вас работает 100 агентов
fluentd. Это антипаттерн для микросервисов.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph DaemonSetPattern["Рекомендуемый паттерн: DaemonSet"]
P1["Go Pod 1"]:::process -->|"stdout"| F1["/var/log/pods/1.log"]
P2["Go Pod 2"]:::process -->|"stdout"| F2["/var/log/pods/2.log"]
F1 --> FB["Fluent Bit DaemonSet\n(1 инстанс на хост)"]
F2 --> FB
FB -->|"HTTP Batch"| Loki["Grafana Loki"]
end
5. Специфика Go: Структурированное логирование с log/slog
До релиза Go 1.21 стандартный пакет log умел генерировать лишь неструктурированные текстовые строки:
2024/05/10 12:00:00 User 42 bought item 100
Для систем агрегации логов (Loki, ELK) парсинг такого текста с помощью регулярных выражений превращается в вычислительный ад и сжигает процессорные мощности сборщиков логов.
С выходом Go 1.21 в стандартную библиотеку был внедрен пакет log/slog, сделавший высокоскоростное JSON-логирование стандартным языковым идиоматическим решением:
package main
import (
"log/slog"
"net/http"
"os"
)
func main() {
// Настраиваем структурированный JSON-обработчик для K8s инфраструктуры
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
slog.SetDefault(logger)
http.HandleFunc("/api/v1/order", func(w http.ResponseWriter, r *http.Request) {
// Лог автоматически сериализуется в валидный плоский JSON
slog.Info("обработка заказа",
slog.String("path", r.URL.Path),
slog.Int("user_id", 42),
slog.String("trace_id", r.Header.Get("X-Trace-ID")),
)
w.WriteHeader(http.StatusOK)
})
slog.Info("HTTP сервер запущен на порту :8080")
http.ListenAndServe(":8080", nil)
}
Контейнер выведет в stdout чистую валидную JSON-строку:
{"time":"2026-09-18T12:00:00Z","level":"INFO","msg":"обработка заказа","path":"/api/v1/order","user_id":42,"trace_id":"a1b2c3d4"}
Агент Fluent Bit мгновенно парсит эти поля за наносекунды без тяжелых регулярок, автоматически превращая user_id и trace_id в фильтруемые индексы в Loki или OpenSearch!
6. Безопасность: Секреты в логах
В статье 3. ConfigMap и Secret мы подробно изучили, как безопасно хранить пароли, API-ключи и сертификаты. Однако самая частая причина утечки закрытых данных в продакшене — банальная неосторожность разработчика при логировании:
// ОПАСНО ДЛЯ ЖИЗНИ: случайная запись паролей и токенов в открытый лог!
slog.Info("попытка авторизации", slog.Any("body", req))
Если сериализовать объект запроса req целиком, конфиденциальный пароль пользователя попадет в /var/log/pods, оттуда через Fluent Bit перелетит в центральное хранилище логов, где станет доступен любому сотруднику компании с доступом к Grafana.
Правила безопасности логов в Go:
- Никогда не логируйте тела HTTP-запросов (Request Body) целиком: Логируйте только идентификаторы сущностей (
user_id,order_id), но не чувствительное содержимое (пароли, номера кредитных карт). - Используйте типы-обертки для конфиденциальных данных: Скрывайте значение при форматировании (например, переопределение метода
String()). - Фильтрация на уровне агентов: На уровне Fluent Bit используйте фильтры
grepилиparserдля маскирования полей, выглядящих как кредитные карты (PCI DSS) или токены.
Резюме
- Иммутабельность и эфемерность: Поды не хранят логи на внутренних дисках. Всё логирование строится как непрерывный поток данных в дескрипторы
stdout(FD 1) иstderr(FD 2). - Kubelet и пути: Логи физически хранятся на хосте в
/var/log/pods/. Утилитаkubectl logsчитает их напрямую черезkubelet. - Борьба с Disk Pressure: Неограниченный поток логов способен убить ноду и etcd. Настраивайте
containerLogMaxSizeиcontainerLogMaxFilesв конфигурации Kubelet. - Mechanical Sympathy: Запись в
stdoutидет через системный буфер пайпа в 64 КБ. При перегрузке диска хоста вызовwrite(1, data)блокирует горутины Go, взвинчивая задержки. - DaemonSet — выбор продакшена: Сбор логов агентом Fluent Bit по модели DaemonSet экономит гигабайты оперативной памяти по сравнению с Sidecar.
- Пакет
log/slog: Стандартизирует структурированные логи в формате JSON, избавляя сборщики от дорогого парсинга регулярками и решая проблему многострочных стектрейсов паники.
Метрики сигнализируют о масштабе пожара, логи указывают на загоревшийся модуль. Но когда инцидент уже случился, и серверы задыхаются прямо сейчас, инженеру требуется четкий, хладнокровный протокол поиска неисправностей. В следующей статье мы разберем комплексный траблшутинг продакшена: 6. Troubleshooting продакшена.