load-tester Все курсы

✻ Урок 7.4 · Тема 7: Метрики, логи, трейсы и алерты

Экспортеры: хост, контейнеры и PostgreSQL

⏱ 3.5 ч

Зачем это нужно

Я много лет смотрю на графики во время тестов, и вот ситуация, которая ждёт тебя в первую же неделю. Перед распродажей ты поднимаешь нагрузку на «Магазин», и p95 из урока 7.2 прыгает с 80 до 900 мс. Дашборд RED из урока 7.3 честно говорит: запросы стали медленными. А вот почему, он не знает.

В одной команде на такой вопрос ответили «надо больше серверов». Купили серверы получше, p95 не сдвинулся. Оказалось, что тормозила не машина, а выданный сервису лимит процессора, и машина при этом простаивала.

Метрики самого «Магазина» (http_requests_total, http_request_duration_seconds) описывают то, что видит клиент. Про процессор и память они ничего не знают. Эти данные собирают маленькие программы, экспортёры (exporter). В стенде их три: node-exporter следит за хостом, cadvisor за контейнерами, postgres-exporter за базой. Во время теста ты кладёшь рядом два набора графиков, «что видит клиент» и «что с ресурсами», и ищешь ресурс, упёршийся в предел.

Шаг проекта: ты пишешь скрипт orders_load.py, который создаёт на стенде фоновую нагрузку (он пригодится в уроках 7.5 и 7.6). По метрикам экспортёров ты находишь узкое место: в тесте входа оно окажется не на хосте, а внутри контейнера shop. Результат ты записываешь в таблицу USE ~/perf-lab/07-monitoring/use-table.md.

Что нужно знать

  • Prometheus, цели (targets), up, типы метрик и метки: урок 7.1. Профиль мониторинга уже должен работать (docker compose --profile monitoring up -d --wait).
  • PromQL: rate, sum by, avg, деление рядов: урок 7.2. Здесь запросы длиннее, но собраны из тех же деталей.
  • Grafana Explore или Prometheus UI, чтобы выполнять запросы: урок 7.3.
  • Контейнер, лимит процессора и памяти, compose.yaml: урок 5.1 и урок 5.3. В нашем стенде у shop и postgres стоит cpus: "1.0", у shop ещё mem_limit: 512m, и это важно для всего урока.
  • Ограничения стенда и поиск узких мест: теория очередей из урока 8.2: очередь появляется, когда ресурс занят почти всё время.

Картина целиком

Приборная панель автомобиля показывает скорость. Если машина вдруг поехала медленнее, спидометр не скажет почему: для этого есть датчики температуры, давления масла, топлива. Метрики приложения это спидометр, они описывают результат. Экспортёры это датчики в моторном отсеке: они описывают ресурсы, из-за которых результат стал таким. Аналогия ломается в одном: у сервиса датчики приходится ставить самому.

flowchart TD
    A["Ядро Linux хоста<br>/proc и /sys"] --> N["node-exporter<br>:9100"]
    B["Docker и cgroups<br>контейнеров"] --> C["cAdvisor<br>:8080"]
    D["PostgreSQL<br>pg_stat_*"] --> P["postgres-exporter<br>:9187"]
    S["shop и payment<br>/metrics"] --> PR["Prometheus<br>:9090"]
    N --> PR
    C --> PR
    P --> PR
    PR --> G["Grafana<br>:3000"]

На схеме у каждого источника свой «переводчик» в формат Prometheus, а он раз в 5 секунд обходит всех. Сервисы shop и payment отдают метрики сами, им экспортёр не нужен. В конце ты соберёшь всё в короткий чек-лист вопросов к каждому ресурсу, чтобы не забыть ни один.

Теория

Экспортёр: переводчик между системой и Prometheus

Prometheus умеет ровно одно: ходить по адресам и читать текст в своём формате. Но Linux не отдаёт свои счётчики по HTTP, и PostgreSQL тоже. Как же Prometheus узнает, сколько процессора занято?

Представь переводчика на встрече. Ты говоришь на одном языке, собеседник на другом, и на каждый вопрос переводчик переспрашивает собеседника и пересказывает ответ. Такая программа и называется экспортёром. Аналогия ломается тем, что переводчик ничего не помнит: счётчики копит сама система, а экспортёр отдаёт их текущие значения.

Раз в 5 секунд (scrape_interval в monitoring/prometheus/prometheus.yml) Prometheus делает скрейп, то есть запрос GET /metrics, как в уроке 7.1. Экспортёр в этот момент читает источник. node-exporter читает виртуальные файлы ядра /proc и /sys, cadvisor такие же счётчики контейнеров, postgres-exporter системные представления pg_stat_* через SELECT. В prometheus.yml они подключены обычными целями:

  - job_name: node
    static_configs: [{targets: ["node-exporter:9100"]}]
  - job_name: cadvisor
    static_configs: [{targets: ["cadvisor:8080"]}]
  - job_name: postgres
    static_configs: [{targets: ["postgres-exporter:9187"]}]

Имена в адресах это имена сервисов Compose. Порты 9100, 8080 и 9187 не опубликованы на твою машину: в compose.yaml у этих сервисов нет ports:. Из браузера и curl с хоста ты к ним не попадёшь. Смотреть их можно через Prometheus или изнутри сети Compose (в практике).

Осторожно: экспортёр не агент. Агент (программа-сборщик, о ней в уроке 7.5) сам отправляет данные, а экспортёр пассивен и просто отвечает на /metrics. Он знает про ресурсы, но не знает, какому запросу пользователя они достались.

Главное: экспортёр это пассивный переводчик: Prometheus сам приходит за значениями, экспортёр читает систему и отвечает.

Экспортёров три. Начнём с того, о чём забывают первым: здоров ли сам хост.

node-exporter: здоров ли хост

Прежде чем жаловаться на медленную кассу, убедись, что в магазине есть свет. Хост это машина, на которой стоит стенд, и все контейнеры делят её процессор, память и диск. Если хост перегружен, любой тест покажет плохие числа. Метрики node-exporter называются node_*, подключён он так:

  node-exporter:
    image: prom/node-exporter:v1.12.1
    profiles: [monitoring]
    pid: host
    command: ["--path.rootfs=/host"]
    volumes: ["/:/host:ro,rslave"]

Корень хоста подключён внутрь контейнера по пути /host только для чтения (ro): экспортёр ничего не сломает, но видит диски. Флаг --path.rootfs=/host подсказывает, где настоящий корень хоста.

Главная метрика процессора node_cpu_seconds_total это counter (урок 7.1): сколько секунд каждое ядро провело в каждом режиме. Метки: cpu (номер ядра) и mode (режим). Режим idle значит «простаивает», user «выполняет код программ», system «работает ядро», iowait «ждёт диск». Режим steal бывает в виртуалке: процессор отобрал гипервизор, программа, которая делит одно железо между виртуалками.

За секунду каждое ядро проживает ровно секунду. Хост на четыре ядра, и rate(node_cpu_seconds_total{mode="idle"}[1m]) вернул 0,80; 0,90; 0,85; 0,95: столько секунды каждое ядро простаивало. Среднее (0,80 + 0,90 + 0,85 + 0,95) / 4 = 0,875, простой 87,5%. Занято 1 − 0,875 = 0,125, то есть 12,5%. Запросом это пишется так:

