Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В нашем грустном и печальном коронавирусном мире очень хочется смотреть на все таким же печальным взглядом. Ну и вот, так случилось, что приходит к нам с жалобой ооочень важный клиент. И говорит: “А у вас молоко убежало! Ваши снапшоты виртуальных машин работают ну очень медленно и печально.” В этом посте мы рассказываем, как наша команда справлялась с этой неприятностью.
На первый взгляд, оно примерно так и должно быть. 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’.
virsh qemu-monitor-command VM-name [--hmp] <command>
Стандартное описание протокола обычно поставляется вместе с самим 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. По этим описаниям при сборке проекта автоматически генерируется код парсера и проектная документация. В данном случае сложные команды нам не нужны, достаточно воспользоваться следующей простой командой:
# virsh qemu-monitor-command VM --hmp trace-event qcow2* on
Запускаем создание снапшота
# virsh snapshot-create VM
И начинаем смотреть в лог
qcow2snapshotcreatefinish bs 0x55c1e47ca000 id 1
qcow2writevstartreq co 0x55c1e47adb80 offset 0x788346000 bytes 20480
qcow2writevstartpart co 0x55c1e47adb80
qcow2allocclustersoffset co 0x55c1e47adb80 offset 0x788346000 bytes 20480
qcow2handlecopied co 0x55c1e47adb80 guestoffset 0x788346000 hostoffset 0x0 bytes 0x5000
qcow2l2allocate bs 0x55c1e47ca000 l1index 0
qcow2cacheget co 0x55c1e47adb80 isl2¨C11Cfrom¨C12Ccache¨C13Cdone co 0x55c1e47adb80 is¨C14Ccache 0 index 0
qcow2¨C15Cget co 0x55c1e47adb80 is¨C16Ccache 0 offset 0x200000 read¨C17Cdisk 1
qcow2¨C18Cget¨C19Cl2¨C20Ccache¨C21Cl2¨C22Ccache¨C23Cflush co 0x55c1e47adb80 is¨C24Ccache 0 index 0
qcow2¨C25Cflush co 0x55c1e47adb80 is¨C26Ccache 1
qcow2¨C27Centry¨C28Cl2¨C29Cl2¨C30Cget¨C31Cindex 0
Что нам интересно в этом логе (после фильтрации несущественного):
qcow2¨C32Cstart¨C33Cwritev¨C34Creq co 0x55ee78176f40 offset 0x109a307d3c bytes 131337
qcow2¨C35Cstart¨C36Cwritev¨C37Creq co 0x55ee7858e010 offset 0x109a347f45 bytes 53360
На первый взгляд все выглядит неплохо. Запись идет последовательно, блоки относительно большие. Но, все-таки, почему-то это работает медленно. Надо копать глубже. Берем в руки blktrace и смотрим, как это выглядит с точки зрения ядра.
# blktrace -d /dev/sda -o - | blkparse -i -
На системе сейчас у нас ничего не запущено, поэтому особенно напрягаться с точки зрения фильтрации результата не обязательно. Результат выглядит примерно вот так
8,0 4 40 0.120622778 677708 D R 347181323 + 1 [qemu-kvm]
8,0 3 98 0.121070367 0 C R 347181323 + 1 [0]
8,0 3 106 0.121181060 677708 D R 347181580 + 1 [qemu-kvm]
8,0 3 107 0.121230086 0 C R 347181580 + 1 [0]
8,0 4 48 0.121512160 678123 D WS 347181323 + 258 [qemu-kvm]
8,0 3 108 0.121963520 0 C WS 347181323 + 258 [0]
8,0 4 56 0.122192028 677708 D R 347181580 + 1 [qemu-kvm]
8,0 3 109 0.122592687 0 C R 347181580 + 1 [0]
8,0 3 117 0.122700027 677708 D R 347181837 + 1 [qemu-kvm]
8,0 3 118 0.122980774 0 C R 347181837 + 1 [0]
8,0 4 64 0.123417678 678123 D WS 347181580 + 258 [qemu-kvm]
8,0 3 119 0.123871902 0 C WS 347181580 + 258 [0]
и вот эта картинка уже выглядит очень печально и все объясняет.
Итак, что же мы видим. Мы видим, что каждый запрос на запись предваряется двумя запросами на чтение, размером в один сектор. При этом есть строгая очередность - 2 запроса на чтение, 1 запрос на запись. Параллельности никакой нет. Пока не закончится чтение, записи нет. Пока не закончится запись, нового чтения тоже нет. Для операций с диском такая нагрузка в принципе не может приблизиться к пределу пропускной способности накопителя ни при каких разумных условиях. Почему же так получилось?
Вообще говоря, такая структура операций даже имеет специальное название: “read-modify-write”. Как правило, она наблюдается при выполнении операций, не выровненных на страницу. Более того, в нашем случае файловый дескриптор, который использовал QEMU для операций записи, был открыт в режиме O¨C38CDIRECT — зло, и от него надо избавляться?
Вопрос на самом деле не такой уж и простой. Как выглядит структура любой IO операции с точки зрения QEMU? Гость присылает в дисковый контроллер запрос. Этот запрос должен быть транслирован в read/write/discard операцию в файл, лежащий где-то в файловой системе хоста. И пока этот запрос исполняется, было бы неплохо обрабатывать какие-то другие дисковые запросы, которые лежат в очереди контроллера. Таким образом, надо уметь реализовывать асинхронные IO операции.
Основных вариантов реализации асинхронных IO операций в Linux только два:
libaio (через io¨C39Csubmit работает асинхронно только с O¨C40CDIRECT получается быстрее. Ну и некоторым бонусом идет то, что нам не требуется память на уровне хостового ядра для эффективной записи или чтения. В случае оверкоммита системы по памяти так будет работать, очевидно, лучше.
Дополнительно стоит отметить еще одну особенность, которая хорошо видна по оригинальной трассировке уровня QEMU. Все записи данных происходят в QCOW2 и эти записи идут в новые блоки. Такие операции имеют милую особенность. Новый блок образа всегда выделяется целиком. Частичное выделение невозможно. Это значит, что если данных не хватает на блок полностью, на оставшийся кусок вызывается fallocate(FALLOC¨C41CZERO¨C42Ctask_pool нам в помощь. Размер очереди мы взяли в 8 запросов по 1 Mb каждый и сразу же получили прекрасный результат (время создания снапшота, в секундах).
Операция выполняется быстрее, во всех режимах. В том числе и в стандартном кешированном режиме из-за того, что теперь все операции выровнены на блоки образа.
Теперь самое время обсудить бонус. Совершенно очевидно, переключение на снапшот устроено абсолютно таким же неэффективным образом. Надо делать примерно тоже самое. Но также не получается. На SSD/NVME с очередью все прекрасно работает, а на HDD это вызывает падение производительности около 30%. И вот тут мы были поражены. Операция переключения на снапшот на NVME работает медленнее, чем на HDD. Этот факт мы смогли объяснить только наличием очереди в стандартном вращающемся диске и наличием read-ahead самого диска, который при последовательном чтении хорошо угадывает, какие данные надо зачитать и когда.
Значит, надо менять подход. Давайте читать данные последовательно: один запрос в очереди с кешом в те же 10 Мб и стартом нового запроса в момент окончания предыдущего. Вот это уже начинает работать правильно и дает отличный результат.
Данные результаты были рассказаны на KVM Forum-е 2020. Патчи отправлены в mainstream в июне и пока не приняты. Но мы не теряем надежды, что Kevin Wolf и Max Reitz таки проникнутся необходимостью взять этот код в основную ветку.