Хабр, приветствую! Я Сергей Лысанов — технический лидер и руководитель разработки системы хранения данных. Наша команда начала создавать собственное хранилище с нуля в 2021 году и через три года мы вышли в продакшн вместе с публичным облаком Cloud.ru Evolution. В этой статье я подробно расскажу, как устроено наше хранилище и поделюсь интересными техническими решениями. Welcome!

Как устроена инфраструктура публичного облака

Инфраструктура типового публичного облака строится на трех основных компонентах:

  1. Compute. Это физическая инфраструктура, предоставляющая вычислительные мощности для создания виртуальных машин (VM) под задачи пользователей. Обычно VM запускают на x86-серверах с помощью QEMU+KVM на операционной системе Linux. Для повышения эффективности использования ресурсов на сервере может использоваться переподписка виртуальных ядер (VCPU) к физическим CPU. То есть если переподписка 1:10, то на одно физическое ядро сервера приходится 10 ядер VM. Иначе говоря, на сервере с 96 ядрами можно запустить 960 виртуальных машин. Однако для критически важных приложений рекомендуется использовать переподписку 1:1, что, конечно же, стоит дороже.
    Compute-ресурсы относительно просто масштабировать. В стойку добавляются новые хосты с CPU, RAM, коммутируется физическая сеть и на них запускаются новые виртуальные машины. 

  2. Network. Чтобы виртуальные машины взаимодействовали между собой или выходили наружу в интернет, им необходимо предоставить сеть. Причем это должна быть изолированная от других пользователей сеть. Создавать, конфигурировать и изменять эту сеть надо уметь на лету — в пару кликов. За это отвечает софт, который называется Software-Defined Network (SDN).
    Наш SDN построен на open source решении OVN. Как и любой другой открытый исходный код, его пришлось серьезно дорабатывать под наши потребности.

  3. Storage. СХД — сердце всей системы, которое позволяет хранить пользовательские данные: от дисков виртуальных машин до бакетов S3. Как вы знаете, для запуска виртуальной машины ей необходим диск. В простом варианте можно предоставлять локальный физический диск на compute-хосте, но тогда встает вопрос про  отказоустойчивость. В случае потери хоста или диска на нем мы теряем виртуальную машину целиком. Также если мы захотим мигрировать VM с одного гипервизора на другой, то получим много проблем, поскольку привязаны к физическому диску.

Решение тут простое — предоставлять диски для виртуальных машин по сети. Это и делает наш собственный Software-Defined Storage, который мы разработали с нуля.

Software-Defined инфраструктура нашего публичного облака
Software-Defined инфраструктура нашего публичного облака

Какие у нас были требования к SDS

В самом начале разработки мы сформулировали основные требования к нашему SDS:

⚙️ Должен предоставлять три интерфейса:

  • Блочный. Через блочный интерфейс работают диски для виртуальных машин. Он относительно простой. Нужен для того, чтобы записать или прочитать n блоков по заданному адресу. Блок фиксированного размера, 512 Б или 4 КБ. Адрес всегда кратен размеру блока. Конечно, кроме записи и чтения там гораздо больше команд (про них можно почитать в SCSI Commands Reference Manual). В этой статье речь пойдет именно об устройстве блочного хранилища.

  • Объектный. Стандартом объектного протокола де-факто стал AWS S3, который работает через REST API. Основные операции над объектами: PUT, GET, DELETE, LIST. Объектов может быть много (реально много, триллион — не предел). В отличие от файла, объект нельзя частично переписать, поэтому работа с ним строится на других принципах. Наше объектное хранилище также написано с нуля и работает поверх блочного — о нем мы позже расскажем в отдельной статье.

  • Файловый. Всем привычный интерфейс, который есть на вашем компьютере. Главное отличие нашего хранилища в том, что он предоставляет кластерную файловую систему. Таким образом, несколько пользователей могут одновременно работать с файлами, и все данные в них будут в консистентном состоянии. Как вы уже поняли, мы любим писать все с нуля, поэтому и кластерная файловая система — не исключение?. Она тоже работает поверх блочного хранилища. 

⚙️ Масштабируемость

Так как публичное облако постоянно растет, и к нам приходят новые пользователи, то и SDS должен расти вместе с ним. Мы должны уметь на лету добавлять новый диск или хост в кластер, тем самым масштабируя как емкость, так и производительность хранилища. 

⚙️ Отказоустойчивость

SDS должен быть отказоустойчивым. Кластер должен переживать выход из строя хоста или диска так, чтобы клиент этого даже не заметил. Для этого применяется хранение данных с избыточностью.

⚙️ Отсутствие vendor lock

В целом, это одна из главных причин, почему мы решились на разработку собственного SDS. И благодаря этому наш SDS может работать на любом commodity-железе. Сейчас мы проводим эксперименты по запуску SDS на ARM и на RISC-V архитектуре. В целом они успешные, хоть пока и далеки от прода. 