1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))

Читаем изнутри: rate превращает счётчик в долю по каждому ядру, avg усредняет по ядрам, 1 - переходит от «простаивает» к «занято». Это выражение алерта HostHighCpu (правила, которое срабатывает при превышении порога; подробнее в уроке 7.6) (порог > 0.9). А чтобы увидеть, на что уходит процессор, разложи занятость по режимам:

sum by (mode) (rate(node_cpu_seconds_total{mode!="idle"}[1m]))

Прикинь сам: запрос вернул user 1.1, system 0.3, iowait 0.0 на хосте с четырьмя ядрами. Сколько ядер занято и какой это процент?

Складываем: 1,1 + 0,3 = 1,4 ядра из четырёх, то есть 35%. Много iowait значит, что узкое место диск, а не процессор. Много steal значит, что мощность виртуалки плавает, и результаты теста тоже.

Рядом с занятостью смотри на очередь. Метрика node_load1 это load average из урока 1.3: среднее число задач за минуту, которые ждут ядро или диск. Само число ничего не говорит: четвёрка на четырёх ядрах значит «очереди нет», а на одном ядре «в очереди три лишних». Поэтому делят на число ядер:

node_load1 / count(node_cpu_seconds_total{mode="idle"})

Рядов idle по одному на ядро, так что count даёт число ядер. Результат 0,3 комфорт, 1,0 предел, 2,0 очередь вдвое длиннее числа ядер. Это насыщение (saturation, урок 7.3): работа ждёт, потому что ресурс не успевает.

Проверь понимание: node_load1 = 6 на хосте с 4 ядрами, а CPU занят на 55%. Возможно ли такое? Что посмотреть?

Ответ

Да. Load считает и задачи, ждущие диск. Если много процессов упёрлись в медленный диск, ядра простаивают, а load высокий. Смотри iowait в node_cpu_seconds_total и rate(node_disk_io_time_seconds_total[1m]): скорее всего, узкое место диск.

Главное: загрузку процессора считают как «единица минус простой», а очередь к нему показывает load average, поделённый на число ядер.

С процессором разобрались. Но у хоста есть память, и с ней новичков пугают чаще всего.

Память и диск хоста

На хосте с 16 ГБ памяти свободными числятся 300 МБ. Паника? Нет, и сейчас станет ясно почему.

Linux намеренно занимает свободную память под кэш файлов (page cache, «кэш диска» из урока 1.3): диск медленный, а память быстрая. Когда программе нужна память, ядро вытесняет кэш. Поэтому node_memory_MemFree_bytes почти всегда мала и ничего не говорит. Смотри на node_memory_MemAvailable_bytes: сколько памяти программы могут получить, если попросят. Занятость:

1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes

Всего 16 ГБ, доступно 11 ГБ: 1 − 11/16 = 0,31, занято 31%. Настоящая беда выглядит иначе. Первый признак: своп (swap, файл на диске, куда ядро выгружает память), то есть rate(node_vmstat_pswpout[1m]) больше нуля. Тогда всё замедляется в десятки раз. Второй признак: increase(node_vmstat_oom_kill[1h]) > 0: OOM-killer, механизм ядра, убил самый «толстый» процесс.

С диском две заботы. Заполненность: 1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}, где условие отсекает «мнимые» файловые системы в памяти. Забитый диск ломает PostgreSQL и логи, и узнать о нём надо за часы. Для этого predict_linear(node_filesystem_avail_bytes[1h], 4 * 3600) < 0 продолжает последний час прямой и проверяет, кончится ли место через 4 часа. Занятость диска работой покажет rate(node_disk_io_time_seconds_total[1m]): 0,95 значит «занят 95% времени».

Для любопытных: сеть хоста и строка pid: host

Метрики сети называются node_network_*. Входящая скорость в битах в секунду: rate(node_network_receive_bytes_total{device="eth0"}[1m]) * 8 (каналы меряют в битах, диски в байтах). Рост node_network_receive_errs_total и node_network_receive_drop_total это тревога.

Но сеть контейнера отделена от хоста. Эти ряды показывают интерфейсы самого контейнера (виртуальный eth0), а не сетевую карту хоста. Для карты хоста нужен network_mode: host. Трафик сервисов точнее покажет cAdvisor. Строка pid: host даёт контейнеру видеть процессы хоста: для CPU, памяти, диска и сети она не нужна, их экспортёр читает из /proc и /sys.

Главное: смотри на MemAvailable, а не на MemFree, и тревожься не из-за высокой занятости, а из-за свопа и OOM-убийств.

Хост разобрали. Но на нём живёт десяток контейнеров, и общий счётчик не скажет, кто тянет ресурсы.

cAdvisor: что творится внутри контейнеров

Хост занят на 40%, а shop тормозит. node-exporter молчит о том, чей это процессор. Хуже того, у контейнера выдан лимит: в compose.yaml у shop стоит cpus: "1.0" и mem_limit: 512m. Хост может быть свободен наполовину, а контейнер, упёршийся в лимит, уже тормозит.

Представь счётчики света в доме. Общий (node-exporter) показывает, что дом тратит много, квартирные (cAdvisor, Container Advisor, «советчик по контейнерам») показывают, какая квартира. Лимит это автомат: он срабатывает, хотя в доме света сколько угодно. Аналогия ломается тем, что контейнеру процессор не отключают, а лишь притормаживают.

Цифры cAdvisor берёт из cgroups, контрольных групп ядра из урока 5.4: у каждой группы процессов свои счётчики и лимиты. Docker кладёт каждый контейнер в свою группу, cAdvisor читает их из /sys/fs/cgroup и называет контейнеры по данным Docker. В стенде он подключён так:

  cadvisor:
    image: ghcr.io/google/cadvisor:v0.60.6
    profiles: [monitoring]
    privileged: true
    command: ["--docker_only=true", "--housekeeping_interval=5s"]
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker:/var/lib/docker:ro
      - /dev/disk:/dev/disk:ro

Режим privileged: true снимает защитные ограничения контейнера, а тома дают заглянуть в cgroups и Docker: иначе cAdvisor ничего бы не увидел. Флаг --docker_only=true оставляет только контейнеры Docker, --housekeeping_interval=5s обновляет значения каждые 5 секунд, как scrape_interval. У каждой метрики есть метка name: имя контейнера в Compose (shop-shop-1, shop-postgres-1). Ещё cAdvisor копирует метки Docker с префиксом container_label_, например container_label_com_docker_compose_service="shop" (имя сервиса без -1): ею пользуются уроки темы 11. Если запрос вернул пусто, проверь имя: count by (name) (container_cpu_usage_seconds_total{name!=""}).

Counter container_cpu_usage_seconds_total это CPU-секунды контейнера. Берёшь rate и получаешь, сколько ядер он занимает сейчас:

rate(container_cpu_usage_seconds_total{name="shop-shop-1"}[1m])

Значение 0,35 значит «35% одного ядра», 1,0 «ядро занято полностью». Лимит записан в container_spec_cpu_quota (сколько микросекунд CPU разрешено за период) и container_spec_cpu_period (длина периода, обычно 100 000 мкс, то есть 100 мс). У shop квота 100 000 на период 100 000: ровно одно ядро. Загрузка относительно лимита:

