
Всем привет! Меня зовут Василий Иванов, я ведущий разработчик в команде Data Storage в MWS Cloud Platform, занимаюсь тем, чтобы диски наших виртуальных машин были надёжные и быстрые.
В этой статье я расскажу, как данные попадают из виртуальной машины в хранилище. Рассмотрим, как мы используем SPDK, зачем мы вообще взялись за этот низкоуровневый фреймворк, а также почему просто «заиспользовать» не получилось и пришлось копать, как всё устроено в самой глубине. Также мы увидим, как high performance фреймворка при нашем количестве устройств не хватало и какие доработки пришлось сделать.
Эта статья выходит по следам моего доклада на летнем Highload 2025 года.
Задача
Прежде всего разберёмся с тем, какую задачу мы будем решать. Пользователь при создании виртуальной машины имеет возможность подключить туда диски, и, запустив виртуалку, он ожидает, что все подключённые диски будут видны как обычные блочные устройства: /dev/sda, /dev/sdb… При этом диски бывают разных типов: сетевые, локальные. Соответственно, наша задача состоит в том, чтобы при операциях записи данные пользователя попадали в нужный тип хранилища: либо SDS по сети, либо на «сырое» SSD для локальных дисков. В этой статье будем рассматривать всё на примере Ceph как одного из бэкендов наших сетевых дисков.

Как можно решить эту задачу
Конечно, такая задача стара, как все облака, поэтому вариантов решения существует множество. Разберёмся, что есть и почему мы остановились именно на SPDK.
Стандартным способом доставки I/O пользователя из виртуалки является virtio-протокол. Приятным бонусом при использовании virtio является возможность избежать копирования данных между виртуалкой и OS. Это достигается за счёт использования shared memory и кольцевых буферов. На старте общения устанавливаются специальные виртуальные очереди (virtqueues) и механизм нотификации между виртуалкой и OS. Когда осуществляется I/O — оно кладётся в соответствующую очередь, после чего происходит уведомление о том, что другой стороне есть чего обрабатывать. Таким образом избегается копирование всех данных.
Осталось только решить, как мы будем налаживать общение. Самым простым решением было бы использовать функционал QEMU, в котором можно создавать virtio-устройства и для которого существуют различные типы backend’а, в том числе и Ceph. Однако это решение нам не подошло из-за недостаточной производительности. QEMU добавляет дополнительный overhead, так как сначала сначала виртуалка должна пообщаться с QEMU, которая потом со своих I/O-потоков пойдёт писать в устройства в хостовой ОС или в SDS. Также дополнительное неудобство доставляют I/O thread’ы QEMU. Их количество задаётся на старте QEMU и не меняется без перезапуска VM. Кроме того, этим потокам нужно выделять отдельные ядра, что приводит к лишнему потреблению ресурсов VM, за которое кому-то нужно платить: или пользователю, или само облако увеличит накладные расходы инфраструктуры, или приходится воровать CPU у виртуалки.
При создании большого количества виртуальных машин (напомним, что мы строим публичное облако) этим становится тяжело управлять.

Поэтому уже достаточно давно появилась надстройка над virtio протоколом, которая называется vhost. Vhost-протокол позволяет замапить память между гостевой и хостовой ОС и общаться напрямую с помощью событий, пересылаемых через файловый дескриптор. Это позволяет избежать лишнего хопа, который добавляла QEMU.
Классическая вариация vhost-протокола — ядерная. Писать ядерный код можно, но сложно и не очень хочется. К счастью, есть такая вещь, как vhost-user, т. е. реализация vhost-протокола, которая позволяет пошарить память не с ядром, а с любым user-space приложением.
Одной из существующих реализаций vhost-user является DPDK — Data Plane Development Kit.

