MAATRIX / Блог / Снапшот жил полгода и превратил диск виртуалки в цепочку из 40 файлов

Снапшот жил полгода и превратил диск виртуалки в цепочку из 40 файлов

MAATRIX

Виртуалка с базой данных начала тормозить на чтении — не постоянно, а рывками, будто диск иногда «задумывается» на полсекунды. Мониторинг не находил ничего криминального: CPU свободен, RAM в норме, RAID здоров. Разгадка нашлась не в железе и не в конфиге PostgreSQL, а в директории с дисками виртуалки, где вместо одного qcow2-файла лежала цепочка из сорока. Рассказываю, как мы её нашли и что с ней сделали.

Как всё начиналось: снапшот «для надёжности» перед бэкапом

Полгода назад мы настроили простой процесс резервного копирования этой виртуалки: раз в неделю скрипт делал внешний снапшот диска через virsh snapshot-create-as, снимал консистентную копию файлов rsync'ом с замороженного base-образа, а потом должен был «схлопнуть» снапшот обратно командой virsh blockcommit --pivot. Схема рабочая и во многих местах у нас так и работает — снапшот снимается за миллисекунды, бэкап идёт с гарантированно консистентного состояния, а после мержа диск снова становится одним файлом.

Проблема была не в самой схеме, а в том, что шаг мержа в скрипте не проверял код возврата как следует. После обновления пакета libvirt-clients на хосте немного изменился формат вывода virsh domblklist, скрипт парсил его через grep и awk, и с новым форматом парсинг стал возвращать пустую строку вместо имени диска. Команда blockcommit с пустым аргументом диска просто ничего не делала и завершалась с ненулевым кодом — но чуть выше в скрипте когда-то добавили || true для другого, не связанного шага, и по невнимательности эта конструкция унаследовалась на весь блок через общий set -e без pipefail. Ошибка проглатывалась молча, в логах бэкапа стояло «OK», и следующий запуск создавал уже второй внешний снапшот поверх первого, который так и не смержился.

Так продолжалось примерно раз в неделю на протяжении полугода. Каждый новый снапшот добавлял ещё один qcow2-файл в цепочку backing-файлов, а верхним активным диском виртуалки становился самый свежий — пустой, без данных, только с указателем на предыдущий. К моменту, когда мы это заметили, qemu-img info --backing-chain выдавал сорок звеньев подряд.

Что мы увидели в логах и метриках

Первым сигналом были жалобы приложения на «медленные» запросы к базе — не все, а конкретные: выборки по старым записям, которым несколько месяцев. Свежие данные читались нормально, а обращения к архивным строкам иногда занимали секунды вместо миллисекунд. Это сразу навело на мысль, что дело не в самой базе — план запроса (EXPLAIN ANALYZE) был одинаковым что для быстрых, что для медленных выборок, PostgreSQL честно делал то же самое, просто физическое чтение блока с диска иногда затягивалось.

На хосте iostat -x 1 показывал резкие скачки await именно у процесса qemu, обслуживающего эту виртуалку — до 300-400 мс на отдельных операциях чтения, тогда как соседние виртуалки на том же физическом диске показывали обычные единицы миллисекунд. При этом iotop подсвечивал, что qemu делает много мелких операций чтения подряд там, где приложение внутри гостевой ОС запрашивало один блок. То есть гипервизор совершал в разы больше физических I/O, чем гостевая система «просила».

Ключевую подсказку дал qemu-img info --backing-chain /var/lib/libvirt/images/db01.qcow2: вывод растянулся на экран с сорока последовательными записями backing file:, каждая — отдельный qcow2-файл в директории снапшотов, датированный примерно раз в неделю за последние шесть месяцев.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые отбросили