rate(container_cpu_usage_seconds_total{name="shop-shop-1"}[1m])
  / (container_spec_cpu_quota{name="shop-shop-1"} / container_spec_cpu_period{name="shop-shop-1"})

Прикинь сам: у контейнера лимит cpus: "0.5", и он использует 0,35 ядра. Какова загрузка относительно лимита?

Квота 50 000 на период 100 000 даёт 0,5 ядра. Делим 0,35 на 0,5 и получаем 0,7, то есть 70% лимита.

Главное: у контейнера свой лимит, и загрузку считают от него, а не от всего хоста.

Но даже при загрузке 60% от лимита контейнер может тормозить. Сейчас объясню как.

Троттлинг: когда контейнеру жмёт лимит

Загрузка контейнера 60%, а p95 растёт. Так не должно быть? Должно, всё дело в том, как ядро выдаёт процессорное время.

Тебе выдали на день 100 «монет на такси». Потратил все к обеду, и остаток дня стоишь на месте. С контейнером так же. Ядро делит время на периоды по 100 мс и в каждом выдаёт «бюджет»: 100 мс при лимите в одно ядро, 50 мс при 0,5 ядра. Когда бюджет потрачен, процессы контейнера замораживаются до следующего периода. Это троттлинг (throttling) из урока 5.4. Запрос начал выполняться, его заморозили, и он закончился позже. Снаружи это «лаги» и рост p95 при нормальной средней загрузке.

Его видно по счётчикам container_cpu_cfs_periods_total (сколько периодов прошло) и container_cpu_cfs_throttled_periods_total (в скольких контейнер заморозили). Доля замороженных периодов:

rate(container_cpu_cfs_throttled_periods_total{name="shop-shop-1"}[1m])
  / rate(container_cpu_cfs_periods_total{name="shop-shop-1"}[1m])

За минуту проходит 600 периодов по 100 мс, rate(periods) равен 10 в секунду. Контейнер заморожен в 384 из них, то есть rate(throttled_periods) равен 6,4. Доля 6,4 / 10 = 0,64: в 64% времени контейнер работал урывками, и любой запрос в такой период получает прибавку до 100 мс. Повод для внимания: стабильно больше 5-10%, дальше p95 заметно страдает. Вот тест входа пользователей: пароль проверяется алгоритмом bcrypt, он намеренно медленный и грузит процессор единственного воркера shop. Мы повторим его в практике.

Линия хоста (36%) вводит в заблуждение, две другие показывают реальную картину.

Главное: процессор контейнера не отключают, а притормаживают, и видно это по доле замороженных периодов, а не по загрузке хоста.

С процессором понятно. С памятью жёстче: притормозить её нельзя.

Память контейнера и OOM

Когда не хватает процессора, программа замедляется. Когда не хватает памяти, программу убивают.

У cAdvisor три близкие метрики памяти (gauge из урока 7.1: значение, которое растёт и падает; в байтах). container_memory_usage_bytes включает кэш файлов, который можно выбросить. container_memory_working_set_bytes (working set) это «рабочий набор»: та же память, но без кэша файлов, которым давно не пользовались. В нём остаются память процессов и кэш недавно прочитанных файлов. container_memory_rss это память самих процессов (RSS из урока 1.3). Для решений бери working_set: из трёх метрик она ближе всех к тому, что контейнеру действительно нужно. Но это оценка, а не точная граница: часть кэша в рабочем наборе ядро при нехватке ещё может освободить. Её сравнивают с лимитом container_spec_memory_limit_bytes (у shop 536 870 912 байт, то есть 512 МБ):

container_memory_working_set_bytes{name="shop-shop-1"} / container_spec_memory_limit_bytes{name="shop-shop-1"}

Когда память контейнера упирается в лимит, ядро сначала выбрасывает кэш файлов. Если освобождать больше нечего, а памяти всё равно не хватает, ядро убивает процесс контейнера (OOM-kill, урок 5.4), и клиенты получают обрыв соединений. Дальше решает политика перезапуска: при restart: unless-stopped Docker запускает контейнер снова, а счётчики приложения обнуляются. Следы видны по increase(container_oom_events_total{name="shop-shop-1"}[10m]) и changes(container_start_time_seconds{name="shop-shop-1"}[10m]) > 0 (число стартов за 10 минут). Осторожно: автоперезапуск прячет проблему, память снова начнёт расти, поэтому на боевых системах вешают алерт на рестарты и OOM.

Посмотрим на утечке. Переменная LEAK_ENABLED=1 заставляет «Магазин» навсегда сохранять 10 КБ на каждый запрос (подробнее в теме 11). При 100 запросах в секунду это 100 × 10 КБ, около 1 МБ в секунду. Базовый уровень около 120 МБ, до лимита 512 МБ остаётся примерно 390 МБ. Делим 390 на 1: около 6,5 минут до убийства. График рабочего набора будет растущей линией, и по её наклону заранее видно, когда контейнер упадёт.

Прикинь сам: утечка 10 КБ на запрос, 50 запросов в секунду, рабочий набор 200 МБ, лимит 512 МБ. Через сколько минут контейнер упадёт?

Утекает 50 × 10 КБ = 500 КБ, около 0,5 МБ в секунду. Запас 512 − 200 = 312 МБ. Делим: 312 / 0,5 = 624 секунды, около 10 минут. Смысл метрик памяти в этом: смотреть не «сколько сейчас», а «куда идёт».

Главное: память контейнера оценивай по working_set относительно лимита и по скорости роста, а не по текущему значению.

Остаётся база: по метрикам «Магазина» видно, что запрос медленный, но не видно, что делала PostgreSQL.

postgres-exporter: что делает база

Я однажды полдня винил код, а виноват был один запрос без индекса. Чаще всего тормозит база. PostgreSQL сам ведёт статистику о себе в системных представлениях (views, виртуальные таблицы) pg_stat_*, а postgres-exporter превращает их в метрики. Подключён он так:

  postgres-exporter:
    image: prometheuscommunity/postgres-exporter:v0.20.1
    profiles: [monitoring]
    environment:
      DATA_SOURCE_NAME: postgresql://shop:shop@postgres:5432/shop?sslmode=disable

DATA_SOURCE_NAME это строка подключения: пользователь shop, пароль shop, адрес postgres:5432, база shop. Параметр sslmode=disable (SSL, шифрование канала) отключает шифрование, внутри учебной сети оно не нужно. На работе заводят отдельного пользователя с ролью pg_monitor: у неё права только читать статистику. Экспортёр открывает своё соединение и занимает один слот из max_connections=100 (значение из compose.yaml). Набор метрик зависит от версии, поэтому сначала спроси Prometheus: count by (__name__) ({job="postgres"}).