DPDK и SPDK
DPDK — это набор библиотек, изначально разработанный компанией Intel для построения высокопроизводительных приложений в user-space, но он в первую очередь предназначается для сетевого стека.
В то же время задача kernel bypass актуальна и для блочных устройств, поэтому в 2015 году Intel выпустил SPDK (Storage Performance Development Kit). Изначально SPDK появился для эффективной работы с Optane SSD, которые не могли утилизироваться существующими ядерными решениями. Поэтому был создан user-space стек для работы с блочными устройствами.
На текущий момент проект по-прежнему активно развивается. Его функционал давно вышел за пределы обычной работы с NVMe-дисками и представляет из себя целую экосистему для построения высокоскоростных storage-решений.
Давайте разбираться, что это за фреймворк!
Архитектура SPDK
User-space драйверы
По аналогии с DPDK в SPDK реализованы user-space драйверы, но для блочных устройств. Наиболее интересным является NVMe-драйвер, из-за которого когда-то и появился сам SPDK.
На примере этого драйвера можно посмотреть на несколько концептов, которые прослеживаются во всём фреймворке:
Polling-модель. Несмотря на то что широко распространённый подход, основанный на прерываниях, лучше утилизирует CPU, его недостаточно для ультранизколатентной работы с флеш-накопителями. Поэтому для более высокой производительности SPDK придерживается polling-модели.
Huge pages. Также, для лучшей производительности, SPDK живёт на больших страницах. При инициализации SPDK environment отхватывает себе кусок памяти, задаваемый при старте, и в дальнейшем использует для всего I/O только буферы, выделенные на больших страницах. Это решение, унаследованное от DPDK.
1 hardware queue == 1 thread. Позже мы увидим, что такая идеология share nothing и thread-local прослеживается во всём SPDK.
Fun fact: если хочется использовать NVMe-драйверы в вашем приложении, а со всем остальным SPDK не возиться — это возможно! Большинство компонентов SPDK реализованы так, что их можно слинковать и использовать обособленно. Однако важно помнить, что SPDK сможет подключить NVMe-диски, только если драйвер будет переведён на uio или vfio, а это значит, что они не будут видны ОС.
Fun fact #2: но всё-таки если очень нужно видеть устройство стандартными средствами ОС — есть специальный механизм NVMe-CUSE. С его помощью можно создать character device для устройства, подключённого в SPDK.
Runtime
Но всё-таки SPDK — это не только набор библиотек. Фреймворк из коробки поставляет сразу несколько полноценных приложений: vhost, nvmf_tgt и iscsi_tgt для создания приложений с определённым frontend, spdk_tgt для использования разных frontend в рамках одного приложения, а также набор утилит для мониторинга.
У приложений есть свой runtime, который работает по всё той же polling-модели. При запуске приложения мы передаём ему CPU-маску, после чего на каждом из ядер создаётся специальная сущность — реактор. Реактор работает в busy-loop и отправляет на исполнение легковесные потоки (spdk_thread).
Своя потоковая модель
Потоковая модель у SPDK своя. Каждый spdk_thread содержит очередь сообщений и список поллеров.
Каждое сообщение — это указатель на функцию и контекст к ней. Многопоточность в SDPK построена на сообщениях. Весь I/O-путь к блочным устройствам по возможности реализуется без блокировок, а если есть какой-то не разделяемый ресурс, то ему как правило назначается поток-владелец, и при необходимости изменения этому потоку отправляется сообщение.
Для задач, которые нужно выполнять периодически, в SPDK есть специальная сущность — spdk_poller. Они бывают:
с таймаутом, который исполняется раз в заданный промежуток времени, например начисление квот при QoS;
активные (без таймаута), которые исполняются каждый раз, когда spdk_thread был отдан на исполнение. Такие поллеры используются для busy-poll I/O-очередей, а также асинхронного ожидания событий.
Таким образом, отправка потока на исполнение означает взятие первых нескольких сообщений из буфера и выполнение их, после чего просматривается список поллеров, исполняются все активные и те с таймаутом, чьё время пришло. Важно помнить, что spdk_poller привязан к определённому spdk_thread и sheduler SPDK отправляет на исполнение именно spdk_thread, которые сами следят за своими поллерами.

SPDK_BDEV: объединяем runtime SPDK и блочные устройства
Для того чтобы объединить блочные устройства и рантайм SPDK, вводится такая абстракция, как spdk_bdev, который унифицирует работу с разными типами устройств.
Определяется интерфейс для создания/удаления, а также осуществления I/O. SPDK придерживается модульной архитектуры, и все конкретные реализации блочных устройств — это отдельные модули.
Из коробки доступно много разных устройств. Их можно логически разделить на два типа:
Конечные устройства, которые осуществляют запись непосредственно в хранилище. Например bdev_nvme, используя SPDK-драйвер, осуществляет запись на диск, а самим bdev в данном случае является NVMe namespace. Или bdev_rbd для работы с Ceph, который работает поверх librbd.
Устройства-декораторы, реализующие специфическую логику хранения. Например, bdev_raid (есть реализации RAID0 и RAID5 c возможностью только полнострайповой записи) и bdev_crypto для шифрования трафика.
Также SPDK позволяет создавать пользовательские модули, для этого нужно реализовать набор функций callback’ов и зарегистрировать модуль стандартным API.
Сама библиотека bdev выступает прослойкой при работе с устройствами и добавляет такие общие механизмы, как организация очередей, ведение статистики и задание QoS (quality of service).

