
Всем привет. Я — Дмитрий Шапошников, Tech Lead в команде Object Storage в MWS Cloud Platform. Сегодня мы поговорим о том, как устроено наше объектное хранилище.
В этой статье я объясню, что такое Object Storage, и поделюсь нашим опытом создания сервиса. Расскажу о преимуществах и недостатках работы с Ceph, на котором базировалось S3-хранилище в первой версии нашего облака, и подробно опишу архитектуру нового сервиса, его масштабируемость, надёжность и механизмы защиты.
Что такое Object Storage
Object Storage (объектное хранилище) — это сервис хранения данных, в котором информация представляется не в виде блоков или файловой иерархии, а в виде отдельных объектов. Каждый объект состоит из самих данных, их метаданных и уникального идентификатора, что упрощает масштабирование, управление и доступ.
Такой подход востребован для хранения больших объёмов неструктурированных данных — резервных копий, мультимедиа, логов и архивов. Сейчас это стандарт в современных облачных инфраструктурах.
Amazon Web Services (AWS) в 2006 году запустил сервис S3 и предложил единый API для работы с объектами. Он быстро стал отраслевым ориентиром: разработчики и компании начали проектировать приложения и сервисы именно под S3-совместимость.
Сегодня практически каждый облачный провайдер стремится поддержать этот интерфейс, чтобы обеспечить совместимость с огромной экосистемой существующих инструментов и библиотек. Наше облако здесь не исключение. Это значит, что для работы с нашим объектником вы можете использовать привычные вам инструменты AWS S3.
Ниже расскажу, какие фичи S3 API мы поддержали, а также кратко о тех, от которых отказались.
S3 compatibility
Object Storage — это сервис, где данные хранятся в виде файлов (объектов), сгруппированных в логические контейнеры — бакеты.

Доступ к объекту определяется парой «бакет + ключ».
Ключ — это уникальное имя объекта внутри бакета, а именно — строка, часто с префиксами, которые имитируют каталоги. Например:
my.great_photos-2014/jan/myvacation.jpg
Такая адресация даёт нам структуру «бакет → ключ → объект». Она не зависит от файловой иерархии и хорошо масштабируется.
Объектное хранилище в MWS Cloud Platform позволяет хранить объекты любого формата до 5 ТБ каждый. Файлы, превышающие 5 ГБ, загружают по частям через механизм multipart upload.
В отличие от простого key‑value хранилища S3 предлагает целый набор высокоуровневых функций:
Метаданные и теги обогащают объекты. С объектом можно ассоциировать метаинформацию (content type, content length, content language), кастомные атрибуты через x-amz-meta и теги — это упрощает поиск и обработку данных.

Версионирование объектов. У одного объекта может быть несколько версий. При случайном удалении объекта вместо реального удаления появляется специальный delete marker. Если вы хотите восстановить последнюю версию, то просто удаляете этот маркер.

-
Lifecycle, то есть политики жизненного цикла, помогают автоматически управлять содержимым бакета. Например, удалять резервные копии старше недели или автоматически удалять старые незавершённые мультипарт-загрузки.
В качестве фильтров можно указать префикс имени файла и тег. Пример:
prefix: backup
tag: prod
Для аудита S3 предоставляет Server Access Logging. Логи HTTP‑операций записываются в отдельный бакет, что полезно для команд безопасности.