Первая группа: соединения. pg_stat_database_numbackends{datname="shop"} показывает, сколько соединений открыто к базе shop. У «Магазина» один воркер (WEB_CONCURRENCY=1) и пул DB_POOL_MIN=1, DB_POOL_MAX=5 (урок 2.3). Плюс соединение экспортёра. Значит, в покое обычно 2, под нагрузкой не больше 6 (5 пула + 1 экспортёра). Если число упёрлось в 6 и стоит, пул «Магазина» исчерпан: на этот случай настроен алерт ShopDbPoolExhausted по shop_db_pool_waiting. До max_connections=100 далеко, поэтому «Магазин» упрётся в свой пул намного раньше, чем в базу.

Вторая: кэш. Доля чтений, обслуженных из памяти, а не с диска (hit ratio, «доля попаданий»):

sum(rate(pg_stat_database_blks_hit{datname="shop"}[5m]))
  / (sum(rate(pg_stat_database_blks_hit{datname="shop"}[5m])) + sum(rate(pg_stat_database_blks_read{datname="shop"}[5m])))

Значение 0,99 хорошее, ниже 0,9 значит частые чтения с диска.

Третья: полный перебор таблицы. В «Магазине» нет индекса на orders.user_id, и запрос SELECT ... FROM orders WHERE user_id = ... читает все 200 000 строк (Seq Scan, урок 2.4). Каждый такой запрос увеличивает pg_stat_user_tables_seq_scan{relname="orders"} на 1, а seq_tup_read примерно на 200 000:

rate(pg_stat_user_tables_seq_scan{relname="orders"}[1m])

Результат 3,5 значит 3,5 полных перебора в секунду. После создания индекса (тема 11) seq_scan перестанет расти, а idx_scan начнёт.

Осталось найти, какой запрос виноват. В compose.yaml PostgreSQL запускается с shared_preload_libraries=pg_stat_statements: это расширение записывает, какие запросы выполнялись, сколько раз и сколько времени заняли. Смотреть его удобнее прямо в базе:

cd ~/learning/load-tester/project/shop
docker compose --profile monitoring exec postgres psql -U shop -d shop -c \
  "select calls, round(mean_exec_time::numeric, 1) as mean_ms, round(total_exec_time::numeric) as total_ms, left(query, 60) as query from pg_stat_statements order by total_exec_time desc limit 5;"

Разбор: exec postgres запускает команду внутри контейнера postgres, psql -U shop -d shop подключается к базе, -c выполняет один запрос, order by total_exec_time desc limit 5 берёт пять самых затратных. Колонки: calls число вызовов, mean_exec_time среднее время одного, total_exec_time суммарное. Суммарное показывает, что дороже всего для базы.

Проверь понимание: в numbackends стабильно 6, на графике ровная полка, а p95 «Магазина» растёт. Что это значит и что проверить дальше?

Ответ

Шесть это максимум: пул «Магазина» исчерпан, запросы ждут свободного соединения. Проверь shop_db_pool_waiting и shop_db_connection_wait_seconds. Узкое место это пул, а не сама база: PostgreSQL при этом может быть почти свободна.

Для любопытных: справочник метрик PostgreSQL для нагрузочника
Метрика Что значит
pg_up 1, если экспортёр подключился к базе
pg_stat_database_xact_commit, ..._xact_rollback завершённые транзакции: скорость (TPS) и доля откатов
pg_stat_database_tup_returned, ..._tup_fetched сколько строк прочитано и отдано
pg_stat_database_deadlocks взаимные блокировки: редкая, но серьёзная проблема
pg_stat_user_tables_idx_scan{relname} сколько раз использовали индекс: сравни с seq_scan
pg_locks_count{mode} сколько блокировок каждого режима
pg_database_size_bytes{datname} размер базы: рост за тест

Главное: по pg_stat_* видно три вещи: сколько соединений занято, читает ли база из памяти и не перебирает ли она таблицы целиком.

Три источника, десятки метрик. Как не утонуть в них во время теста?

Метод USE: список вопросов к каждому ресурсу

Тест идёт, дашборд красный, метрик десятки. На что смотреть первым? Инженер Брендан Грегг (Brendan Gregg) предложил чек-лист, знакомый тебе по уроку 1.3: для каждого ресурса (процессор, память, диск, сеть, пул соединений) задать три вопроса. Это метод USE. Utilization (загрузка): какую долю времени ресурс занят. Saturation (насыщение): сколько работы ждёт в очереди. Errors (ошибки): сколько отказов он уже выдал.

Вспомни кассу. Загрузка: кассир занят 90% дня. Насыщение: у кассы семь человек. Ошибки: чек не пробился. Кассир занят на 100%, а очереди нет, потому что покупатели приходят по одному: не беда. И наоборот: занят на 40%, но очередь растёт, потому что он на десять минут отошёл.

Прикинь сам: у контейнера загрузка CPU 40%, а запросы ждут в очереди. Какие две причины из этого урока возможны?

Первая: троттлинг, контейнер заморожен до конца периода, хотя в среднем загрузка невысока. Вторая: пул соединений, запросы ждут соединения, а процессор простаивает. Оба случая видны по насыщению, а не по загрузке.

Метод превращается в таблицу «ресурс на U/S/E». Вот она для нашего стенда:

Ресурс Загрузка (U) Насыщение (S) Ошибки (E)
CPU хоста 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) node_load1 / число ядер нет отдельной
CPU контейнера rate(container_cpu_usage_seconds_total{name=...}[1m]) / лимит доля троттлинга throttled_periods / periods нет отдельной
Память контейнера working_set / spec_memory_limit нет отдельной (смотри рост) container_oom_events_total
Диск хоста rate(node_disk_io_time_seconds_total[1m]) rate(node_disk_io_time_weighted_seconds_total[1m]) нет отдельной
Пул соединений БД 1 - shop_db_pool_available / shop_db_pool_size shop_db_pool_waiting http_requests_total{status="503"}
Соединения PostgreSQL numbackends / max_connections нет отдельной pg_stat_database_deadlocks

Пул соединений тоже ресурс: у него есть ёмкость (до 5 соединений, загрузка считается от открытых), очередь (waiting) и отказы (503 по таймауту). С RED из урока 7.3 они не соперники: RED смотрит на сервис глазами клиента, USE на ресурсы глазами инженера. Сначала RED подтверждает, что плохо, потом USE обходит ресурсы.

flowchart TD
    A["RED: пользователям<br>плохо?"] -->|да| B["Берём ресурс<br>по списку USE"]
    B --> C{"Есть очередь<br>или ошибки?"}
    C -->|да| D["Нашли<br>узкое место"]
    C -->|нет| E["Следующий<br>ресурс"]
    E --> B

Теперь посмотри на живую доску: переключай сценарии и следи за подсвеченным ресурсом.

В каждой поломке виноват другой ресурс. Только в сценарии утечки памяти загрузка 94% опасна сама по себе, в остальных тревожит очередь (троттлинг, ожидание соединения), а не высокая загрузка.

Разберём сценарий bcrypt, как твой тест входа. Хост: загрузка 36%, load на ядро 0,3, красных сигналов нет. Контейнер shop: загрузка 99% от лимита в одно ядро, троттлинг 64% периодов, оба красные. Контейнер postgres: 8%, свободен. Пул: 20%, очереди нет. Вывод: приложение стоит в процессор, но в процессор контейнера. Что с этим делать, решит тема 11: добавить воркеров (WEB_CONCURRENCY), поднять лимит cpus или снизить BCRYPT_ROUNDS. Нагрузочник знает, какое число упёрлось, и показывает его на графике, а не гадает. В прошлом году команде магазина не хватило ровно этой таблицы: смотрели на хост, а упёрся лимит контейнера.

