7. Observability в Kubernetes

Прозрение в темноте: Как понять, почему всё сломалось

«Нельзя управлять тем, что вы не можете измерить. Но еще опаснее — пытаться чинить распределенную систему, в которой вы ослепли».
— Принцип наблюдаемости распределенных систем

В предыдущих лекциях мы упаковали наш Go-сервис в компактный контейнер, настроили автоматическое масштабирование через HPA (см. 5. Horizontal scaling) и научились бесшовно обновлять версии без единой секунды простоя (см. 6. Rolling updates).

А теперь представьте типичный рабочий день инженера: в боевом кластере работает 50 реплик микросервиса. Из службы поддержки приходит тревожное сообщение: у части пользователей мобильного приложения оплата падает с таймаутом или ошибкой HTTP 500, а у других пользователей оформление заказа занимает 10 секунд вместо привычных 80 миллисекунд. Вы открываете терминал, вводите kubectl get pods, видите 50 зеленых строчек со статусом Running — и понимаете страшную вещь: ваша распределенная система превратилась в непроницаемый черный ящик.

В классическую эпоху монолитов инженер мог подключиться к физическому серверу по SSH, открыть интерактивную утилиту top или заглянуть в локальный файл /var/log/syslog. В Kubernetes этот подход мертв:

  1. Эфемерность подов: Контейнеры живут часы, минуты, а иногда секунды. Если Pod был уничтожен ядром по нехватке памяти (OOM — Out Of Memory), он просто исчезает вместе со всей локальной файловой системой.
  2. Динамическая топология: Трафик одного пользователя размазывается между сотнями подов и десятками микросервисов. Искать проблему локально на одной ноде бессмысленно.

Observability (Наблюдаемость) — это инженерный подход, позволяющий математически точно реконструировать внутреннее состояние любой части распределенной системы исключительно на основе анализа ее внешних артефактов. В современной индустрии наблюдаемость опирается на «три столпа» (Three Pillars): Метрики (Metrics), Логи (Logs) и Распределенные трейсы (Traces).

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
    subgraph ThreePillars["Три столпа Observability"]
        M["1. Метрики (Metrics)\nЧТО сломалось?\n(RPS, Latency, Errors, Heap)"]
        T["2. Трейсы (Traces)\nГДЕ сломалось?\n(Путь запроса через 10 сервисов)"]
        L["3. Логи (Logs)\nПОЧЕМУ сломалось?\n(Стек ошибки, контекст, параметры)"]
    end
    M ==> T ==> L
  

В этой лекции мы разберем, как спроектировать сквозную наблюдаемость Go-приложения в Kubernetes, почему наивный fmt.Println может заблокировать все потоки рантайма Go, как защитить мониторинг от ловушки High Cardinality и как расследовать загадочные «тихие смерти» подов.


Три столпа Observability в кластере

Чтобы видеть систему насквозь, нам нужны три фундаментально разных типа телеметрии, каждый из которых собирается специализированным агентом и передается в оптимизированное хранилище данных:

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
    subgraph PodInstance["Go-приложение внутри Pod"]
        App["Go Application Process\n• Port 8080: Бизнес-API\n• Port 8081: /metrics & /healthz"]:::network
    end

    subgraph MetricsSubsystem["Подсистема 1: Метрики (Pull)"]
        App -->|"1. Экспонирует /metrics (текст)"| Prom["Prometheus Server\n(TSDB Time-Series)"]
        cAdv["cAdvisor (kubelet на ноде)"] -->|"Экспонирует CPU/RAM cgroups"| Prom
        Prom --> Grafana["Grafana Dashboards"]
    end

    subgraph LoggingSubsystem["Подсистема 2: Логи (Push via DaemonSet)"]
        App -->|"2. Пишет JSON в stdout/stderr"| CRI["containerd (CRI-O)"]
        CRI -->|"Сохраняет на диск ноды"| LogFiles["/var/log/containers/*.log"]
        DaemonCollector["Promtail / Fluentbit / Vector\n(DaemonSet на каждой ноде)"] -->|"Вычитывает файлы"| LogFiles
        DaemonCollector -->|"Батчинг и компрессия"| Loki["Loki / Elasticsearch"]
    end

    subgraph TracingSubsystem["Подсистема 3: Трейсы (Push via OTel)"]
        App -->|"3. Spans по gRPC/OTLP"| OTelCollector["OpenTelemetry Collector\n(DaemonSet / Sidecar)"]
        OTelCollector -->|"Агрегация и сэмплирование"| Jaeger["Jaeger / Tempo"]
    end
  