В этом примере мы формируем файлы с логами операций из source bucket и переносим их в destination bucket c помощью Server Access Logging, где их уже может анализировать команда ИБ.
-
Больше 30 операций с файлами и бакетами. Среди самых важных операций:
GetObject — получить объект.
PutObject — залить объект.
ListObjects — получить список объектов.
Multipart-загрузка. Позволяет загружать объекты частями. Мы контролируем заливку каждой из этих частей: если какая-то из них не загрузилась, можно повторить попытку. В конце достаточно просто сделать complete, после чего объект будет создан.
Наконец, важное преимущество S3 API — развитая экосистема. Имеются SDK и CLI для разных языков, и множество сторонних приложений умеют работать с S3‑совместимыми сервисами.
Практически все современные системы хранения совместимы с S3 API, и это главная причина, по которой мы его выбрали.
Стоит упомянуть о ACL — Access Control List. Это перечень правил, который определяет, кому разрешён или запрещён доступ к определённым ресурсам. Это deprecated-фича Amazon S3, поэтому мы отказались от неё. Для управления доступом мы используем роли из IAM платформы и bucket policy, с помощью которых можно очень гранулярно навешивать правила доступа к бакетам, объектам в бакетах и так далее.
Наш опыт с Ceph в 1-й версии MWS Cloud (ex. MTS Cloud)
Текущая итерация построения нашего облака уже не первая. В первой версии облака, построенного на вендорских технологиях виртуализации, объектное хранилище полностью строилось на Ceph.
Ceph — свободная и открытая платформа, предоставляющая объектное, блочное и файловое хранилище на общей кластерной основе. Система спроектирована без единой точки отказа, данные реплицируются или кодируются через erasure coding и распределяются по множеству узлов.
Ceph бывает нескольких видов:
RADOS (Key-value storage) — базовая версия.
Ceph RADOS
Ceph RADOS (Reliable Autonomic Distributed Object Store) — это распределённая объектная система хранения данных файловой системы Ceph, которая состоит из самостоятельных узлов.
RADOS обеспечивает распределённое хранение объектов, высокую доступность, надёжность, отсутствие единой точки отказа, самовосстановление и самоуправление.
FS — версия, которая работает с файловой системой.
RADOS Gateway (RGW) — версия, которая реализует S3 API. В 1-й версии облака использовалось именно это решение.
RADOS Gateway открывает доступ к объектному хранилищу Ceph через REST‑интерфейс, совместимый с Amazon S3 и OpenStack Swift.
Наш опыт эксплуатации Ceph показал, что для небольших инсталляций это решение отлично справляется с задачами бэкапов, архивов и медиа. Однако при попытке масштабировать до уровня публичной облачной платформы будут проблемы. О них ниже.
Ограничения Ceph RGW
Трудно масштабировать
Один RGW‑кластер трудно растить бесконечно: операции обслуживания — фоновое удаление, работа lifecycle, асинхронные задачи — негативно сказываются на производительности. Если в кластере 50–100 миллионов объектов, работа lifecycle может идти часами и блокировать часть операций обслуживания.
А если поднять ещё один кластер RGW? Логичное решение, однако каждый такой кластер имеет собственный namespace бакетов и отдельный endpoint. Это ломает глобальную уникальность имён в Object Storage: одинаковые названия могли появиться в разных кластерах, а клиентам приходилось бы придумывать дополнительную логику на уровне приложений, чтобы решить, в какой кластер отправлять файлы.

Кроме того, Enterprise-клиенту с огромным бакетом может не хватит одного кластера RGW (да, такое возможно). В такой топологии этот клиент вообще не сможет хранить свои данные.
Трудно интегрировать с другими системами облака
Дополнительная сложность заключалась в интеграции: RGW реализует собственную систему IAM, отличную от IAM платформы, а при размещении очень больших бакетов — десятков петабайт — обслуживание становилось невозможным.
Эти ограничения привели нас к мысли, что для публичного облака нужно собственное хранилище, совместимое с S3, но лишённое недостатков Ceph RGW. При этом у нас уже была накоплена серьёзная экспертиза по работе с Ceph и мы решили не отказываться от него до конца. В новой реализации Object Storage на платформе MWS Cloud Platform мы используем Ceph RADOS как основной building-блок для слоя хранения данных.
Давайте подробнее поговорим про архитектуру.
Новый Object Storage в MWS Cloud Platform
Главная задача объектного хранилища в новом облаке — заложить возможность бесконечного масштабирования. Первое, что мы для этого сделали, — разделили слои хранения на слой данных (data) и слой метаданных (metadata).
Масштабируемость
Слой данных
В качестве building-блока для слоя данных мы взяли Ceph RADOS.
Масштабирование слоя данных происходит за счёт добавления новых Ceph RADOS кластеров.

При записи новый объект разбивается на чанки и кодируется с помощью erasure coding, о котором я расскажу дальше в статье.
За то, как данные будут распределены между кластерами, отвечает самописный бэкграунд-сервис Ceph Mastermind. Он знает всё о кластерах, включая их capacity и usage.
Работает это так: если первый кластер заполнен на 90%, а второй — на 10%, то наш бэкенд, отвечающий за непосредственную запись объектов в Ceph, получит от Ceph Mastermind распоряжение, что во второй кластер нужно писать больше, чем в первый.