Осторожно: «загрузка 100%» не всегда беда, пока очереди нет, ресурс просто хорошо использован. И наоборот, при низкой загрузке очередь бывает: её создают лимиты и пулы.

Проверь понимание: shop_db_pool_waiting = 4, CPU shop 15%, CPU postgres 10%, хост свободен. Какой ресурс насыщен и что подозревать?

Ответ

Насыщен пул соединений: очередь 4 при свободных процессорах. Подозревай то, что держит соединение долго: медленный внешний вызов внутри транзакции (в «Магазине» это оплата заказа) или долгие запросы к базе. Следующий шаг: shop_payment_duration_seconds и pg_stat_statements.

Главное: для каждого ресурса спрашивай про загрузку, очередь и ошибки, и ищи не самое высокое число, а то место, где появилась очередь.

Практика

Все файлы урока складывай в ~/perf-lab/07-monitoring/. Нагрузку даём только на свой стенд.

1. Убедись, что экспортёры живы

Стенд с мониторингом должен работать:

cd ~/learning/load-tester/project/shop
docker compose --profile monitoring ps --status running --services | sort | tr '\n' ' '; echo

Разбор: ps --status running --services печатает только имена запущенных сервисов, sort сортирует, tr '\n' ' ' склеивает в строку.

alertmanager alloy cadvisor grafana loki node-exporter payment postgres postgres-exporter prometheus redis shop tempo 