⚙️ Эффективность

  • Erasure Coding (EC). Для экономии места на дисках мы выбрали erasure-коды как основной способ хранения данных, а не реплики. При таком подходе несколько смежных блоков данных кодируются вместе для получения одного или нескольких дополнительных блоков контрольных сумм. Избыточную информацию в дальнейшем можно использовать для восстановления данных. Для вычисления erasure-кодов используем коды Рида-Соломона и библиотеку isa-l. Почти как у компакт-дисков.

    Мы используем схему 4+2 для дисков виртуальных машин и 9+3 для более холодного объектного storage. Для схемы 4+2 overhead по дисковому пространству получается 50% (два блока избыточных данных на четыре блока с данными), для 9+3 — 33%. Это существенно меньше, чем для трех реплик, где overhead по выделяемому пространству составляет 200%.

  • Thin Provisioning. Диски должны уметь быть «тонкими», то есть позволять эффективно использовать дисковое пространство. Практика показала, что в нашем продакшне пользователи используют в 2-2,5 раза меньше объема, чем запрашивают. Другими словами, берут диск на 100 ГБ, а заполняют его максимум на половину. Вот эту пустую половину невыгодно оставлять «висеть» просто так в воздухе.

  • Deduplication/Compression. Знали бы вы, как хочется написать «мы сделали дедупликацию и компрессию данных как у лучших мировых СХД и сэкономили кучу железа». Но, к сожалению, пока у нас есть только анализатор потенциально возможной степени дедупликации. А остальная часть в процессе активной разработки и исследования. Но, если верить внутренним данным, мы ожидаем коэффициент дедупликации в районе 1,5-2,5.

⚙️ Функциональные возможности

Снапшоты дисков для резервного копирования, клонирование дисков для быстрого создания виртуальных машин из образов, QoS (Quality of Service) для управления производительностью, справедливого распределения IOPS и полосы пропускания между клиентами, а также другой функционал важны для публичного облака.

Архитектура нашего Software-Defined Storage

Давайте наконец расскажу про архитектуру SDS и его основные сущности.

Архитектура Software-Defined Storage
Архитектура Software-Defined Storage

Клиент

В нашей терминологии клиентом является любое приложение (FIO, iSCSI Target, NBD(network block device), QEMU и т. д.), которое взаимодействует с хранилищем и слинковано с библиотекой libclient. Все взаимодействие с SDS происходит через библиотеку libclient, которая предоставляет простой интерфейс блочного девайса:

сlass IBlockDevice {
public:
   virtual ~IBlockDevice() = default;
   virtual seastar::future<> write(seastar::abort_source& as, u64 offset, const iovec* iovec, size_t iov_cnt) noexcept = 0;
   virtual seastar::future<> read(seastar::abort_source& as, u64 offset, iovec* iovec, size_t iov_cnt) noexcept = 0;
   virtual seastar::future<> flush(seastar::abort_source& as) noexcept = 0;
   virtual seastar::future<> compare_and_write(seastar::abort_source& as, u64 offset, iovec* iovec, size_t iov_cnt) noexcept = 0;
   virtual seastar::future<> unmap(seastar::abort_source& as, std::list<std::pair<u64, u64>>&& blocks) noexcept = 0;
};

Библиотека libclient выполняет операции над блочным девайсом взаимодействуя с хранилищем по RPC. 

Volume 

В нашей терминологии блочный девайс, который эмулирует libclient через интерфейс IBlockDevice, — это volume. Библиотека libclient работает сразу с несколькими открытыми volume.

По умолчанию volume состоит из чанков размером 1 ГБ. Нумерация (UID) чанков сквозная на весь кластер. Таким образом, volume — это логическая сущность, контейнер для чанков.

Разделение volume на чанки
Разделение volume на чанки

Chunk

Чанк — это последовательный кусок данных размером 1 ГБ, а также единица отказоустойчивости и аллокации в нашем хранилище. У каждого чанка есть свой набор Chunk Server’ов (CS), где он хранится как страйп. Число CS устанавливается схемой кодирования.

Схема размещения данных для схемы кодирования 4+2
Схема размещения данных для схемы кодирования 4+2

Stripe

Чанк разделен на страйпы. Страйп является единицей для кодирования erasure-кодов(EC) и представляет собой цепочку стрипов. Для схемы 4+2 страйп состоит из четырех стрипов с данными и двух стрипов с чек-суммами EC.

Strip

Минимальная единица данных, которая кодируется с помощью EC. Каждый стрип имеет индекс внутри страйпа. Для схемы 4+2 стрипы с индексом 0,1,2,3 хранят данные с индексом 4,5 erasure-коды. По умолчанию размер стрипа равен размеру блока (4 КБ или 512 Б). Но, в принципе, ничего не препятствует тому, что стрип может быть любого размера и может состоять из нескольких блоков. 

Chunklet

Чанклет представляет из себя файл на XFS, в который складываются стрипы с одним индексом внутри страйпа. То есть чанклет с idx=0 хранит стрипы 0, 3, 6 и так далее. Для схемы 4+2 чанк разбивается на четыре чанклета по 256 МБ с данными. К ним рассчитываются еще два чанклета с чек-сумами с помощью erasure-кодов.

Если мы хотим переживать выход из строя диска, хоста или стойки, то чанклеты одного чанка должны быть расположены на разных дисках, хостах или стойках соответственно.

Block

Минимальная единица данных, которую клиент может отправить в запросе на ввод-вывод. Типичный размер блока составляет 4 КБ или 512 Б — для соответствия физическому размеру блока на дисках.

Chunk Server (CS)

Chunk Server хранит данные. Как мы уже поняли, данные хранятся в чанклетах, а не в чанках, несмотря на название чанк-сервер. Упрощенно можно сказать, что чанклет хранится в виде файла размером 256 МБ (для EC=4+2). 

Chunk Server обрабатывает все I/O клиента, записывает данные на диск, рассчитывает erasure-коды для данных, читает данные с диска и отвечает клиенту. Также при необходимости он восстанавливает данные с помощью erasure-кодов.

