✻ Урок 7.4 · Тема 7: Метрики, логи, трейсы и алерты
Экспортеры: хост, контейнеры и PostgreSQL
Содержание урока
Зачем это нужно
Я много лет смотрю на графики во время тестов, и вот ситуация, которая ждёт тебя в первую же неделю. Перед распродажей ты поднимаешь нагрузку на «Магазин», и 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, CPUshop15%, CPUpostgres10%, хост свободен. Какой ресурс насыщен и что подозревать?Ответ
Насыщен пул соединений: очередь 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.