Если чего-то нет, подними всё заново: docker compose --profile monitoring up -d --wait. Теперь в Prometheus (http://localhost:9090) выполни:

up{job=~"node|cadvisor|postgres"}
up{instance="node-exporter:9100", job="node"}              1
up{instance="cadvisor:8080", job="cadvisor"}               1
up{instance="postgres-exporter:9187", job="postgres"}      1

Как читать вывод: три ряда, все со значением 1, значит, три экспортёра отвечают. Если у какого-то 0, открой http://localhost:9090/targets: там причина (connection refused, context deadline exceeded).

Теперь посмотри на «сырой» ответ экспортёра. Порты не опубликованы, поэтому заходим в сеть Compose изнутри контейнера Prometheus (в нём есть wget):

docker compose --profile monitoring exec prometheus \
  wget -qO- http://node-exporter:9100/metrics | grep -E '^node_(load1|memory_MemTotal_bytes|memory_MemAvailable_bytes) '

Разбор: exec prometheus выполняет команду внутри контейнера Prometheus; wget -qO- скачивает страницу и печатает её в стандартный вывод (-q тихо, -O- в консоль); grep -E '^node_(...) ' оставляет три нужные строки (^ начало строки, пробел в конце не даёт поймать node_load15).

node_load1 0.42
node_memory_MemAvailable_bytes 1.1823e+10
node_memory_MemTotal_bytes 1.6636e+10

Как читать вывод: запись 1.1823e+10 это 1,1823 × 10¹⁰ байт ≈ 11 ГБ. Видно: формат точно такой же, как у /metrics магазина из урока 7.1: имя, пробел, число.

Типичные ошибки:

  • wget: bad address 'node-exporter': контейнер node-exporter не запущен или ты не в каталоге ~/learning/load-tester/project/shop.
  • Пустой вывод после grep: опечатка в имени метрики. Убери | grep ... и смотри | head -30.

2. Напиши генератор фоновой нагрузки

Нужна нагрузка, при которой метрики шевелятся. Напиши маленький скрипт на Python (виртуальное окружение из урока 4.5):

source ~/perf-lab/.venv/bin/activate
cat > ~/perf-lab/07-monitoring/orders_load.py <<'EOF'
"""Фоновая нагрузка на «Магазин» для уроков 7.4-7.6.

Запуск: python orders_load.py <режим> <пользователей> <секунд> [пауза]
Режимы: orders (покупки: каталог, корзина, заказ) и login (только вход).
"""
import random
import sys
import threading
import time

import requests

BASE = "http://localhost:8000"
mode = sys.argv[1] if len(sys.argv) > 1 else "orders"
users = int(sys.argv[2]) if len(sys.argv) > 2 else 6
seconds = int(sys.argv[3]) if len(sys.argv) > 3 else 120
pause = float(sys.argv[4]) if len(sys.argv) > 4 else 1.0
stop_at = time.time() + seconds
stats = {"ok": 0, "fail": 0}
lock = threading.Lock()


def count(ok):
    with lock:
        stats["ok" if ok else "fail"] += 1


def login(session, number):
    body = {"email": f"user{number:04d}@shop.lab", "password": "password"}
    reply = session.post(f"{BASE}/api/login", json=body, timeout=30)
    if reply.status_code != 200:
        return False
    session.headers["Authorization"] = "Bearer " + reply.json()["token"]
    return True


def worker(number):
    session = requests.Session()
    try:
        while time.time() < stop_at:
            if mode == "login":
                count(login(session, number))
                continue
            if "Authorization" not in session.headers and not login(session, number):
                count(False)
                time.sleep(1)
                continue
            product = random.randint(1, 10000)
            session.get(f"{BASE}/api/products", timeout=30)
            session.get(f"{BASE}/api/products/{product}", timeout=30)
            session.post(f"{BASE}/api/cart/items", json={"product_id": product, "qty": 1}, timeout=30)
            reply = session.post(f"{BASE}/api/orders", timeout=60)
            count(reply.status_code == 201)
            if reply.status_code != 201:
                # Неудачный заказ оставляет товар в корзине: уберём, чтобы корзина не росла.
                session.delete(f"{BASE}/api/cart/items/{product}", timeout=30)
            time.sleep(pause)
    except requests.RequestException as error:
        print(f"пользователь {number}: {error}")


threads = [threading.Thread(target=worker, args=(n,)) for n in range(1, users + 1)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
print(f"режим {mode}, {users} пользователей, {seconds} с: успешно {stats['ok']}, с ошибкой {stats['fail']}")
EOF
python -c "import ast,sys; ast.parse(open('$HOME/perf-lab/07-monitoring/orders_load.py').read()); print('синтаксис в порядке')"

Разбор скрипта по частям:

  • sys.argv это аргументы командной строки: режим, число «пользователей», сколько секунд работать, пауза между покупками. Значения по умолчанию записаны после else.
  • threading.Thread запускает каждого пользователя в отдельном потоке: они работают одновременно, как настоящие покупатели. Генератор нагрузки, у которого только один поток, не нагрузит никого (подробно в теме 9).
  • login отправляет POST /api/login, забирает token из ответа и кладёт его в заголовок Authorization: Bearer ... (формат из темы 2).
  • Режим login повторяет только вход. В «Магазине» пароль проверяется алгоритмом bcrypt (BCRYPT_ROUNDS=12): он намеренно медленный и нагружает процессор, ровно то, что нужно для теста узкого места.
  • Режим orders для каждой итерации: открыть каталог, открыть карточку случайного товара, положить его в корзину, оформить заказ. Заказ вызывает оплату (payment), она нам пригодится в уроке 7.6.
  • count(...) складывает успехи и неудачи под блокировкой lock, чтобы потоки не затирали друг друга. В конце печатается итог.
синтаксис в порядке

Типичные ошибки:

  • ModuleNotFoundError: No module named 'requests': не активировано окружение. Выполни source ~/perf-lab/.venv/bin/activate.
  • Connection refused: стенд не запущен (curl -s localhost:8000/readyz).

3. Хост под нагрузкой

Открой Prometheus (http://localhost:9090/graph) или Grafana Explore (http://localhost:3000/explore, источник Prometheus) и добавь вторую вкладку с запросом из этого пункта. Во втором терминале запусти тест входа на две минуты:

source ~/perf-lab/.venv/bin/activate
python ~/perf-lab/07-monitoring/orders_load.py login 8 120

Пока он идёт, в Prometheus выполни по очереди:

1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))
sum by (mode) (rate(node_cpu_seconds_total{mode!="idle"}[1m]))
1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes

Ожидаемый вид (числа зависят от твоей машины):

{}                0.37
{mode="user"}     1.28
{mode="system"}   0.19
{mode="iowait"}   0.01
{}                0.31

Как читать вывод: первая строка это общая загрузка процессора хоста: 37%. На хосте на 4 ядра user равно 1,28 ядра: основная часть (user 1,28 ядра) это bcrypt в shop (до одного ядра по лимиту) и сам скрипт нагрузки. iowait около нуля: диск не мешает. Память почти не изменилась: тест входа её не ест. Запиши числа в блокнот: у тебя «хост здоров, ресурс не упёрся».

4. Контейнеры под той же нагрузкой

Пока (или сразу после) тест идёт, выполни:

rate(container_cpu_usage_seconds_total{name="shop-shop-1"}[1m])
rate(container_cpu_cfs_throttled_periods_total{name="shop-shop-1"}[1m])
  / rate(container_cpu_cfs_periods_total{name="shop-shop-1"}[1m])
container_memory_working_set_bytes{name=~"shop-(shop|postgres|redis|payment)-1"} / 1024 / 1024
{name="shop-shop-1"}   0.98
{name="shop-shop-1"}   0.61
{name="shop-shop-1"}   143.2
{name="shop-postgres-1"}  96.5
{name="shop-redis-1"}   6.4
{name="shop-payment-1"} 48.8

Как читать вывод: первая строка 0,98: shop использует почти одно целое ядро, то есть весь свой лимит. Вторая строка 0,61: в 61% периодов контейнер заморожен. Третий запрос показывает память в мегабайтах по четырём контейнерам «Магазина» (регулярка shop-(shop|postgres|redis|payment)-1 выбирает четыре контейнера «Магазина», а не весь мониторинг). Для контейнера shop лимит 512 МБ, и 143 МБ это комфортные 28%. Теперь сравни с хостом из пункта 3: хост на 37%, контейнер на 98%. Вот что называется узкое место: тормозит не машина, а ограничение контейнера.

Сохрани для себя три-четыре числа, они пригодятся в итоге.

Типичные ошибки:

  • Пустой ответ: название контейнера у тебя другое. Выполни count by (name) (container_cpu_usage_seconds_total{name!=""}) и возьми имя оттуда.
  • Троттлинг 0 при загрузке 0,98: подожди 1-2 минуты, rate[1m] усредняет за минуту. Если не растёт, проверь, что ты не изменял cpus: в compose.yaml.

Не знаешь, что значит метрика экспортёра? Скопируй её имя вместе с запросом и спроси нейросеть, что она измеряет и в каких единицах. Проверь по # HELP в выводе экспортёра: имя нейросеть может запомнить из старой версии.

5. PostgreSQL: соединения и полный перебор таблицы

Сначала найди, какие метрики есть:

count by (__name__) ({job="postgres", __name__=~"pg_stat_database_.*|pg_stat_user_tables_.*"})

Вернётся список имён: pg_stat_database_numbackends, pg_stat_user_tables_seq_scan и другие. Если какого-то из имён нет в твоей версии экспортёра, используй близкое по смыслу из этого списка.

Теперь вызови «дорогой» запрос заказов 200 раз. Для этого получи токен и сделай серию запросов:

TOKEN=$(curl -s -X POST localhost:8000/api/login -H 'Content-Type: application/json' \
  -d '{"email":"user0001@shop.lab","password":"password"}' | jq -r .token)
for i in $(seq 1 200); do
  curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" localhost:8000/api/orders
done
echo готово

Разбор: $(...) подставляет результат команды в переменную TOKEN; jq -r .token вынимает поле token из JSON-ответа; for i in $(seq 1 200) повторяет GET /api/orders двести раз; -o /dev/null выбрасывает тело ответа, нам нужен только эффект на базу.

Через минуту в Prometheus:

increase(pg_stat_user_tables_seq_scan{relname="orders"}[5m])
pg_stat_database_numbackends{datname="shop"}
{relname="orders"}   201
{datname="shop"}     2

Как читать вывод: при каждом из 200 запросов PostgreSQL прочитал таблицу orders целиком (200 запросов дают примерно 200 новых seq_scan, один лишний пришёл от других запросов). Это и есть «нет индекса на orders.user_id»: чем больше таких запросов, тем сильнее нагружен процессор базы. Соединений в покое два: одно у пула «Магазина» и одно у экспортёра (запущенный psql добавил бы ещё одно).

Заодно посмотри топ запросов (команда из теории):

docker compose --profile monitoring exec postgres psql -U shop -d shop -c \
  "select calls, round(mean_exec_time::numeric, 1) as mean_ms, left(query, 50) as query from pg_stat_statements order by total_exec_time desc limit 3;"
 calls | mean_ms |                       query
-------+---------+----------------------------------------------------
   201 |    38.4 | SELECT id, status, total, created_at FROM orders W
  4020 |     0.6 | SELECT oi.order_id, oi.product_id, p.name, oi.qty,
   210 |     3.2 | SELECT id, name, price, category_id, stock FROM pr

Как читать вывод: запрос по orders вызван 201 раз и в среднем занимает 38 мс. Это в двенадцать раз дольше каталога (3,2 мс). Колонка calls у второй строки примерно в двадцать раз больше: заказ возвращает 20 заказов, и на каждый делается отдельный запрос позиций, это и есть «N+1», о нём в теме 11.

Типичные ошибки:

  • jq: error ... null: токен не получен. Проверь, что вход возвращает токен: curl -s -X POST localhost:8000/api/login -H 'Content-Type: application/json' -d '{"email":"user0001@shop.lab","password":"password"}'.
  • relation "pg_stat_statements" does not exist: расширение создаётся при первой инициализации базы; если том создан до включения, выполни docker compose --profile monitoring down -v и подними заново (данные пропадут, они восстановятся из сида).

6. Таблица USE для стенда

Заполни файл ~/perf-lab/07-monitoring/use-table.md. Первые строки даны, остальные допиши сам по таблице из теории:

# Таблица USE: стенд «Магазин»

| Ресурс | Загрузка (запрос) | Насыщение (запрос) | Ошибки | Значение в покое | Значение в тесте входа |
|---|---|---|---|---|---|
| CPU хоста | `1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))` | `node_load1 / число ядер` | нет | 0.06 | 0.37 |
| CPU контейнера shop | `rate(container_cpu_usage_seconds_total{name="shop-shop-1"}[1m])` | доля троттлинга | нет | 0.04 | 0.98 / 61% |
| Память shop | | | | | |
| CPU postgres | | | | | |
| Пул соединений | | | | | |

## Вывод по тесту входа

- Что упёрлось: ____
- Чем доказано (две метрики): ____
- Что проверил и исключил (три ресурса): ____

Если ты уже прошёл тему 3, зафиксируй:

cd ~/perf-lab && git add 07-monitoring && git commit -m "7.4: таблица USE и генератор нагрузки" && git push

Сломай и почини

Поломка: слепой мониторинг. Остановим экспортёр контейнеров и посмотрим, что сделает с графиками его отсутствие.

cd ~/learning/load-tester/project/shop
docker compose --profile monitoring stop cadvisor

Подожди 15 секунд и выполни up{job="cadvisor"}, а потом rate(container_cpu_usage_seconds_total{name="shop-shop-1"}[1m]).

Задача: без подсказок выясни, что ты видишь, чем «пустой график» отличается от «график с нулём» и заметил бы это алерт стенда. Просмотри monitoring/prometheus/rules/alerts.yml: есть ли там правило на up{job="cadvisor"} и что с ним стало через минуту?

Что должно получиться

up{job="cadvisor"} равен 0, а запрос по CPU контейнера возвращает пустой результат: рядов просто нет, потому что экспортёр не отвечает. Пять основных правил alerts.yml (shop, ошибки, p95, пул и CPU хоста) этого не видят, но в группе shop-meta есть ExporterDown на up == 0 для экспортёров: через минуту он перейдёт в firing (страница http://localhost:9090/alerts). Без него слепая зона никого не разбудила бы. Починка: docker compose --profile monitoring start cadvisor. Выводы запиши в use-table.md: «каждый экспортёр нужно сторожить алертом up == 0». Как устроен этот алерт и как его проверить, смотри в уроке 7.6.

ИИ в помощь

Нейросеть быстро объясняет, что измеряют метрики node_*, container_* и pg_*, но имена метрик у экспортёров менялись между версиями, и она часто называет старые. Общие правила: ИИ-помощник.

Задача: выбрать метрики для таблицы USE (utilization, saturation, errors).

Мне нужна таблица USE для хоста и контейнеров стенда: node-exporter и cAdvisor, Prometheus 3.
Для CPU, памяти, диска и сети предложи запрос PromQL на использование, насыщение и ошибки.
Для каждой метрики одной фразой объясни, что она измеряет. Если не уверен в имени метрики,
скажи об этом, а не выдумывай.

Проверь ответ: каждое имя проверь в Prometheus (localhost:9090, поле запроса с автодополнением) или в выводе /metrics экспортёра. Типичные ошибки: имя без префикса node_ или со старым суффиксом (node_memory_MemAvailable вместо node_memory_MemAvailable_bytes), метрики PSI, которых нет на Docker Desktop.

Задача: понять симптом на хосте.

Под нагрузкой load average вырос до <число> при <число> ядрах, занятость CPU по запросу
1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) составляет <число>%.
Как понять, это нехватка процессора или ожидание диска? Какие запросы выполнить следующими?

Проверь ответ: выполни предложенные запросы, например по mode="iowait", и сверь с рассуждением из урока.

Словарик урока

Термин Простыми словами
Экспортёр (exporter) Программа-переводчик: читает состояние чужой системы и отдаёт его на /metrics в формате Prometheus
node-exporter Экспортёр хоста: CPU, память, диск и сеть машины
cAdvisor Экспортёр контейнеров: ресурсы каждого контейнера по отдельности
postgres-exporter Экспортёр PostgreSQL: соединения, транзакции, обращения к таблицам
cgroup (контрольная группа) Механизм ядра Linux: группа процессов со своими счётчиками и лимитами; на нём стоят контейнеры
Лимит CPU контейнера Сколько ядер контейнеру разрешено использовать: cpus: "1.0" значит одно ядро
Троттлинг (throttling) Принудительная пауза контейнера до конца 100-миллисекундного периода, когда лимит CPU исчерпан
Working set «Рабочий набор» памяти контейнера: память процессов и недавний кэш файлов; лучшая оценка близости к лимиту, но приблизительная
OOM-kill Убийство процесса ядром, когда память кончилась (out of memory)
Page cache Кэш файлов в оперативной памяти; поэтому «свободно» мало, а «доступно» много
Load average Среднее число задач, которым нужен процессор или диск; делят на число ядер
iowait Доля времени, когда ядро простаивает в ожидании диска
steal Доля времени, которую процессор виртуалки отобрал гипервизор
pg_stat_* Системные представления PostgreSQL, где база хранит статистику о себе
Seq Scan Полный перебор таблицы без индекса: база читает все строки
pg_stat_statements Расширение PostgreSQL: статистика по каждому запросу (сколько раз, сколько времени)
Метод USE Список вопросов к каждому ресурсу: загрузка (utilization), насыщение (saturation), ошибки (errors)
Насыщение (saturation) Сколько работы ждёт в очереди, потому что ресурс не успевает
Hit ratio кэша Доля чтений, нашедшихся в памяти; ниже 0,9 значит частые обращения к диску

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

Раздел для повторения: ответь вслух, потом открой ответ.

1. [junior] [часто] Что такое экспортёр в Prometheus и зачем он нужен?

Ответ

Экспортёр это отдельная программа, которая читает состояние системы, не умеющей сама говорить на языке Prometheus (Linux, базы данных, железо), и отдаёт его на /metrics. Prometheus сам ходит к нему по pull-модели. Примеры: node-exporter для хоста, postgres-exporter для PostgreSQL.

Что хотят услышать: переводчик между системой и Prometheus; экспортёр пассивен, это не агент, который отправляет данные.

Красный флаг: «экспортёр отправляет метрики в Prometheus».

2. [junior] [часто] Какие три вопроса задаёт метод USE?

Ответ

Для каждого ресурса: загрузка (utilization), насыщение (saturation), ошибки (errors). Загрузка: доля времени, когда ресурс занят. Насыщение: сколько работы стоит в очереди. Ошибки: сколько отказов.

Что хотят услышать: примеры из практики (CPU: загрузка, load на ядро, нет ошибок; память: working set, своп или OOM) и то, что метод применяют к каждому ресурсу, включая пул соединений.

Красный флаг: путают USE с RED.

3. [junior] [часто] Как посчитать загрузку процессора из node_cpu_seconds_total?

Ответ

1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])). Метрика это счётчик секунд по режимам для каждого ядра; rate превращает его в долю времени, idle это простой, avg усредняет по ядрам, 1 - даёт долю занятости.