CS написан на C++20 с использованием coroutine из стандарта и фреймворка Seastar. Этот фреймворк также разрабатывает и использует команда ScyllaDB. Мы выбрали этот фреймворк, так как нам близка идеология shared nothing.  

Чанк-сервер является однопоточным (одношардовым в терминах seastar) сервисом, который работает на отдельном ядре и выделенном диске. В нем нет привычных примитивов синхронизаций между потоками, будь то std::mutex или lock free структуры данных, которые ведут к lock contention и потере производительности. При этом seastar дает из коробки много примитивов для упрощения работы: stackless корутины, stackfull корутины, семафоры, мьютексы и кучу других механизмов синхронизации (они все равно нужны даже в однопоточном режиме, так как есть concurrency), RPC (хотя мы его сильно переписали и оптимизировали под свои нужды), scheduling-группы для IO и CPU. Благодаря этому у нас, например, поток данных от клиента имеет более высокий приоритет, чем задача восстановления данных. Вся работа с диском и сетью в seastar происходит асинхронно через интерфейс linux aio или io_uring.

После экспериментов мы решили, что для HDD-диска достаточно одного CS. Однако с NVMe ситуация другая — они легко могут выдать больше 500 kIOPS, которые попросту невозможно утилизировать одним процессом. В результате на некоторых конфигурациях у нас получалось до четырех CS на один NVMe. Таким образом в кластере получаются тысячи чанк-серверов, которые общаются друг с другом по сети.

Metadata Server (MDS)

Как понятно из названия, MDS предназначен для хранения метаданных. MDS написан на Golang, и это классическая Replicated State Machine (RSM), которая реплицирует свой стейт с помощью алгоритма RAFT. В качестве имплементации RAFT мы использовали готовую библиотеку Dragonboat. Библиотека имеет хороший интерфейс для переиспользования и за время эксплуатации хорошо себя показала. Багов в самом алгоритме выявлено не было, но были минорные замечания и доделки к функциональности библиотеки (например, нельзя было достоверно узнать, удалили ли участника кластера). 

Сами метаданные хранятся персистентно в PebbleDB. PebbleDB является key-value хранилищем и использует Log-Structured Merge-tree (LSM-tree) для хранения данных на диске. По сути, это форк LevelDB/RocksDB на Golang. PebbleDB также используется внутри dragonboat как WAL для RAFT.

При запуске MDS состояние стейт-машины загружается в память, и далее клиентские запросы читают состояние только из памяти лидера. Любое изменение состояния происходит через MDS-лидера через команду propose в RAFT. После успешного propose команда проигрывается (replay) и применяется к стейт-машинам на остальных метадата-серверах, таким образом обеспечивается консистентность состояния на всех MDS. Для отказоустойчивости мы используем пять серверов MDS на различных хостах, чтобы переживать выход из строя двух серверов.

Внутри MDS
Внутри MDS

KV-store внутри MDS хранит следующее:

  • Chunk Server (ID, software version, статус, на каком хосте расположен).

  • Volume (ID, имя, размер, block size, storage policy).

  • Chunk (UID, Volume ID, версия, список чанклетов и их расположение на CS и состояние).

  • Lease (UID, Volume ID, Client ID, тип лизы).

MDS KV-Store
MDS KV-Store

На самом деле MDS представляет не один большой реплицированный конечный автомат, а много маленьких. Каждый отдельный чанк-сервер или чанк представляет из себя конечный автомат со своими состояниями и переходами.

Внутри MDS в фоне работают следующие worker’ы, которые физически являются отдельными горутинами:

  • Watchdog. Следит за статусом чанк-сервера. CS ежесекундно посылает keepalive сообщение в MDS. На основе этих keepalive watchdog понимает состояние CS: доступен ли он по сети, не вышел ли из строя диск, сколько места осталось и т. д. Стоит возникнуть любым проблемам с CS, watchdog переводит CS в состояние UNHEALTHY. И следом в работу включается другой worker под названием Recoverer.

  • Recoverer. Следит за статусом CS и чанклетов чанка и при необходимости планирует задачи на восстановление данных. Если CS перешел в состояние UNHEALTHY, то recoverer переводит чанклеты на этом CS в состояние RECOVERING и отправляет задачу на восстановление данных на чанк-сервера. После успешного восстановления данных чанк-сервера, репортят в MDS статус завершения и чанклет чанка переходит в состояние UPTODATE.

  • Balancer. Балансировщик следит за равномерным заполнением дисков. К примеру, если в кластер добавили новый диск, то задачей балансировщика станет планирование задач по миграции чанклетов на этот новый диск. Для отображения этого статуса у чанклета используется состояние MIGRATING.

  • Scrubber. Данные на дисках со временем могут портиться. Чтобы предотвратить потерю данных, необходимо как можно скорее выявлять и устранять повреждения. Для этого используется фоновый процесс scrubber, который регулярно сканирует все данные кластера, читая их с дисков и проверяя целостность с использованием контрольной суммы CRC32. Если контрольная сумма не совпадает, автоматически запускается процедура восстановления данных. Таким образом решается проблема silent corruption.

Как volume подключается к виртуальной машине 

Volume подключается как диск к виртуальной машине с помощью стандартного драйвера QEMU vhost-user-blk. Этот драйвер использует vhost-user протокол как control plane для настройки virtio-очереди между виртуальной машиной и нашим vhost-сервером. Virtio-очередь используется как data plane для непосредственной передачи блочных команд. Через нее блочные команды от дисков виртуальной машины попадают в наш vhost-сервер.