Прежде чем дошли до цепочки снапшотов, проверили несколько других версий, каждая по 15-30 минут:

  • Деградация диска. Проверили smartctl -a на всех дисках массива — атрибуты в норме, реаллоцированных секторов нет. Похожий случай, когда SMART молчит, а диск реально сыпется, разбирали отдельно — там симптомы совсем другие: постепенный рост ошибок чтения, а не стабильные задержки на конкретных блоках.
  • Шумный сосед на хосте. На хосте крутится ещё пять виртуалок. Проверили blkio-статистику через cgroup (/sys/fs/cgroup/.../io.stat) — ни одна другая виртуалка не давала аномальной нагрузки на диск в моменты просадок.
  • Перегрузка storage-пула. Диски локальные, не сетевые, поэтому сетевую задержку до NAS сразу исключили. Загрузка самого RAID-контроллера (iostat на уровне физических устройств /dev/sd*) была в пределах нормы — просадки были именно у qemu-процесса, а не у железа под ним.
  • Регресс в приложении. Проверили git log и деплои за последние недели — ничего не менялось в запросах к базе, а проблема нарастала месяцами, а не появилась разом после релиза.
  • Тонкое переподписание диска. Проверили свободное место в storage-пуле — с местом было всё в порядке, это не тот случай перегруженного thin-provisioning.

Ни одна из версий не объясняла, почему тормозят именно чтения старых данных, а не любые операции подряд. Это и стало зацепкой: избирательность симптома указывала на что-то, что зависит от того, где физически лежат данные, а не на общую перегрузку.

Реальная причина: цепочка из 40 backing-файлов

Copy-on-write в qcow2 устроен так, что верхний (активный) файл содержит только те блоки, которые были записаны после создания снапшота. Если гостевая ОС читает блок, которого нет в верхнем файле, qemu идёт смотреть в backing-файл. Если и там пусто — идёт в следующий backing-файл по цепочке, и так далее, пока не найдёт данные или не дойдёт до самого первого, «базового» образа.

Для свежих данных, записанных за последние недели, всё нормально — они лежат в верхних файлах цепочки, и поиск завершается быстро. А вот старые записи, которые не менялись полгода, физически остались в самом первом файле — том, что был активным диском ещё до первого снапшота. Чтобы их прочитать, qemu приходится последовательно проверить все сорок файлов в цепочке, прежде чем добраться до нужного блока. Каждая такая проверка — это открытие файла (если он ещё не в кэше файловых дескрипторов) и чтение его метаданных L1/L2-таблиц, чтобы понять, есть там нужный кластер или нет.

Именно поэтому симптом был избирательным: свежие блоки читались с одного-двух файлов и быстро, а обращения к архивным строкам базы упирались в полный проход по всей цепочке. Чем старше данные, тем дальше вниз по цепочке приходится идти — и тем заметнее задержка. Подробнее о том, почему сама механика copy-on-write откладывает «плату» за снапшот на потом, мы разбирали в статье про copy-on-write в qcow2 — там же объясняется, почему один-два снапшота эту плату почти не создают, а вот десятки — совсем другая история.

Отдельная неприятность: пока цепочка не смержена, ни один из промежуточных файлов удалить нельзя — каждый следующий ссылается на предыдущий как на backing-файл, и разрыв цепочки в середине превращает данные в мусор. То есть за полгода накопился не просто мусор на диске, а цепочка зависимостей, которую нужно аккуратно схлопывать по порядку, а не чистить как попало.

Как чинили: живой blockcommit без остановки виртуалки

Первым делом остановили источник проблемы — исправили бэкап-скрипт, добавив set -euo pipefail и явную проверку, что blockcommit действительно получил непустое имя диска перед запуском, с аварийным алертом в Telegram-бот при любой аномалии на этом шаге.

Дальше нужно было схлопнуть саму цепочку. Останавливать боевую базу на время мержа сорока файлов было нежелательно, поэтому использовали живой (active) blockcommit — он умеет мержить цепочку прямо во время работы виртуалки:

virsh blockcommit db01 vda --active --pivot --wait

Флаг --active говорит libvirt мержить изменения в активный (верхний) диск цепочки, не останавливая виртуалку, а --pivot — сразу переключить виртуалку на смерженный файл как на новый активный диск. Перед запуском проверили две вещи, которые в такой ситуации обязательны:

  • Свободное место. Мерж цепочки из сорока файлов требует места, сопоставимого с суммарным объёмом изменений во всех снапшотах — в нашем случае это оказалось около трети от полного размера диска виртуалки. Место на storage-пуле было, но перед подобной операцией стоит проверять заранее, а не по факту.
  • Время операции. С такой длинной цепочкой мерж занял не секунды, а больше часа — libvirt честно проходил по всем сорока файлам и переносил данные наверх. Всё это время виртуалка продолжала работать, но I/O на диск был выше обычного, поэтому операцию запускали в окно минимальной нагрузки.

После завершения qemu-img info --backing-chain показал уже один-единственный файл без цепочки backing-файлов. Задержки на чтении архивных данных пропали сразу — сравнили EXPLAIN (ANALYZE, BUFFERS) на тех же запросах до и после, физическое время чтения вернулось к типичным для этого диска значениям.

Что изменили в процессе после инцидента

Одного исправления скрипта было мало — нужна была защита от повторения истории, даже если в другом месте появится похожая тихая ошибка:

  1. Мониторинг глубины цепочки. Добавили в systemd-таймер ежедневную проверку через qemu-img info --backing-chain для всех дисков виртуалок с алертом, если глубина цепочки превышает 3 файла — это с запасом покрывает нормальный рабочий цикл «снапшот → бэкап → мерж» и ловит зависшие цепочки на второй-третий день, а не через полгода.
  2. Явный таймаут на жизнь снапшота. Отдельный скрипт раз в сутки ищет снапшоты старше 48 часов, которые ещё не смержены, и шлёт алерт с именем виртуалки и датой создания снапшота — раньше эта информация нигде не собиралась централизованно.
  3. Ревизия остальных бэкап-скриптов. Прошлись по всем похожим сценариям на других хостах — где-то нашли ту же уязвимую конструкцию с проглоченным кодом возврата, поправили по единому шаблону с set -euo pipefail.
  4. Документированный ручной чек-лист на случай, если цепочка всё-таки где-то накопится: сначала проверить свободное место, затем — окно для операции с повышенным I/O, и только потом запускать blockcommit --active --pivot.

Отдельно проговорили с командой: внешние снапшоты для консистентных бэкапов — нормальная и рабочая практика, если про них не забывают. Общая механика снапшотов в Proxmox и их жизненный цикл описаны в статье про снапшоты в Proxmox — там же видно, что риск не в самом снапшоте, а именно в том, сколько времени он живёт несмерженным.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему нельзя было просто удалить старые снапшот-файлы?

Каждый файл в цепочке — это backing-файл для следующего. Данные внутри цепочки распределены между всеми файлами: часть блоков лежит в первом, часть — в десятом, часть — в тридцать пятом. Удаление файла из середины цепочки ломает доступ ко всем данным, которые физически хранятся именно в нём и не были перезаписаны выше.

Можно ли было заметить проблему раньше, без специального мониторинга?

Да, если бы кто-то регулярно заглядывал в директорию с дисками виртуалок или запускал virsh snapshot-list — количество файлов сразу бросилось бы в глаза. Но вручную такие вещи не проверяют месяцами, поэтому автоматический алерт на глубину цепочки — не роскошь, а обязательная часть процесса бэкапа со снапшотами.

Чем внешний снапшот отличается от обычного virsh snapshot-create без --disk-only?

Обычный снапшот без флага --disk-only может дополнительно сохранять состояние памяти виртуалки (для отката к «живому» состоянию), что делает его тяжелее и медленнее. Внешний снапшот с --disk-only работает только с диском, создаёт новый qcow2-оверлей и обычно применяется именно для консистентного бэкапа с последующим мержем — как в нашем случае.

Мог ли --active blockcommit повредить данные, если бы виртуалка упала посреди мержа?

Libvirt и QEMU проектировали эту операцию как устойчивую к прерыванию: до завершения --pivot виртуалка продолжает писать в исходный верхний файл, а мерж идёт «в фоне» без переключения. Если операция прервётся раньше --pivot, виртуалка просто останется на прежней цепочке — данные не теряются, но мерж придётся перезапускать.

Стоит ли вообще отказываться от снапшотов ради бэкапов в пользу другого способа?

Нет, снапшот-механика для консистентного бэкапа — стандартный и надёжный подход, если процесс мержа гарантированно отрабатывает и это проверяется автоматически. Отказ от снапшотов в пользу, например, остановки виртуалки на время копирования просто меняет одну проблему (простой) на другую, а не решает исходную задачу.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →