Learning Platform
Глоссарий Troubleshooting
Урок 17.04 · 35 мин
Начальный
monitor/proc/stat/proc/meminfo/proc/diskstatsalertswatchdog

Build resource-monitor: watchdog по /proc с alerts

top, htop, vmstat 1, iostat -x 1 — все они делают одно: каждую секунду читают несколько файлов в /proc, считают разницу с прошлой выборкой, выводят красиво. Если CPU > 90% или free memory < 10% — это тревога. Третий и последний capstone-инструмент — наш собственный resource monitor с alerts. ~250 строк Python, без зависимостей. После этого vmstat и top перестанут быть «черными ящиками».

В уроке 2 мы научились читать /proc per-process. В этом — читаем system-wide. /proc/stat, /proc/meminfo, /proc/diskstats. Что показывает, что значит каждое поле, как считать diff.


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

$ python monitor.py --interval 2 --cpu-alert 80 --mem-alert 90
[2026-05-18 10:00:00] CPU:  15.3%  MEM:  62.4%  IO:    1.2 MB/s read  0.4 MB/s write
[2026-05-18 10:00:02] CPU:  18.7%  MEM:  62.5%  IO:    0.8 MB/s read  0.5 MB/s write
[2026-05-18 10:00:04] CPU:  89.2%  MEM:  63.1%  IO:    2.1 MB/s read  0.7 MB/s write
  ALERT CPU = 89.2% > threshold 80.0%
[2026-05-18 10:00:06] CPU:  92.5%  MEM:  91.3%  IO:    1.5 MB/s read  0.3 MB/s write
  ALERT CPU = 92.5% > threshold 80.0%
  ALERT MEM = 91.3% > threshold 90.0%
^C
shutdown gracefully

Каждые --interval секунд: timestamp, CPU%, MEM%, IO. Если порог превышен — печатаем ALERT. Ctrl+C — graceful shutdown (важно, потому что между выборками мы не хотим дёрнуть kill).


Архитектура

Архитектура resource-monitor

Loop: sample -> diff -> report -> sleep