Следом vhost-сервер забирает virtio-blk команды из virtqueue и выполняет их через библиотеку libclient, которая описывалась выше. Далее libclient отправляет команды в SDS по сети через RPC. Vhost server также написан на С++ с помощью seastar и запускается на гипервизоре в единственном экземпляре.

Обслуживание одного volume происходит в одном шарде (потоке). При этом vhost в отличии от CS является многошардовым процессом. Вольюмы равномерно распределяются между разными шардами.

Схема подключения Volume SDS к BM
Схема подключения Volume SDS к BM

Запись данных в Volume

Теперь можно подробно рассмотреть путь данных от клиента SDS до физического диска на сервере. Для упрощения примем, что мы используем схему кодирования ЕС=3+2, размер strip size совпадает с block size и равен 4096.

Прежде чем выполнять какие-либо операции ввода-вывода, нам необходимо взять блокировку на volume. У любого volume есть механизм предотвращения одновременного использования несколькими клиентами, который называется Lease. Клиент запрашивает Lease у MDS, а затем постоянно обновляет ее в процессе использования volume. 

Предположим, что виртуальная машина записала на диск три блока данных A, B, C и эти блоки по офсету попали в chunk #0. Как это выглядит:

  1. Вначале libclient получает информацию о расположении чанка #0 от лидера MDS через вызов GetChunk. GetChunk возвращает следующий ответ c информацией о том, на каких чанк-серверах расположены чанклеты этого чанка, а также версию этого чанка:

Chunk: {
  Index: 0,   // Чанк соответствует первому ГБ в вольюме
  UID: 1,     // Уникальный идентификатор чанка
  Version: 3, // Версия чанка
  Chunklets: 
    [
    {Index: 0, CSID: 1, IP: "192.168.100.1", Type: DATA, State: UPTODATE},
    {Index: 1, CSID: 2, IP: "192.168.100.2", Type: DATA, State: UPTODATE},
    {Index: 2, CSID: 3, IP: "192.168.100.3", Type: DATA, State: UPTODATE},
    {Index: 3, CSID: 4, IP: "192.168.100.4", Type: CHECKSUM, State: UPTODATE},
    {Index: 4, CSID: 5, IP: "192.168.100.5", Type: CHECKSUM, State: UPTODATE}
    ],
}

Прежде чем отдать информацию про чанк #0 клиенту, MDS берет оптимистичную блокировку на чанк-серверах для конкретной версии (3) этого чанка. Версия чанка меняется, когда меняется состояние чанклета, истекает время lease, меняется лидер MDS и так далее. Таким образом, клиент может писать только в те чанк-серверы, на которых версия блокировки совпадает с его версией.

  1. Затем libclient отправляет данные на Master CS. Master CS — это первый checksum чанк-сервер в списке чанклетов, в нашем случае CS4. Сhecksum CS хранит избыточные данные erasure-кодов. А Data CS хранит непосредственно клиентские данные.

  1. Master CS после получение блоков A, B, C вычисляет избыточные блоки P и Q с помощью erasure-кодов. Получившийся страйп {A,B,C,P,Q} master CS транзакционно пишет на другие чанк-сервера этого чанка. Все записи на диск идут с флагами O_DIRECT|O_SYNC, поэтому ответ «ок» приходит клиенту только после того, как транзакция завершилась на всех CS и все его данные сохранились на диске.

Запись в Volume для схемы 3+2
Запись в Volume для схемы 3+2

При записи на каждый блок данных 4 КБ рассчитывается CRC32 и также сохраняется на диск. На дисках часто используется CRC16 (если используется вообще, так как диск является черным ящиком), что представляет собой слабый алгоритм контроля целостности с высокой вероятностью коллизий. При каждом клиентском чтении мы считываем данные и контрольную сумму с диска, рассчитываем CRC32 для прочитанных данных и сравниваем ее с полученной. Если контрольные суммы не совпадают, данные повреждены и запускается процесс восстановления. Такой механизм называется End-to-End data protection.

Кроме того, все RPC-сообщения также защищены контрольными суммами, которые проверяются на стороне получателя. Да, в TCP возможны повреждения передаваемых данных, но при этом все контрольные суммы будут совпадать (proof). 

Чтение данных из Volume

Теперь рассмотрим обратную ситуацию, когда виртуальной машине требуется прочитать блоки данных A, B, C:

  1. Вначале libclient опять получает информацию о расположении чанка #0 от лидера MDS через вызов GetChunk, если эта информация еще не закеширована.

  2. Чтение идет через Data CS. Блок A читается с CS1, блок B с CS2 и т. д.
    Если один из Data CS по какой-то причине недоступен, то чтение идет через Master CS. Master CS восстанавливает данные с помощью erasure-кодов и отвечает клиенту.

Схема чтения из volume
Схема чтения из volume

Обработка ошибок чтения/записи

В том случае, когда клиент получает любую ошибку при работе с CS, он сообщает об этом в MDS. MDS исключает этот CS из чанка, одновременно помечая чанклет как OUTDATED и запуская восстановление данных. Все это обязательно происходит через RAFT, что обеспечивает строгую консистентность данных. Следом MDS возвращает клиенту чанк новой версии с новым списком чанклетов, позволяя клиенту повторить операцию.

