Спешим сообщить новость: мы переписали API облачного хранилища. Теперь всё работает гораздо стабильнее и быстрее благодаря новой платформе — Hummingbird, которая по сути представляет собой реализацию некоторых компонентов OpenStack Swift на Go. О том, как мы внедряли Hummingbird и какие проблемы нам удалось решить с его помощью, мы расскажем в этой статье.



Модель объектного хранилища OpenStack Swift

Модель объектного хранилища OpenStack Swift включает в себя несколько интегрированных сущностей:

  • прокси-сервер, который получает от конечного пользователя определенный набор данных, выполняет служебные запросы к прочим компонентам хранилища и, наконец, формирует и отправляет правильный ответ.
  • серверы аккаунтов и контейнеров. Здесь я объединяю эти два сервиса, т.к. они очень похожи по принципу работы – каждый из них сохраняет метаданные, а также списки контейнеров (сервер аккаунтов) и файлов (сервер контейнеров) в отдельных sqlite-базах и выдает эти данные по запросам.
  • серверы объектов, которые, собственно, хранят пользовательские файлы (и метаданные уровня файлов в расширенных атрибутах). Это самый примитивный уровень абстракции – здесь существуют только объекты (в виде файлов), записываемые в определенные партиции.


Каждый из этих компонентов представляет собой отдельный демон. Все демоны объединены в кластер с помощью так называемого кольца — хэш-таблицы для определения места размещения объектов внутри кластера. Кольцо создается отдельным процессом (ring builder) и разносится по всем узлам кластера. Оно задаёт количество реплик объектов в кластере для обеспечения отказоустойчивости (обычно рекомендуется хранить три копии каждого объекта на разных серверах), количество партиций (внутренняя структура swift), распределение устройств по зонам и регионам. Также кольцо предоставляет список так называемых handoff-устройств, на которые будут заливаться данные в случае недоступности основных устройств.

Рассмотрим все компоненты хранилища более подробно.

Proxy

Первоначально мы использовали стандартный swift-proxy, затем, когда нагрузка увеличилась, а нашего собственного кода стало больше — перевели все это на gevent и gunicorn, позже заменили gunicorn на uwsgi ввиду того, что последний лучше работает под большими нагрузками. Все эти решения были не особо эффективны, время ожидания, связанное с прокси, было достаточно большим и приходилось использовать все больше серверов для обработки авторизованного трафика, т.к. Python cам по себе работает очень медленно. В итоге весь этот трафик пришлось обрабатывать на 12 машинах (сейчас весь трафик — и публичный, и приватный, — обрабатывается всего на 3 серверах).