sample_cpu()open('/proc/stat'), читаем первую строку 'cpu user nice system idle iowait irq softirq ...'. Возвращаем tuple (busy_total, total)
sample_mem()/proc/meminfo. Парсим MemTotal, MemAvailable. Considered: MemFree обманчив на Linux, MemAvailable показывает 'реально доступно' учитывая, что cache можно вытеснить
sample_io()/proc/diskstats. Колонки: major minor name reads_completed reads_merged sectors_read time_reading writes ... Берём read_sectors + write_sectors для всех дисков. Сектор = 512 байт
diff with prev sampleCPU% = (busy_delta) / (total_delta) × 100. IO speed = (sectors_delta × 512) / time_delta. Mem% -- instant, не нужен diff (это снимок, не счётчик)
format + printf-string c timestamp и метриками. Color escapes опционально (\\033[31m для красного на alert) -- но мы избегаем для упрощения
check thresholdsif cpu_pct > cpu_threshold: print ALERT. Те же для mem. IO threshold опционален
signal.signal(SIGTERM)Регистрируем handler для graceful shutdown. Также signal.SIGINT (Ctrl+C). Handler устанавливает running=False, главный цикл завершается
time.sleep(interval)Простой sleep. Не точный -- может слиппать чуть больше. Для большей точности -- time.monotonic() и вычислить разницу. Для базового monitor ОК

Шаг 1: чтение /proc/stat для CPU

$ cat /proc/stat | head -1
cpu  12345678 1234 567890 99887766 5432 0 12345 0 0 0

Числа после cpu — это тики (обычно сотые секунды), накопленные с момента boot:

  • user: время в user mode
  • nice: время в user mode с положительным nice
  • system: время в kernel mode
  • idle: время в idle (kernel ничего не делает)
  • iowait: время в idle, но ждали диск
  • irq: время на interrupt handlers
  • softirq: время на soft interrupts
  • steal: время украденное гипервизором (VM only)
  • guest, guest_nice: время на гостях (KVM host)

Чтобы получить CPU%, нужны два snapshot-а с разницей во времени. CPU% между ними:

busy = user + nice + system + irq + softirq + steal
total = busy + idle + iowait

cpu_percent = (busy_after - busy_before) / (total_after - total_before) × 100

Парсер:

def sample_cpu() -> tuple[int, int]:
    """Read /proc/stat. Return (busy_ticks, total_ticks) since boot."""
    with open('/proc/stat') as f:
        line = f.readline()  # first line: 'cpu  user nice system idle iowait irq softirq steal ...'
    
    fields = line.split()
    # fields[0] == 'cpu', fields[1..] are numbers
    values = [int(x) for x in fields[1:]]
    
    user, nice, system, idle, iowait = values[0], values[1], values[2], values[3], values[4]
    irq = values[5] if len(values) > 5 else 0
    softirq = values[6] if len(values) > 6 else 0
    steal = values[7] if len(values) > 7 else 0
    
    busy = user + nice + system + irq + softirq + steal
    total = busy + idle + iowait
    
    return busy, total


def cpu_percent(prev: tuple[int, int], curr: tuple[int, int]) -> float:
    """Compute CPU% between two samples."""
    busy_delta = curr[0] - prev[0]
    total_delta = curr[1] - prev[1]
    if total_delta == 0:
        return 0.0
    return busy_delta / total_delta * 100.0

if total_delta == 0 — защита от деления на ноль, может быть на очень коротком interval (< 1 tick = 10ms).

Как считать CPU% (одна из самых частых ошибок)

Нельзя просто взять busy/total из одного snapshot -- это процент с boot

ОШИБКАbusy_now / total_now × 100 даёт средний CPU% за всё время с момента boot. На 7-day-uptime получите ~10% даже на 100% loaded момент. Бесполезная метрика
ПРАВИЛЬНО(busy_now - busy_before) / (total_now - total_before) × 100. Это CPU% ЗА INTERVAL между двумя выборками. Если interval=1s -- почти instantaneous. Если interval=60s -- средняя за минуту

Шаг 2: чтение /proc/meminfo

$ head -5 /proc/meminfo
MemTotal:       16384000 kB
MemFree:         1234000 kB
MemAvailable:    8765000 kB
Buffers:          234000 kB
Cached:          4567000 kB

Формат — Key:\t value kB. Все значения в KB.

Какое поле использовать для «свободной памяти»? В Linux есть две метрики:

  • MemFree — память, в которой ничего нет вообще. Обычно очень мало (~1 GB) даже на свежем сервере, потому что kernel агрессивно использует свободную память под page cache.
  • MemAvailable — оценка того, сколько памяти можно реально получить под новую аллокацию, с учётом того, что cache и buffers можно вытеснить. На большинстве систем это много (несколько GB).

Правильно: смотреть MemAvailable, а не MemFree. Иначе любой нормальный сервер «вечно near OOM».

def sample_mem() -> tuple[int, int]:
    """Read /proc/meminfo. Return (total_kb, available_kb)."""
    fields = {}
    with open('/proc/meminfo') as f:
        for line in f:
            key, _, rest = line.partition(':')
            value_str = rest.strip().split()[0]
            fields[key] = int(value_str)
    
    total = fields['MemTotal']
    available = fields.get('MemAvailable', fields['MemFree'])  # fallback for very old kernels
    return total, available


def mem_percent(total_kb: int, available_kb: int) -> float:
    """% used."""
    used = total_kb - available_kb
    return used / total_kb * 100.0

fields.get('MemAvailable', ...) — MemAvailable появилось в kernel 3.14 (2014). На очень старых ядрах его нет — fallback на MemFree.

WARNING

Если ваш monitor показывает 95% mem на здоровой системе — почти наверняка путаница MemFree и MemAvailable. Цифра 95% означает «mostly cached», и это норма. Linux cache всю свободную память, потому что лучше использовать чем не использовать.


Шаг 3: чтение /proc/diskstats

$ head -3 /proc/diskstats
   8       0 sda 12345 0 9876543 1234 1234 0 9876 567 0 1234 1801 0 0 0 0
   8       1 sda1 1234 0 56789 12 0 0 0 0 0 12 12 0 0 0 0
 259       0 nvme0n1 98765 567 12345678 9876 5432 234 87654 4321 0 7890 14197 0 0 0 0

Колонки (формат сложный, см. Documentation/iostats.txt в kernel sources):

  1. major number
  2. minor number
  3. device name
  4. reads completed
  5. reads merged
  6. sectors read (1 sector = 512 bytes)
  7. milliseconds reading
  8. writes completed
  9. writes merged
  10. sectors written
  11. milliseconds writing
  12. I/Os in progress
  13. milliseconds doing I/O
  14. weighted ms doing I/O

Нам нужны: sectors read (col 6), sectors written (col 10). По всем «реальным» дискам (не partitions, не loop).

«Реальный диск» — это sda, nvme0n1, vda (KVM virtio). Не считать sda1, sda2 (partitions) — суммирование задвоит. Не считать loop0 — это loopback (контейнерные образы).

def sample_io() -> tuple[int, int]:
    """Read /proc/diskstats. Return (read_sectors_total, write_sectors_total) across real disks."""
    read_total = 0
    write_total = 0
    with open('/proc/diskstats') as f:
        for line in f:
            fields = line.split()
            if len(fields) < 14:
                continue
            name = fields[2]
            # filter: skip partitions (have digit at end after disk name) and loop devices
            if name.startswith('loop') or name.startswith('ram'):
                continue
            # crude: partitions of sda are sda1, sda2 — last char is digit AND name has length > 3
            # nvme0n1 ends with digit but is whole disk; nvme0n1p1 is partition
            if name[-1].isdigit():
                # nvme partitions are nvme0n1p1, contain 'p' before digit
                if 'p' in name and name[name.rindex('p') + 1:].isdigit():
                    continue
                # sda1, vda1 — partition if base part exists
                base = name.rstrip('0123456789')
                if base in ('sd', 'vd', 'hd', 'xvd'):
                    # это не partition, а disk типа sda без цифр в конце? base sd, name sda — OK
                    pass
                # Грубо: для sda(1,2,3), vda(1,2) пропустим
                # ... это упрощение, для production используйте udev/lsblk
            
            read_sectors = int(fields[5])
            write_sectors = int(fields[9])
            read_total += read_sectors
            write_total += write_sectors
    
    return read_total, write_total

Корректная фильтрация partitions нетривиальна (sda vs sda1, nvme0n1 vs nvme0n1p1, mmcblk0 vs mmcblk0p1). Реальные тулзы лезут в /sys/block/ для канонического списка дисков.

Для нашего monitor можно упрощённо: суммировать всё, что не loop/ram. Это задвоит partition + disk, но даст консервативную оценку («много I/O»). Если хотим точно — берём только записи без цифр в конце или с явным p в nvme.

def is_whole_disk(name: str) -> bool:
    """True if this is a whole disk, not partition."""
    if name.startswith('loop') or name.startswith('ram'):
        return False
    # nvme0n1 — whole, nvme0n1p1 — partition
    if 'nvme' in name:
        return 'p' not in name.split('n')[-1]
    # sda, vda — whole. sda1, vda1 — partition.
    if name[-1].isdigit() and name.rstrip('0123456789') in ('sd', 'vd', 'hd', 'xvd'):
        return False
    return True

Тоже не идеально (mmcblk0p1 нужно ещё обработать), но 90% случаев.

def io_speed_mb_per_sec(prev: tuple[int, int], curr: tuple[int, int], 
                       seconds: float) -> tuple[float, float]:
    """Return (read_MB/s, write_MB/s)."""
    read_sectors_delta = curr[0] - prev[0]
    write_sectors_delta = curr[1] - prev[1]
    
    SECTOR_BYTES = 512
    read_mb = read_sectors_delta * SECTOR_BYTES / (1024 * 1024)
    write_mb = write_sectors_delta * SECTOR_BYTES / (1024 * 1024)
    
    return read_mb / seconds, write_mb / seconds

Шаг 4: главный цикл + signals

import signal
import time
from datetime import datetime

running = True

def handle_signal(signum, frame):
    global running
    running = False

def main_loop(interval: float, cpu_alert: float, mem_alert: float):
    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)
    
    # initial samples — need a baseline for first diff
    prev_cpu = sample_cpu()
    prev_io = sample_io()
    prev_time = time.monotonic()
    
    # sleep until first interval
    time.sleep(interval)
    
    while running:
        now = time.monotonic()
        elapsed = now - prev_time
        
        curr_cpu = sample_cpu()
        mem_total, mem_avail = sample_mem()
        curr_io = sample_io()
        
        cpu_pct = cpu_percent(prev_cpu, curr_cpu)
        mem_pct = mem_percent(mem_total, mem_avail)
        read_mbps, write_mbps = io_speed_mb_per_sec(prev_io, curr_io, elapsed)
        
        ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        print(f'[{ts}] CPU: {cpu_pct:>5.1f}%  MEM: {mem_pct:>5.1f}%  '
              f'IO: {read_mbps:>6.2f} MB/s read  {write_mbps:>6.2f} MB/s write',
              flush=True)
        
        if cpu_pct > cpu_alert:
            print(f'  ALERT CPU = {cpu_pct:.1f}% > threshold {cpu_alert:.1f}%', flush=True)
        if mem_pct > mem_alert:
            print(f'  ALERT MEM = {mem_pct:.1f}% > threshold {mem_alert:.1f}%', flush=True)
        
        prev_cpu = curr_cpu
        prev_io = curr_io
        prev_time = now
        
        # sleep with check on running flag (interruptible)
        time.sleep(interval)
    
    print('shutdown gracefully', flush=True)

signal.signal(SIGTERM, handle_signal) — регистрация handler. При получении SIGTERM (kill PID), Python вызывает handle_signal. Тот ставит running = False. После текущей итерации цикл завершается.

SIGINT (Ctrl+C) — то же, но Python по умолчанию бросает KeyboardInterrupt. Мы перехватили — теперь Ctrl+C ставит флаг и даёт корректно завершить итерацию.

flush=True в print — иначе stdout buffer-ит, и при перенаправлении вывода в файл/pipe вы видите данные с задержкой. monitor должен писать в realtime.

time.monotonic() — это «время с boot, не зависит от системного времени». В отличие от time.time() (wall clock), monotonic не прыгает при переводе часов / NTP-коррекциях. Для измерения интервалов — always monotonic.


Шаг 5: argparse и финальный main

import argparse

def main():
    parser = argparse.ArgumentParser(description='Simple system resource monitor')
    parser.add_argument('--interval', type=float, default=2.0,
                        help='Sample interval in seconds (default 2)')
    parser.add_argument('--cpu-alert', type=float, default=80.0,
                        help='CPU%% threshold for alert (default 80)')
    parser.add_argument('--mem-alert', type=float, default=90.0,
                        help='MEM%% threshold for alert (default 90)')
    args = parser.parse_args()
    
    main_loop(args.interval, args.cpu_alert, args.mem_alert)

if __name__ == '__main__':
    main()

Запуск:

$ python monitor.py
[2026-05-18 10:00:00] CPU:  12.4%  MEM:  61.2%  IO:    0.5 MB/s read  0.2 MB/s write
[2026-05-18 10:00:02] CPU:  10.1%  MEM:  61.2%  IO:    0.0 MB/s read  0.1 MB/s write
^C
shutdown gracefully

Тестирование под нагрузкой

Чтобы увидеть alerts, нужно нагрузить CPU/память.

CPU:

# одно ядро на 100% — через stress (если установлен)
stress --cpu 1 --timeout 30s

# или через Python в фоне
python -c "while True: pass" &

Memory:

# жрём память постепенно
python -c "
import time
data = []
for i in range(100):
    data.append(b'x' * 100_000_000)  # 100 MB chunks
    print(f'allocated {len(data) * 100} MB')
    time.sleep(0.5)
"

В соседнем окне запустите python monitor.py. Увидите, как растут CPU/MEM, потом alerts.

Disk I/O:

# write 1 GB sequential
dd if=/dev/zero of=/tmp/bigfile bs=1M count=1000

# read 1 GB
dd if=/tmp/bigfile of=/dev/null bs=1M

monitor покажет ~500 MB/s read/write — на NVMe SSD. На механическом диске будет ~100 MB/s.


Edge cases и расширения

CPU% > 100%

На multi-core машине total CPU% по «cpu » строке в /proc/stat — это суммарное время всех ядер за интервал. То есть 4 ядра × 100% = 400% если все максимум. У нас формула busy_delta / total_delta нормализует на сумму idle+busy всех ядер, поэтому всегда даёт 0-100%. Это «aggregate CPU%». Если хотим знать «один из cores занят 100%, остальные idle» — нужно читать cpu0, cpu1, … из /proc/stat отдельно.

MemAvailable отсутствует

На kernel 3.13 и старше — нет MemAvailable. У нас fallback на MemFree, но это плохо отражает реальность. Если работаете с old kernel — добавьте оценку:

# rough estimate когда нет MemAvailable
available = fields['MemFree'] + fields['Buffers'] + fields['Cached']

Аномалии при boot/sleep

Сразу после boot (uptime < interval) total_ticks мало, числа могут прыгать. Сразу после wake-from-suspend — monotonic не прыгает, но wall clock может, тогда timestamp в логе подскочит. Для production-monitor это нужно учитывать; для нашего — игнорируем.

Алерты в логи / Slack

Расширения для Lab/практики: писать ALERT в syslog (через syslog модуль), отправлять webhook (через urllib.request POST), или просто в файл ~/.monitor/alerts.log. Что-то одно — упражнение в conf-driven dispatch.


Сравнение с top / vmstat