Реплики vs Erasure Coding

Почему мы решили использовать erasure coding, а не реплики? Давайте сравним на простом примере.

Реплики

Преимущества: простота реализации и высокая производительность из-за отсутствия накладных расходов на расчет кодов.

Недостаток: большой расход дискового пространства. Если нам надо хранить две дополнительные реплики, то надо делить физическую емкость дисков на три. Получается overhead по space 200%.

Хранение данных в трех репликах на пяти чанк-серверах
Хранение данных в трех репликах на пяти чанк-серверах

Erasure Coding 3+2

В erasure-кодах для трех оригинальных блоков A, B, C мы высчитываем два дополнительных блока P и Q с помощью кодов Рида-Соломона. Вместе с оригинальными данными A, B, C блоки P и Q составляют страйп. В итоге для схемы 3+2 у нас получается перерасход по месту на диске в 66%. Для схемы 4+2 этот показатель станет уже 50% и так далее. И это гораздо лучше, чем 200% при трех репликах.

Хранение данных в erasure-кодах для схемы 3+2 на пяти чанк-серверах
Хранение данных в erasure-кодах для схемы 3+2 на пяти чанк-серверах

Недостатки: есть накладные расходы на расчет кодов, а также ряд проблем в реализации, которые рассмотрим дальше.

Частичная запись данных страйпа EC

Теперь рассмотрим другую ситуацию: клиент записал только один блок данных B в volume. Master CS должен вычислить erasure-коды, а для этого ему необходим целый страйп ABC. Поэтому сначала он читает недостающие блоки A и C и только потом уже кодирует P и Q и транзакционно пишет страйп на другие СS.

Проблема частичной записи страйпа для схемы 3+2
Проблема частичной записи страйпа для схемы 3+2

Таким образом у нас получается Read Before Write на каждую запись, и это очень сильно ухудшает время исполнения запросов. Эта проблема очень актуальна для дисков виртуальных машин, где очень много мелких случайных записей по всему диску. 

Тем не менее, нам хотелось использовать erasure-коды, экономить место на диске и при этом иметь производительность хотя бы близкую к производительности реплик. 

Мы пришли к гибридному решению и разделили горячие данные, которые хранятся в репликах в так называемом Hot Storage, и холодные данные, которые хранятся в EC в Cold Storage.

Структура размещения холодных и горячих данных
Структура размещения холодных и горячих данных

Hot Storage

Предназначен для хранения данных в репликах и обработки мелких случайных записей. Hot Storage всегда расположен на быстром диске NVMe/SSD. По мере устаревания данных или при заполнении Hot Storage freezer в фоне перекладывает данные из реплик Hot Storage в Cold Storage, одновременно пересчитывая erasure-коды. Этот процесс перекладывания мы называем заморозкой. Так как у нас энкодинг EC происходит в бэкграунде, то это практически не влияет на latency клиентских write-реквестов.

Факты про Hot Storage:

  • Работает практически как персистентный кеш в репликах.

  • Занимает 5% от общего объема NVMe диска на AllFlash-сетапах. По разным исследованиям, активный working set в хранилищах, т. е. количество горячих данных — это 5-15% от общего объема.

  • Помогает накапливать целый страйп с течением времени, что также минимизирует проблему частичной записи страйпа.

  • Представляет из себя дерево экстентов в памяти + Write-Ahead Log (WAL) для метаданных + блочный аллокатор.

  • Реализована схема Redirect-On-Write. В Redirect-On-Write для записи всегда аллоцируются блоки в новом месте, а старые блоки освобождаются.

Cold Storage

Cold Storage хранит данные и erasure-коды к ним. Он может быть расположен на том же NVME/SSD диске, если это AllFlash-хранилище, либо на HDD. Большие последовательные write-реквесты от клиента сразу попадают в Cold Storage.

Факты про Cold Storage:

  • Хранит чанклеты как файлы на XFS.

  • Занимается кодированием erasure-кодов.

  • Транзакционно пишет страйп с помощью двухфазного коммита.

  • Данные обновляются inplace в файле, но обязательно через WAL.

Текущая реализация Cold Storage не самая оптимальная, так как имеет write amplification, поскольку данные у нас сначала попадают в WAL, а затем в файл. Однако текущий подход относительно прост, потому что он работает поверх файловой системы XFS, которая берет на себя задачи по аллокации экстентов на диске.

Сейчас идут работы над новой версией Cold Storage. Эта версия будет хранить данные поверх голого блочного девайса. В ней также будет реализована схема Redirect-On-Write, как в Hot Storage, и эта версия будет уметь дедупликацию и компрессию данных.

Консистентность данных и erasure-кодов

Еще одна распространенная проблема в erasure-кодах, с которой встречаются даже локальные файловые системы, когда пытаются сделать RAID (например, ZFS), — это RAID Write Hole. Пример: https://www.raid-recovery-guide.com/raid5-write-hole.aspx

Суть проблемы заключается в следующем: представьте, что вы хотите заморозить блок B', т. е. переместить блок из реплик Hot Storage в erasure-коды Cold Storage. Master CS вычисляет новые значения P' и Q',  а затем отправляет обновленные данные B', P', Q' через сеть. Но по какой-то причине CS 5 не смог записать данные — например, из-за сбоя сети или отключения питания. В результате данные B' и P' были записаны, а блок Q' нет. Таким образом, данные страйпа оказались поврежденными. Если мы попытаемся восстановить блок A или C с использованием erasure-кодов из блоков B', P', Q, мы получим мусор вместо правильных данных.