1. Метрики: Пульс системы и Prometheus

Метрики — это числовые временные ряды (Time Series), отражающие динамику поведения сервиса:

  • Сколько HTTP-запросов обработано в секунду (Throughput / RPS);
  • Каков 99-й перцентиль задержки ответа (P99 Latency);
  • Сколько активных горутин и мегабайт кучи (Heap Alloc) сейчас удерживает рантайм Go.

В мире Kubernetes непререкаемым стандартом де-факто является Prometheus. В отличие от устаревших систем (таких как StatsD или Graphite, куда приложение само вынуждено отправлять данные по UDP — модель Push), Prometheus реализует Pull-модель (Scraping):

  1. Ваше Go-приложение на служебном порту (например, :8081) открывает стандартный эндпоинт /metrics.
  2. Prometheus раз в 15 секунд отправляет легковесный HTTP GET запрос на /metrics каждого пода и забирает текстовый поток счетчиков.
Go
package main

import (
    "net/http"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Суммарное количество обработанных HTTP-запросов",
        },
        []string{"method", "status", "path"},
    )
)

func init() {
    prometheus.MustRegister(httpRequestsTotal)
}

func startMetricsServer() {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    _ = http.ListenAndServe(":8081", mux)
}

Mechanical Sympathy: Ловушка High Cardinality

Самая коварная ловушка для новичков при внедрении Prometheus в Go — это High Cardinality (Взрывная кардинальность меток).

База данных Prometheus (TSDB) устроена так: каждая уникальная комбинация имени метрики и значений ее меток (Labels) создает в оперативной памяти абсолютно новый временной ряд (Time Series) со своими чанками кэша и индексами.

Представьте, что вы логируете длительность HTTP-запросов и добавили метку path:

Go
// ❌ КАТАСТРОФИЧЕСКАЯ ОШИБКА: сырой путь в лейбле
httpRequestsTotal.WithLabelValues(r.Method, "200", r.URL.Path).Inc()

Если клиент обращается по путям с динамическими идентификаторами:

  • path="/api/users/123"
  • path="/api/users/456"
  • path="/api/users/789"

То при наличии миллиона пользователей вы создадите в памяти Prometheus миллион независимых временных рядов! TSDB Prometheus моментально исчерпает всю оперативную память сервера, свалится в бесконечный Garbage Collection и упадет по OOM Killer.

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
    subgraph Bad["Опасный путь: High Cardinality"]
        RawURL["r.URL.Path = /api/users/12345"]:::network --> MetricBad["path=\#quot;/api/users/12345\#quot;\nНовая Time Series!"]
        MetricBad --> OOM["💥 Миллион серий = OOM в Prometheus TSDB"]
    end

    subgraph Good["Правильный путь: Low Cardinality"]
        CleanPattern["Роутер: pattern = /api/users/:id"]:::network --> MetricGood["path=\#quot;/api/users/:id\#quot;\nВсего 1 Time Series!"]
        MetricGood --> Healthy["✅ Стабильная память (микросекундный сбор)"]
    end
  

Метрики железа: cAdvisor

Вам не требуется писать специальный код на Go, чтобы собирать физические метрики контейнера (нагрузку на ядра процессора, RSS оперативной памяти, сетевой трафик сетевого интерфейса eth0).

Демон kubelet (см. 2. Kubernetes. Основы), работающий на каждой ноде, содержит встроенный модуль cAdvisor (Container Advisor). Он напрямую подключается к файловой системе ядра Linux cgroups (контрольные группы cpu.stat, memory.current) и автоматически экспонирует для Prometheus стандартные системные метрики: container_cpu_usage_seconds_total, container_memory_working_set_bytes и другие.


2. Логирование: Иллюзия stdout

В архитектуре Kubernetes логирование подчинено принципу: максимальная простота для приложения, вся сложность — на инфраструктуре.

Приложению на Go категорически запрещено писать логи в файлы внутри контейнера. Оно должно отправлять события исключительно в стандартные потоки вывода операционной системы — stdout (информационные сообщения) и stderr (ошибки и паники).

Дальше в дело вступает инфраструктурный конвейер:

  1. Container Runtime (например, containerd) перехватывает потоки вывода процесса и записывает их в локальные файлы на диске ноды по пути /var/log/containers/.
  2. Специализированный сборщик логов (Promtail, Fluentbit, Vector), развернутый как DaemonSet (по одному экземпляру на каждую ноду), вычитывает эти файлы в режиме tailing, парсит строки, обогащает их метаданными пода (namespace, pod_name, container_name) и батчами отправляет в централизованное хранилище — Grafana Loki или Elasticsearch.