Frontend-компоненты
Так как при использовании SPDK всё I/O оказывается внутри user-space приложения, то нужно решить задачу по выставлению созданных устройств наружу. Для этого существуют специальные компоненты — frontend.
Задача frontend — это приём команд по нужному протоколу и перекладывание принятых команд на bdev_io для общения с блочными устройствами внутри SPDK. Доступны как frontend, выставляющие устройства в классическом storage-интерфейсе — NVMeoF и iscsi, так и vhost, позволяющий прокидывать устройства внутрь виртуальных машин, т. е. как раз то, что нам нужно.
API
Последнее, что отметим из стандартного функционала, — API для общения с SPDK. SPDK принимает команды по unix socket в формате JSON. Этими командами можно сконфигурировать все используемые модули, а также расширять API для своих собственных модулей. Кроме общения через сокет напрямую, есть CLI, реализованный в виде python-скрипта, а также биндинги к API на python и Go.

Запускаем тесты
Требования
Разобравшись, что у нас есть внутри SPDK, давайте соберём схему, нужную для нашего облака. Будем делать это на примере сетевых дисков.
На старте у нас по-прежнему устройство внутри виртуальной машины, которое маппится 1 к 1 к vhost-scsi-target внутри SPDK. Мы выбрали vhost-scsi реализацию vhost-протокола, так как несмотря на то, что vhost-blk немного быстрее, он не настолько удобен с точки зрения эксплуатации. Например, при использовании scsi из коробки работает hot plug и unplug устройства внутрь виртуальной машины, без необходимости ответной реакции со стороны гостевой ОС. Поэтому даже если гостевой ОС поплохело — мы можем осуществлять операции по отключению дисков.
После vhost-scsi начинается блочный слой внутри SPDK. На самом верху у нас bdev_crypto, который создаётся поверх bdev_rbd. Он привязывается к rbd-устройству внутри Ceph.

Такой пайплайн из устройств можно собрать и сконфигурировать в ванильном SPDK, но давайте теперь попробуем посмотреть на производительность.
У нас была цель, чтобы SPDK:
мог обслуживать 48 двухъядерных виртуальных машин, в каждой из которых может быть создано по 64 диска;
на 1 ядро выделяется 3000 IOPS.
Итого получается, что 1 экземпляр SPDK должен выдерживать 288 KIOPS и 3000 дисков.
Тестируем
Для того чтобы это проверить, мы собрали тестовый стенд, на котором сначала без всякого SPDK нагрузили Ceph и убедились, что он спокойно выдерживает такую нагрузку.
После этого мы собрали следующий сетап SPDK:
Подняли 48 виртуальных машин, в каждой 64 виртуальных диска.
На каждую виртуальную машину создали 8 vhost-scsi контроллеров, в каждом из которых 8 vhost-scsi таргетов.
Как уже писали раньше, каждый vhost-scsi таргет — это bdev_crypto + bdev_rbd и rbd диск в Ceph.
После этого мы запустили fio внутри каждой виртуальной машины и нагрузили каждый из дисков. Будем тестировать запись 4K-блоком, с глубиной очереди 32. Собирать и анализировать получившиеся результаты будем на хосте.
При первом запуске получились следующие числа (суммарное значение показателей со всех виртуальных машин):
avg |
peak |
|
read |
332 KIOPS |
343 KIOPS |
write |
319 KIOPS |
328 KIOPS |
rw 70/30 |
326 KIOPS |
340 KIOPS |
Отлично! Получилось даже больше, чем нам нужно!
Но мы упустили из виду одну деталь: мы не включили quality of service. Без него может легко получиться, что виртуальные машины разных пользователей, обслуживающиеся одним SPDK, могут повлиять друг на друга. К счастью, bdev-слой в SPDK позволяет задавать устройствам QoS. Давайте включим и ограничим для каждой виртуальной машины IOPS, равный необходимым 6000. После этого снова запустим тест.
Результаты удивили:
avg |
peak |
|
read |
129 IOPS |
1013 IOPS |
write |
114 IOPS |
998 IOPS |
rw 70/30 |
120 IOPS |
1002 IOPS |
Вместо падения на ~10% производительность упала в 3000 раз!
Конечно, это не ожидаемое поведение и явная проблема в коде. Для того чтобы раскопать, что не так, повторно запустим тот же тест, но теперь с запущенным perf на хосте SPDK.
Изучив профиль, мы увидели, что вместо полезной работы (обработки I/O) SPDK почти всё время занимается spdk_for_each_channel и spdk_for_each_channel_continue. Давайте разбираться, что это за каналы и что же мешает нашему приложению.
Разбираемся, что не работает
Выше по тексту мы уже вспоминали про thread-locality и share-nothing, который так любят в SPDK. Но что же делать с сущностями блочного устройства, которые нужно агрегировать? Например, где хранить очереди запросов и статистику?
В SPDK для этого придумали специальную сущность — spdk_io_channel. Этот канал является thread-local контекстом для какого-то блочного устройства. То есть это связка одного spdk_thread и spdk_bdev. Пользователь bdev получает канал с помощью spdk_bdev_get_io_channel() и использует его при осуществлении I/O в устройство. Таким образом канал используется как своего рода билетик для доступа к устройству, но кроме этого канал — это место для хранения служебной информации.

