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

Поможет нам в этом Михаил Сидоров. Сейчас он работает в hft, а раньше занимался облачной платформой в Сбертехе. В ходе одного из треков ему нужно было сделать мультитенантный Kubernetes, чтобы была возможность шерить один кластер между большим количеством команд. Так как команд много, были очень высокие требования к изоляции.

Терминология

  • Мультитенантность — это представление изолированного доступа к общим ресурсам разным арендатором, то есть тенантам. Можно привести пример с домиком. Допустим, что одинарный тенант — это домик, в котором живёт всего один человек. Тогда мультитенантность — это домик, в котором живёт много людей (пользователей) или целых групп (команд);

  • Soft multitenancy — разделение ресурсов без жёсткой изоляции. Здесь подразумевается логическая изоляция, которая актуальна, если тенантов не очень много и между ними хорошая доверенность. Если сравнивать с домиком, то мягкая тенантность равноценна тому, что у каждого клиента в домике есть свое рабочее место, но ничто не мешает ему вмешаться в чужое пространство;

  • Hard multitenancy — разделение ресурсов с жёсткой изоляцией. Тенанты разделяются так, чтобы они никак не могли повлиять друг на друга. Можно представить это как отель, где у каждого клиента ключ только к своему номеру.

Важно понимать, что Soft and Hard мультитенантность — это не два принципиально разных подхода, а разные стороны одного спектра. Как правило, Soft multitenancy проще реализовать и она несёт в себе меньше ограничений для конечных пользователей. В то время как Hard multitenancy сложнее реализовать, но лучше и надёжнее.

Виды мультитенантности

Согласно рабочей группе Kubernetes есть 3 вида мультитенантси:

  1. Cluster-as-a-Service;

  2. Namespace-as-a-Service;

  3. Controlplane-as-a-Service.

Разберём каждый из них подробнее, но особое внимание уделим последним двум вариантам.

Cluster-as-a-Service (CaaS)

Тут есть один жирный и довольно очевидный плюс: жёсткая мультитенантси из коробки, то есть отдельные кластеры, которые можно сразу отдать клиентам. Клиент просто заказывает себе кластер и пользуется им.

Основная причина, почему это подходит не всем, заключается в том, что это дорого. Под каждый кластер нужно резервировать ресурсы. Также есть проблема “cluster sprawl”, когда возникает большое количество кластеров,  число которых растет и потому их становится очень сложно менеджерить.

Строго говоря, cluster-as-a-Service — это не совсем мультитенантность в контексте Kubernetes, поскольку в данном случае между тенантами делится нижележащая облачная инфраструктура, а не сами кластера, поэтому подробно останавливаться на этом решении не будем.

Namespace-as-a-Service (NSaaS)

Основная суть этого подхода в построении изоляции через неймспейсы. Поскольку его можно реализовать практически полностью через нативные объекты Kubernetes, на его примере будет удобно разобрать какие аспекты стоит учитывать при построении мультитенантности при этом использовать мы будем стандартные и многим знакомые объекты.

Изоляция API

Самое очевидное — это изоляция на уровне API, то есть разделение доступа к объектам. Это можно сделать с помощью RBAC, который уже встроен в Kubernetes, и namespace’ов. Для этого создаётся Namespace, к которому даётся доступ админу команды. Дальше он может управлять объектами внутри этого Namespace, раздавать на них какие-то полномочия своим сокомандникам. В последующем все изоляции накладываются на этот namespace.

Изоляция компьют-ресурсов

Сам по себе Kubernetes позволяет определённое количество виртуалок или железяк абстрагировать в один большой пул компьют-ресурсов. Поэтому помимо API важно также уметь справедливо разделять эти компьют-ресурсы между тенантами. Для этого есть два нативных объекта k8s: ResourceQuotas и LimitRanges.

ResourceQuotas

Позволяет накинуть на Namespace определённые ограничения по ресурсам, в частности по CPU, памяти, подам, хранилищам. Так мы можем разделять общий пул ресурсов.

Однако, введения только общих квот не будет достаточно, поскольку тенанты также делят ресурсы на каждой отдельно взятой ноде и могут влиять друг на друга. Так, если тенант не выставил лимиты или реквесты для пода, его QoS будет Burstable или BestEffort, что в результате может привести к выселению с ноды.