Следовательно, требуется атомарно обновлять данные страйпа. Однако в распределенных системах эта задача является нетривиальной.

Проблема №2: RAID write hole
Проблема №2: RAID write hole

Вот что мы предприняли для предотвращения проблемы RAID Write Hole:

  1. Версионируем все блоки данных. Причем версия checksum блоков у нас комбинированная. Она состоит из версий блоков данных. Таким образом новый страйп после записи B’ в Cold Storage будет иметь следующие блоки: A0 B1 C0 P0,1,0 Q0,1,0. Версии блоков проверяют во время процесса восстановления данных, что позволяет защитить систему от ошибок в коде, которые могли бы привести к повреждению данных.

  2. Все изменения данных EC проходят через WAL и двухфазный коммит. Координатором двухфазного коммита выступает Master CS. Он вначале выполняет фазу prepare, записывает данные и checksum в журнал. После того, как все prepare разложены, он пишет коммит в журнал. Если хоть один prepare не прошел, то транзакция откатывается. Когда мы накрыли наши данные prepare-коммитом, CS имеет право обновить данные erasure-кодов в файле.

Таким образом мы атомарно обновляем данные и erasure-коды с помощью двухфазного коммита и WAL — это достаточно стандартная схема.

Атомарное обновление EC страйпа в Cold Storage
Атомарное обновление EC страйпа в Cold Storage

Тесты на производительность

Стенд, на котором мы тестировали производительность, состоял из 12 SDS-хостов и 8 нагрузочных хостов следующей конфигурации:

  • CPU: x2 Xeon Gold 5318Y 2.10GHz 24C.

  • RAM: 256GB.

  • Disks: x12 NVMe SSDPF2KX038TZ 3.8TB.

  • Network: x2 2x100GbE Mellanox CX-6 (RDMA).

Результаты fio

Первое тестирование производительности проводили с помощью утилиты fio. Для работы с volume через fio мы написали собственный плагин, который использует библиотеку libclient так же, как и все остальные клиенты SDS. У каждого процесса fio был volume размером 50 ГБ со схемой кодирования 4+2. Стоит также подчеркнуть, что мы используем RDMA (Remote Direct Memory Access) для RPC, а не ядерный TCP-стек.

Тест с использованием одного процесса fio с параметром iodepth=1 демонстрирует среднее значение задержки 137мкс для случайного чтения и 235мкс для случайной записи.

Результат fio -rw=randwrite -bs=4k  -iodepth=1
$ fio  -ioengine=/usr/lib/libfio_sbd.so  -direct=1  -name=randwrite -rw=randwrite -ramp_time=1s -size=10GB -bs=4k -iodepth=1 -volume=10104ee4a1095434 -time_based=1 -timeout=30s
test: (g=0): rw=randwrite, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=sbd, iodepth=1
fio-3.28
Starting 1 process
fio : starting sbd block device(ram=0, volume_id=0x10104ee4a1095434, client_id=0x91440f6bd19319c4, tout=10, cs_rpc_tout=60, cs_rpc_alignment=128, membership='/etc/storage', backend='epoll').
fio : context created
INFO   sds/sbd/alien_app - Path for sbd config will be taken from environmental variable 'SDS_SBD_CONF', path is [sbd.conf]
fio : loop started
fio : volume opened
fio: sbd started
fio : engine cleanup][100.0%][w=15.6MiB/s][w=3990 IOPS][eta 00m:00s]
fio : engine cleanup complete

test: (groupid=0, jobs=1): err= 0: pid=1951633: Tue Oct 22 12:44:51 2024
  write: IOPS=4249, BW=16.6MiB/s (17.4MB/s)(498MiB/30001msec); 0 zone resets
    slat (nsec): min=222, max=8698, avg=377.02, stdev=114.34
    clat (usec): min=128, max=15939, avg=234.70, stdev=258.78
     lat (usec): min=128, max=15940, avg=235.08, stdev=258.78
    clat percentiles (usec):
     |  1.00th=[  137],  5.00th=[  143], 10.00th=[  147], 20.00th=[  157],
     | 30.00th=[  163], 40.00th=[  169], 50.00th=[  182], 60.00th=[  198],
     | 70.00th=[  223], 80.00th=[  281], 90.00th=[  347], 95.00th=[  445],
     | 99.00th=[  717], 99.50th=[ 1057], 99.90th=[ 3851], 99.95th=[ 5604],
     | 99.99th=[ 9110]
   bw (  KiB/s): min=10912, max=19136, per=100.00%, avg=16999.23, stdev=1568.63, samples=60
   iops        : min= 2728, max= 4784, avg=4249.80, stdev=392.16, samples=60
  lat (usec)   : 250=74.81%, 500=21.59%, 750=2.74%, 1000=0.34%
  lat (msec)   : 2=0.21%, 4=0.23%, 10=0.08%, 20=0.01%
  cpu          : usr=27.21%, sys=72.78%, ctx=55, majf=0, minf=153
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=0,127491,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Результат fio randread -rw=randread -bs=4k -iodepth=1
$ fio  -ioengine=/usr/lib/libfio_sbd.so  -direct=1  -name=randread -rw=randread -ramp_time=1s -size=10GB -bs=4k -iodepth=1 -volume=10104ee4a1095434 -time_based=1 -timeout=30s
test: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=sbd, iodepth=1
fio-3.28
Starting 1 process
fio : starting sbd block device(ram=0, volume_id=0x10104ee4a1095434, client_id=0x5a33f87d2b2f6fe6, tout=10, cs_rpc_tout=60, cs_rpc_alignment=128, membership='/etc/storage', backend='epoll').
fio : context created
INFO   sds/sbd/alien_app - Path for sbd config will be taken from environmental variable 'SDS_SBD_CONF', path is [sbd.conf]
fio : loop started
fio : volume opened
fio: sbd started
fio : engine cleanup][100.0%][r=29.1MiB/s][r=7455 IOPS][eta 00m:00s]
fio : engine cleanup complete

