Как строятся объектные хранилища: объясняем на практике и собственных шишках
Объектные хранилища сейчас повсюду. До прихода в Selectel я точно знал, что они живут в облаках, сложно тарифицируются, а Amazon снова впереди планеты всей… Но, если подумать, так можно сказать почти про любую облачную услугу, и это не расскажет нам о ее реальных особенностях.
Быть может, специфика такого хранилища прячется в задачах, которые оно решает? Сложно сказать наверняка, ведь сегодня объектные хранилища занимаются массой вещей: от раздачи статического контента до хранения бэкапов и бэкенда аналитических баз данных.
Попытки понять природу непривычных ограничений порождают лишь новые вопросы: почему можно удалять только пустой контейнер? Почему нельзя быстро перенести большой объем данных из одного контейнера в другой? Да и вообще, что это за название такое — объектные — и какая магия творится под капотом?
На связи Рома из команды объектного хранилища Selectel, и я изучил наш опыт разработки и поддержки такого продукта на протяжении 10 лет. Под катом находится первая часть истории, где я поделюсь своими открытиями о теоретической части вопроса.
Что за объекты и зачем им хранилище? Теория
Объектное хранилище сегодня — это HTTP API, который позволяет загружать, получать и удалять данные по имени. Фактически, это KV-хранилище для больших кусков данных (BLOB). Обычно такие сравнения не приводят, чтобы избежать ненужной путаницы с базами данных, но мы ведь профессионалы и умеем отделять мух от котлет?
Что можно отметить:
- Плоское пространство имен без вложенности. Объекты можно разделить по контейнерам, но на этом все.
- Гарантии доступности и целостности данных.
- Хранилище условно бесконечное для пользователя. Можно хранить пару-тройку гигабайт, а можно — петабайт, если захочется.
Эти особенности уже сами по себе диктуют архитектуру и последствия. Например, нет необходимости хранить сложную структуру хранилища. Значит, алгоритмы доступа проще, а время доступа меньше и не сильно изменяется с ростом количества данных. Миллиард отсортированных объектов в плоском пространстве имен и миллиард файлов в разных папках на обычной файловой системе — это две большие разницы.
Гарантии целостности и бесконечность хранилища сразу приводит нас к вопросу его масштабирования и распределенной природы. Это должно быть фундаментом архитектуры. Отдельно можно упомянуть протокол доступа. Использование HTTP API не представляет никакой сложности. Кроме массы библиотек и утилит, всегда есть возможность реализовать протокол самостоятельно или вообще обратиться к хранилищу «вручную» через curl или, для любителей, telnet.
HTTP реализован даже в чайниках, его отладка проста и привычна, а пространство для возникновения сложной ошибки на клиенте практически отсутствует.
Сверху над основными методами работы с объектами есть место и для дополнительных фишек, вроде массовых операций удаления или разграничения доступа. Но эта функциональность лишь использует базовую архитектуру, которая и представляет основную сложность.
В общем, для пользователя объектное хранилище — это что-то очень высокоуровневое. Способ хранить информацию, не задумываясь о том, как она организована, и не зацикливаясь на сущностях вроде секторов, inode или фрагментации.
В облаках бывают и другие сетевые хранилища. Попробуем рассмотреть их основные особенности:
Я сознательно обошел стороной локальные хранилища, а также существование FUSE-драйверов вроде s3fs, проецирующих объектное хранилище как файловую систему. Это тема для отдельного разговора (возможно, в присутствии санитаров).
Объектное хранилище абстрагируется над реальными способами хранения данных и обеспечивает характеристики, подходящие под высокие нагрузки. Его нельзя напрямую сравнить с файловым или, тем более, блочным, потому что они находятся в принципиально разных системах координат.
Под капотом у объектного хранилища все равно окажется блочное устройство или даже файловая система, но его архитектура задумана так, чтобы избавиться от их ограничений и недостатков.
Практика
Широкую популярность объектные хранилища получили благодаря облаку Amazon.
В 2006 году компания представила S3 (Simple Storage Service, что иронично, ведь даже с авторизацией там без 100 грамм не разберешься). Со временем технология стала настолько популярной, что ее название практически стало нарицательным. Говоришь про S3 — имеешь в виду объектное хранилище. Говоришь про объектное хранилище… ну, вы поняли.
В 2010 году появился OpenStack — совместная разработка NASA и облачного провайдера Rackspace. От первых проекту достался компонент Nova для виртуальных машин, от вторых — объектное хранилище Swift. Открытый исходный код и условная простота (на тот момент) подстегнули волну появления облачных провайдеров.
В 2012 году Selectel не смог обойти популярность и перспективы проекта стороной, поэтому мы взяли OpenStack Swift, разобрались в его работе и запустили наш первый облачный продукт — объектное хранилище.
После запуска хранилище наполнилось статическими сайтами, видеоуроками и большими бэкапами баз данных. С первых дней мы активно дорабатывали и расширяли Swift, впоследствии переработав или заменив его компоненты собственными решениями.
Хранилище Selectel сегодня — след множества синяков, которые мы набили в попытках заставить OpenStack Swift работать хорошо. Мы чинили Python, полностью перешли на Go и внедрили собственный слой хранения на Ceph. Но это впереди, а пока у нас на дворе 2011 год и команда хранилища изучает ванильный OpenStack Swift.
Отвлечемся и построим объектное хранилище
В качестве спойлера придется выдать информацию, что в 2016 году Selectel начал процесс перехода от Swift к Ceph, но об этом мы подробно расскажем в одном из следующих материалов.
По широте задач, которые решаются на базе объектных хранилищ, становится ясно: они привлекают простотой использования, сохранностью и доступностью данных из любой точки мира и скоростью работы.
Становится интересно, как может выглядеть архитектура такого продукта. Какие хитрости или допущения потребуются, чтобы обеспечить нужные характеристики? Но сначала сформулируем основные ожидания от такой системы:
- Доступ по HTTP API, быстрое время отклика.
- Быстрое и удобное масштабирование без ограничений.
- Устойчивость к отказам отдельных нод и дисков, самовосстановление данных при потере меньшинства копий.
- Равномерное распределение нагрузки на хосты и диски и объемов данных внутри них.
Звучит не очень страшно. Давайте разберемся, какие архитектурные сложности могут ждать на этом пути и почему объектное хранилище — это все-таки самостоятельная система, а не просто API, отдающий файлы с диска.
Рисуем квадраты
Идем напролом и начинаем с простого решения: ставим сервер с диском побольше, выделяем отдельную директорию для пользовательских объектов и пишем простейший API, который может сохранять их и отдавать:
Вышло сносно, но соблюдено только требование к протоколу доступа. Масштабирование, отказоустойчивость — это не про нашу схему. Давайте возьмем три диска вместо одного и продублируем на них все операции. Успешной записью или чтением считаем два или больше успешных обращения к дискам:
Уже лучше — мы обезопасили себя от отказа одного диска, но все еще ограничены одним сервером. Что если поставить каждый диск в отдельный сервер? Серверы поставим в отдельных помещениях, а каждому из них сообщим адреса остальных двух:
Подкрались и другие изменения. Облачко на схеме — новый верхнеуровневый API. К нему обращается клиент, а он, в свою очередь, дублирует запрос на машины. На каждом сервере у нас остается простейший API для работы с локальными объектами.
Дадим им названия:
- object-api — находится на каждом сервере и производит простейшие манипуляции с локальными объектами,
- proxy-api — доступен для клиента и дублирует запросы во все object-api.
Еще один новый компонент: replicator. Так как данные на диске могут быть испорчены, их необходимо как-то восстанавливать. Предположим, что он периодически опрашивает другие серверы и при наличии более свежих данных скачивает их и обновляет локальную копию. Остальное — детали, не столь важные сейчас.
С защитой от потерь разобрались: разнесли машины, сделали тройную репликацию. Классика. Но как нам наращивать кластер? У нас есть три диска — группа репликации, в рамках которой все данные продублированы. Кажется логичным добавить еще по одному диску на каждую машину и считать их отдельной группой.
Правда, теперь у нас есть две группы дисков. Как нам размещать на них объекты и получать их обратно? Нужен способ выбирать одну группу дисков для объекта и каждый раз при обращении к этому объекту работать именно с ней.
Такие задачи обычно решаются хэшированием — вычислением числа на основе имени объекта. Математика хэшей работает так, что для одного и того же имени всегда будет получаться одинаковое число, но для разных имен числа скорее всего никогда не совпадут.
Чтобы определить номер группы по хэшу, будем брать остаток от деления хэша на количество групп. Это распространенный подход, который даст относительно равномерное распределение:
Чтобы различать группы дисков, мы их пронумеровали: 0 и 1. Как это теперь работает:
- Клиент отправляет в proxy-api запрос на получение объекта /abcde.
- proxy-api вычисляет хэш от имени объекта и номер группы, в которой должна храниться информация, — в нашем случае это группа 0.
- proxy-api отправляет запросы в object-api, указывая, что мы работаем с группой дисков 0.
- Дальнейшие действия происходят, как и раньше. Репликатор теперь обновляет данные в каждой группе независимо.
Такая архитектура уже похожа на то, что мы пытаемся сделать, но у нее есть большие проблемы.
Во-первых, при добавлении новых групп дисков распределение значений изменится и потребуется серьезная миграция данных между группами. Это связано с тем, что результат взятия остатка от деления изменится для всех объектов, в том числе для уже существующих. Во-вторых, рано или поздно место для дисков в шасси закончится, и для расширения объема потребуется добавить сразу 3 новых сервера.
В общем, как-то несерьезно. Вылезем из песочницы и придумаем что-то похитрее.
Играем с хэшами
Начнем с проблемы распределения значений. Графически она выглядит так:
То есть при добавлении новой группы окажется, что часть уже существующих данных должна мигрировать между группами (заштрихованные области). Чтобы подтвердить математику, Возьмем Python и посчитаем:
DATA_COUNT = 2 ** 16 # Количество объектов в хранилище. Допустим, 65536 штук
GROUPS_COUNT_BEFORE = 2 # Количество групп дисков до масштабирования
GROUPS_COUNT_AFTER = 3 # Количество групп дисков после масштабирования
print(f'Cluster scaling: {GROUPS_COUNT_BEFORE} -> {GROUPS_COUNT_AFTER} groups')
changed = 0
# Небольшое допущение: мы будем использовать монотонный счетчик вместо хэша.
# Это сделано, чтобы сделать код более читаемым, и незначительно влияет на результат теста.
for i in range(DATA_COUNT):
# Вычисляем номер группы до масштабирования
group_before = i % GROUPS_COUNT_BEFORE
# Вычисляем номер группы после масштабирования
group_after = i % GROUPS_COUNT_AFTER
# Считаем количество объектов, для которых изменится номер группы
if group_before != group_after:
changed += 1
percent_changed = round(changed / DATA_COUNT * 100, 2)
print(f'Total changed: {changed} of {DATA_COUNT} ({percent_changed}%)')
Посчитаем, у скольких объектов изменится номер группы дисков при изменении их количества с 2 до 3. Запустим код:
user@machine ~ % python3 ./calculate-data-migration.py
Cluster scaling: 2 -> 3 groups
Total changed: 43690 of 65536 (66.67%)
Опасения со схемы подтвердились: около ⅔ объектов придется мигрировать между группами для добавления ⅓ объема. Может, ситуация будет не так плоха в будущем? Например, что будет, когда мы дорастем до 8 групп и захотим добавить еще одну?
Поменяем значения и запустим:
user@machine ~ % python3 ./calculate-data-migration.py
Cluster scaling: 8 -> 9 groups
Total changed: 58248 of 65536 (88.88%)
Ситуация и правда изменилась, но не в нашу пользу. Добавление девятой группы выльется в миграцию уже 89% данных. Мы переместим намного больше информации между существующими группами, чем отправим в новую. Эта зависимость сохранится: мы всегда будем перемещать 100-x% данных для добавления x% объема.
Здесь стоит отметить, что миграция — это не праздник. Ее всегда хочется избежать по нескольким причинам:
- Это потенциально высокая нагрузка на сеть и диски, а ведь там «живут» клиенты.
- Это может вызвать временную недоступность клиентских данных, пока они доезжают до новых дисков.
- Это может вызвать потерю консистентности данных, если во время миграции клиент захочет их изменить.
Но как сделать так, чтобы при добавлении новой группы дисков не приходилось жонглировать практически всем содержимым кластера? Да и сами группы, как уже говорилось, не самая удобная единица для масштабирования — нет радости в том, чтобы всегда добавлять ровно по 3 диска, а не 2 или 4.
Проектируем как взрослые
Что если мы сможем сделать миграции данных контролируемыми? Хэширование и остаток от деления — это просто, но архитектурно это обязывает нас перестраивать содержимое дисков сразу после введения новых хостов.
Первое, что приходит в голову: разделить вычисленный номер группы и реальный, а отдельно хранить их сопоставление. Но это нам не помогает. Мы даже не сможем отсрочить проблемы, обновив сопоставление отложено, ведь группы не заменяют друг друга полностью, просто данные перемешиваются между ними. Нужно думать дальше.
Что если развить эту мысль и при делении хеша назначать объектам «виртуальный» номер группы из намного большего диапазона? То есть за количество виртуальных групп мы возьмем такое число, к которому не приблизимся никогда — пусть это будет 16 384.
Теперь каждому объекту будет назначаться одна из 16 тысяч виртуальных групп. А отдельно мы будем хранить сопоставление, какие виртуальные группы назначены на физические. Тогда при добавлении новых групп дисков мы можем порционно перемещать виртуальные группы между ними, и миграции будут целиком под нашим контролем.
Но раз мы теперь храним в памяти сопоставление групп, то и сами группы как таковые нам больше не нужны. Теперь мы можем просто иметь список физических дисков и хостов, к которым они относятся. Наша задача будет состоять в том, чтобы каждую логическую группу присвоить 3-м дискам на разных машинах. Но на каких именно — значения не имеет. Бинго!
Давайте порисуем:
Здесь я ввожу новый термин: partition (партиция). Именно так теперь и будем называть наши логические группы. Как и раньше, у нас остается proxy-api, который умеет вычислять номер партиции. Но теперь у него в памяти есть табличка, где каждая возможная партиция присвоена нескольким дискам. Теперь нужно перенаправить запрос на хосты и дело в шляпе.
Для примера мы взяли 12 партиций и 4 машины по 3 диска, чтобы наглядно показать новое распределение данных. В реальности такое количество партиций не подходит, так как равняется количеству дисков. Оптимальное значение будет значительно выше максимального количества дисков в нашем кластере. В целом, чем больше у нас партиций, тем более гранулярно мы управляем нашими данными и тем меньший объем миграций нам предстоит пережить.
Видно, что теперь мы можем расширять кластер, добавляя всего по одному хосту либо даже по одному диску к уже существующим. Если мы будем учитывать еще и объем дисков, сможем назначать каждому из них пропорциональное количество партиций и гибко управлять распределением данных.
Более того, теперь миграции полностью в наших руках. Мы можем запускать их порционно, регулируя количество перемещаемых за раз данных или, что более важно, количество копий одной партиции. То есть если мы научим наш генератор сопоставлений не мигрировать одновременно больше одного экземпляра партиции. У нас всегда будет выполняться гарантия доступности 2 копий данных из 3, и потенциальные проблемы с недоступностью обойдут нас стороной.
Но что же с нашей главной проблемой? Приятно контролировать миграцию, но хотелось бы снизить ее масштаб. Это как раз еще одна приятная вещь, которую дает нам использование партиций. Дело в том, что при добавлении нового диска нам придется мигрировать
Напомню, что наша первая реализация принципиально меняла распределение данных и большая часть миграций приходилась на перемещение информации между старыми хостами. Теперь же, имея много маленьких партиций на каждом диске, мы можем аккуратно взять с каждого из них по чуть-чуть и переместить на новый диск только то, что нужно:
В остальном все то же самое: object-api производит действия с объектами на указанном диске в указанной директории, proxy-api считает хэши и делает запросы в нужные object-api, а компонент replicator, как и раньше, поддерживает консистентность локальной копии, только теперь в рамках партиций.
Прямо сейчас мы с вами изобрели Consistent Hashing. Такая штука используется в базах данных, в сетевой балансировке, а еще в OpenStack Swift. В терминологии последнего это называется кольцом (ring), а намного достовернее о его организации можно узнать из компиляции статей.
Вспомним, какие задачи мы поставили себе в начале:
- Быстрое и удобное масштабирование — выполнено.
- Устойчивость к отказам отдельных нод и дисков, самовосстановление данных при потере меньшинства копий — выполнено.
- Равномерное распределение нагрузки на хосты и диски и объема данных внутри них — выполнено частично.
Кажется, вышло неплохо. Исключение — последний пункт. Во-первых,»горячие» объекты будут создавать значительную нагрузку на одни и те же диски и машины, а во-вторых, хэширование имен хоть и позволяет более-менее распределить объекты количественно, но все-таки не учитывает их размеры.
Но давайте пока оставим эти проблемы за кадром. Да, многие вещи просят иной реализации, но мы неспроста пришли именно к такой архитектуре. С таким списком перед миром и перед нашей командой предстал OpenStack Swift.
Результат
Мы едва ли обсудили вопросы размещения объектов, а уже стало понятно, что задача точно не сводится к чему-то существующему и требует грамотной проработки. Начинают прослеживаться причины ограничений объектных хранилищ и становится понятно, что впереди будет еще немало интересного.
В следующий частях мы поговорим о практике. О том, как работает сам OpenStack Swift, сколько кругов ада прошла наша команда в попытке заставить его работать хорошо и почему нельзя просто так взять и начать продавать OpenSource.