Библиотечная часть spdk_bdev хранит там очереди и статистику. Кроме того, пользовательские модули могут задавать свой контекст для сохранения.
Такой подход позволяет избежать лишних блокировок в I/O-пути: всё, что нужно, можно получить из локального контекста. Но что делать, если нужно агрегировать информацию для всего устройства целиком, например для сбора статистики, когда нужно просуммировать все значения из всех каналов?
Как раз для такого случая в SPDK есть специальная функция spdk_for_each_channel, которую мы видели на профиле ранее.
Если посмотреть на неё, то можно увидеть, что внутри она берёт mutex, общий для всех устройств и потоков. Под этим mutex ищется поток, у которого есть канал к устройству, и к нему отправляется сообщение. То есть это именно то место, где lock-free программирование не получилось, и именно на него мы наткнулись.
Отметим, что само взятие mutex, который является системной блокировкой, противоречит идеологии user-space приложения, так как при ожидании блокировки спит не легковесный spdk_thread, а тяжёлый системный поток, а значит, невозможна вся работа на рабочем ядре. В SPDK есть собственный spdk_spinlock, который является обёрткой над POSIX spinlock, но при этом имеет дополнительные проверки и может быть взят только с spdk_thread. Однако оказывается, что даже использование местного spdk_spinlock вместо mutex не исправило бы ситуацию.
Всё потому, что в SPDK кооперативная многозадачность, а значит, что, даже если spdk_thread ждёт спинлок для лёгких потоков, это всё равно блокирует работу на ядре. Соответственно, беря долгую блокировку, мы убиваем производительность.