Что хотят услышать: почему rate (counter), почему именно idle; про iowait и steal как отдельные причины.

Красный флаг: берёт node_cpu_seconds_total без rate и удивляется, что график только растёт.

4. [junior] Почему MemFree на хосте маленький, а проблемы с памятью нет?

Ответ

Linux использует свободную память под кэш файлов (page cache) и при необходимости мгновенно отдаёт её программам. Реальную доступность показывает MemAvailable. Занятость: 1 - MemAvailable / MemTotal. Тревожные признаки: активный своп и OOM-убийства.

Что хотят услышать: page cache, MemAvailable, своп.

Красный флаг: «памяти нет, надо докупить», глядя на MemFree.

5. [middle] Что такое троттлинг CPU контейнера и как его увидеть?

Ответ

Когда контейнер расходует лимит CPU за 100-миллисекундный период, ядро замораживает его процессы до следующего периода. Видно по rate(container_cpu_cfs_throttled_periods_total[1m]) / rate(container_cpu_cfs_periods_total[1m]). Значение больше 5-10% стабильно: контейнер упирается в лимит.

Что хотят услышать: CFS-периоды, что процессор может быть «в среднем свободен», но запросы тормозят; решения: поднять лимит, уменьшить работу (воркеры сами лимит не увеличивают).

