Всем привет! На связи Виктор Стародуб — технический лидер команды S3, один из авторов и создателей объектного СХД в Cloud.ru. Недавно вышла статья, в которой мой коллега Сергей Лысанов @LysanovSergey рассказал, как мы сделали собственный Software-Defined Storage для дисков виртуальных машин в публичном облаке Cloud.ru Evolution. А в этой статье я расскажу о том, как мы написали свое объектное хранение, имея отказоустойчивое блочное хранилище в качестве базового слоя, с какими сложностями столкнулись, как их решили и какие сделали выводы.
Что такое объектное хранение
Не существует строгого определения объектного хранения. Под объектным хранилищем обычно подразумевается сервис, который обладает типичным набором характеристик:
1. Immutable-данные — объект можно перезаписать только целиком, поменять пару байт в середине объекта нельзя.
2. Key-value — данные доступны только по пользовательскому ключу без каких-либо дополнительных индексов.
3. Универсальность — данные не имеют никакой семантики с точки зрения хранилища. Это не ограничивает сферу применимости, но часто требует дополнительной application-логики.
4. Отказоустойчивость — есть конкретный уровень (или уровни) отказоустойчивости.
5. AWS S3 — де-факто стандарт. Есть базовый набор операций: put, get, list, delete. Есть типичная схема организации хранилища в бакеты и определенные подходы к аутентификации и авторизации.
Зачем мы создали свое объектное хранилище
Зачем мы решили написать свое объектное хранилище? Во-первых, пользователи нашей платформы Cloud.ru Evolution хотят напрямую использовать такое хранилище для своих данных. Во-вторых, данные нужно хранить самим сервисам платформы — как пользовательские, так и дополнительные, которые нужны для их работы.
Конечно, для этого есть уже готовые решения, но с ними не все так гладко, как может показаться. Зачастую они плохо масштабируются, когда речь идет о размере даже до десятков, не говоря уже о сотнях петабайт. Возможно, вы и сами слышали истории про то, как разваливаются кластеры CEPH, про разные сложности с его настройкой и поддержанием работоспособности. В случае с коммерческими решениями от различных вендоров многое тоже не идеально — мы уже сталкивались с проблемами совместимости, масштабирования и утилизации оборудования.
Кажется, что можно просто доработать какое-то из имеющихся open source решений. Но с большой вероятностью это выльется в необходимость поддерживать свой форк, который в том числе включает и ненужный функционал. И наверняка тот, кто пытался коммитить в какие-то большие open source проекты, знает, что зачастую maintainer-ов нужно долго уговаривать, чтобы они приняли большие изменения.
Писать систему хранения совсем с нуля — сложная и долгая задача. Однако низкоуровневая часть такого хранения у нас уже была написана. Была готова наша собственная SDS и мы не могли этим не воспользоваться 😉. Пришли к единогласному решению — нам нужно свое объектное хранилище.
V1 или не минимальный MVP
Почему нельзя вот так просто взять и написать свое объектное хранение? На то есть несколько причин.
AWS — референсная имплементация. Когда говорят об объектном хранении, то в 95% или даже в 99% случаев имеют в виду протокол S3, который является стандартом де-факто. Однако, это не формализованный стандарт и AWS S3, которое можно считать референсной имплементацией, может хранить неограниченное количество объектов в бакете, а сами объекты могут быть от 0 байт до 5 ТБ. Чтобы оправдать ожидания пользователей, нам не хотелось вносить более жесткие архитектурные ограничения.
Обширное API. В S3 довольно обширный API. Из простых вещей: имитация директорий при помощи delimiter в алфавитном листинге, загрузка объектов по частям, версионирование, теги объектов, HTTP preconditions. Есть даже экзотика вроде GetObjectTorrent. Клиенты действительно используют разнообразный функционал и совершенно по-разному — мы учитывали это на этапе выбора функциональности первой версии нового хранилища.
Legacy. Поскольку протокол S3 уже не молодой, накопилось какое-то количество легаси. Документация не всегда соответствует тому, что есть на самом деле: что-то не совсем подробно описано, где-то не до конца понятно поведение в краевых случаях и т. п. Иногда приходится долго вчитываться, чтобы понять, что именно имел в виду автор документации, а что-то можно понять исключительно эмпирическим путем.
Версионирование и consistency. Это не фичи, а сложная часть архитектуры, которая влияет на формат индексов и на производительность, которую важно предусмотреть и реализовывать сразу, чтобы потом не было мучительно больно.
Отказоустойчивость. С самого старта хотелось иметь решение, выдерживающее отказ дата-центра и дополнительного хоста. Это не универсальное требование к объектному хранению, но без должной отказоустойчивости хранилище просто не нужно пользователям.
Так мы определились с тем, что хотим написать. Расскажу, что у нас в итоге получилось.
SDS как базовый слой: что нам это дало
Об SDS мы подробно рассказывали в первой части статьи, но я выделю ключевые для организации объектного хранения моменты:
-
Низкоуровневая работа с диском. SDS уже умеет хардкорный С++ код, который работает с диском, занимается сложным сетевым полингом, поэтому нам не пришлось писать все это с нуля самим.
-
Erasure-кодирование. Пользователи хотят более экономное отказоустойчивое хранение. SDS снова упростил нам жизнь — этим вопросом тоже не пришлось заниматься.
-
Hot storage. Достаточно сложная конструкция, но с точки зрения объектного хранилища она выглядит как write-буфер на NVME-диске. Объекты хочется хранить на жестких дисках — так дешевле, но для жестких дисков довольно остро стоит проблема случайной записи. SDS позволяет нам накапливать небольшие обновления, чтобы не делать много запросов на жесткие диски. При этом большие записи проходят мимо буфера, минимизируя износ NVME.
-
Автоматическая балансировка и починка данных. Происходит в пределах одного SDS-кластера — если какой-то диск вылетел, SDS восстановит данные, пересоздаст нужные реплики и т. п.
Какие проблемы не решает SDS
Не все так просто и кое-что нам все-таки пришлось написать. Во-первых, в протоколе S3 есть много сущностей, которые в принципе плохо ложатся на существующие сущности SDS: бакеты, lifecycle policy, website, ACL, bucket policy. Даже сами объекты имеют много разных видов метаданных. Для всего этого нужны дополнительные сервисы, но об этом чуть позже.
Во-вторых, S3-хранилища часто используются для хранения небольших объектов, и это создает дополнительные трудности. Чтобы было понятнее, что я имею в виду, вспомним вкратце архитектуру SDS:
SDS работает с volume, виртуальными дисками. Список volume, метаданные и логическая структура volume хранятся на MDS. Все MDS конкретной инсталляции SDS составляют один RAFT-кластер и хранят информацию обо всех volume в памяти. Информацию об объекте не получится хранить в памяти, как информацию о volume-ах, т. к. в типичном объектном хранилище объектов слишком много для того, чтобы они уместились в память на одном хосте.
Volume состоит из chunk-ов размером порядка 1ГБ, каждый из которых делится на небольшое число chunklet-ов в зависимости от выбранной схемы репликации. Т. е. при хранении небольших объектов как отдельных volume, подсистема хранения данных также будет использоваться не по назначению.
Для хранения мы используем volume как write-ahead log. Volume состоит из заголовка и последовательности записей различных типов. У каждой записи есть идентификатор, представляющий собой виртуальное смещение внутри volume-а.
Большие объекты режутся на кусочки по 64МБ. Кусочки объектов последовательно пишутся в volume как отдельные записи типа Add. Удаления таких Add-записей пишутся как отдельные записи типа Delete и применяются отложено.
Удаления применяются во время compaction: данные volume переписываются в новый volume без удаленных записей. Для неизменности идентификаторов, volume содержит индекс в начале. Периодическое переписывание всех данных в новые volume также позволяет изменять параметры хранения, например, применяемое erasure-кодирование, при их изменении.
Последней трудностью с применением SDS была репликация. Хранением данных chunklet-ов в SDS занимается сервис CS (chunk server). CS-ы могут обмениваться друг с другом данными при первичной репликации, миграции или починке данных. Этот обмен может быть довольно интенсивным в плане пропускной способности и суммарного трафика, особенно в случае выхода из строя целого сервера. Это общение происходит по внутренней сети и не подходит для репликации между разными дата-центрами в неизменном виде.
Для решения этой проблемы мы просто поднимаем по SDS-кластеру в каждом дата-центре и, при выборе multi-AZ схемы хранения, данные объекта будут лежать в нескольких копиях в разных volume в разных SDS в разных Дата-центрах. Т. е. в данном случае мы совмещаем 2x репликацию для обработки случая выхода из строя целого дата-центра с erasure-кодированием внутри SDS для более «дешевой» обработки выхода из строя одного диска.
Почему тяжело хранить метаданные объектов
Хранение метаданных — ключевая проблема в построении объектного хранилища. На объект размером в 10-100 мегабайт будет от силы десяток килобайт метаданных. Запросов к метаданным приходит как минимум столько же, сколько и к данным. При этом, их объем достаточно велик, чтобы полностью поместиться в разумное количество RAM.
Исторически, до популярности NVME, метаданные хранились вместе с данными объектов на одних и тех же HDD/SSD. Часто использовался consistent hashing для того, чтобы быстро добраться до нужного сервера при точечных put/get, однако листинг при этом становится проблематичным, т. к. нужно опросить все серверы для листинга одного бакета.
Для решения этой проблемы чаще всего составлялся индекс объектов в бакете и фактически он хранился как отдельный объект. Затем такой объект мог шардироваться для более равномерной загрузки дисков и индексироваться для ускорения доступа. Кусочки листинга могли кешироваться в памяти, чтобы уменьшить время доступа. Т. е. индекс объектов превращался в самописную базу данных.
Если использовать оба подхода, то и листинги, и точечные get-ы будут быстрыми. Однако, несмотря на название consistent hash, именно в этом подходе есть проблемы с консистентностью данных получаемых по хешу и через индекс.
Использование только индекса без consistent hashing до появления NVME-дисков не позволяло добиться высокой производительности get/put-запросов, кроме случая хранения исключительно больших объектов. Использование отдельно стоящей БД, например PostgreSQL, работает потенциально хуже, т. к. в этом случае под БД доступно меньше дисков для распределения нагрузки.
Это сильно повлияло на архитектуру объектных хранилищ написанных до появления NVME, но в нашем случае мы смогли использовать подход с отдельным индексом по метаданным, разместив его на отдельных дисках без дополнительных манипуляций с consistent hashing.
LSM и RAFT
Итак, метаданные мы храним на отдельных дисках. Для хранения используем встраиваемое KV-хранилище pebble — реализацию LSM-дерева. Поверх pebble для репликации используем протокол RAFT (dragonboat). Т. е. используем проверенную связку из SDS.
Как и во многих типичных сценариях применения, LSM-дерево позволяет нам добиться разумного компромисса между накладными расходами на чтение/запись, а также выполнять алфавитный листинг.
Сортированность LSM также позволила относительно эффективно реализовать версионирование, положив версию в ключ. Однако здесь не обошлось без «боли» связанной с тем, что версионирование можно приостанавливать и включать обратно. Из-за этого объект NULL-версии у нас имеет NULL-версию в ключе, но при этом хранит реальную версию в значении.
Кроме того, LSM можно рассматривать как «WAL на стероидах» и pebble из коробки умеет batch-обновления. Т. е. несколько операций можно выполнить атомарно, что сильно упрощает вопрос атомарности запросов по S3-протоколу.
Поверх LSM мы используем RAFT — готовый алгоритм, закрывающий вопросы репликации и восстановления. Если аналоги типа PAXOS — это просто набор деталей, из которых еще нужно собрать готовое решение, то RAFT — коробочное решение в плане репликации.
Протокол S3 требует консистентности в рамках отдельного объекта. RAFT обеспечивает линеаризуемость и хорошо дружит с батчами в LSM. Одной командой в RAFT может быть какая-то составная операция с точки зрения pebble и последовательность таких команд гарантирована. Т. е. можно безопасно делать compare-and-swap, «автоинкремент» для версий и даже какие-то более сложные проверки атомарно с другими операциями, например, object lock и/или обработку заголовка If-None-Match.
Помимо RAFT+pebble мы сначала пробовали использовать ScyllaDB. Поскольку механизмы консистентности хоть и не идеальны, но достаточны для S3-протокола, она умеет шардирование из коробки и показывает неплохую производительность. Однако, модель с ключом шардирования для нас оказалась недостаточно гибкой и иерархия «бакет -> объект -> версия объекта» в нее не вписалась. Листинги и версионирование встроить так же эффективно, как в чистый LSM у нас не получилось.
SQL-базы для индекса объектов использовать не хотелось, т. к. нам не сильно нужен дополнительный функционал — на горячем пути для get/put-операций даже с версионированием нам достаточно key/value-семантики, шардирование и репликацию с большой вероятностью пришлось бы делать или дорабатывать отдельно. При этом довести производительность SQL-базы до уровня KV-хранилища было бы нереально.
Структура индексов
Для хранения метаданных мы используем разные сервисы. Для хранения «глобальных» пользовательских метаданных, в том числе списка и метаданных бакетов, — MS (metadata server). Для хранения метаданных объектов внутри бакетов — NS (name server).
MS-ы сгруппированы в один RAFT-кластер по девять узлов, по три узла в трех разных дата-центрах. Одного кластера достаточно, поскольку метаданных этого уровня немного, а девять узлов нужны для работоспособности в случае выхода из строя целого дата-центра и одного дополнительного узла.
В случае NS-ов их сильно больше, т. к. метаданных объектов много. NS-ы объединены в несколько разных RAFT-кластеров по девять узлов. Каждый кластер имеет отдельный идентификатор. Мы используем multi-raft и для некоторых кластеров набор узлов NS у нас совпадает, но работают при этом они как независимые кластеры. Получается, один процесс NS может участвовать в нескольких RAFT-кластерах, что позволяет равномернее распределить нагрузку от leader-ов RAFT.
В метаданных бакета указана таблица шардирования и сгенерированный суррогатный ключ для этого бакета. При запросах ключ объекта хешируется и согласно таблице шардирования выбирается соответствующий кластер NS. NS хранит метаданные объекта используя набор из ключа бакета, имени объекта и версии объекта в качестве ключа в KV-хранилище. Все версии одного объекта всегда лежат на одном кластере NS для консистентности версионирования.
Хеширование позволяет равномерно распределить ключи объекта по шардам и избежать излишнего решардинга, например, в тех случаях, когда ключ объекта всегда монотонно возрастает. Этот случай может возникнуть, если в бакет, например, пишутся логи и ключ выглядит как timestamp.
Обратная сторона — при алфавитном листинге мы вынуждены опрашивать все шарды бакета, но такие запросы на листинг относительно редки, выполняются параллельно и отрабатывают достаточно быстро.
Архитектура системы
Теперь у нас есть понимание общего подхода к хранению, мы можем посмотреть на всю систему, обсудить отдельные компоненты и их отказоустойчивость.
На самом деле, у нас получилось довольно много сервисов:
-
Балансировщик нагрузки — мы используем Nginx.
-
S3GW (S3 gateway) — содержит логику аутентификации и отказоустойчивой обработки пользовательских запросов в S3 API.
-
CFS (config server) — хранит список серверов и volume-ов SDS в системе, выполняет шедулинг maintenance-задач.
-
MS (metadata server) — хранит список бакетов и метаданные для них. К этим метаданным относятся в том числе bucket policy и bucket ACL, поэтому MS также выполняет авторизацию.
-
NS (name server) — хранит список, метаданные объекта и «указатель» на данные.
-
OS (object server) — работает с отдельными volume-ами на уровне записей и является «клиентом» для SDS.
-
SDS мы используем через его реализацию NBD-протокола, а также дополнительно используем API для работы с volume и для опроса статуса кластера.
Кроме того, мы интегрируемся с личным кабинетом и разными сервисами платформы Cloud.ru Evolution через Service Controller, который пишет отдельная команда. В IAM за аутентификационными и авторизационными данными мы ходим напрямую, но кешируем ответ на некоторое время.
CFS и MS — это 9x RAFT-кластера, настроенные в трех разных дата-центрах. NS, как было описано выше — это также набор 9x RAFT-кластеров в разных дата-центрах. В случае недоступности каких-то реплик, мы просто идем в оставшиеся реплики.
S3GW у нас запущены на каждом узле кластера, они ничего не хранят и много инстансов здесь нужно для отказоустойчивости и распределения нагрузки. Если какой-то S3GW недоступен, будет выбран другой на уровне балансировщика.
У нас есть несколько SDS-кластеров в разных дата-центрах. На каждом узле запущен OS, работающий с «локальным» SDS-кластером. SDS обрабатывает случай отказа одного или нескольких дисков за счет erasure-кодирования. В случае недоступности целого SDS-кластера для multi-AZ хранения у нас есть вторая реплика.
Каждый volume привязан к конкретному OS и только этот OS может в него писать. В случае недоступности OS при чтении, мы просто читаем volume из другого OS (выбирая его детерминированным образом). В случае недоступности OS при записи, мы просто выбираем другие volume из набора доступных для записи.
Что у нас в итоге получилось
Выбор RAFT в качестве основного инструмента был удачным выбором: с одной стороны, он навязывает модель работы, к которой тяжеловато привыкнуть поначалу, но с другой — дает возможность довольно тривиально сделать атомарными какие-то очень громоздкие и составные вещи, которых появляется много при выходе за рамки базового S3.
По части доступности Availability RAFT зарекомендовал себя очень хорошо и с ним самим по себе проблем не было. Однако настройка логики failover-ов при разных сценариях отказа на уровне самого приложения — дело очень тонкое и специфичное для каждого приложения. Здесь пришлось приложить определенное количество усилий.
На данном этапе мы умеем значительную часть S3 API, включая, например, website. У нас есть поддержка разных storage-class-ов, мы умеем хранить данные в multi-AZ и single-AZ режиме, умеем прозрачно для пользователя осуществлять сжатие данных. Пока мы не умеем счетчики ссылок для данных и как следствие операции вроде CopyObject у нас работают неэффективно.
SDS балансирует нам данные внутри кластера автоматически, а со стороны объектного хранилища у нас есть maintenance-операции в «ручном» режиме, такие как решардинг бакетов, починка и миграция volume. В совсем недалеком будущем мы планируем автоматизировать maintenance, который возможно делать автоматически.
На самом деле, наш подход к хранению метаданных позволил сильно отложить по времени работу над решардингом, поскольку pebble неплохо масштабируется и фиксированного начального количества шардов бакета нам пока хватает с запасом.
Также со стороны объектного хранилища мы планируем научиться шифровать данные, хранить multi-AZ данные с фактором репликации x1.5 между SDS (вместо x2). Планируем сделать счетчики ссылок для эффективного CopyObject/CopyPart. Кроме того, счетчик ссылок пригодится и для дедупликации данных.
В ближайшее время также планируем запустить первые инсталляции on-premise версии объектного хранилища.
Заключение
Несмотря на обилие готовых решений для хранения, вполне может оказаться так, что в силу объективных причин вы захотите написать что-то свое, пусть даже не настолько массивное, как своя имплементация S3. В этом случае важно изучить доступные технологии, как аппаратные (NVME в нашем случае), так и программные (RAFT) — они могут кардинально поменять подход к решению и сильно упростить задачу.
Кроме того, необязательно писать все с нуля, и помимо использования готовых библиотек с github можно использовать готовое решение в качестве backend-системы. Например, вы можете использовать наше объектное хранилище в качестве основы решения для более специализированных задач.
Интересное в блоге:
ссылка на оригинал статьи https://habr.com/ru/articles/850526/
Добавить комментарий