Идиоматичный Go: log/slog и JSON

Начиная с версии Go 1.21 в стандартную библиотеку языка встроен высокопроизводительный пакет структурированного логирования — log/slog:

Go
package main

import (
    "log/slog"
    "os"
    "time"
)

func initLogger() {
    // Создаем JSON-обработчик, пишущий напрямую в stdout
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    logger := slog.New(handler)
    slog.SetDefault(logger)
}

func ProcessPayment(userID string, amount int) {
    start := time.Now()
    // ... бизнес-логика транзакции ...

    slog.Info("payment processed",
        slog.String("user_id", userID),
        slog.Int("amount", amount),
        slog.Duration("latency", time.Since(start)),
    )
}

Такая запись выводится в одну JSON-строку:

JSON
{"time":"2026-09-17T14:30:00.000Z","level":"INFO","msg":"payment processed","user_id":"12345","amount":500,"latency":45000000}

Хранилище Grafana Loki парсит такой лог мгновенно, позволяя аналитикам и инженерам выполнять фильтрацию любой сложности через язык LogQL:

Text
{app="payment"} | json | amount > 100

Mechanical Sympathy: Блокирующий stdout

Многие Go-разработчики считают, что вызов fmt.Println или slog.Info выполняется мгновенно и не несет архитектурных рисков. Это опасная иллюзия.


3. Трассировка (Tracing): Поиск виновного в толпе

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

$$\text{Client} \to \text{API Gateway} \to \text{Order Service} \to \text{Payment Service} \to \text{PostgreSQL}$$

И общий ответ занял 5.2 секунды вместо 50 мс — кто именно из участников виноват в задержке?

Ответить на этот вопрос может только распределенная трассировка (Distributed Tracing) (см. 6. Distributed tracing). В экосистеме Cloud Native единым стандартом стал проект OpenTelemetry (OTel).

Механика контекста и TraceID

При входе запроса на пограничный прокси (API Gateway) генерируется глобально уникальный идентификатор сквозного следа — TraceID. При каждом межсервисном вызове по HTTP или gRPC клиентская библиотека обязана внедрять (inject) этот идентификатор в сетевые заголовки согласно стандарту W3C Trace Context (заголовок traceparent).

sequenceDiagram
    autonumber
    actor User as Клиент
    participant GW as API Gateway
    participant Order as Order Service
    participant Pay as Payment Service
    participant DB as PostgreSQL

    User->>GW: POST /checkout
    Note over GW: Генерация TraceID = 4bf92f3577b34da6a3ce929d0e0e4736
    GW->>Order: gRPC CreateOrder (Заголовок: traceparent)
    Order->>Pay: HTTP POST /pay (Заголовок: traceparent)
    Pay->>DB: SQL: UPDATE balances
    Note over DB: Задержка: 4.9 сек! (Lock contention)
    DB-->>Pay: OK
    Pay-->>Order: OK
    Order-->>GW: OK
    GW-->>User: HTTP 200 (Total: 5.0 сек)
  

Трассировка в Go через context

В языке Go передача трассировочной информации органически опирается на стандартный интерфейс context.Context.
Поскольку каждый входящий запрос обрабатывается в изолированной горутине, хранить TraceID в глобальных переменных категорически невозможно. Передача контекста через ctx во все функции сервиса — абсолютный стандарт:

Go
package main

