Экономим на спичках: как повысить локальность в OpenStack при помощи фильтров

в 9:05, , рубрики: api, grizzly, open source, openstack, ram, Блог компании Mirantis/OpenStack, метки: , , , , ,

Автор: Алексей Овчинников

Довольно часто при создании виртуальной машины на облаке возникает желание связать её с некоторым устройством хранения. Довольно часто при создании виртуальной машины на облаке хочется, чтобы она работала по возможности быстро. В случае, когда с виртуальной машиной (ВМ) связано некоторое устройство хранения данных, обмен информацией с ним может значительно ухудшить производительность связки. Поэтому ясно, что если устройство хранения будет размещено на том же физическом узле, на котором развёрнута ВМ, то задержка будет минимальной. Что не очевидно, так это — как же добиться такого удобного размещения, используя платформу OpenStack.

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

Своё обсуждение я начну с простого вопроса, а именно каким образом ВМ может быть размещена на определённом узле.

Как всем (возможно) хорошо известно, за размещение ВМ на узлах отвечает планировщик (компонента nova-scheduler), поэтому, чтобы достичь первоначальной цели, необходимо как-то модифицировать его поведение, чтобы он принимал во внимание особенности распределения устройств хранения. Стандартный подход к этому — использовать фильтры планировщика. Фильтры могут оказывать влияние на выбор узла планировщиком, в то время как работой фильтров можно управлять из командной строки, передавая им характеристики, которым должны соответствовать отобранные планировщиком узлы. Существует несколько стандартных фильтров, позволяющих решить достаточно широкий класс задач планировки и описанных в документации проекта OpenStack Docs. Для менее тривиальных задач всегда есть возможность разработать собственный фильтр. Этим мы сейчас и займёмся.

Несколько слов о фильтрах

Общая идея планирования с фильтрацией достаточно проста: пользователь указывает характеристики, которым должен отвечать узел, после чего планировщик выбирает набор узлов, которые им отвечают. Затем ВМ может быть запущена на одном из отобранных на предыдущем этапе узлов. На каком именно, определяется его загрузкой и рядом других характеристик, которые несущественны на этапе фильтрации. Рассмотрим процедуру фильтрации более подробно.

Достаточно часто в системе присутствует сразу несколько фильтров. Планировщик сначала составляет список всех доступных узлов, затем применяет каждый из фильтров к этому списку, отбрасывая неподходящие узлы на каждой итерации. В такой модели задача фильтра очень проста: рассмотреть узел, поданный ему на вход, и принять решение, соответствует ли он критерию фильтрации или нет. Каждый из фильтров представляет собой объект одного из классов фильтров, у которого есть по меньшей мере один метод — host_passes(). Этот метод должен принимать на вход узел и критерии фильтрации и возвращать True или False в зависимости от того, удовлетворяет ли узел указанным критериям. Все классы фильтров должны наследоваться от базового класса BaseHostFilter(), определённого в nova.scheduler.filters. В момент запуска планировщик импортирует все модули, указанные в списке доступных фильтров. Затем, когда пользователь отправляет запрос на запуск ВМ, планировщик создаёт объект каждого из классов фильтров и использует их для отсеивания неподходящих узлов. Важно отметить, что эти объекты существуют в течение одного сеанса планирования.

Для примера можно рассмотреть RAM фильтр, отбирающий узлы с достаточным количеством памяти. Это стандартный фильтр, имеющий достаточно простую структуру, поэтому на его основе можно разрабатывать более сложные фильтры:

class RamFilter(filters.BaseHostFilter):
    ""«Ram Filter with over subscription flag»""

    def host_passes(self, host_state, filter_properties):
        ""«Only return hosts with sufficient available RAM.»""
        instance_type = filter_properties.get('instance_type')
        requested_ram = instance_type['memory_mb']
        free_ram_mb = host_state.free_ram_mb
       total_usable_ram_mb = host_state.total_usable_ram_mb

        memory_mb_limit = total_usable_ram_mb * FLAGS.ram_allocation_ratio
        used_ram_mb = total_usable_ram_mb — free_ram_mb
        usable_ram = memory_mb_limit — used_ram_mb
        if not usable_ram >= requested_ram:
            LOG.debug(_("%(host_state)s does not have %(requested_ram)s MB "
                    «usable ram, it only has %(usable_ram)s MB usable ram.»),
                    locals())
            return False

        # save oversubscription limit for compute node to test against:
        host_state.limits['memory_mb'] = memory_mb_limit
        return True

Чтобы определить, подходит ли данный узел для будущей ВМ, фильтру необходимо знать, какое количество оперативной памяти свободно на узле в настоящий момент, а также сколько памяти требуется для ВМ. Если оказывается, что на узле меньше свободной памяти, чем необходимо для ВМ, то host_passes() возвращает False, и узел удаляется из списка доступных узлов. Все сведения о состоянии узла содержатся в аргументе host_state, тогда как сведения, необходимые для принятия решения размещаются в аргументе filter_properties. Константы, отражающие некоторую общую стратегию планирования, как например ram_allocation_ratio, могут быть определены где-либо ещё, в конфигурационных файлах или в коде фильтра, но это, по большому счёту, не принципиально, поскольку всё необходимое для планирования может быть передано в фильтр при помощи так называемых подсказок планировщика.