После всех этих паллиативных действий я переписал прокси-сервер на go. В качестве основы был взят прототип из проекта Hummingbird, далее, я дописал middleware, которые реализуют вcю нашу пользовательскую функциональность – это авторизация, квоты, прослойки для работы со статическими сайтами, символические ссылки, большие сегментированные объекты (динамические и статические), дополнительные домены, версионирование и т.д. Кроме того у нас реализованы отдельные эндпоинты для работы некоторых наших специальных функций – это настройка доменов, ssl-сертификатов, подпользователей. В качестве средства для формирования цепочек middleware мы используем justinas/alice (https://github.com/justinas/alice), для хранения глобальных переменных в контексте запроса — gorilla/context.

Для отправки запросов к сервисам OpenStack Swift используется компонент directclient, имеющий полный доступ ко всем компонентам хранилища. Помимо прочего, мы активно используем кэширование метаданных уровней аккаунта, контейнера и объекта. Эти метаданные будут включаться в контекст запроса; они нужны для принятия решения о дальнейшей его обработке. Чтобы не выполнять слишком много служебных запросов к хранилищу, мы держим эти данные в кэше memcache. Таким образом, прокси-сервер получает запрос, формирует его контекст и пропускает через различные прослойки (middleware), одна из которых должна сказать:“Этот запрос — для меня!”. Именно эта прослойка обработает запрос и вернёт пользователю ответ.

Все неавторизованные запросы в к хранилищу сначала пропускаются через кэширующий прокси, в качестве которого нами был выбран Apache Trafficserver.
Так как стандартные политики кэширования подразумевают довольно долгое нахождение объекта в кэше (иначе кэш бесполезен) — мы сделали отдельный демон для очистки кэша. Он принимает события PUT-запросов из прокси и очищает кэш для всех имён изменившегося объекта (у каждого объекта в хранилище есть как минимум 2 имени: userId.selcdn.ru и userId.selcdn.com; ещё пользователи могут прикреплять к контейнерам свои домены, для которых тоже требуется чистить кэш).

Аккаунты и контейнеры

Слой аккаунтов в хранилище выглядит так: для каждого пользователя создаётся отдельная база данных sqlite, в которой хранится набор глобальных метаданных (квоты на аккаунт, ключи для TempURL и другие), а также список контейнеров этого пользователя. Количество контейнеров у одного пользователя не исчисляется миллиардами, поэтому размер баз невелик, и реплицируются они быстро.
В общем, аккаунты работают отлично, проблем с ними нет, за что их и любит все прогрессивное человечество.

С контейнерами дело обстоит по-другому. С технической точки зрения они представляют собой точно такие же sqlite-базы, однако эти базы содержат уже не крошечные списки контейнеров одного пользователя, а метаданные определенного контейнера и список файлов в нем. Некоторые из наших клиентов хранят в одном контейнере до 100 миллионов файлов. Запросы к таким большим sqlite-базам выполняются медленнее, и с репликацией (учитывая наличие асинхронных записей) дело обстоит гораздо сложнее.

Конечно, sqlite-хранилище для контейнеров можно было бы на что-то заменить — но на что? Мы делали тестовые варианты серверов аккаунтов и контейнеров на базе MongoDB и Cassandra, но все подобного рода решения, “завязанные” на централизованную базу вряд ли можно назвать удачными с точки зрения горизонтального масштабирования. Клиентов и файлов со временем становится всё больше, поэтому хранение данных в многочисленных маленьких базах выглядит предпочтительнее, чем использование одной здоровенной базы с миллиардами записей.

Не лишним было бы реализовать автоматический шардинг контейнеров: если бы появилась возможность разбивать огромные контейнеры на несколько sqlite-баз, было бы вообще отлично!
О шардинге подробнее можно прочитать здесь. Как видим, всё пока что в процессе.

Еще одной функцией, непосредственно связанной с сервером контейнеров, является ограничение срока хранения объектов (expiring). Посредством заголовков X-Delete-At, либо X-Delete-After можно задавать период времени, по истечении которого будет удален любой объект объектного хранилища. Эти заголовки могут равно передаваться как при создании объекта (PUT-запрос), так и при изменении метаданных оного (POST-запрос). Однако, нынешняя реализация данного процесса выглядит совсем не так хорошо, как хотелось бы. Дело в том, что первоначально эта фича реализовывалась так, чтобы внести как можно меньше исправлений в имеющуюся инфраструктуру OpenStack Swift. И здесь пошли по самому простому пути — адреса всех объектов с ограниченным сроком хранения решили помещать в специальный аккаунт “.expiring_objects” и периодически просматривать этот аккаунт с помощью отдельного демона под названием object-expirer. После этого у нас появилось две дополнительные проблемы:

  • Первая — служебный аккаунт. Теперь, когда мы делаем PUT/POST запрос к объекту с одним из указанных заголовков- на этом аккаунте создаются контейнеры, имя которых представляет собой временную метку (unix timestamp). В этих контейнерах создаются псевдообъекты с именем в виде временной метки и полного пути к соответствующему реальному объекту. Для этого используется специальная функция, которая обращается к серверу контейнеров и создаёт запись в базе; сервер объектов при этом не задействован вообще. Таким образом, активное использование функции ограничения срока хранения объектов в разы увеличивает нагрузку на сервер контейнеров.
  • Вторая связана с демоном object-expirer. Этот демон периодически проходит по огромному списку псевдообъектов, проверяя временные метки и отправляя запросы на удаление просроченных файлов. Главный его недостаток заключается в крайне низкой скорости работы. Из-за этого часто бывает так, что объект уже фактически удалён, но при этом все равно отображается в списке контейнеров, потому что соответствующая запись в базе контейнеров всё ещё не удалена.


В нашей практике типичной является ситуация, когда в очереди на удаление находятся более 200 миллионов файлов, и оbject-expirer со своими задачами не справляется. Поэтому нам пришлось сделать собственный демон, написанный на Go.

Есть решение, которое уже довольно давно обсуждается и, надеюсь, скоро будет реализовано.

В чем оно заключается? В схеме базы контейнеров появятся дополнительные поля, которые позволят репликатору контейнеров удалять просроченные файлы. Это решит проблемы с наличием в базе контейнеров записей об уже удаленных объектах + object auditor будет удалять файлы истекших объектов. Это позволит также полностью отказаться от object-expirer’а, который на данный момент является таким же атавизмом, как многососковость.

Объекты

Уровень объектов — самая простая часть OpenStack Swift. На этом уровне существуют только наборы байт объектов (файлов) и определенный набор операций над этими файлами (запись, чтение, удаление). Для работы с объектами используется стандартный набор демонов:

  • сервер объектов (object-server) — принимает запросы от прокси-сервера и размещает/отдаёт объекты реальной файловой системы;
  • репликатор объектов (object-replicator) — реализует логику репликации поверх rsync;
  • аудитор объектов (object-auditor) — проверяет целостность объектов и их атрибутов и помещает повреждённые объекты в карантин, чтобы репликатор мог восстановить верную копию из другого источника;
  • корректор (object-updater) — предназначен для выполнения отложенных операций обновления баз данных аккаунтов и контейнеров. Такие операции могут появиться, например, из-за таймаутов, связанных с блокировкой sqlite-баз.


Выглядит довольно просто, не так ли? Но, у этого слоя есть несколько значительных проблем, переходящих из релиза в релиз:

  1. медленная (очень медленная) репликация объектов поверх rsync. Если в небольшом кластере с этим можно смириться, то после достижения пары миллиардов объектов всё выглядит совсем печально. Rsync предполагает push-модель репликации: сервис object-replicator просматривает файлы на своей ноде и пытается запушить эти файлы на все прочие ноды, где этот файл должен быть. Сейчас это уже не такой простой процесс – в частности, для увеличения быстродействия используются дополнительные хэши партиций. Подробнее обо всём этом можно прочитать здесь
  2. периодические проблемы с сервером объектов, который запросто может блокироваться при зависании операций ввода-вывода на одном из дисков. Это проявляется в резких скачках времени ответа на запрос. Почему так происходит? В больших кластерах не всегда удается достичь идеальной ситуации, когда абсолютно все сервера запущены, все диски примонтированы, и все файловые системы доступны. Диски в серверах объектов периодически “подвисают” (для объектов используются обычные hdd с небольшим лимитом по iops'ам), в случае с OpenStack Swift это часто ведет к временной недоступности всей object-ноды, т.к. в стандартном object-сервере нет механизма изоляции операций к одному диску. Это ведет, в частности, к большому количеству таймаутов.


К счастью, совсем недавно появилась альтернатива Swift, позволяющая вполне эффективно решить все описанные выше проблемы. Это тот самый Hummingbird, который мы уже упоминали выше. В следующем разделе мы расскажем о нём более подробно.

Hummingbird как попытка решения проблем Swift

Совсем недавно компания Rackspace начала работу по переработке OpenStack Swift и переписыванию его на Go. Сейчас это практически готовый к использованию слой объектов, включающий сервер, репликатор и аудитор. Пока что нет поддержки политик хранения (storage policies), но в нашем хранилище они и не используются. Из демонов, связанных с объектами, нет только корректора (object-updater).

В целом, hummingbird — это feature branch в официальном репозитории OpenStack, проект этот сейчас активно развивается и в скором времени будет включен в master (возможно), можете участвовать в разработке — github.com/openstack/swift/tree/feature/hummingbird.

Чем Hummingbird лучше Swift?

Во-первых, в Hummingbird изменилась логика репликации. Репликатор обходит все файлы в локальной файловой системе и отправляет всем нодам запросы: “Вам это нужно?” (для упрощения роутинга таких запросов используется метод REPCONN). Если в ответе сообщается, что где-то есть файл более новой версии — локальный файл удаляется. Если данный файл где-то отсутствует — создаётся недостающая копия. Если файл уже был удалён и где-то обнаружится tombstone-файл с более новой временной меткой, локальный файл тотчас же будет удалён.

Здесь необходимо пояснить, что такое tombstone-файл. Это пустой файл, который помещается на место объекта при его удалении.

Зачем такие файлы нужны? В случае с большими распределенными хранилищами мы не можем гарантировать, что при отправке DELETE-запроса сразу же будут удалены абсолютно все копии объекта, потому что для подобного рода операций мы отправляем пользователю ответ об успешном удалении после получения n запросов, где n соответствует кворуму (в случае 3-х копий — мы должны получить два ответа). Это сделано намеренно, так как некоторые устройства могут быть недоступны по различным причинам (например, плановые работы с оборудованием). Естественно, копия файла на этих устройствах не будет удалена.
Более того, после возвращения устройства в кластер, файл будет доступен. Поэтому при удалении объект заменяется на пустой файл с текущей временной меткой в имени. Если в ходе опроса серверов репликатором будут обнаружены два tombstone-файла, да ещё и с более новыми временными метками, и одна копия фала с более старым last-modified — значит, объект был удалён, и оставшаяся копия тоже подлежит удалению.

С сервером объектов то же самое: диски изолированы, есть семафоры, которые ограничивают конкурентные соединения с диском. Если какой-то диск по тем или иным причинам “подвисает” или переполняет очередь запросов к нему, можно просто отправиться на другую ноду.

Проект Hummingbird успешно развивается; будем надеяться, что совсем скоро он будет официально включён в OpenStack.

Мы перевели на Hummingbird весь кластер облачного хранилища. Благодаря этому снизилось среднее время ожидания ответа от сервера объекта, и ошибок стало гораздо меньше. Как уже было отмечено выше, мы используем свой прокси-сервер на базе прототипа из Hummingbird. Слой объектов также заменён на набор демонов из Hummingbird.
Из компонентов стандартного OpenStack Swift используются только демоны, связанные со слоями аккаунтов и контейнеров, а также демон-корректор (object-updater).

Заключение

В этой статье мы рассказали о Hummingbird и о том, какие проблемы нам удалось решить с его помощью. Если у вас есть вопросы, мы с удовольствием ответим на них в комментариях.

Благодаря переходу на новую платформу нам удалось существенно расширить спектр возможностей API хранилища. Появились новые функции: управление пользователями, доменами, SSL-сертификатами и многое другое. В ближайшее время мы разместим в панели управления документацию к обновленному API, а обо всех нововведениях подробно расскажем в отдельной статье.
Поделиться с друзьями
-->

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


  1. celebrate
    06.07.2016 02:00

    Просто из интереса — а Цеф не пробовали? Там и индекс шардинг есть и совместимость со Swift API.


    1. sams-gleb
      07.07.2016 17:46

      Я пробовал запускать ceph в тестовом окружении. Но, сейчас переходить на него не вижу смысла, т.к. разницы в производительности не будет (на скорость получения объектов, в основном, влияет наличие кэша на фронтэнд-серверах). К тому же, даже в том случае, если у нас возникнет непреодолимое желание запустить ceph в production'е, нужно будет разрабатывать новый прокси: radosgw не подойдет — там нет многих функций, реализуемых нашими middleware + модель авторизации не совместима с той, что мы используем сейчас. И я не знаю, как сейчас дела с librados, но когда я его тестировал с gevent, были заметные проблемы с производительностью и подвисаниями запросов при большом количестве параллельных соединений. В общем, т.к. у нас уже был кластер со swift'ом — более простым путем оказалась модернизация наличной инфраструктуры/софта, ну и я плохо представляю себе процесс безболезненной миграции миллиардов объектов…


  1. o_serega
    06.07.2016 11:54

    Вопрос по tombstone-файлам, они потом подчищаются или swift считает, что иноды бесконечны)


    1. sams-gleb
      07.07.2016 17:47

      По умолчанию у ts-файлов reclaim_age равен неделе (этот параметр можно настраивать в object-server.conf), т.е. файлы старше недели репликатор удаляет (но, тут нужно учитывать, что еще какое-то время потребуется репликатору для того, чтобы дойти до этого файла)


      1. o_serega
        07.07.2016 21:43

        Вот интересует, не было ли проблем с инодами на больших объемах данных и активном создании, и удалении объектов?


        1. sams-gleb
          08.07.2016 14:08

          Мы используем 4Тб диски, если такой диск занят почти полностью используется, как правило, 20-30 млн инод, так что проблем не было. Ну и при создании xfs можно увеличивать кол-во инодов


          1. o_serega
            08.07.2016 14:14

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


  1. iglov
    07.07.2016 17:46

    Шикарная статья!
    P.S. Глебас, ты обещал писАть, но забыл о нас :(


  1. gluck59
    14.07.2016 09:39

    Добрый день
    А позволяет ли новый API получать ссылку в зоне .com на файлы из хранилища? Или по-прежнему парсинг-поиск-замена на клиенте?


    1. sams-gleb
      15.07.2016 17:28

      Апи — это же просто эндпоиты для реализации функций, посредством коих можно, например, получать листинги файлов, загружать новые объекты или что-то настраивать. Не могли бы вы подробнее описать, как вы получаете ссылки и что требуется парсить/заменять?