import (
    "context"
    "database/sql"
    "net/http"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

var tracer = otel.Tracer("order-service")

func HandleOrder(w http.ResponseWriter, r *http.Request) {
    // 1. Извлекаем контекст запроса (содержащий TraceID от вышестоящего API Gateway)
    ctx := r.Context()

    // 2. Создаем локальный Span (отрезок времени исполнения бизнес-функции)
    ctx, span := tracer.Start(ctx, "process_order_db_transaction")
    defer span.End()

    // 3. Передаем обогащенный контекст в вызов базы данных!
    if err := executeDatabaseQuery(ctx); err != nil {
        span.RecordError(err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusOK)
}

func executeDatabaseQuery(ctx context.Context) error {
    // Под капотом драйвер базы данных зафиксирует Span "UPDATE" и свяжет его с TraceID
    var db *sql.DB
    _, err := db.ExecContext(ctx, "UPDATE balances SET balance = balance - 100 WHERE id = 1")
    return err
}

Библиотека OpenTelemetry в Go в фоновом неблокирующем режиме упаковывает завершенные спаны и по протоколу gRPC транслирует их в OpenTelemetry Collector, который собирает, фильтрует и передает трассы в хранилища визуализации — Jaeger или Grafana Tempo. В веб-интерфейсе Jaeger инженер видит наглядную водопадную диаграмму (Waterfall), из которой мгновенно ясно: из 5 секунд общего времени 4.9 секунды ушло на блокировку строки в операции UPDATE в базе данных.


Расследование инцидентов: Когда поды умирают молча

Один из самых болезненных сценариев в эксплуатации Kubernetes — это «тихая смерть» (Silent Death) пода.

Представьте: в вашем Go-сервисе возникла утечка памяти (например, горутины складывают указатели в глобальный срез и никогда не очищают его). Потребление RAM контейнера растет, пока не упирается в лимит resources.limits.memory: 512Mi.

В этот момент вмешивается подсистема ядра Linux — OOM Killer (Out Of Memory Killer). Ядро мгновенно стирает процесс контейнера системным вызовом SIGKILL.

В чем трагедия для инженера?

Сигнал SIGKILL обрабатывается исключительно ядром операционной системы на аппаратном уровне. Процесс Go не может перехватить SIGKILL. Приложение не успеет выполнить defer, не запишет предсмертное сообщение slog.Error("dying from OOM") и не сбросит стек вызовов. В логах Loki вы увидите лишь внезапный обрыв строк.

flowchart LR
    GoApp["Go-процесс (Утечка памяти)"] -->|"Превышен cgroup limit 512Mi"| Kernel["Ядро Linux (OOM Killer)"]
    Kernel -->|"Мгновенный аппаратный SIGKILL (Exit Code 137)"| Kill["Процесс уничтожен! Никаких логов!"]

    style Kernel fill:#3b1d22,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
  

Как расследовать причину гибели:

  1. События кластера (Cluster Events):
    Агент kubelet фиксирует факт уничтожения контейнера ядром и генерирует событие в API Server. Инженер может увидеть причину через консоль:

    Bash
    kubectl describe pod <name>
    kubectl get events --sort-by='.metadata.creationTimestamp'
    

    В выводе вы увидите характерную метку:

    Text
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
    

    (Код возврата 137 расшифровывается как $128 + 9$, где 9 — номер сигнала SIGKILL).

  2. Kube-state-metrics и автоматический алертинг:
    Специализированный сервис kube-state-metrics непрерывно транслирует статусы жизненного цикла подов в метрики Prometheus.
    Настроив алерт в Alertmanager на условие:

    PROMQL
    kube_pod_container_status_terminated_reason{reason="OOMKilled"} > 0
    

    дежурная команда инженеров получает мгновенное уведомление в корпоративный мессенджер в ту же секунду, когда контейнер пал жертвой OOM Killer.


Вопросы для собеседования


Резюме

  1. Три столпа: Метрики показывают наличие и масштаб проблемы, трейсы локализуют виновный компонент в графе вызовов, а логи вскрывают детальную причину сбоя (например, детальный текст ошибки sql: connection refused).
  2. Pull-модель Prometheus: Приложение открывает эндпоинт /metrics, а сервер мониторинга сам опрашивает поды благодаря Service Discovery в Kubernetes.
  3. Защита от High Cardinality: Никогда не используйте динамические идентификаторы в лейблах метрик. Лейблы должны быть строго ограниченным набором фиксированных значений.
  4. Структурированный JSON в stdout: Применяйте пакет log/slog для вывода структурированных логов. Помните об опасности переполнения буфера Unix Pipe при чрезмерном логировании.
  5. OpenTelemetry и Context: Передавайте context.Context через все слои Go-архитектуры для сквозного проброса TraceID.
  6. OOMKilled расследуется снаружи: Контейнер, убитый по SIGKILL, не оставляет записей в логах. Причину аварии выявляют через kubectl describe pod и метрику kube_pod_container_status_terminated_reason.

Мы оснастили наши сервисы полноценным мониторингом, научились контролировать использование ресурсов и мгновенно расследовать любые аномалии. Но как сетевой пакет клиента, летящий из браузера через глобальный интернет, физически находит нужный Pod среди сотен других внутри закрытого кластера? Как взаимодействуют Ingress-контроллеры, CoreDNS, ClusterIP и оверлейные сети CNI? В заключительной лекции подраздела мы разберем главный фундамент распределенной инфраструктуры: 8. Kubernetes networking.