Взаимосвязь между слоем метаданных и слоем данных устроена так: каждая запись об объекте в метаслое содержит имя файла (file_name), имя бакета (bucket_name) и набор дополнительных метаданных (metadata), а также ссылку на фактическое расположение данных объекта в RADOS-кластере (pointer_to_data) — id кластера RADOS и id объекта в этом кластере. Это обеспечивает чёткое разделение логики управления и физического хранения данных, но при этом позволяет быстро находить и считывать нужный объект.
При такой архитектуре для масштабирования мы просто поднимаем новые кластеры RADOS и дополняем ими нашу систему.
Слой метаданных
В качестве билдинг-блока для хранения метаданных мы выбрали PostgreSQL, так как он поддерживает транзакции и надёжно работает с большими объёмами — в одном кластере может храниться несколько миллиардов объектов. Но для нас даже этот объём — потолок, который мы хотим пробить, чтобы иметь возможность масштабироваться бесконечно. Поэтому мы используем шардированный PostgreSQL. О нём подробнее далее.
Слой метаданных в нашей архитектуре разделён ещё на 2 уровня:
В базе objects-db хранятся метаданные объектов и pointer-to-data.

В базе buckets-db хранится информация о бакетах, а также о том, на каком из шардов objects-db нужно искать конкретный файл какого-то из бакетов.
В такой схеме метаданные файлов одного бакета могут жить на разных шардах objects-db и в разных RADOS-кластерах — именно это решает проблему с масштабируемостью, которая присутствует в Ceph.
Давайте на примере запроса GetObject к бакету Cars рассмотрим, как это будет работать на практике.
Пространство имён бакета делится на ренджи, информацию о которых хранит база buckets_db.
Например, бакет Cars можно поделить на несколько range’ей:
(-∞, audi) — хранится на 1-м шарде;
[audi, lada) — на 2-м;
[lada, +∞) — на 3-м.
Когда пользователь делает запрос — GetObject, — на вход есть имя бакета и имя файла, по которым сначала в buckets-db мы ищем, к какому range принадлежит наш объект. После этого мы понимаем, в каком шарде objects-db находится metadata о нашем объекте, и забираем всю информацию, включая поинтер, который говорит о том, где нам искать данные. Далее мы идём в конкретный RADOS-кластер и по Ceph Object ID начинаем стримить его пользователю.
Каждый range мы делаем такого размера, чтобы его было легко обслуживать и чтобы присутствовала возможность переноса данных. За это отвечают два бэкграунд-сервиса:
chunk_scheduler — анализирует состояние всех шардов objects_db, используя методы предподсчитанной статистики. Понимая, где есть дисбаланс, он создаёт задачу для chunk_worker;
chunk_worker — выполняет задачи от chunk_scheduler.
Представим, что нам нужно перенести range (-∞, audi) из PG_1 в PG_2. В процессе участвует 3 компонента: 2 шарда PostgreSQL в objects_db и buckets_db. После того как chunk_worker перенёс range (-∞, audi) из PG_1 в PG_2, мы также должны обновить информацию о маппинге в buckets-db.
Для консистентности мы используем двухфазный коммит.
1-я фаза — prepare. Здесь мы готовим транзакции на каждом из участников.
2-я фаза — commit. Здесь мы последовательно выполняем эти транзакции.
При этом логика выполнения commit’ов сделана таким образом, чтобы в любой момент времени данные в buckets_db указывали на актуальный шард objects_db, чтобы избежать corrupt’а данных.
Надёжность
Надёжность — наш ключевой приоритет. Любой пользователь ждёт, что с его данными ничего не произойдёт. И для этого мы комбинируем несколько подходов.
Прежде чем перейдём к конкретике, сделаем два уточнения:
Сейчас мы размещаем данные в двух дата-центрах.
Каждый из RADOS-кластеров поднимается в рамках одного ДЦ. Он не растягивается на 2 ДЦ.
Erasure coding
Под надёжностью в первую очередь подразумевают избыточность — это означает, что данные хранятся не в единственном экземпляре. Обычно для реализации этого используется репликация, но также есть и другой метод защиты данных — erasure coding.
Для хранения данных мы используем схему erasure coding 4+2 (итоговый коэффициент репликации в одном кластере — 1,5): объект разбивается на четыре основных data-чанка и два дополнительных parity-чанка с кодами восстановления.

