Мониторинг показывает: дисковая запись на SSD утилизирована почти вполовину сверх ожидаемого, iotop рисует десятки мегабайт в секунду, а приложение, работающее на сервере, клянётся, что почти ничего не пишет. Ситуация знакомая до боли. SSD при этом деградирует, счётчик записанных терабайт растёт, а внятного ответа "кто пишет" нет. Отличие грамотной диагностики от гадания в том, что администратор проходит путь от общей картины к конкретному процессу и конкретному файлу, опираясь на iotop, /proc/diskstats, blktrace и проверку гипотез через fio. Эта статья разбирает весь маршрут на реальных командах, их выводе и числах из практики.
Первичная оценка нагрузки через iotop в аккумулирующем режиме
Первый инструмент, который запускают в такой ситуации, это iotop. Ключевой нюанс в том, что мгновенный режим обманывает: процесс может писать короткими всплесками, и в случайный момент наблюдения его просто не видно. Поэтому запускают аккумулирующий режим:
# -o показывать только процессы, реально пишущие или читающие
# -a накапливать суммарный счётчик с момента запуска
# -P только процессы, без разбивки по потокам
# -d 1 обновлять раз в секунду
iotop -oaP -d 1
Через десять минут наблюдения вывод, отсортированный по накопленной записи, выглядит так:
Total DISK READ : 0.00 B/s | Total DISK WRITE : 42.51 M/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 87.30 M/s
TIME TID PRIO USER DISK READ DISK WRITE COMMAND
10:00 4821 be/4 mysql 0.00 B 12.40 G mysqld
10:00 1203 be/4 root 0.00 B 3.10 G jbd2/dm-0-8
10:00 9912 be/4 appuser 0.00 B 2.70 G java -jar billing.jar
10:00 901 be/4 root 0.00 B 512.00 M rsyslogd -n
Разбор по строкам. Первая строка Total показывает суммарную скорость, которую запросили процессы у ядра. Вторая, Actual, это реальная запись, которую увидел блочный слой. Разрыв в два раза, 42 против 87 мегабайт в секунду, это первый тревожный сигнал: что-то порождает запись сверх того, что явно запросили приложения. Второй сигнал: поток jbd2/dm-0-8 записал 3.1 гигабайта за окно наблюдения. Это поток журналирования файловой системы ext4, и он не пишет сам по себе, он обслуживает чужие транзакции. Когда jbd2 попадает в топ вместе с приложением, чаще всего виноват частый fsync от этого приложения, а не "сломавшаяся файловая система".
Строка с mysqld и 12.4 гигабайтами за окно ожидаема для базы данных, а вот rsyslogd с половиной гигабайта стоит запомнить: это почти всегда следствие раздутого логирования, а не норма.
Проверка реальной записи на устройство через /proc/diskstats
iotop показывает сверху вниз, по процессам. Обратную, снизу вверх, картину даёт /proc/diskstats. Здесь ядро отчитывается по каждому блочному устройству с момента загрузки. Быстрый способ увидеть дельту записи:
# два снимка с интервалом 10 секунд, считаем разницу
grep ' sda ' /proc/diskstats > /tmp/d1; sleep 10; grep ' sda ' /proc/diskstats > /tmp/d2
awk '{print $1, $4, $8, $10}' /tmp/d1 /tmp/d2
Поля в /proc/diskstats для устройства идут в таком порядке: major, minor, имя, затем счётчики. Для записи важны поля 8 (число завершённых записей) и 10 (число записанных секторов по 512 байт). Пример пары снимков:
/tmp/d1: 8 0 sda 40123 1204551 98765432 5120 1880422 311204 55021100 ...
/tmp/d2: 8 0 sda 40220 1213650 98765432 5120 1884710 312090 55501900 ...
Разница по записанному полю: 55501900 минус 55021100, итого 480800 секторов за 10 секунд, то есть 24040 килобайт в секунду, около 23.5 мегабайта в секунду. Если при этом iotop показывает суммарно 10 мегабайт в секунду от процессов приложения, остаток это метаданные, журнал, отложенная запись page cache или работа другого устройства через LVM. Такой сверочный подсчёт отрезает половину ложных гипотез ещё до запуска тяжёлой трассировки.
Трассировка блочного слоя утилитами blktrace и blkparse
Когда сверху всё чисто, а снизу пишется лишнее, нужен третий уровень: прямая трассировка запросов к устройству. Здесь работает связка blktrace и blkparse. Собирать трассу следует коротко, 20-30 секунд, иначе файлы разрастутся до гигабайт:
# -d /dev/sda целевое устройство
# -o trace префикс выходных файлов (получится trace.blktrace.0 и т.д. по CPU)
# -w 30 собирать ровно 30 секунд
# -a write фильтр только по операциям записи, чтобы не тонуть в чтении
blktrace -d /dev/sda -o trace -w 30 -a write
Затем парсинг в читаемый вид:
# -i trace входные файлы
# -d out.txt бинарный свод, пригодится для btrecord
blkparse -i trace | head -40
Типовой фрагмент вывода blkparse:
8,0 6 1 0.000000412 4821 Q WS 130241032 + 8 [mysqld]
8,0 6 2 0.000021187 4821 G WS 130241032 + 8 [mysqld]
8,0 6 3 0.000041904 4821 D WS 130241032 + 8 [mysqld]
8,0 6 4 0.015724110 1203 Q WS 130245128 + 8 [jbd2/dm-0-8]
8,0 6 5 0.015890221 1203 D WS 130245128 + 8 [jbd2/dm-0-8]
Разбор полей слева направо: 8,0 это major и minor устройства sda; 6 это CPU, на котором обработано событие; порядковый номер; метка времени в секундах от старта трассы; PID процесса; приложение blktrace событие из одного-двух символов; сектор и количество секторов после знака плюс; в квадратных скобках имя процесса.
События читают так. Q означает, что запрос поставлен в очередь ядра. G это выделение структуры запроса. I это вставка в очередь планировщика ввода-вывода. D это выдача запроса драйверу устройства. C это завершение запроса. Префикс WS означает запись (W) синхронную (S). Именно S тут важнее всего: синхронная запись это следствие fsync, fdatasync или O_DIRECT/O_SYNC, и её нельзя отложить или объединить в page cache. Если в трассе строки Q, G, D от одного PID идут подряд с разрывом в микросекунды и каждая с "+ 8" секторов, имеем классический паттерн путь от fsync: приложение пишет 4 килобайта и сразу синкает.
Статистика по процессам за всю трассу удобнее собирать скриптом:
# суммируем записанные сектора по именам процессов
blkparse -i trace | awk '$7 ~ /WS|W$/ {split($10,a,"]"); s[substr(a[1],2)] += $9} END {for (k in s) printf "%-20s %10.1f MB
", k, s[k]*512/1048576}' | sort -k2 -nr | head
Результат из реального инцидента:
java 1843.2 MB
jbd2/dm-0-8 921.6 MB
mysqld 410.5 MB
kswapd0 12.1 MB
Здесь видно две вещи. Во-первых, java-процесс пишет вдвое больше, чем предполагалось по его логике. Во-вторых, jbd2 написал половину того, что написал java: журнал ext4 дублирует метаданные транзакций, и высокая доля jbd2 сама по себе указывает на мелкую частую синхронную запись. В нормально работающей системе с пакетной записью доля журнала обычно в разы меньше.
Отдельно в трассах blktrace ищут аномальные паттерны: запись с постоянным смещением (один и тот же сектор, перезаписываемый тысячами запросов, типично для truncate-цикла приложения), чередование секторов двух файлов с шагом 4 килобайта (два потока с fsync убивают последовательность), и мелкие записи размером 4-16 килобайт, составляющие более 80 процентов объёма. Любой из этих паттернов напрямую ведёт к write amplification внутри SSD: контроллер перезаписывает страницу 256 килобайт ради изменения 4 килобайт, и фактически изношенный объём флеш-памяти в 10-60 раз превышает логическую запись хоста.
Проверка гипотезы и аномальных паттернов через fio
Трассировка показала "кто", теперь нужно воспроизвести "как", чтобы доказать механизм и оценить выигрыш от исправления. Инструмент fio. Ключевой эксперимент: сравнение произвольной синхронной записи мелкими блоками с пакетной записью без частых fsync:
# Худший случай: 4K random write с fsync на каждую операцию
fio --name=worst --filename=/data/fiotest --size=2G --rw=randwrite \
--bs=4k --ioengine=sync --fsync=1 --direct=1 --runtime=60 --time_based
# Контрольный: те же 4K, но fsync раз в 32 операции
fio --name=better --filename=/data/fiotest2 --size=2G --rw=randwrite \
--bs=4k --ioengine=psync --fsync=32 --direct=1 --runtime=60 --time_based
# Референс: крупная последовательная запись
fio --name=seq --filename=/data/fiotest3 --size=2G --rw=write \
--bs=1M --ioengine=libaio --iodepth=32 --direct=1 --runtime=60 --time_based
Характерные результаты на потребительском SATA SSD:
worst: write: IOPS=380, BW=1520KiB/s, clat 99.00th=[25223] usec
better: write: IOPS=6100, BW=23.8MiB/s, clat 99.00th=[1811] usec
seq: write: IOPS=455, BW=455MiB/s, clat 99.00th=[9801] usec
Типичные причины аномальной записи с конкретными опознавательными признаками
По итогам сотен таких расследований причины складываются в небольшой повторяющийся набор:
- Приложение или СУБД делает fsync на каждую логическую операцию вместо группового коммита, в трассах это сплошная грязь из записей WS размером 4-16 килобайт и двойной объём запросов от jbd2;
- Раздутый уровень логирования с синхронным сбросом, программа пишет debug-лог на боевом контуре, rsyslogd стабильно в топе iotop, лечится переходом на буферизованный режим с дефисом перед путём в конфиге rsyslog, например "*.debug -/var/log/app.log";
- Отложенная запись с агрессивным сбросом page cache: vm.dirty_writeback_centisecs выставлен в 50 или 100, из-за чего ядро сбрасывает грязные страницы каждые полсекунды мелкими порциями, лечится значением 1500 и vm.dirty_expire_centisecs 6000;
- Регулярная перезапись одного и того же небольшого файла через open-truncate-rewrite в цикле, в blkparse это один повторяющийся диапазон секторов, классика для самописных систем локов и метрик;
- Снапшоты LVM или CoW-файловые системы, где логическая запись физически удваивается, на трассах это видно как запись от kworker в область снапшота одновременно с записью приложения;
- Swap на SSD под давлением памяти, kswapd0 попадает в верх таблицы blkparse, а si/so поля vmstat 1 отличны от нуля на протяжении минут.
Каждый пункт легко подтверждается именно связкой iotop плюс blkparse: имена процессов и паттерны секторов не врут, в отличие от рассказов владельцев приложений.
Настройка планировщика и фильтрация рассинхронизации журнала
На современных ядрах для NVMe и виртуальных дисков правильный планировщик io это none (mq-deadline тоже допустим), для SATA SSD обычно mq-deadline. Проверка и установка:
cat /sys/block/sda/queue/scheduler
# [mq-deadline] kyber bfq none
echo mq-deadline > /sys/block/sda/queue/scheduler
Постоянно правило закрепляют через udev:
cat > /etc/udev/rules.d/60-ssd-scheduler.rules <<'EOF'
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
EOF
Параллельно снижают износ ФС: в /etc/fstab для ext4 добавляют noatime и commit=60 вместо пятисекундного значения по умолчанию, а режим журналирования оставляют data=ordered, вариант data=writeback без понимания рисков целостности файлов при сбое брать не стоит.
Для СУБД стоит пересмотреть параметры flush: у InnoDB это innodb_flush_log_at_trx_commit=2 вместо 1 там, где потеря секунды транзакций допустима, innodb_flush_method=O_DIRECT, и group commit, который при 2 работает сам по себе пакетно. В спорных ситуациях решение принимают после повторного прогона fio с параметрами, близкими к реальному рабочему профилю.
Пошаговый план администратора и профилактика повторения проблемы
Практический маршрут действий при аларме "high disk write on SSD" выглядит так. Сначала iotop -oaP на 10-15 минут для аккумулированной картины по процессам. Затем сверка с /proc/diskstats, чтобы отделить запись процессов от записи ядра. Если в топе jbd2 или kworker, запуск blktrace -d /dev/sda -o trace -w 30 -a write и подсчёт статистики по процессам и характеру запросов через blkparse. Далее воспроизведение профиля в fio для проверки гипотезы и оценки потенциального выигрыша. После этого исправление источника: переход на буферизованное логирование, пакетные коммиты, увеличение commit-интервала ФС, перенастройка dirty-таймеров sysctl. И в финале контрольный 15-минутный iotop и сравнение прироста Host_Writes в smartctl за сутки до и после.
Профилактика сводится к трём механизмам. Первый это метрики: в системе мониторинга должны быть rate из /proc/diskstats по каждому SSD, значение Host_Writes из smart, и доля записи jbd2, при превышении порога в 20 процентов от общей записи это отдельный аларм. Второй это выявление приложений с частым fsync ещё на стенде, тем же fio-профилем worst из этой статьи. Третий это регулярный аудит logrotate и уровней логирования: половина всех инцидентов высокой записи на SSD в проде, честно говоря, начиналась с включённого debug-лога, забывшего про ротацию, и заканчивалась одной строкой в конфиге. Диск умирает медленно и незаметно, но счётчик записанных терабайт ведёт дневник событий, и регулярное чтение этого дневника гораздо дешевле внеплановой замены накопителя.