В принципе, не критично, но может быть неприятно. Это можно регулировать с помощью LimitRanges.

LimitRanges

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

Во-первых, исчезают поды без лимитов и реквестов. Во-вторых, появляется возможность заставлять тенантов использовать определенный QoS . Например, можно сделать так, чтобы у всех подов в неймспейсе был, как минимум, Burstable QoS, то есть выставляются дефолтные лимиты и/или дефолтные реквесты.

Помимо этого, можно ограничивать размер подов, с помощью максимальных и минимальных лимитов.

Сетевая изоляция

Нужно уметь ограничивать сетевое общение сервисов команд.. По дефолту мы разрешаем подам общаться только внутри namespace’а.. Нативным объектом Kuber для решения этой задачи является NetworkPolicies, который опять же накладывается на namespace. Вот пример:

Изоляция контейнеров

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

PodSecurityPolicies

Для начала приведем простой пример, почему это важно для как для хард так и для софт мултьитенантности. На одной ноде запускаются поды двух разных тенантов и они оба хотят замаунтить какую-то директорию на хосте. Поскольку они ничего не знают друг о друге, они могут захотеть замаунтить один и тот же путь. Понятно что ни к чему хорошему это не приведет.
Раньше это можно было контролировать с помощью PodSecurityPolicies.

Сейчас же есть PodSecurity admission controller, который навешивается с помощью лейблов на namespace и проверяет каждый созданный под на соответствие тому или иному стандарту.

Container Runtime Interface

Для большей безопасности можно использовать другие рантаймы для контейнеров, такие как gVisor, Kata containers, Firecracker. Подробнее о них можно узнать тут. Например Firecracker интересен тем, что вместо привычных контейнеров запускаются микровиртуалки, поэтому ядро получается изолированным.

Taints & Tolerations

Наконец, можно управлять шедулингом подов. Например, это делается с помощью выделенных нод для тенантов. Соответственно, для этого используется нод селектор, а также сочетание taint+tolerations, навешанные на поды тенанта.

Однако, если в PodSecurity есть встроенный admission контроллер, который поставляется вместе с Kuber’ом, то в данном случае ничего подобного нет. Нужно сделать так, чтобы на все поды, которые прилетают в Kuber, навешивался нужный taint и node selector. При этом важно проследить, чтобы тенант никак не мог этот нод селектор убрать.

NSaaS: имплементации

Существует несколько опенсорсных имплементаций: hierarchical namespaces и capsule. В первую очередь они решают проблему ограниченности тенанта одним namespace’ом и в целом призваны упростить сетап.

Hierarchical Namespaces

Данное решение интересно тем, что разрабатывается SIG Multitenancy, однако на момент доклада, довольно плохо подходило для построения мультитенантности. 

Работает она следующим образом.

Тенант по прежнему не может напрямую создать неймспейс, зато появляется новое объект: Subnamespace. Когда пользователь его создает, контроллер создает неймспейс и копирует туда все политики. Таким образом гарантируется, что на каждый дочерний неймспейс будут наложены все политики из родительского. 

Проблема в том, что это тупо репликация: объекты из родительского namespace реплицируются в дочерний. Таким образом, появляется очень простая уязвимость: если в родительском неймспейсе были созданы квоты, они будут просто скопированы в дочерний. В итоге тенант может получить сколько угодно ресурсов.

Capsule

Чтобы реально сделать мультитенантность, есть очень классный опенсорсный проект — Capsule. Там сначала вводится своя CRD под названием Tenant, в которой можно строить namespace для нарезки тенантом. Далее, указывается сервис аккаунт и юзеры, которые этим тенантом могут управлять. И это, по сути, некий admission controller, который ещё умеет вешать на созданные namespace’ы LimitRanges, NetwordPolicies, нод селекторы и ResourceQuotas. Он по сути учитывает все возможные хотелки. Можно даже навешивать аннотации лейбла namespace для PodSecurity/Standard.

Резюме

С одной стороны, есть только один кластер и, соответственно, один общий пул нод для всех тенантов, что позволяет гораздо более эффективно использовать ресурсы. Плюс можно переиспользовать общие контроллеры и операторы. Например, если есть ингресс контроллер, можно создать ингресс внутри namespace, и ингресс контроллер его подхватит. Сейчас существует огромное количество разных операторов и практически все подходят для такого использования, это очень удобно для небольших платформ.

Но с другой стороны есть и ряд ограничений.

Во-первых, появляются ограничения, из-за которых очень сложно достичь полной изоляции. Кроме того, урезаны права админов команд. Даже в случае с Capsule не получится пользоваться cluster-wide-ресурсами: CRD, ноды, сторадж классы. При этом доступ к ним давать нельзя. Поэтому если команде нужен будет какой-то свой оператор или контроллер, это придется делать централизованно.. И решения тут, в принципе, никакого нет. Единственное, что у Capsule есть возможность ставить перед API сервером свою прокси, которая хранит может отдавать тенанту список созданных им неймспейсов, например. Однако, проблему с CRD никак нельзя решить.

Controlplane-as-a-Service (CPaaS)

Суть подхода довольно простая: у каждого тенанта свой API, но вычислительные ресурсы — общие. Реализуется это через виртуальные кластеры . Есть две опенсорсные имплементации: одна, разработанная SIG Multitenancy, которая на момент доклада была на ранней стадии разработки и в целом выглядела заброшено, и vcluster, достаточно динамично развивающийся проект. 

Устройство виртуальных кластеров

Сравним Controlplane ванильного кубера (слева) и Controlplane у виртуальных кластеров (справа):

Справа появляется syncer, которые синхронизирует базовые объекты, такие как  поды, PVC, сервисы, secret’ы, конфигмапы на низлежащий хостовый кластер.

Идея здесь следующая: все базовые ресурсы, требующие взаимодействия с нижележащей инфраструктурой создаются на хостовом кластере и их стейт транслируется из хостового кластера в виртуальный и обратно. Например под является базовым ресурсом, поскольку после его создания на ноде запускается контейнер. А вот при создании деплоймента никакие реальные ресурсы не создаются, поскольку он управляет lifecycle’ом подов, которые уже есть в виртуальном кластере. Поэтому высокоуровневые ресурсы по типу деплойментов, стейтфулсетов и т.д. на хостовый кластер не синкаются.

Теперь посмотрим как это происходит на практике. В виртуальном кластере мы создаем деплоймент, который в свою очередь создает три пода. Эти поды синкаются на хостовый кластер. При этом их названия имеют вид <pod_name>-x-<namespace>-x-<virtual_cluster_name>. Сам деплоймент при этом не синкается.

Благодаря такой трансляции все поды виртуального кластера из всех неймспейсов в хостовом кластере живут только в одном namespace. Это позволяет вводить ограничения на виртуальный кластер используя стандартные объекты, которые можно навесить на  namespace, например использовать LimitRanges, ResourceQuotas и все что было описано в Namespace-as-a-Service.

В нашем дистрибутиве под виртуальные кластера мы нарезаем два namespace’а. Первый - под контролплейн и второй под ворклоады, куда синкер и транслирует виртуальные ресурсы.

На самом деле, для хостового можно использовать и один namespace, но это усложняет изоляцию, поскольку сontrolplane, например, нужно общаться с хостовым api-server.

Стоит заметить, в каждом виртуальном кластере дополнительно заводится core dns. Он нужен для нормальной работы service discovery, поскольку, если бы мы использовали хостовый core dns, пришлось бы использовать транслированные имена подов. 

Здесь демонстрируется, что деплойменты из виртуального кластера и правда не синкаются на хостовый:

Сами по себе виртуальные кластеры позволяют добиваться полной изоляции на уровне API. Никаких ограничений в установке CRD в свою API нет, поскольку весь контролплейн поднимается отдельно. Но нет никаких ограничений по разделению общих ресурсов, сетевой изоляции, изоляции рантайма и прочего. Это нужно дополнительно допиливать. Однако, поскольку все поды виртуального кластера живут в одном namespace, можно просто взять и накинуть все штуки про которые мы говорили раньше на namespace где создаются ворклоады виртуального кластера. Так получается жёсткая мультитенантность.

Но накидывать нужно довольно много чего, поэтому стоит сделать для этого свой оператор. Так, по крайней мере, сделал Михаил: 

Однако, если вам не нужен full lifecycle менеджмент виртуального кластера, то свой оператор писать особо смысла нет. Достаточно просто использовать какой-нибудь харм чарт.

Итоговая спека CRD, отвечающем за создание виртуальных кластеров в операторе выглядит примерно так:

Тут опущено очень много нюансов, потому что есть defaulting webhook, который автоматически подставляет дефолтные значения.

Что создаётся?

Теперь посмотрим, что создается при создании виртуального кластера через оператор. Сначала создаётся два namespace. Один — для controlplane, другой — для ворклоудов. В namespace для controlplane есть api-server, кластер etcd, kube-controller-manager. Это по сути все стандартные компоненты ванильного контроплейна. Далее идут синкер, и несколько допилок, которые нужны для бэкапов, мониторинга и прочего. В ворклоудных namespace’ах крутятся только ворклоуды.

K3s

Можно заметить, что в продемонстрированном примере, при запуске ванильного контролплейна запускается много подов. Это хороший вариант для долгоживущих продакшен кластеров. Однако такой контролплейн достаточно тяжеловесный  и его запуск занимает пару минут. Но есть классная опция — запуск виртуальных кластеров на K3s.

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

Создаётся один под, в котором два контейнера. Первый — это сам k3s, второй — это синкер, который просто подвешивается туда sidecar’ом. Вместо etcd здесь используется SQLite вшитый в k3s. В итоге такой контролплейн:

  1. Очень быстро нарезается;

  2. Потребляет очень мало ресурсов.

В целом получается полноценный виртуальный кластер, но не очень отказоустойчивый (если упадет любой из компонентов, контролплейн упадет целиком).

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

Расширяемость

Синкер по дефолту умеет транслировать только базовые объекты.. Однако с помощью плагинов можно добавить поддержку трансляции дополнительных CRD, если хочется использовать какой-то хостовый контроллер в виртуальных кластерах Плагины не сильно отличаются от обычных контроллеров kuberenetes. Их специфика в том, что они всегда обрабатывают два вида эвентов - когда в хостовом кластере меняется объект, эти изменения нужно транслировать в виртуальный кластер и наоборот, когда объект меняется в виртуальном,  их нужно транслировать на хостовый. 

Также есть возможность писать хуки. Это то же самое, что мьютетинг хуки в обычном k8s. Его специфика в том, чтобы мьютейтить базовые ресурсы, например, сервисы или поды. У Михаила был такой кейс, когда он с командой крутился в облаке с managed kubernetes, у которого был своеобразный cloud controller. Чтобы создать load balancer, приходилось прописывать 100500 аннотаций, которые можно взять только из API облака. Понятное дело, что их тенантам это всё не нужно и эти данные они ниоткуда взять не могут. Поэтому они сделали хук, который автоматически генерировал и прилеплял эти аннотации ко всем создающимся сервисам во время синка.

Резюме

Controlplane-as-a-Service — это лучшее от двух миров. Он даёт хорошую утилизацию ресурсов, как в Namespace-as-a-Service и в то же время большую изоляцию, приближенную к отдельным кластерам. Причём с немного пониженной нагрузкой на хостовый controlplane, поскольку большая часть контроллеров (кроме базовых объектов) живут в controlplane тенанта. На хостовом кластере создаются только поды и базовые абстракции. Помимо этого можно добавлять в синкер поддержку дополнительных CRD, позволяющих . Конечно, для этого придется  писать свой собственный плагин, что в NSaaS обычно решается более просто. Зато не возникают никакие проблемы с cluster-wide ресурсами и в целом у команды есть полные права на в API.

Итоги

Если вам нужно простое решение и при этом нужна высокая изоляция, то из всех трёх вариантов самый лучший — cluster-as-a-Service. Но тогда нужно быть готовым к тому, что с ростом числа кластеров управлять ими будет все сложнее и утилизация ресурсов будет ниже.

Если у вас не самое большое количество команд и централизованное управление платформой не будет вредить, то оптимальным вариантом является Namespace-as-a-Service.

Если же у вас планируется большой скейл и вам важна изоляция, то стоит подумать о Controlplane-as-a-Service.

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


  1. 1nd1go
    22.08.2023 09:41

    Спасибо за статью. А какие решения посоветуете для нарезания GPU между тенантами?


    1. mikelsid
      22.08.2023 09:41
      +1

      Есть девайс плагины, которые позволяют управлять ресурсами гпу так же как и cpu/mem:
      https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/