test: (groupid=0, jobs=1): err= 0: pid=1951563: Tue Oct 22 12:43:40 2024
  read: IOPS=7285, BW=28.5MiB/s (29.8MB/s)(854MiB/30001msec)
    slat (nsec): min=205, max=3811, avg=319.83, stdev=137.72
    clat (usec): min=62, max=12335, avg=136.72, stdev=135.80
     lat (usec): min=62, max=12336, avg=137.04, stdev=135.81
    clat percentiles (usec):
     |  1.00th=[   96],  5.00th=[  101], 10.00th=[  106], 20.00th=[  112],
     | 30.00th=[  117], 40.00th=[  124], 50.00th=[  129], 60.00th=[  137],
     | 70.00th=[  143], 80.00th=[  149], 90.00th=[  163], 95.00th=[  196],
     | 99.00th=[  212], 99.50th=[  223], 99.90th=[ 1074], 99.95th=[ 2704],
     | 99.99th=[ 7504]
   bw (  KiB/s): min=26840, max=30256, per=100.00%, avg=29142.25, stdev=662.09, samples=60
   iops        : min= 6710, max= 7564, avg=7285.55, stdev=165.55, samples=60
  lat (usec)   : 100=4.06%, 250=95.61%, 500=0.18%, 750=0.04%, 1000=0.01%
  lat (msec)   : 2=0.04%, 4=0.04%, 10=0.02%, 20=0.01%
  cpu          : usr=25.69%, sys=74.30%, ctx=33, majf=0, minf=173
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=218561,0,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
   READ: bw=28.5MiB/s (29.8MB/s), 28.5MiB/s-28.5MiB/s (29.8MB/s-29.8MB/s), io=854MiB (895MB), run=30001-30001msec

В тесте на IOPS было задействовано 8 отдельных нагрузочных хостов. На каждом хосте запускалось 32 процесса fio, что в общей сложности составило 256 процессов.

Случайное чтение блоками по 4 КБ показывает чуть больше 6 миллионов операций в секунду независимо от того, где находятся данные — в Hot или в Cold, поскольку чтение в нашем случае сводится к простому чтению из файловой системы и отправке ответа клиенту. При этом мы упирались в CPU нагрузочных хостов, где были запущенны процессы fio, а не в SDS.

Запись в Hot Storage и Cold Storage показывает разные результаты. В начале тестирования, когда Hot Storage пуст, мы фактически пишем только в реплики и достигаем 2,6 миллиона операций в секунду. По мере заполнения Hot Storage в фоновом режиме начинает работать freezer, и производительность снижается до 2 миллионов операций в секунду, когда Hot Storage полностью заполняется.

Результаты тестирования производительности с помощью fio
Результаты тестирования производительности с помощью fio

Результаты vdbench

Второе тестирование производительности проводили с помощью утилиты vdbench. Vdbench запускался на виртуальных машинах, диски которых являются вольюмами SDS и работали через vhost. Всего было запущено 8 виртуальных машин, по одной на каждом нагрузочном хосте. У каждой виртуальной машины было по 8 дисков размером 250 ГБ. Таким образом, всего в тесте участвовало 64 диска. Профиль нагрузки нацелен на имитацию работы базы данных: full random, 65/35% чтение/запись, размер блока = 8 КБ.

Конфиг vdbench