Красный флаг: считает, что по средней загрузке CPU всегда видно проблему.

6. [middle] Чем отличаются container_memory_usage_bytes и container_memory_working_set_bytes? Какую берут для алерта?

Ответ

usage включает весь кэш файлов, который ядро выбросит при нехватке, поэтому часто «растёт» без проблем. working_set это usage без кэша, которым давно не пользовались: память процессов и недавний кэш. Это лучшая доступная оценка близости к OOM, её сравнивают с лимитом (container_spec_memory_limit_bytes). Оценка приблизительная: часть кэша в рабочем наборе ядро ещё может освободить, а OOM-kill приходит, только когда освобождать больше нечего. Алерты и графики строят по working_set.

Что хотят услышать: почему usage вводит в заблуждение; про OOM-kill и что дальше зависит от политики перезапуска.

Красный флаг: алерт по usage с порогом 90% и постоянные ложные срабатывания.

7. [middle] Хост занят на 35%, а сервис тормозит. Какие причины ты проверишь?

Ответ

Проверю ресурсы по USE: лимит CPU контейнера и троттлинг, память контейнера и OOM, пул соединений к базе (очередь), CPU и соединения базы, блокировки и полные переборы таблиц, внешние вызовы (оплата). Средняя загрузка хоста ничего не говорит о лимитах и очередях внутри.

Что хотят услышать: лимиты контейнера, пул, очереди; порядок проверки, а не угадывание.

Красный флаг: «значит, проблема в коде, будем оптимизировать» без проверки метрик.

8. [middle] Как понять по метрикам PostgreSQL, что не хватает индекса?

Ответ

Если у таблицы быстро растёт pg_stat_user_tables_seq_scan и seq_tup_read (много полных переборов с чтением многих строк), а idx_scan почти не растёт, значит, запросы читают таблицу целиком. Дальше найти запрос в pg_stat_statements и посмотреть план через EXPLAIN.

Что хотят услышать: seq_scan против idx_scan, связка с pg_stat_statements.

Красный флаг: предлагает «увеличить память базы», не посмотрев запросы.

9. [middle] Почему shop_db_pool_waiting растёт, хотя CPU базы свободен?

Ответ

Пул приложения (DB_POOL_MAX=5) занят: каждое соединение держится долго. В «Магазине» заказ держит соединение, пока ждёт оплату во внешнем сервисе, поэтому медленная оплата исчерпывает пул без нагрузки на CPU. Проверить: shop_payment_duration_seconds, pg_stat_database_numbackends.

Что хотят услышать: пул как отдельный ресурс по USE; соединение, занятое ожиданием.

Красный флаг: «надо увеличить max_connections в базе».

10. [middle] Почему нельзя полагаться на node_network_* в контейнере node-exporter без network_mode: host?

Ответ

Сетевое пространство контейнера отделено от хоста. Экспортёр читает /proc/net/dev в своём пространстве, и видит только интерфейсы контейнера, а не сетевую карту хоста. Для реального трафика хоста нужен network_mode: host, для трафика сервисов используют метрики контейнеров cAdvisor.

Что хотят услышать: изоляция сетевого пространства имён, различие хост и контейнер.

Красный флаг: считает сетевые графики экспортёра точными без проверки конфигурации.

11. [middle] Зачем следить за самими экспортёрами?

Ответ

Если экспортёр упал, график не покажет ноль: данных просто не будет, и проблема может пройти незамеченной. Нужен алерт up{job="..."} == 0 на каждую цель. Иначе наблюдение «слепнет» молча.

Что хотят услышать: разница «нет данных» и «ноль»; алерт на up.

Красный флаг: считает, что пустой график значит «всё хорошо».

12. [junior] [на скорость] Какой экспортёр покажет CPU и память контейнера?

Ответ

cAdvisor (метрики container_*). node-exporter показывает хост, а не отдельные контейнеры.

13. [junior] [на скорость] Что значит «загрузка 100%, очереди нет»?

Ответ

Ресурс используется полностью, но работа не копится. Это эффективно, а не обязательно плохо. Беда начинается, когда появляется очередь (насыщение) или ошибки.

14. [middle] [на скорость] Как по метрикам узнать, что контейнер перезапускался?

Ответ

changes(container_start_time_seconds{name="..."}[10m]) > 0 (работает, потому что у контейнера есть политика restart: он стартует заново, и время старта меняется) или increase(container_oom_events_total[10m]), если причина OOM.

15. [junior] [на скорость] Какая метрика показывает число соединений с базой shop?

Ответ

pg_stat_database_numbackends{datname="shop"}.

Проверено на версиях

Ubuntu 24.04, Docker Engine с Compose v2, стенд «Магазин» из project/shop, node-exporter 1.12.1, cAdvisor 0.60.6, postgres-exporter 0.20.1, PostgreSQL 18.6, Prometheus 3.15. Названия метрик экспортёров могут немного отличаться между версиями: если запрос вернул пусто, найди имя через count by (__name__) ({job="..."}). Октябрь 2026.

Итог урока: ты умеешь

  • Объяснить, зачем нужен экспортёр, и найти три экспортёра стенда в prometheus.yml и compose.yaml.
  • Посчитать загрузку CPU и памяти хоста по node_* и объяснить, почему смотрим MemAvailable, а не MemFree.
  • Прочитать метрики контейнера: CPU относительно лимита, троттлинг, working set и риск OOM.
  • Увидеть в метриках PostgreSQL соединения и полные переборы таблиц, а топ запросов получить из pg_stat_statements.
  • Применить метод USE: для каждого ресурса назвать загрузку, насыщение и ошибки с конкретным запросом.
  • Показать, что узкое место теста входа внутри контейнера shop, а не на хосте, двумя метриками.
  • Объяснить, чем «нет данных» отличается от «ноль», и почему экспортёры нужно сторожить.
  • Вести таблицу USE в ~/perf-lab/07-monitoring/use-table.md и запускать orders_load.py.

Дальше: урок 7.5. Логи: уровни, структура, Loki и Alloy: метрики показали, что и где сломалось, а логи расскажут, что происходило с конкретным запросом.

Глубже: разбор экспортёров и мониторинга «чёрного ящика» в курсе DevOps.

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 7 урок 7.4 3.5 ч курс 0/0 ← → уроки