В нашем случае длина блокировки складывается из количества устройств и количества SPDK-потоков. Каждый поток хранит список каналов к устройствам в виде RB-дерева. И для каждого устройства нам нужно проитерироваться по всем каналам.
Значит, сложность получается M N logN, где N — количество устройств, а M — количество потоков. В нашем случае N = ~3000, M = ~1000, а значит, на каждый проход получаем по ~10^7.
У внимательного читателя может возникнуть вопрос, а почему же тогда сначала все работало? Ведь выглядит, что всё очень плохо и не оптимизировано, а первые тесты всё-таки показали приличные числа.
Оказывается, что такой процесс итерации по каналам — достаточно редкая в SPDK штука ровно до тех пор, пока вы не используете QoS.
Механизм QoS в SPDK реализован по алгоритму фиксированного окна. И каждую миллисекунду обновляются квоты. Обновление квот влечёт за собой итерацию по всем каналам для того, чтобы попробовать переотправить I/O.
Таким образом, на одну секунду приходится 10^3 * 10^7 итераций только для того, чтобы QoS работал. А с таким количеством итераций для служебных задач времени на полезную работу совсем не остаётся.
Как исправили
Оказалось, что поправить это не очень сложно. Мы уже выяснили, что в момент итерации у нас происходит лок, от которого никуда не деться. Давайте тогда немного поменяем структуру хранения для каналов и начнём хранить для устройства все потоки, которые открыли к нему канал. Таким образом мы немного замедлим процесс открытия канала, но зато ускорим итерацию по ним, не повлияв на оставшийся I/O-путь.
Хранить потоки будем также в RB-дереве, а значит, для нашей реализации итерация по всем каналам для всех устройств займёт: N * logM, т. е. всего 10^4 итераций.
Таким достаточно простым изменением мы кардинально меняем ситуацию с производительностью и получаем:
avg |
peak |
|
read |
288 KIOPS |
288 KIOPS |
write |
282 KIOPS |
288 KIOPS |
rw 70/30 |
284 KIOPS |
288 KIOPS |
Это как раз целевые показатели!
Кстати, за время написания статьи, патч с нашим улучшением допрошёл ревью и был замерджден в мастер SPDK. Так что мы надеемся, что после релиза 25.09 никто с подобной проблемой больше не столкнётся.
Что ещё важно помнить при работе с Ceph
bdev_rbd работает с Ceph с помощью librbd. У librbd свои pthread-потоки, которые тоже хотят много CPU. Поэтому нельзя селить потоки librbd, которые создаются посредством создания rbd_cluser, на те же ядра, на которых работают busyloop-потоки SPDK. В нашей схеме мы отдаём 7 ядер на SPDK и 10 ядер на librbd:

Note: с librbd мы работаем по схеме thread per client, а не с многопоточным клиентом, так как тесты в такой конфигурации показали лучшую производительность.
Нужно ли было ещё что-то доделывать в SPDK
Да, нужно было :)
Большинство доработок были продиктованы дополнительным функционалом, который нам хотелось реализовать.
Из основных доработок:
Написали свой rbd-модуль. Нам хотелось использовать асинхронное API librbd, чтобы при подвисании или просто долгом исполнении операций с Ceph мы не вешали всю работу на реакторе (вспоминаем про кооперативную многозадачность). Выяснили мы это на первых же тестах recovery при отказе сети, так как синхронные вызовы Ceph API зависали на главном потоке SPDK и он полностью переставал отвечать на попытки что-то с ним сделать.
Поэтому вместо стандартного bdev_rbd мы используем свой асинхронный.Реализовали возможность создания групп устройств и назначения QoS на группу. Это нужно для правильного ограничения I/O на всю VM.
Поддержали vhost-user client mode. В таком режиме при подключении устройств QEMU выступает сервером, а SPDK — клиентом, что позволяет более незаметно для клиента обновлять SPDK. Если полагаться на QEMU в этой части, то придётся смириться с тем, что она реконнектится только раз в секунду, что явно будет замечено пользователями. Когда мы переносим ответственность за переподключение на SPDK — мы можем делать это намного чаще.
Мы используем blue-green для обновления: поднимаем все нужные устройства в новой версии SPDK, после чего меняем контроллер, подключённый к QEMU.
Note: можно было запатчить QEMU в этом месте, но было принято решение унифицироваться с подключениями сетевых адаптеров и больше управляемости делегировать в I/O-компонент, чтобы оставить QEMU попроще.Добавили в SPDK метрик, чтобы observability его дисков соответствовало уровню observability дисков других облачных провайдеров.
И конечно же, кроме всего этого мы поправили очень много багов и data race’ов, возникающих у нас в процессе работы. Большинство этих багов связаны не с datapath, а с интенсивным созданием/удалением ресурсов внутри SPDK.
Подводим итоги
Для нас SPDK оказался подходящим решением. Да, он потребовал долгих разбирательств с ним, но это была полезная экспертиза и многие практики, которые мы увидели в SPDK, мы смогли использовать и в других своих компонентах.
Мы считаем, что для наших задач мы довели реализацию SPDK до production ready. Кроме всего прочего, мы стараемся апстримить наши изменения в комьюнити. Причём получается отдавать не только багфиксы, но и некоторые feature-реквесты. Также видно, что некоторые наши идеи находят отклик и у других пользователей. Например, сейчас идёт обсуждение нового механизма QoS, который в том числе будет включать механизм группирования устройств.
Спасибо за прочтение статьи! Делитесь вашим опытом и впечатлениями от SPDK или рассказывайте, как вы решаете аналогичные задачи без него.
Интересные статьи про MWS Cloud Platform: