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:

Go
for {
    log.Printf("failed to connect to database: %v", err)
}

Такой контейнер способен за считанные часы выплюнуть на диск хоста сотни гигабайт текстового мусора.

Когда физический диск сервера заполняется до 85–90%, ядро Linux и Kubernetes включают защитные механизмы, оборачивающиеся катастрофой:

  1. Состояние DiskPressure: Планировщик Kubernetes помечает ноду флагом DiskPressure и запрещает запуск новых подов на этом узле.
  2. Падение Kubelet: Демон kubelet не может создавать временные сокеты и обновлять чекпоинты. Сервер выпадает из кластера в статус NotReady.
  3. Краш базы данных etcd: Если на этом узле развернут контроллер кластера, база метаданных etcd падает с фатальной ошибкой: она не может выполнить системный вызов fsync для сброса WAL-журнала на переполненный диск.

Решение: Настройка ротации логов в Kubelet

На каждом сервере кластера в файле конфигурации Kubelet (/var/lib/kubelet/config.yaml) обязательно должны быть зафиксированы жесткие квоты ротации:

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(), рантайм вызывает системный вызов ядра:

Bash
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-логирование стандартным языковым идиоматическим решением:

Go
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-строку:

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

Go
// ОПАСНО ДЛЯ ЖИЗНИ: случайная запись паролей и токенов в открытый лог!
slog.Info("попытка авторизации", slog.Any("body", req))

Если сериализовать объект запроса req целиком, конфиденциальный пароль пользователя попадет в /var/log/pods, оттуда через Fluent Bit перелетит в центральное хранилище логов, где станет доступен любому сотруднику компании с доступом к Grafana.

Правила безопасности логов в Go:

  1. Никогда не логируйте тела HTTP-запросов (Request Body) целиком: Логируйте только идентификаторы сущностей (user_id, order_id), но не чувствительное содержимое (пароли, номера кредитных карт).
  2. Используйте типы-обертки для конфиденциальных данных: Скрывайте значение при форматировании (например, переопределение метода String()).
  3. Фильтрация на уровне агентов: На уровне Fluent Bit используйте фильтры grep или parser для маскирования полей, выглядящих как кредитные карты (PCI DSS) или токены.

Резюме

  1. Иммутабельность и эфемерность: Поды не хранят логи на внутренних дисках. Всё логирование строится как непрерывный поток данных в дескрипторы stdout (FD 1) и stderr (FD 2).
  2. Kubelet и пути: Логи физически хранятся на хосте в /var/log/pods/. Утилита kubectl logs читает их напрямую через kubelet.
  3. Борьба с Disk Pressure: Неограниченный поток логов способен убить ноду и etcd. Настраивайте containerLogMaxSize и containerLogMaxFiles в конфигурации Kubelet.
  4. Mechanical Sympathy: Запись в stdout идет через системный буфер пайпа в 64 КБ. При перегрузке диска хоста вызов write(1, data) блокирует горутины Go, взвинчивая задержки.
  5. DaemonSet — выбор продакшена: Сбор логов агентом Fluent Bit по модели DaemonSet экономит гигабайты оперативной памяти по сравнению с Sidecar.
  6. Пакет log/slog: Стандартизирует структурированные логи в формате JSON, избавляя сборщики от дорогого парсинга регулярками и решая проблему многострочных стектрейсов паники.

Метрики сигнализируют о масштабе пожара, логи указывают на загоревшийся модуль. Но когда инцидент уже случился, и серверы задыхаются прямо сейчас, инженеру требуется четкий, хладнокровный протокол поиска неисправностей. В следующей статье мы разберем комплексный траблшутинг продакшена: 6. Troubleshooting продакшена.