В нашем грустном и печальном коронавирусном мире очень хочется смотреть на все таким же печальным взглядом. Ну и вот, так случилось, что приходит к нам с жалобой ооочень важный клиент. И говорит: “А у вас молоко убежало! Ваши снапшоты виртуальных машин работают ну очень медленно и печально.” В этом посте мы рассказываем, как наша команда справлялась с этой неприятностью. На первый взгляд, оно примерно так и должно быть. Open Source технологии, конечно, хороши, но бывают и отдельные невеселые моменты. Снапшоты виртуальных машин в QEMU в настоящее время абсолютно синхронны. А это значит, что все время, пока сохраняется состояние и память машины, гость остается полностью остановленным. Это (почти) правда. Есть специальный вариант, когда сохранение снапшота работает как итеративная миграция, то есть мы начинаем сохранение памяти на запущенной виртуальной машине: проходим по всем страницам и сохраняем их на диск. После этого проходим второй раз и сохраняем только измененные страницы и т. д. Делаем это до тех пор, пока измененных страниц не станет относительно немного. Далее идет точно такой же синхронный процесс. Но этот вариант нас не устроил, так как состояние фиксируется не на момент отправки команды на создание снапшота, а в произвольный (абсолютно непредсказуемый) момент времени. Разговоры про нормальную реализацию процесса миграции, когда мы фиксируем состояние виртуальной машины на момент вызова команды и устанавливаем защиту от записи на всю память виртуальной машины по состоянию на весну-лето этого года в основном потоке разработки QEMU были завешены тем, что выставить write protect с уведомлением через user fault fd было нельзя. Этот функционал в ядро Linux еще не попал. Наши попытки реализовать эту возможность через специальный KVM userspace exit коммьюнити отвергло 2 года назад. Как и было сказано выше, не все так хорошо с OpenSource разработкой. Сейчас этот функционал в ядре появился и процесс разработки возобновился, но пока еще ничего не готово к работе в боевом режиме. Ок. Примерно это мы и планировали написать клиенту. Но, не тут-то было. Наши инженеры из саппорта поймали нас с этим письмом за руку. На машине клиента работало наше же распределенное хранилище и на тестах оно выдавало производительность в районе 120-200 Mb/sec на запись с одного хоста. А вот время сохранения снапшота для 8 GB ВМ было в районе 300 секунд, что давало только 27 Mb/sec на запись. Оказалось, что-то тут не так, и с этой проблемой надо было разбираться даже с учетом кривой архитектуры. Надо разбираться, так надо разбираться. QEMU неплохо инструментирован. В коде довольно много разного рода trace point-ов, каждый из которых можно включить независимо от других. Трассировка пишется в лог виртуальной машины, который обычно лежит в /var/log/libvirt/qemu/vm-name.log. Вообще, если говорить про отладку QEMU, то стоит вспомнить об идеологии связки QEMU/libvirtd. Это действительно важно. QEMU сам по себе не содержит внутри себя никакой логики, связанной с организацией той или иной операции. Все управляется внешним образом через стандартизованный интерфейс. Таких интерфейсов, на самом деле два — HMP (human monitor protocol) и QMP (QEMU machine protocol). QMP более полон. Все HMP команды присутствуют в QMP. Обратное уже неверно. Более того, существует стандартная возможность отослать произвольную команду в этот интерфейс через ‘virsh’.  Стандартное описание протокола обычно поставляется вместе с самим QEMU и любой желающий может его почитать по адресу https://github.com/qemu/qemu/blob/master/docs/interop/qmp-spec.txt. Запросы ходят в JSON формате и они полностью специфированы в https://github.com/qemu/qemu/blob/master/qapi/, например https://github.com/qemu/qemu/blob/master/qapi/block.json. По этим описаниям при сборке проекта автоматически генерируется код парсера и проектная документация. В данном случае сложные команды нам не нужны, достаточно воспользоваться следующей простой командой:  Запускаем создание снапшота   И начинаем смотреть в лог  Что нам интересно в этом логе (после фильтрации несущественного): На первый взгляд все выглядит неплохо. Запись идет последовательно, блоки относительно большие. Но, все-таки, почему-то это работает медленно. Надо копать глубже. Берем в руки blktrace и смотрим, как это выглядит с точки зрения ядра.  На системе сейчас у нас ничего не запущено, поэтому особенно напрягаться с точки зрения фильтрации результата не обязательно. Результат выглядит примерно вот так и вот эта картинка уже выглядит очень печально и все объясняет. Итак, что же мы видим. Мы видим, что каждый запрос на запись предваряется двумя запросами на чтение, размером в один сектор. При этом есть строгая очередность - 2 запроса на чтение, 1 запрос на запись. Параллельности никакой нет. Пока не закончится чтение, записи нет. Пока не закончится запись, нового чтения тоже нет. Для операций с диском такая нагрузка в принципе не может приблизиться к пределу пропускной способности накопителя ни при каких разумных условиях. Почему же так получилось? Вообще говоря, такая структура операций даже имеет специальное название: “read-modify-write”. Как правило, она наблюдается при выполнении операций, не выровненных на страницу. Более того, в нашем случае файловый дескриптор, который использовал QEMU для операций записи, был открыт в режиме O_DIRECT. Это означает, что не выровненные операции запрещены, и в коде программы мы должны вычитывать те самые маленькие кусочки перед основной операцией записи. Так режим O_DIRECT —  зло, и от него надо избавляться? Вопрос на самом деле не такой уж и простой. Как выглядит структура любой IO операции с точки зрения QEMU? Гость присылает в дисковый контроллер запрос. Этот запрос должен быть транслирован в read/write/discard операцию в файл, лежащий где-то в файловой системе хоста. И пока этот запрос исполняется, было бы неплохо обрабатывать какие-то другие дисковые запросы, которые лежат в очереди контроллера. Таким образом, надо уметь реализовывать асинхронные IO операции. Основных вариантов реализации асинхронных IO операций в Linux только два: libaio (через io_submit с уведомлением о завершении через eventfd) preadv/pwritev работающие на каком-то thread pool-е  Второй подход выглядит проще, но имеет ряд принципиальных проблем глубина очереди IO запросов контроллера равна количеству thread-ов в пуле для выполнения каждого запроса требуется переключение контекста в контекст треда, что вносит дополнительные задержки   В первом подходе существует принципиальное ограничение Linux-ядра. io_submit работает асинхронно только с O_DIRECT файловыми дескрипторами. Нет, ошибки при вызове не возникает, но возврат происходит только после полного завершения операции. Итак, а что использовать лучше? Очевидный ответ: “То, что работает быстрее”.    Запустив FIO в госте (размер запроса 4к, глубина очереди 128) получаем простой ответ. C O_DIRECT получается быстрее. Ну и некоторым бонусом идет то, что нам не требуется память на уровне хостового ядра для эффективной записи или чтения. В случае оверкоммита системы по памяти так будет работать, очевидно, лучше. Дополнительно стоит отметить еще одну особенность, которая хорошо видна по оригинальной трассировке уровня QEMU. Все записи данных происходят в QCOW2 и эти записи идут в новые блоки. Такие операции имеют милую особенность. Новый блок образа всегда выделяется целиком. Частичное выделение невозможно. Это значит, что если данных не хватает на блок полностью, на оставшийся кусок вызывается fallocate(FALLOC_FL_ZERO_RANGE), и при выполнении следующей операции мы получаем дополнительное обновление метаданных на файловой системе хоста. Наиболее быстрым образом выполняются операции над данными, выровненными на размер кластера. Почему все работает так не эффективно? Ответ, на самом деле, достаточно прост. Весь код создания снапшота вырос из кода миграции. А миграция — это сокет для отправки данных на другой компьютер или в другой процесс. Про оптимизацию для записи на диск, получается, никто и не задумывался. Пора, наконец, лечить проблему. Для этого надо сделать всего-ничего выровнять записываемые данные на размер кластера организовать очередь из записываемых кластеров некоторой разумной глубины  Инструменты для этого уже давно реализованы моим коллегой Владимиром Семенцовым-Огиевским в рамках ускорения работы с большими запросами из гостя https://lists.gnu.org/archive/html/qemu-devel/2019-08/msg03152.html. aio_task_pool нам в помощь. Размер очереди мы взяли в 8 запросов по 1 Mb каждый и сразу же получили прекрасный результат (время создания снапшота, в секундах). Операция выполняется быстрее, во всех режимах. В том числе и в стандартном кешированном режиме из-за того, что теперь все операции выровнены на блоки образа. Теперь самое время обсудить бонус. Совершенно очевидно, переключение на снапшот устроено абсолютно таким же неэффективным образом. Надо делать примерно тоже самое. Но также не получается. На SSD/NVME с очередью все прекрасно работает, а на HDD это вызывает падение производительности около 30%. И вот тут мы были поражены. Операция переключения на снапшот на NVME работает медленнее, чем на HDD. Этот факт мы смогли объяснить только наличием очереди в стандартном вращающемся диске и наличием read-ahead самого диска, который при последовательном чтении хорошо угадывает, какие данные надо зачитать и когда. Значит, надо менять подход. Давайте читать данные последовательно: один запрос в очереди с кешом в те же 10 Мб и стартом нового запроса в момент окончания предыдущего. Вот это уже начинает работать правильно и дает отличный результат. Данные результаты были рассказаны на KVM Forum-е 2020. Патчи отправлены в mainstream в июне и пока не приняты. Но мы не теряем надежды, что Kevin Wolf и Max Reitz таки проникнутся необходимостью взять этот код в основную ветку.