$ vmstat 2 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 1234000 567000 4567000    0    0    12    34  150  300  5  2 92  1  0
 2  0      0 1230000 567000 4570000    0    0     0    20  140  290  8  3 89  0  0
 ...

vmstat показывает то же. procs r/b — running/blocked. memory free/buff/cache — /proc/meminfo. io bi/bo — blocks in/out (наш IO). cpu us/sy/id/wa/st — user/system/idle/iowait/steal.

Главные отличия:

  • vmstat читает /proc/stat и /proc/meminfo. Те же файлы.
  • vmstat показывает по столбцу на метрику, нам удобнее одной строкой.
  • vmstat нет alerts. Наш — есть.

Это и есть смысл capstone: вы поняли, что vmstat — это парсер /proc в 200 строк C. Magic в этом нет.


Попробуй сам

  1. Запустите monitor, поработайте 30 секунд. Должны видеть нормальные числа.

  2. Создайте нагрузку CPU в соседнем терминале:

python -c "while True: pass" &

monitor должен показать рост CPU (~25% на 4-core машине от одного процесса). Через 30 секунд — kill %1 для остановки.

  1. Расширьте: per-core CPU. Читайте все строки cpu0, cpu1, … из /proc/stat. Считайте % для каждого ядра. Выводите как [c0: 12% c1: 87% c2: 5% c3: 3%].

  2. Расширьте: load average. Прочтите /proc/loadavg (одна строка: 1.23 1.45 1.67 1/234 12345). Первые три числа — load over 1/5/15 минут. Добавьте в вывод.

  3. Расширьте: top N процессов по CPU. После каждой выборки делать аналог mini-ps (урок 2), но смотреть utime+stime, считать diff с прошлой выборкой. Показывать топ-3.

  4. Расширьте: вывод в JSON. Флаг --json — печатать каждую строку как JSON-объект. Это позволит pipe-нуть в jq или Datadog/Splunk.


KnowledgeCheck

Проверка знанийKnowledge check
Юниор пишет monitor. Читает /proc/stat один раз, считает busy/total × 100, выводит. Жалуется, что monitor показывает 7% даже когда система явно загружена 90%. Что не так?
ОтветAnswer

Capstone-итог

Три инструмента готовы:

  • mini-ps (~150 строк): /proc/[pid]/* -> ProcessInfo -> колонки.
  • mini-shell (~200 строк): fork + exec + pipe + dup2 + builtins.
  • resource-monitor (~250 строк): /proc/stat /meminfo /diskstats -> diff -> alerts.

Что важно: никакой магии. Все эти системные тулзы — это парсеры /proc + fork/exec + dup2. То, что вы знаете теперь, достаточно, чтобы:

  1. Понимать вывод любого системного инструмента (top, vmstat, iostat, ps).
  2. Дебажить проблемы продакшен-систем («почему CPU 100% — это какой процесс?»).
  3. Писать кастомные monitors / agents для specific use case.

В labs (labs/LAB-01-process-inspector, LAB-02-syscall-tracer, LAB-03-capstone-mini-shell) — расширенный hands-on с автоматизированной проверкой. Это последний шаг — сесть и написать своё.


Что вы выносите с курса

После 15 модулей вы понимаете:

  • Что такое процесс vs поток vs syscall. PID, PPID, UID. /proc как универсальный API.
  • fork, exec, wait — три syscalls, на которых построен весь Unix.
  • Virtual memory: pages, page tables, MMU, copy-on-write, swap, OOM.
  • IPC: pipes, signals, shared memory, Unix sockets.
  • Файловые системы: VFS, inodes, links, page cache.
  • File I/O: open, read, write, dup2, fsync, mmap.
  • Permissions: rwx, setuid, capabilities, namespaces.
  • Syscalls: strace, ABI, vDSO.
  • Boot: BIOS -> bootloader -> kernel -> init/systemd.
  • Observability: top, vmstat, iostat, perf, /proc, eBPF.
  • Capstone: как всё это собрать в работающий код.

Дальше — практика. Берёте какой-нибудь тикет или эксперимент и применяете. Через год вы — middle developer, который понимает, что физически делает каждая строка кода.

kubectl top и Metrics API: resource monitor на уровне кластера

Спасибо за то, что прошли курс до конца. Если что-то неясно — labs дадут структурированный практический разбор.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Junior пишет monitor. Читает /proc/stat ОДИН раз, считает busy/total × 100, выводит. Жалуется, что monitor показывает ~7% даже когда система явно нагружена 90%. Что не так?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4