dataerrors=99999
messagescan=no
histogram=(default,400u,600u,800u,1m,1500u,2m,3m,5m,7m,10m,15m,20m,30m,50m,100m,200m,1000m)
hd=default,vdbench=/root/vdbench,user=root,shell=ssh,jvms=8
hd=hd1,system=172.31.11.1
hd=hd2,system=172.31.12.1
hd=hd3,system=172.31.13.1
hd=hd4,system=172.31.14.1
hd=hd5,system=172.31.16.1
hd=hd6,system=172.31.17.1
hd=hd7,system=172.31.18.1
hd=hd8,system=172.31.19.1
sd=sd1,host=hd1,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd2,host=hd1,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd3,host=hd1,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd4,host=hd1,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd5,host=hd1,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd6,host=hd1,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd7,host=hd1,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd8,host=hd1,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd9,host=hd2,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd10,host=hd2,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd11,host=hd2,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd12,host=hd2,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd13,host=hd2,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd14,host=hd2,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd15,host=hd2,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd16,host=hd2,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd17,host=hd3,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd18,host=hd3,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd19,host=hd3,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd20,host=hd3,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd21,host=hd3,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd22,host=hd3,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd23,host=hd3,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd24,host=hd3,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd25,host=hd4,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd26,host=hd4,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd27,host=hd4,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd28,host=hd4,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd29,host=hd4,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd30,host=hd4,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd31,host=hd4,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd32,host=hd4,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd33,host=hd5,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd34,host=hd5,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd35,host=hd5,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd36,host=hd5,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd37,host=hd5,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd38,host=hd5,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd39,host=hd5,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd40,host=hd5,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd41,host=hd6,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd42,host=hd6,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd43,host=hd6,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd44,host=hd6,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd45,host=hd6,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd46,host=hd6,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd47,host=hd6,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd48,host=hd6,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd49,host=hd7,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd50,host=hd7,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd51,host=hd7,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd52,host=hd7,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd53,host=hd7,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd54,host=hd7,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd55,host=hd7,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd56,host=hd7,lun=/dev/vdi,openflags=o_direct,size=250g
sd=sd57,host=hd8,lun=/dev/vdb,openflags=o_direct,size=250g
sd=sd58,host=hd8,lun=/dev/vdc,openflags=o_direct,size=250g
sd=sd59,host=hd8,lun=/dev/vdd,openflags=o_direct,size=250g
sd=sd60,host=hd8,lun=/dev/vde,openflags=o_direct,size=250g
sd=sd61,host=hd8,lun=/dev/vdf,openflags=o_direct,size=250g
sd=sd62,host=hd8,lun=/dev/vdg,openflags=o_direct,size=250g
sd=sd63,host=hd8,lun=/dev/vdh,openflags=o_direct,size=250g
sd=sd64,host=hd8,lun=/dev/vdi,openflags=o_direct,size=250g
wd=wd1,sd=sd*,xfersize=8k,rdpct=65,seekpct=100,range=(0,250G)
rd=rdx1,wd=wd*,iorate=max,elapsed=300,warmup=0,interval=1,forthreads=(1-256,d)

Результаты запуска vdbench
Результаты запуска vdbench

Заключение

Итак, за три года мы с нуля написали собственный Software-Defined Storage и вышли в продакшн?.

У нас большие планы на будущую разработку:

  1. Компрессия и дедупликация. Наши ребята из команды R&D провели исследования и выяснили, что дедупликация на продовых данных даст экономию примерно в два-три раза по емкости в случае дедупликации на уровне кластера. И это круто, ведь железо для СХД — дорогая шутка. Но сделать распределенную дедупликацию между десятками серверов в одном кластере — нетривиальная архитектурная и алгоритмическая задача. Как я ранее писал, мы разрабатываем новый бэкенд для Cold Storage, который будет работать на диске без файловой системы. Также этот новый бэкенд будет отвечать за компрессию и дедупликацию. 

  2. Storage vMotion. Пользователи известных enterprise-решений привыкли к определенному набору функций, поэтому их важно предоставить. Например, функция Storage vMotion позволяет «бесшовно» перемещать диск виртуальной машины с одного кластера SDS на другой без прерывания работы.

  3. Производительность. Мы постоянно ведем работы по улучшению производительности. Не так давно мы добавили поддержку RDMA вместо TCP и это дало ощутимый прирост. Будем и дальше продолжать искать узкие места и оптимизировать их.

К слову, вы можете бесплатно сделать виртуальную машину или S3-bucket с помощью нашего Evolution free tier.


Другие статьи в блоге:

Комментарии (5)


  1. Thomas_Hanniball
    08.11.2024 00:24

    И благодаря этому наш SDS может работать на любом commodity-железе. Сейчас мы проводим эксперименты по запуску SDS на ARM и на RISC-V архитектуре.

    Вот это реально круто. Приятно видеть, что в СНГ появляется такой центр экспертизы.

    Судя по последним новостям yandex, sber, yandro и им подобные компании будут двигать в вперёд технологическое развитие IT в СНГ регионе. Это радует.


  1. vtolstov
    08.11.2024 00:24

    в итоге на чем написан sds - на go? (учитывая pebble и dragonboat) ?


    1. LysanovSergey Автор
      08.11.2024 00:24

      Часть которая отвечает за метаданные(MDS) на Golang. Часть которая отвечает за данные(CS) на C++.


  1. MBkkt
    08.11.2024 00:24

    Я правильно понял что например в 4+2, у вас участвует 6 cs? Получается в отличие от схемы с репликами, если хотя бы 1 cs недоступен, оно становится read-only? Ну мб это ок так как только на холодный сторадж.

    А есть бенчмарки с какими-то из конкурентов?

    encryption остается на пользователе виртуалки?


    1. LysanovSergey Автор
      08.11.2024 00:24

      Я правильно понял что например в 4+2, у вас участвует 6 cs? Получается в отличие от схемы с репликами, если хотя бы 1 cs недоступен, оно становится read-only? Ну мб это ок так как только на холодный сторадж.

      Да участвует 6 CS. С недоступностью не совсем так. В схеме 4+2 можно потерять и 2 CS и продолжить писать, но без отказоустойчивости. Сколько можно потерять CS и продолжить писать регулируется отдельным параметром и настраивается гибко. Для 4+2 мы выставляем этот параметр в 1, т.е. потеря одного CS не приводит к приостановке записи, а потеря 2-х CS переведет чанк в read-only.

      А есть бенчмарки с какими-то из конкурентов?

      Есть, но лично я считаю некорректным их выкладывать. В целом некоторые бенчмарки вендорских СХД или CEPH можно найти в открытом доступе.

      encryption остается на пользователе виртуалки?

      В моменте да. На самом деле наш SDS умеет в encryption и шифрованием занимается libclient. Но нам надо доделать хранение ключей шифрования во внешнем KMS, чтобы шифрование было доступно в публичном облаке. Скоро это будет.