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).
Архитектура
Loop: sample -> diff -> report -> sleep
Шаг 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).
Нельзя просто взять busy/total из одного snapshot -- это процент с boot
Шаг 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.
Если ваш 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):
- major number
- minor number
- device name
- reads completed
- reads merged
- sectors read (1 sector = 512 bytes)
- milliseconds reading
- writes completed
- writes merged
- sectors written
- milliseconds writing
- I/Os in progress
- milliseconds doing I/O
- 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 в этом нет.
Попробуй сам
-
Запустите monitor, поработайте 30 секунд. Должны видеть нормальные числа.
-
Создайте нагрузку CPU в соседнем терминале:
python -c "while True: pass" &
monitor должен показать рост CPU (~25% на 4-core машине от одного процесса). Через 30 секунд — kill %1 для остановки.
-
Расширьте: per-core CPU. Читайте все строки
cpu0,cpu1, … из /proc/stat. Считайте % для каждого ядра. Выводите как[c0: 12% c1: 87% c2: 5% c3: 3%]. -
Расширьте: load average. Прочтите /proc/loadavg (одна строка:
1.23 1.45 1.67 1/234 12345). Первые три числа — load over 1/5/15 минут. Добавьте в вывод. -
Расширьте: top N процессов по CPU. После каждой выборки делать аналог mini-ps (урок 2), но смотреть utime+stime, считать diff с прошлой выборкой. Показывать топ-3.
-
Расширьте: вывод в JSON. Флаг
--json— печатать каждую строку как JSON-объект. Это позволит pipe-нуть в jq или Datadog/Splunk.
KnowledgeCheck
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. То, что вы знаете теперь, достаточно, чтобы:
- Понимать вывод любого системного инструмента (top, vmstat, iostat, ps).
- Дебажить проблемы продакшен-систем («почему CPU 100% — это какой процесс?»).
- Писать кастомные 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 дадут структурированный практический разбор.