Подсказки планировщика

Подсказки планировщика представляют собой не что иное, как словарь пар ключ-значение, который содержится в каждом запросе, формируемом командой nova boot. Если ничего не предпринимать, то этот словарь так и останется пустым и ничего интересного не произойдёт. Если же пользователь решит передать некоторую подсказку и таким образом пополнить словарь с подсказками, то это может быть легко сделано при помощи ключа --hint как, например, в следующей команде: nova boot … --hint your_hint_name=desired_value. Теперь словарь с подсказками не пуст, в нём содержится переданная пара. Если какое-либо расширение планировщика умеет использовать эту подсказку, то ему только что были переданы сведения, которые следует учесть при работе. Если же такого расширения нет, то снова ничего не произойдёт. Второй случай не так интересен, как первый, поэтому остановимся на первом. Посмотрим, как именно расширение может воспользоваться подсказками.

Чтобы воспользоваться подсказками, их, очевидно, надо извлечь из запроса. Эта процедура тоже вполне проста: все подсказки хранятся в словаре filter_properties по ключу scheduler_hints. Следующий фрагмент кода полностью объясняет процедуру получения подсказки:

        scheduler_hints = filter_properties['scheduler_hints']
        important_hint = scheduler_hints.get('important_hint', False)

В планировщике в nova scheduler_hints всегда присутствуют в запросе, так что при разработке своего расширения можно не ждать здесь неприятных сюрпризов, однако, читая значение подсказки, следует проявлять осторожность.

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

Повысить локальность подключаемых устройств хранения!

Имея на вооружении знания о способах расширения функционала планировщика, можно с лёгкостью спроектировать фильтр, который позволит запускать ВМ на тех же узлах, на которых физически находятся интересующие пользователя устройства хранения. Очевидно, нам понадобится неким образом отличать устройство хранения, которые мы собираемся использовать. Здесь нам на помощь может прийти строка volume_id, уникальная для каждого устройства. Из volume_id следует некоторым образом получить имя узла, которому оно принадлежит, и затем на этапе фильтрации отобрать этот узел. Обе последние задачи должны быть решены фильтром, а чтобы всё заработало, фильтру нужно сообщить имя узла с помощью соответствующей подсказки.

Для начала используем механизм подсказок, чтобы передать volume_id в фильтр. Для этого условимся использовать имя same_host_volume_id. Это простая задача, решив которую мы сразу же встречаемся со следующей, решаемой менее очевидно: как получить имя узла, зная идентификатор устройства хранения? К сожалению, судя по всему, не существует простого способа решить эту проблему, поэтому мы обратимся за помощью к тому, кто несёт ответственность за хранение данных: к компоненте cinder.

Воспользоваться услугами cinder можно разными способами: например использовать комбинацию API-вызовов, чтобы получить метаданные, связанные с данным volume_id, а затем извлечь из них имя узла. Однако на этот раз мы применим более простой метод. Мы воспользуемся умением модуля cinderclient формировать необходимые запросы и будем работать с тем, что она вернёт:

volume = cinder.cinderclient(context).volumes.get(volume_id)
vol_host = getattr(volume, 'os-vol-host-attr:host', None)

Здесь следует заметить, что такой подход будет работать только для релиза Grizzly и более поздних, поскольку расширение для cinder, позволяющие получить интересующую нас информацию, доступно только в них.

Дальнейшая реализация тривиальна — необходимо сравнивать vol_host с поступающими на вход именами и возвращать True только когда они совпадают. Детали реализации можно посмотерть либо в пакете для Grizzly, либо в реализации для Havana. При некотором размышлении над получившимся фильтром неизбежно возникает вопрос:

Это лучшее, что можно сделать?

Нет, рассмотренный способ не является ни оптимальным, ни единственно возможным. Так, в прямолинейной реализации существует проблема, связанная со множественными обращениями к cinder, что достаточно накладно, и ряд других проблем, замедляющих работу фильтра. Эти проблемы несущественны для кластеров малого размера, однако могут приводить к существенным задержкам при работе с большим числом узлов. Чтобы улучшить ситуацию можно модифицировать фильтр: например, введя кэш для имени узла, что позволит ограничиться одним обращением к cinder за загрузку ВМ, или добавив флаги, которые позволят фактически выключить фильтр как только искомый узел будет обнаружен.

Подводя итог, отмечу, что VolumeAffinityFilter — это только начало работы над использованием локальности для повышения производительности облака, и в этом направлении есть куда развиваться.

Вместо послесловия

Рассмотренный мною пример показывает, как можно разработать фильтр для планировщика nova, обладающий особенностью, отличающей его от других. Этот фильтр использует API другого компонента платформы OpenStack, чтобы выполнить своё предназначение. Вместе с добавлением большей гибкости такой подход может нанести ущерб производительности в целом, так как сервисы могут находиться на значительном удалении друг от друга. Возможным решением проблемы подобной тонкой настройки может стать объединение планировщиков всех сервисов в один, имеющий доступ ко всем характеристикам облака, однако на настоящий момент простого и эффективного способа решить эту проблему не существует.

Оригинал статьи на английском языке

Автор: Mirantis_OpenStack

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js