Технологии

Cloud Native LVM: как автоматизировать поиск и разметку локальных дисков в Kubernetes

Всем привет, меня зовут Александр Зимин, я руковожу разработкой подсистемы хранения данных в Deckhouse. Сегодня хочу поговорить о хранении данных на локальных дисках в Kubernetes и поделиться тем, как мы автоматизируем их подготовку для администраторов и пользователей. Обычно решения, которые работают с локальными дисками в K8s, не предоставляют нативных инструментов для их поиска и разметки. Мы закрыли этот пробел и создали Cloud Native LVM. В статье я расскажу, как этот инструмент работает и как мы обошли подводные камни в процессе его разработки. Это статья по мотивам доклада Container Storage Interface: как это работает на практике Один из первых вопросов, с которым сталкивается любая команда: где вообще хранить данные? Вариантов, по сути, два — внешнее хранилище и локальные диски. Но сначала разберёмся, как Kubernetes в принципе работает с хранилищами. CSI (Container Storage Interface) — это открытая спецификация, которая стандартизирует то, как оркестраторы контейнеров, включая Kubernetes, взаимодействуют с системами хранения. Благодаря CSI вендоры могут писать свои драйверы (plugins) и подключать любые блочные или файловые СХД к Kubernetes, не влезая в его исходный код. Спецификация описывает набор gRPC-методов вроде CreateVolume , ControllerPublishVolume , NodePublishVolume и других для операций на уровне control plane и узлов. Логически CSI-драйвер делится на две части (хотя почти всегда это один и тот же бинарь): 1. Первая часть живёт на стороне контроллера и отвечает за всё, что происходит в бэкенде хранилища — здесь реализованы методы с префиксом Controller. 2. Вторая часть работает на узлах кластера и подключается, когда на ноде запускается под с томом, использующим этот CSI-драйвер, — она реализует методы с префиксом Node. kubelet вызывает эту часть драйвера, чтобы примонтировать том в контейнер и при необходимости создать на нём файловую систему. Полезные ссылки: Portworx, Medium, GitHub, Kubernetes. В Kubernetes за подсистему хранения и, по сути, за всё вокруг CSI отвечает Storage Special Interest Group — SIG Storage. Именно SIG Storage поддерживает стандартную «обвязку» вокруг CSI — набор сайдкар‑контейнеров, которые берут на себя типовую логику. Они слушают Kubernetes API и в нужный момент вызывают соответствующие gRPC-методы у вашего или вендорского CSI‑драйвера. Речь про external-provisioner (Create/DeleteVolume), external-attacher (ControllerPublish/Unpublish), external-resizer , external-snapshotter , node-driver-registrar и другие. Это классическая схема работы с внешними хранилищами. Однако ничто не мешает адаптировать её для работы с локальными дисками, которые уже есть на узлах кластера. В 2025 году, когда вокруг все говорят про облака и искусственный интеллект, статья про локальные диски может показаться неожиданной. Но на практике они по-прежнему востребованы, а в некоторых сценариях даже предпочтительны. Посмотрим, когда локальные диски действительно нужны. Локальные диски: когда это всё ещё нужно Первый очевидный сценарий — когда внешнего хранилища просто нет. Второй — когда нужно компактное, независимое и доступное решение, особенно если быстрый доступ к инфраструктуре невозможен. Такие кейсы встречаются, например, в edge-инсталляциях, в филиалах или на удалённых площадках, куда инженеры не смогут быстро добраться. В таких кластерах важны три вещи: минимум ручной настройки; максимальная автоматизация; отказ одного узла не должен влиять на работу приложений. Локальные диски также отлично подходят для dev-сред. Там важно быстро развёртывать динамические стенды без высокой доступности и с разумной стоимостью. Если стенд упал и данные потерялись — это не проблема, его можно просто поднять заново. Ещё один кейс — высокая производительность. Локальные NVMe-диски работают в разы быстрее внешних хранилищ, особенно если говорить о доступных по цене решениях. Здесь важно минимизировать оверхед на подключение к хранилищу, а репликацию обычно выносят на уровень приложения. Репликация в таких сценариях не всегда обязательна, но если вам нужна высокая доступность, лучше её предусмотреть. Потеря одной реплики в этом случае не приведёт к потере данных. В целом, у нас два варианта: внешние хранилища и локальные диски. Про внешние хранилища сегодня рассказывать не буду. В этой статье я сосредоточусь на локальных дисках и на том, как мы подключаем их в Kubernetes, а именно в Deckhouse Kubernetes Platform. На первый взгляд всё выглядит просто: подключаем локальный диск к узлу, создаём на нём файловую систему и для каждого пода заводим отдельную директорию, которую потом монтируем в контейнер. По такому принципу, например, работает local-path-provisioner . Но у этого подхода есть ряд проблем. Главная проблема — отсутствие изоляции ресурсов. Если два пользователя заказали два тома, по факту это просто две папки, и один может занять всё место, повлияв на другого. Кроме того, файловая система — это не блочное хранилище, и если вам нужно именно блочное подключение, такой вариант не подойдёт. Когда папки уже не спасают: подключаем LVM Для работы с локальными дисками лучше использовать Logical Volume Manager, или LVM, — надёжный менеджер логических томов в Linux, который отлично справляется с этой задачей уже много лет. LVM позволяет объединять несколько физических дисков в одну группу томов и создавать в ней логические тома. А также даёт: надёжную изоляцию: пользователь не выйдет за границы выделенного объёма; поддержку снимков (snapshots), недоступных в модели с каталогами; работу в блочном режиме, если приложение требует именно блочное устройство, а не файловую систему (например, виртуальная машина, которая запускается в Kubernetes). LVM поддерживает два типа томов: Толстые (thick) — место резервируется сразу. Тонкие (thin) — место выделяется физически только по факту записи, что позволяет экономить ресурсы. Такие тома также поддерживают overprovisioning, то есть логический объём может быть больше, чем фактически доступное физическое пространство. Как всё это можно использовать в Kubernetes? Есть простой путь, который позволяет не использовать динамический provisioning и CSI. Сначала создают группу томов, в ней создают логические тома, настраивают StorageClass с no-provisioner и описывают каждый логический том через ресурс PersistentVolume. В последнем указывают путь к логическому тому (например, /dev/vg-k8s/lv-1 ) и узел, на котором расположен логический том. После этого пользователь может запросить том через ресурс PersistentVolumeClaim. Kubernetes сначала выберет подходящий том из существующих и незанятых PersistentVolume, займёт выбранный PersistentVolume и смонтирует этот том в под. Проблема в том, что этот процесс требует большого количества ручных действий. Администраторы сами создают тома и — при необходимости — файловую систему, а также чистят и удаляют тома. Это неудобно, особенно при большом количестве запросов: легко ошибиться, перепутать путь или забыть удалить том. Даже если подготовить тома заранее, они могут закончиться в нужный момент, и тогда потребуется срочное вмешательство. Такой подход не годится для качественного сервиса: ручная работа, задержки, ошибки. Решение — автоматизация через собственный оператор LVM и полноценный CSI-драйвер. Оператор в Kubernetes — это стандартный компонент, который следит за объектами и сам выполняет нужные действия. Необходимую логику нам здесь позволяют реализовать CustomResourceDefinitions (CRD). Как работает наш оператор? На узле запущен его агент, который выполняет обнаружение дисков (блочных устройств). Этот же агент создаёт в Kubernetes кастомный ресурс BlockDevice , который содержит информацию о диске. Администратор в свою очередь может выполнять действия с дисками с помощью оператора. Например, создать кастомный ресурс LVMVolumeGroup и объединить несколько блочных устройств в одну Volume Group. После этого агент создаст Volume Group на узле. В связке с оператором работает наш CSI-драйвер. Он позволяет полностью автоматизировать процесс и получить Software-Defined Storage на локальных дисках. Пользовательский запрос на создание тома приходит в CSI-драйвер, и тот с помощью оператора создаёт логический том на выбранном узле. Дальше другая часть нашего CSI-драйвера, запущенная на узле, монтирует этот том в под, при необходимости предварительно создав файловую систему: Благодаря оператору и СSI-драйверу мы автоматизировали большинство ручных процессов по работе с локальными дисками. Конечно, при разработке решения мы столкнулись с рядом вызовов. Расскажу, как мы с ними справились. Планирование: учим Kubernetes понимать локальные ресурсы У StorageClass есть параметр volumeBindingMode с двумя значениями: Immediate и WaitForFirstConsumer . В случае Immediate привязка PVC к PV и (при динамическом провижининге) создание тома происходят сразу после создания PersistentVolumeClaim — это полезно, если хранилище не зависит от топологии и доступно с любых узлов. В случае WaitForFirstConsumer том создаётся только после того, как будет создан под, использующий этот PersistentVolumeClaim , и планировщик выберет для него узел. Это позволяет учитывать топологию при создании тома, потому что к моменту вызова CSI уже известен узел, на котором должен запускаться под. Для локальных дисков такой режим жизненно необходим, однако остаются нюансы с тем, как научить планировщик правильно раскладывать поды по узлам. Планировщик Kubernetes, который планирует поды на узлы, из коробки ничего не знает о свободном месте в локальных хранилищах или на LVM-группах томов. Конечно, можно использовать Storage capacity tracking в CSI, но при массовом планировании это ведёт к проблемам. По нашему опыту, при создании порядка 600 томов в течение 5–6 минут начинаются ошибки и перепланирование (поскольку информация о свободном месте в этом случае быстро устаревает). А это ведёт к увеличению времени планирования. Чтобы Kubernetes мог быстро планировать поды с локальными дисками, мы использовали scheduler-extender. Это сервис, который расширяет стандартный планировщик. При каждом запросе на размещение пода планировщик сначала делает запрос в scheduler-extender для дополнительной фильтрации, а затем запрос на скоринг узлов. В Kubernetes эти две фазы — фильтрация и приоритизация — разделены, поэтому extender вызывается отдельно для каждой: сначала он отбрасывает неподходящие узлы, а потом оценивает оставшиеся. Extender анализирует запрос, смотрит StorageClass и допустимые Volume Group, собирает информацию из кастомных ресурсов, фильтрует узлы или рассчитывает оценки для каждого оставшегося узла. Помимо этого Kubernetes проверяет доступную RAM, CPU и количество подов на узлах. Когда планировщик собрал информацию от всех источников, он выбирает подходящий узел, ставя на ресурс PersistentVolumeClaim специальную аннотацию. После выбора узла CSI-контроллер создаёт кастомный ресурс LVMLogicalVolume . На узле отрабатывает агент оператора и создаёт том, после чего в CSI-контроллере создаётся PersistentVolume и выполняется его bound с PersistentVolumeClaim. Только на этом этапе информация о занятом на узле месте доходит до extender'a и соответственно планировщика — и это создаёт задержку в несколько секунд. В это время он продолжает назначать новые поды на тот же узел, думая, что место ещё есть. В реальности его уже нет, и при создании тома CSI-драйвер возвращает ошибку. В случае Storage Capacity Tracking задержка может быть ещё больше, потому что CSI-компоненты обновляют данные о доступном месте с определённой периодичностью, а не мгновенно. Чтобы решить проблему, мы внедрили упреждающее резервирование. Наш экстендер постоянно следит за LVMVolumeGroup через watch и хранит актуальные данные о доступном месте в памяти. Как только приходит запрос на фильтрацию, экстендер резервирует у себя в памяти место на всех подходящих узлах. Когда планировщик выбирает конкретный узел, мы освобождаем резервы на остальных. После создания тома агент на узле обновляет состояние Volume Group, и экстендер синхронизирует данные. Эта схема устранила проблему параллельного планирования на один и тот же узел. Быстрое обнаружение изменений на узлах Когда подключается новый диск, важно вовремя создать ресурс BlockDevice, но задержка в несколько минут здесь не критична. Гораздо важнее быстро узнавать о сбоях на существующих в Volume Group устройствах, чтобы вовремя отправить алерты и исключить узел из планирования. Постоянно сканировать диски каждую секунду слишком дорого. Мы минимизировали нагрузку, настроив сканирование по событиям через netlink socket. Этот механизм ядра Linux позволяет подписаться на изменения блочных устройств. Как только что-то происходит, мы тут же запускаем проверку. Чтобы это работало в контейнере, нужно запускать под в сетевом пространстве узла (использовать параметр hostNetwork: true ). Управление LVM из контейнера Была и другая проблема: утилита LVM внутри контейнера иногда видела состояние Volume Group, не совпадающее с тем, что есть на хосте. Мы решили это, отказавшись от слоя контейнеризации в этой части. Вместо этого мы используем утилиту nsenter, чтобы выйти из контейнерного пространства имён и работать напрямую в пространстве имён хоста. Запускаем под с hostPID: true и дополнительными привилегиями securityContext.privileged: true , подключаемся к PID 1 и запускаем на узле LVM-бинарник, который заранее разложили на всех узлах. В итоге команды выполняются как будто изнутри хоста, а проблема с неконсистентностью исчезает. Как было и как стало Дисклеймер: здесь мы сравниваем свою реализацию с тем, как всё могло бы работать в гипотетическом сценарии без CSI. «Было» по индустрии может отличаться от описанного видения. Раньше администратор подключал диски к узлам, создавал Volume Group вручную, настраивал StorageClass с no-provisioner, сам создавал тома, монтировал и описывал их в PersistentVolume. Только после этого пользователь мог запросить PVC. Теперь при подготовке системы администратор подключает диски и создаёт ресурс LVMVolumeGroup в API Kubernetes. StorageClass настраивается с указанием нашего provisioner. Было при предварительной настройке системы | Стало при предварительной настройке системы | 👤 Подключает диски к узлам | 👤 Подключает диски к узлам | 👤 Создаёт Volume Group на каждом узле | 👤 Создаёт ресурс | | 🤖 Контроллер создаёт Volume Group на каждом узле | 👤 Создаёт StorageClass | 👤 Создаёт StorageClass | А операции при заказе томов полностью автоматизированы. Теперь это self-service: человек сам создаёт PVC и под, остальное система делает автоматически. Было при заказе томов | Стало при заказе томов | 👤 Администратор выбирает узел | 🙍♂️ Пользователь создаёт PersistentVolumeClaim и под | 👤 Администратор создаёт logical volume на выбранном узле | ⎈ 🤖 Планировщик с помощью scheduler-extender выбирает узел | 👤 Администратор создаёт и монтирует файловую систему | 🤖 sci-controller создаёт logical volume на выбранном узле | 👤 Администратор создаёт PersistentVolume | ⎈ csi-provisioner создаёт PersistentVolume | 🙍♂️ Пользователь создаёт PersistentVolumeClaim и под | 🤖 csi-node создаёт и монтирует файловую систему | ⎈ Kubelet создаёт под и монтирует том в него | ⎈ Kubelet создаёт под и монтирует том в него | Своё решение мы назвали Cloud Native LVM. В Deckhouse Kubernetes Platform этот модуль называется sds-local-volume. С помощью Cloud Native LVM пользователь может: создавать тонкие и толстые тома; автоматически расширять и удалять их; создавать и восстанавливать снимки (только для тонких томов). Мы оставили только тонкие тома для снимков, так как у толстых томов каждый активный снимок добавляет накладные расходы на запись, и при нескольких снимках производительность заметно падает. В тонких томах накладные расходы на наличие снимков почти не зависят от их количества — один и десять снапшотов дают примерно ту же производительность, основное влияние оказывает сам thin-пул, а не число снимков. Что с отказоустойчивостью Одна проблема остаётся: локальный том живёт на одном узле. Если узел падает, данные теряются. Мы решаем это через репликацию. Создаём два одинаковых логических тома на разных узлах, настраиваем репликацию. Если один узел падает, приложение переезжает на другой. Но если второй узел выйдет из строя, пока первый ещё не восстановился, можно получить неконсистентные данные. Чтобы защититься, мы добавили третью реплику и кворум: запись разрешена только при доступности минимум двух из трёх реплик. Если остаётся одна, том становится недоступен для записи, но уже записанные данные остаются согласованными. Это фактически сетевой RAID-1 с кворумом. Для этого мы используем проверенное временем решение — модуль ядра Linux DRBD. При создании тома агенты создают Logical Volume на нескольких узлах, настраивают DRBD-репликацию и предоставляют поду единое DRBD-устройство. В под монтируется именно DRBD-устройство — виртуальный блочный девайс, который объединяет все реплики в единый сетевой том. Репликация: какие бывают варианты Рассмотрим, какие подходы к репликации существуют и как мы реализовали её у себя. Один из классических вариантов — трансзональная репликация. Она может использоваться, если кластер Kubernetes развёрнут в нескольких зонах доступности — желательно в трёх. Это могут быть разные машинные залы или здания на одной площадке. В такой схеме реплики создаются в разных зонах, а между ними настраивается синхронная репликация. Главный плюс — высокая доступность: можно пережить отказ целой зоны. Минус — жёсткие требования к сети. Задержки между зонами должны быть не более 10 миллисекунд, лучше меньше. Это требует хорошего сетевого оборудования и небольших физических расстояний между зонами. Второй вариант — зональная репликация. Здесь все реплики живут в пределах одной зоны. При запуске пода тома создаются именно в той зоне, куда он запланирован. Такой сценарий позволяет пережить отказ отдельных узлов, но если вся зона выйдет из строя, то данные будут недоступны. В этом случае лучше запускать приложение в нескольких экземплярах и использовать репликацию на уровне самого приложения. Есть ещё один интересный сценарий — diskless-режим в DRBD. Обычно планировщик (через scheduler-extender) планирует под на узел с локальной копией данных, чтобы обеспечить быстрое чтение, а запись при этом реплицируется по сети. Но если на всех узлах с локальными репликами не хватает ресурсов (например, перегружены CPU или память), то DRBD позволяет запустить под на другом узле без локальной реплики. Реплика подключится по сети, приложение продолжит работать. Да, чтение в таком случае будет медленнее, но приложение запустится. Таким образом, у нас есть несколько инструментов: сетевой RAID-1 с кворумом; трансзональная репликация между зонами; зональная репликация; diskless-режим с удалёнными репликами. Все эти сценарии мы собрали в один модуль — sds-replicated-volume. Он позволяет настраивать разные схемы репликации под конкретные задачи: от простых dev-сред до продакшен-кластеров с жёсткими требованиями к отказоустойчивости. Итоги У нас получилось собрать надёжную и гибкую систему хранения для Kubernetes — Cloud Native LVM. В неё входят три модуля: sds-node-configurator, sds-local-volume и sds-replicated-volume. Что умеет sds-node-configurator: обнаруживать блочные устройства; работать с volume group и logical volume в LVM. Что умеет sds-local-volume: создавать тонкие и толстые тома; расширять и удалять тома; создавать и восстанавливать снимки. Что добавляет sds-replicated-volume: репликацию с кворумом для отказоустойчивости; поддержку нескольких зон доступности; diskless-режим для сложных сценариев планирования. Вместе эти модули позволяют запускать stateful-нагрузки для контейнеров и виртуальных машин в любой инфраструктуре: в облаке, on-premise или на edge-узлах. Всё работает в Kubernetes, полностью автоматизировано, доступно в формате self-service и подходит для сценариев с высокими требованиями к доступности. Код модулей открыт и доступен в Deckhouse Kubernetes Platform Community Edition: sds-local-volume, sds-replicated-volume, sds-node-configurator. Поддержка снимков сейчас доступна только в коммерческих редакциях, но мы серьёзно рассматриваем возможность включения этой функции в бесплатную редакцию продукта. Спасибо, что дочитали, и давайте разбирать ваши сценарии в комментариях. P. S. Читайте также в нашем блоге:

Фильтры и сортировка