В такой схеме при потере двух из 4 частей оригинального объекта мы сможем восстановить их в бэкграунде с помощью кодов восстановления.
Причём Part 1, Part 2, Part 3 и Part 4 хранятся на разных дисках, которые в свою очередь находятся в разных стойках, что позволяет нам говорить о том, что наш домен отказа — это стойка в дата-центре.
Георепликация
Уровень данных
Мы уже упомянули выше, что RADOS-кластеры у нас поднимаются внутри одного дата-центра. Каким образом мы обеспечиваем георепликацию?
Для этого в ДЦ_2 мы располагаем копию RADOS-кластера из ДЦ_1, также с кодами избыточности 4+2, в который реплицируются данные. В нашем Object Storage реализована асинхронная репликация с помощью бэкграунд-сервиса replicator.
Бэкенд пишет данные в кластер, находящийся в том же ДЦ, где и инстанс бэкенда, куда прилетел запрос. То есть запись объекта происходит local-dc. Одновременно с записью в ДЦ_1 мы кладём сообщение в outbox, из которого replicator достаёт задачи и записывает копии объектов в ДЦ_2. Есть симметричный процесс для репликации данных из ДЦ_2 в ДЦ_1, так как запись объектов от бэкендов осуществляется в оба ДЦ.

Если происходит запрос GetObject в тот ДЦ, куда данные ещё не успели отреплицироваться (редко, но случается), мы осуществляем fallback в тот ДЦ, куда объект был записан изначально. Таким образом мы гарантируем read-after-write-консистентность для пользователей даже в таком случае.
Процесс репликации идёт через распределённую очередь и контролируется метриками, что позволяет поддерживать минимальный лаг между ДЦ и равномерно загружать воркеры.
Уровень метаданных
Как мы уже упомянули выше, метаданные хранятся в PostgreSQL. Для избыточности используем схему 2+2 с мастером и синхронной репликой в ДЦ_1 и синхронной и асинхронной репликами в ДЦ_2.

Наши приложения мы деплоим в Kubernetes, а для деплоя PostgreSQL используем оператор Zalando.
Для лучшей утилизации коннектов к кластерам PG мы используем пуллер соединений. Начинали мы с PgBouncer и вот с какой проблемой столкнулись.
Как известно, PgBouncer — однопоточный, и для масштабирования мы разворачивали несколько его инстансов в обоих ДЦ. Запрос от app backend по принципу round-robin шёл на любой из инстансов PgBouncer, из-за чего возникала ситуация, когда запрос делал 4 cross-dc похода: app (dc2) -> bouncer (dc1) -> master (dc2) -> bouncer (dc1) -> app (dc2). Это приводило к повышенным латенси запросов.

Избавились мы от double hop, перейдя на деплой пуллера сайдкаром к мастеру. Но пуллер хочется масштабировать, и для этого мы перешли на Odyssey, который, как известно, многопоточный.
Защита данных
Защита данных — это в первую очередь разграничение доступа к данным.
В каждом запросе от пользователя есть:
информация о субъекте, например, IAM-токен или подпись, сформированная по HMAC-ключу;
информация о ресурсе, над которым хочет совершить действие субъект, — бакете или объекте;
информация о действии, которое собирается совершать пользователь.
Вся эта информация посылается в IAM, который сообщает, разрешено ли это действие этому субъекту над этим ресурсом.
Так как таких запросов может быть сотни тысяч в секунду, IAM также создаётся с учётом возможности масштабирования. Подробнее прочитать про IAM можно в статье Игоря Михайлюка.
Все данные в нашем сервисе шифруются с помощью сервиса KMS. На примере GetObject на каждый запрос от пользователя мы ходим за мастер-ключом в KMS, с помощью которого мы расшифровываем ключ, которым был зашифрован этот объект. После чего расшифрованные данные отдаются пользователю. При этом сам сервис KMS мы также делаем масштабируемым. Подробнее про KMS рассказал мой коллега Кирилл Беспалов в 5-м выпуске реалити-проекта Building the Cloud.
Итоги
Новая версия объектного хранилища MTS Web Services — это полноценный S3‑совместимый сервис с архитектурой, ориентированной на масштабирование и отказоустойчивость. В отличие от решения на Ceph RGW, в нём нет ограничений по namespace и количеству кластеров. Можно горизонтально увеличивать ёмкость, добавляя новые RADOS‑кластеры и шарды метаданных.
Георепликация, erasure coding и множественные копии метаданных обеспечивают сохранность данных даже в случае выхода из строя узлов или дата-центра. Поддержка версионирования, lifecycle‑политик, тэгов, гибких политик доступа делает его удобным для конечных пользователей.
Сервис уже в общем доступе. Приходите попробовать по ссылке.
endevir
Всеми руками поддерживаю использование одиссея в кач-ве пулера соединений для постгреса, но, справедливости ради
...но ведь нормально параллелится с помощью so_reuseport даже в пределах одного контейнера. Не рассматривали ли такой вариант, и почему отмели, если рассматривали?
Боль понятная, но почему бы не использовать .spec.trafficDistribution: PreferSameZone в сервисе перед пулерами? Который https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution