Что делает эту задачу сложной
Kubernetes последовательно движется к отделению ядра платформы от вендорных решений хранения данных: встроенные (in-tree) плагины уступают место драйверам Container Storage Interface (CSI). В Microsoft Azure встроенный провайдер хранилища kubernetes.io/azure-disk был объявлен устаревшим в версии v1.19 и полностью удалён в v1.26. Отказ от миграции означал, что любое событие планировщика — в том числе обычный деплой — могло заблокировать подключение хранилища к stateful-приложению и привести к его сбою.
Стандартные подходы к миграции требуют остановки сервисов. При нашем масштабе перевести сотни дисков, на которых работают ClickHouse, CockroachDB, Kafka и Prometheus, в офлайн было недопустимо. В этой статье описана техника повторной привязки PVC на месте (in-place PVC re-binding): она подменяет PersistentVolume, лежащий в основе PersistentVolumeClaim, не трогая сам диск. Каждый том требует лишь однократного перезапуска пода, и весь процесс выполняется исключительно через Kubernetes API — без сторонних инструментов и модификации плоскости управления. Для примеров мы используем управляемые диски Azure на Azure Kubernetes Service (AKS), однако метод универсален и работает как на самостоятельно управляемых, так и на облачных дистрибутивах Kubernetes.
Сама по себе CSI-миграция для большинства команд — дело прошлое. Но техника повторной привязки PVC открывает операционные возможности, которые платформенные команды обычно считают слишком рискованными, — например, смену уровня производительности SSD. Мы воспользовались ею для миграции нескольких сотен продакшн-дисков менее чем за два месяца без единого инцидента или потери хотя бы байта данных.
Что делает эту задачу сложной
Чтобы понять, почему эта миграция непроста, нужно разобраться, как Kubernetes работает с постоянным хранилищем и где применяет принцип неизменяемости.
Постоянное хранилище в Kubernetes держится на трёх ключевых компонентах.
-
PersistentVolume (PV) — ресурс уровня кластера, представляющий реальный физический носитель, например Azure Disk.
-
PersistentVolumeClaim (PVC) — запрос на хранилище от приложения, живущий в том же пространстве имён, что и поды.
-
StorageClass — определяет тип хранилища и, что принципиально важно, провайдера, ответственного за его создание (например,
disk.csi.azure.com).
Всё это связывается воедино через динамическое выделение томов. Когда разработчик создаёт PVC с указанием StorageClass, провайдер автоматически создаёт PV, соответствующий требованиям заявки. Kubernetes привязывает PVC к PV — это исключительное отношение «один к одному», закреплённое атрибутом claimRef на PV.
Проблема в том, что почти все поля, важные для миграции, неизменяемы.
-
Поле
provisionerв StorageClass неизменяемо — просто обновить его и переключить на новый CSI-драйвер не выйдет. -
Можно создать новый StorageClass, но после того как PV привязан к PVC, заменить все ссылки на старый класс невозможно.
-
Поле
spec.persistentVolumeSourceв PV, задающее реальный бэкенд хранилища, тоже неизменяемо. Попытка его изменить вернёт ошибку: «spec.persistentVolumeSource is immutable after creation». -
Поле
spec.volumeClaimTemplatesв StatefulSet неизменяемо: изменитьstorageClassNameв этом шаблоне не получится — запрос будет отклонён с ошибкой «forbidden». -
То же касается раздела
spec.volumesу пода — он неизменяем, и патч завершится ошибкой «forbidden».
Эти ограничения введены намеренно: они закрепляют принцип устойчивости хранилища в Kubernetes — хранилище является долговременным и стабильным ресурсом, тогда как поды эфемерны и взаимозаменяемы. StorageClass, PV, PVC и ряд других объектов Kubernetes неизменяемы. Единственный способ их изменить — удалить и пересоздать.
Тонкости поведения Kubernetes
Из-за ограничений неизменяемости прямая миграция невозможна. Однако менее очевидные особенности поведения Kubernetes дают строительные блоки для живой миграции.
Объекты StorageClass пассивны. Они задействуются только в момент создания тома: как только PV привязан к PVC, StorageClass больше не участвует в работе. Это значит, что удаление исходного StorageClass никак не затронет уже существующие PV и PVC. Мы можем использовать это поведение: удалить устаревший встроенный StorageClass и немедленно создать новый, основанный на CSI, с точно таким же именем. На работающие приложения это не повлияет.
Поле claimRef у PV управляет поведением привязки. Когда PV жёстко привязан к PVC, его spec.claimRef содержит kind, name, namespace, а также, что критически важно, uid и resourceVersion этого PVC. Если в ссылке присутствует uid, контроллер PV считает привязку жёсткой. Если uid отсутствует, PV рассматривается как свободный кандидат для привязки к PVC с совпадающими name и namespace. Это и есть ключевое наблюдение. Мы можем вручную создать второй PV, который указывает на тот же физический Azure Disk, но описан как CSI-том. Задав name и namespace в его claimRef, но опустив uid, мы получаем PV-«приманку» (honeypot), который ждёт, пока подходящий PVC не заявит на него права.
Финализатор pvc-protection защищает от преждевременного удаления. Kubernetes автоматически добавляет финализатор kubernetes.io/pvc-protection к любому PVC, который активно используется подом. Пока этот финализатор установлен, удаление PVC лишь выставляет deletionTimestamp, переводя его в состояние Terminating. Сам объект PVC не удаляется до тех пор, пока не будет удалён использующий его под — именно тогда финализатор снимается. Этот встроенный механизм безопасности предотвращает гонки состояний: он гарантирует, что после удаления пода контроллер StatefulSet не успеет немедленно создать новый пустой том прежде, чем наш PV-«приманка» будет захвачен.
Алгоритм повторной привязки PVC на месте
Перед началом пословной миграции дисков мы заменяем устаревший StorageClass новым, основанным на CSI, с тем же именем. Это «обманывает» плоскость управления: при автоматическом пересоздании PVC она будет использовать новый CSI-драйвер.
-
Сохраните резервную копию устаревшего StorageClass.
kubectl get sc managed-premium -o yaml > managed-premium-legacy.yamlУстаревший StorageClass выглядит примерно так:
# managed-premium-legacy.yaml apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: managed-premium provisioner: kubernetes.io/azure-disk # Устаревший встроенный провайдер parameters: storageaccounttype: Premium_LRS kind: Managed reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumer -
Удалите устаревший StorageClass.
kubectl delete sc managed-premium -
Создайте новый CSI StorageClass с тем же именем.
# managed-premium-csi.yaml apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: managed-premium # Точно такое же имя, как у старого provisioner: disk.csi.azure.com # Новый CSI-провайдер parameters: skuName: Premium_LRS reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumerПримените новый StorageClass:
kubectl apply -f managed-premium-csi.yaml
После того как фундамент заложен, для каждого диска выполняем следующие шаги.
Шаг 1. Определите целевой ресурс. Найдите конкретный под StatefulSet, который нужно перенести, получите имя его PVC, привязанного PV и URI физического Azure Disk. URI диска — это наш ключевой идентификатор физического носителя.
# Задайте переменные для вашего окружения
export POD_NAME="<your-pod-name>"
export PVC_NAME=$(kubectl get pod $POD_NAME -o jsonpath='{.spec.volumes[?(@.persistentVolumeClaim)].persistentVolumeClaim.claimName}')
export PV_NAME=$(kubectl get pvc $PVC_NAME -o jsonpath='{.spec.volumeName}')
echo "Pod: $POD_NAME"
echo "PVC: $PVC_NAME"
echo "PV: $PV_NAME"
# Получите URI Azure Disk из старого объекта PV и сохраните его
export DISK_URI=$(kubectl get pv $PV_NAME -o jsonpath='{.spec.azureDisk.diskURI}')
echo "Disk URI: $DISK_URI"
Шаг 2. Установите политику удержания для устаревшего PV в «Retain». Это обязательная мера предосторожности: она гарантирует, что физический Azure Disk не будет автоматически удалён, когда мы позже удалим объект PV в Kubernetes.
kubectl patch pv $PV_NAME -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
Шаг 3. Создайте новый CSI PV. Создайте объект PV, указывающий на тот же физический Azure Disk. В разделе claimRef укажите name и namespace устаревшего PVC, но не включайте поля uid и resourceVersion — это необходимо для повторной привязки. Это и есть наш PV-«приманка».
# pv-csi.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-migrated-csi # Новое уникальное имя объекта PV
spec:
capacity:
storage: 100Gi # ВАЖНО: укажите реальный размер вашего диска
accessModes:
- ReadWriteOnce # Должно совпадать с оригинальными режимами доступа
persistentVolumeReclaimPolicy: Retain # Или Delete, если предпочитаете удалять после миграции
storageClassName: managed-premium # Имя StorageClass, который мы заменили
claimRef:
# ВАЖНО: должно точно совпадать с оригинальным PVC
name: my-claim # Используйте переменную PVC_NAME
namespace: default # Пространство имён вашего PVC
# КРИТИЧЕСКИ ВАЖНО: не указывайте 'uid' и 'resourceVersion'. Это сделано намеренно.
csi:
driver: disk.csi.azure.com
volumeHandle: <YOUR_AZURE_DISK_URI> # Вставьте DISK_URI из шага 1
volumeAttributes:
fsType: ext4 # Или xfs — должно совпадать с оригинальным диском
Примените новый PV:
kubectl apply -f pv-csi.yaml
Шаг 4. Запустите повторную привязку. Удалите устаревший PVC и соответствующий под StatefulSet.
kubectl delete pvc $PVC_NAME
kubectl delete pod $POD_NAME
Далее всё происходит автоматически:
-
Удаление PVC переводит его в состояние «Terminating». Финализатор
pvc-protectionудерживает его в живых, пока работает под. -
Под проходит процедуру завершения работы и удаляется.
-
После удаления пода финализатор снимается с PVC, и тот окончательно удаляется.
-
Контроллер StatefulSet создаёт новый под взамен удалённого. Новый под создаёт новый PVC с тем же именем.
-
Kubernetes обнаруживает созданный на шаге 3 CSI PV-«приманку» и привязывает его к новому PVC.
-
Новый под запускается, и Azure Disk монтируется к нему.
Шаг 5. Проверьте миграцию. Понаблюдайте за успешным стартом пода и убедитесь, что приложение работает корректно.
kubectl get pods -w
Проверьте, что статус PVC — «Bound»:
kubectl get pvc $PVC_NAME
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
# my-claim Bound pv-migrated-csi 100Gi RWO managed-premium 15m
Убедитесь, что PVC привязан к новому CSI PV:
kubectl get pv pv-migrated-csi
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
# pv-migrated-csi 100Gi RWO Retain Bound default/my-claim managed-premium 5m
Убедитесь, что статус устаревшего PV — «Released», то есть он больше не привязан и его можно безопасно удалить:
kubectl get pv $PV_NAME
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
# pv-legacy 100Gi RWO Retain Released ... ... 20m
После того как приложение успешно запустилось на новом поде и все данные на месте, удалите объект устаревшего PV.
Альтернативные подходы
Существует несколько других способов решить эту задачу. У каждого свои компромиссы по части простоя, риска потери данных и операционной сложности.
Статический том от Microsoft
Официальная документация Microsoft по миграции с in-tree на CSI-драйверы в AKS предлагает похожий, но более ручной метод. Он предполагает изменение reclaimPolicy оригинального PV на «Retain», создание вручную манифестов нового PV и PVC, указывающих на тот же физический диск Azure, и последующее обновление деплоя приложения со ссылкой на новый PVC.
Этот подход сохраняет данные на диске. Однако он требует полного передеплоя приложения для переключения на новый PVC, а значит, влечёт простой и необходимость выделять окно обслуживания для каждой миграции.
Осиротение и усыновление (Orphan and Adopt)
Другой подход — осиротить поды, открепив их от управляющего StatefulSet. Для этого StatefulSet удаляется с флагом --cascade=orphan, что оставляет поды и их PVC работающими, но неуправляемыми. После этого можно создать новый StatefulSet с обновлённым CSI StorageClass, который «усыновит» существующие поды.
Риск здесь весьма значителен. Без контроллера поды не будут перезапущены или перепланированы при сбое узла или вытеснении. Для критических stateful-нагрузок такое окно незащищённости чревато безвозвратной потерей данных.
Резервное копирование и восстановление
Платформы с зрелым операционным уровнем (Day-2 operations) могут провести «холодную» миграцию с помощью инструментов вроде Velero. Это предполагает создание полного снимка приложения и его данных с последующим восстановлением, при котором перед восстановлением применяются изменения StorageClass.
Резервное копирование и восстановление — мощный инструмент для аварийного восстановления, но он требует паузы в работе приложений. Для больших дисков это оборачивается значительным простоем. В микросервисной архитектуре, где пауза одного сервиса способна вызвать каскадные сбои, такой вариант неприемлем.
Форк плоскости управления (подход Datadog)
На KubeCon EU 2024 Datadog представил подход, предполагающий форк исходного кода Kubernetes и патч API-сервера для обхода ограничений неизменяемости живых объектов. Это даёт полный контроль над определениями хранилища работающих подов.
Данная стратегия не подходит для управляемых Kubernetes-сервисов — AKS, GKE или EKS, — где доступ к изменению компонентов плоскости управления закрыт. Кроме того, форк кодовой базы Kubernetes создаёт долгосрочные издержки на поддержку и риск расхождения с апстримом. Для большинства платформенных команд это нежизнеспособно.
Кастомный оператор для замены дисков (подход «ATOM»-ов)
ATOMS реализовали кастомный оператор для уменьшения размера управляемых облаком дисков. Он использует кастомный ресурс, мутирующий webhook и volume-populator для создания новых дисков и переноса данных между старым и новым PVC. Изменение размера томов управляется декларативно, без ручных операций.
Для нашего случая это было избыточно: нам не требовалось вносить какие-либо изменения в сам Azure Disk, а значит, и копировать данные не нужно. Тем не менее кастомный оператор — это естественный слой автоматизации поверх техники повторной привязки для команд, которым необходимо регулярно выполнять операции с хранилищем.
Заключение
Мы применили эту технику для миграции нескольких сотен PV на платформе, управляющей сотнями кластеров Kubernetes в мультирегиональной топологии. Основная сложность была в координации работы крупных хранилищ данных — ClickHouse, CockroachDB, ElasticSearch, Kafka и Prometheus, — работающих на облачных управляемых дисках ради надёжности и отказоустойчивости, где ни простой, ни потеря данных были недопустимы. Миграция была завершена менее чем за два месяца небольшой платформенной командой без единого инцидента.
Несколько выводов из этого проекта:
Глубокие системные знания эффективнее грубой силы. Решение появилось благодаря пониманию неочевидных механизмов плоскости управления Kubernetes: как работает привязка через claimRef, когда срабатывают финализаторы, как ведёт себя StorageClass во время выполнения. Работа в рамках ограничений системы, а не против них дала более простой и безопасный результат, чем любой силовой подход.
Эксперименты формируют операционное мастерство. Критические крайние случаи — например, преждевременное удаление PV, вызывавшее ошибки Multi-Attach при дренировании узлов, — мы обнаружили только намеренно доводя систему до отказа на стейджинге. Уверенность в продакшне рождается из понимания того, как система ломается, а не только из знания того, как она работает.
Автоматизация — ключ к надёжности на масштабе. Автоматизация позволяет двигаться быстро и последовательно, снижая риск человеческой ошибки. Мы автоматизировали весь алгоритм, но поставили финальный перезапуск пода под ручное подтверждение, чтобы команды сами контролировали момент, когда перезапуск приложения безопасен.
Отдельная благодарность Расмусу Бах Краббе и команде хранилища ATOMS за то, что провели нас по внутренним механизмам их PvcAutoscaler. Один взгляд на всю эту машинерию — и мы решили, что должен быть путь проще. Их оператор — это серьёзный кусок инфраструктуры при больших масштабах.