Стадия жизненного цикла модуля: Общедоступная версия
У модуля есть требования для установки
Кому адресован этот документ
Это клиентский контракт для внешней системы резервного копирования, которая хранит снимки namespace’ов Deckhouse за пределами кластера. Он покрывает ровно четыре операции:
- создать снимок;
- убедиться, что снимок успешен;
- скачать снимок;
- загрузить снимок обратно в кластер.
Эти четыре операции — весь HTTP-контракт. Здесь описано ещё одно, и это не операция:
формат архива — файловое дерево, в котором снимок лежит на диске. В этом формате
снимок выгружают и загружают наши инструменты, d8 и веб-консоль, и делают это через то же API, что
описано здесь. Хранить снимки в этом же формате мы рекомендуем: тогда архив, собранный вашим
продуктом, заливает в кластер d8 или веб-консоль, а архив, выгруженный ими из кластера, ваш продукт
принимает на хранение. Это рекомендация, а не требование: четыре операции работают и без неё.
Всё описанное — это обычный HTTP к API-серверу Kubernetes плюс объекты Kubernetes: наши библиотеки не нужны, реализовать можно на любом языке.
Восстановление захваченного namespace этот документ не описывает. Чтение, отдающее поддерево в форме для восстановления, выдаётся — что оно даёт и чего не даёт, см. Восстановление, коротко; разбор конфликтов при применении здесь не описан, и рекомендуемое место для него — веб-консоль Deckhouse.
Модель
Снимок — это namespaced-объект Snapshot. Создание его в namespace захватывает этот namespace:
манифесты пользовательских объектов и данные томов, замороженные в надёжно сохранённый артефакт, который
живёт дольше объекта-запроса. Snapshot никогда не захватывает другой namespace и не принимает
параметр целевого namespace.
Снимок — это дерево узлов:
- корневой узел — сам объект
Snapshot; - каждый узел перечисляет своих прямых детей в
status.childrenSnapshotRefs— тройкамиapiVersion/kind/name. Дети живут в том же namespace, что и родитель, поэтому namespace в ссылке не указывается; - узел содержит манифесты своих объектов и не более одного тома с данными. При захвате namespace узлами
с данными тома являются объекты
VolumeSnapshot; - чтение снимка идёт по одному узлу за запрос: эндпоинт скачивания возвращает собственные манифесты адресованного узла и дерево не обходит никогда, поэтому обход — забота клиента. Единственный эндпоинт, отвечающий целым поддеревом, — чтение для восстановления, и в этот контракт он не входит, см. Восстановление, коротко.
Snapshot одноразовый и неизменяемый: спека замораживается при создании, namespace захватывается
ровно один раз, повторного захвата нет — новый снимок — это новый объект.
Всё, что адресует клиент, — namespaced: и объекты, и эндпоинты. Надёжные артефакты, хранящие захваченные байты, — cluster-scoped внутренности модуля: они не входят в этот контракт, роли из раздела Права их не открывают, и клиент их никогда не читает.
Группы и версии API
| Что адресуется | Группа API | Версия |
|---|---|---|
Объекты Snapshot |
state-snapshotter.deckhouse.io |
v1alpha1 |
Эндпоинты узлов Snapshot |
subresources.state-snapshotter.deckhouse.io |
v1alpha1 |
Объекты VolumeSnapshot |
snapshot.storage.k8s.io |
v1 |
Эндпоинты узлов VolumeSnapshot |
subresources.storage-foundation.deckhouse.io |
v1 |
Объекты DataExport / DataImport (данные томов) |
storage-foundation.deckhouse.io |
v1alpha1 |
Самая частая ошибка клиента — версия эндпоинтов узла. Узлы Snapshot обслуживает
subresources.state-snapshotter.deckhouse.io/v1alpha1, узлы VolumeSnapshot —
subresources.storage-foundation.deckhouse.io/v1. Подставить одну группу или версию вместо другой
нельзя: такого пути API-сервер не обслуживает, и запрос с неверной группой или версией завершится
ошибкой — какой именно, см. строки 403 и 404 в разделе Ошибки и повторы.
Имена эндпоинтов в обеих группах одинаковы (manifests-download,
manifests-and-children-refs-upload, manifests-with-data-restoration), тела и статусы тоже, и обе
группы доступны по одному адресу API-сервера: между узлом Snapshot и узлом VolumeSnapshot меняются
только группа, версия и имя ресурса.
Права
Заведите продукту собственный ServiceAccount в каждом namespace, где он работает, и привяжите к
нему одну роль — d8:use:capability:module:state-snapshotter:backup_agent. Это ClusterRole,
которая собирает шесть capability-ролей из таблицы ниже — пять ролей модуля снимков и одну роль
модуля экспорта и импорта данных томов, — так что весь набор приезжает одной привязкой.
RoleBinding делает её действующей только в одном namespace; чтобы охватить ещё
один, заведите в нём аккаунт и привязку — сама роль при этом не меняется.
apiVersion: v1
kind: ServiceAccount
metadata:
name: backup-agent
namespace: my-app
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: backup-agent
namespace: my-app
subjects:
- kind: ServiceAccount
name: backup-agent
namespace: my-app
roleRef:
kind: ClusterRole
name: d8:use:capability:module:state-snapshotter:backup_agent
apiGroup: rbac.authorization.k8s.ioПрава можно выдать и гранулярно — привязав нужные роли по одной. Этот путь нужен, когда продукту положен не весь набор; вот из чего он собирается.
| Роль | Зачем она клиенту |
|---|---|
d8:use:capability:module:state-snapshotter:view_snapshots |
Читать объекты Snapshot и VolumeSnapshot — узлы дерева. Обход дерева — это чтение статусов узлов, поэтому именно эта роль делает обход возможным. |
d8:use:capability:module:state-snapshotter:manage_snapshots |
Создать Snapshot и удалить его. Обновления нет: спека неизменяема, а отмена снимка — это его удаление, которое уносит всё дерево. |
d8:use:capability:module:state-snapshotter:download_snapshots |
Читать манифесты узла — в обеих группах эндпоинтов узлов. |
d8:use:capability:module:state-snapshotter:upload_snapshots |
Записывать манифесты узла обратно — в обеих группах эндпоинтов узлов — и создавать объекты VolumeSnapshot импортируемого дерева: их создаёт клиент, а не модуль. |
d8:use:capability:module:state-snapshotter:restore_snapshots |
Читать целое поддерево сразу, адресованное в namespace запрошенного снимка, — в обеих группах эндпоинтов узлов. Это чтение для восстановления, а не применение: см. Восстановление, коротко. |
d8:use:capability:module:storage-foundation:transfer_volume_data |
Создавать объекты DataExport и DataImport и экспортировать и импортировать данные томов через их HTTP-эндпоинты. |
Привязывайте роль только в тех namespace’ах, которые продукту действительно отданы, и только те роли, которыми он пользуется. Набор даёт право удалять снимки и создавать объекты в живом namespace, поэтому каждая лишняя привязка — это ещё один способ потерять данные.
Токен для сервис-аккаунта получается обычным способом:
d8 k -n my-app create token backup-agent --duration=24hКаждый запрос ниже передаёт его в заголовке Authorization: Bearer <token> и идёт к API-серверу
кластера. Проверяйте сертификат сервера по CA кластера: в ответах едут секреты namespace в том виде, в
каком они были захвачены.
1. Создать снимок
Создайте Snapshot в namespace, который нужно захватить:
apiVersion: state-snapshotter.deckhouse.io/v1alpha1
kind: Snapshot
metadata:
name: my-namespace-snapshot
namespace: my-app
spec:
mode: CaptureВ спеке ровно одно поле, которое задаёт клиент, — spec.mode:
| Значение | Смысл |
|---|---|
Capture (по умолчанию) |
Захватить живой namespace, в котором лежит объект. |
Import |
Ничего не захватывать: снимок материализуется из данных, которые загрузит клиент. Это режим операции 4. |
spec: {} эквивалентно spec.mode: Capture.
Пишите имя поля точно и читайте его обратно. API-сервер отбрасывает неизвестные ему поля до валидации, поэтому объект с опечаткой в имени принимается, поле выбрасывается, и опечатка в маркере импорта даёт живой захват namespace с ответом «создано». После создания прочитайте
spec.modeиз объекта и сравните с отправленным — иначе разницу не увидеть.
Чтобы отдельные объекты не попали в захват, их помечают лейблом до создания снимка: лейбл и его семантика — в разделе «Исключение объектов из снимка» руководства пользователя. Захват одноразовый, поэтому пометить объект после создания снимка бесполезно.
2. Убедиться, что снимок успешен
Опрашивайте объект Snapshot. Признак успеха — его условие Ready:
NAME READY REASON CONTENT AGE
my-namespace-snapshot True Completed nss-content-1f4b0c9d2a3e5f76 30s
status.conditions[type=Ready]соstatus: "True"иreason: Completed— захват завершён и надёжно сохранён. Это единственный признак успеха.status.boundSnapshotContentName— имяSnapshotContent, cluster-scoped объекта, который сводит результат захвата узла воедино: ссылку на сохранённые манифесты и, если у узла есть том, ссылку на артефакт с его данными. Поле есть у узлов обоих видов и всегда называетSnapshotContent. Обращаться к самому объекту вам не нужно, но пустое поле означает, что у узла ещё нечего скачивать: покаSnapshotContentне привязан,manifests-downloadотвечает отказом — см. Ошибки и повторы.status.childrenSnapshotRefsперечисляет прямых детей корня, и именно его обходит операция 3. Список законно бывает пустым.
Захват работает fail-closed: частичного снимка не бывает. Если какую-то часть namespace прочитать
или сохранить не удалось, объект никогда не дойдёт до Ready=True; «ещё не готов» не
значит «готов достаточно».
Пока Ready равен False, reason условия говорит, в каком состоянии находится захват:
| Reason | Класс | Что означает |
|---|---|---|
ArtifactMissing |
Терминальный | Надёжный артефакт с данными захваченного тома отсутствует или удаляется; эти данные для снимка уже не восстановить. |
ChildSnapshotLost |
Терминальный | Узел дерева утрачен безвозвратно. |
ChildrenFailed |
Терминальный | Узел ниже завершился терминальной неудачей; его собственный reason говорит, почему. |
CreateChildFailed |
Терминальный | Создать узел дерева не удалось. |
DomainCaptureFailed |
Терминальный | Захват поддерева, добавленного другим модулем, завершился неудачей; в сообщении — собственный reason того модуля. |
DuplicateCoveredPVCUID |
Терминальный | План захвата покрыл один и тот же том дважды, и захват был отвергнут, а не выполнен с неоднозначностью. |
GraphPlanningFailed |
Терминальный | Планирование дерева снимка не удалось. |
ListFailed |
Терминальный | Один из видов ресурсов namespace не удалось перечислить, то есть захват вышел бы неполным. |
ManifestCheckpointFailed |
Терминальный | Сохранение захваченных манифестов не удалось. |
NamespaceNotFound |
Терминальный | Захватываемого namespace не существует. |
VolumeCaptureFailed |
Терминальный | Захват данных тома не удался. |
ChildSnapshotDeleted |
Восстановимый | Объект узла дерева удалён, а его сохранённые данные целы. Захват завершён и данные не потеряны, но узел больше не доступен через namespaced-поверхность; возврат узла требует ручного вмешательства. |
- Терминальный — снимок готовым уже не станет. Прекратите опрос, сообщите об ошибке; нужен
повтор — создайте новый
Snapshot: спека неизменяема, чинить на месте нечего. - Восстановимый — захваченные данные целы, объект деградировал, не провалившись. Сам собой он не выправится, повторный опрос не поможет — нужен человек.
- Любой другой reason в контракт не входит. Два множества выше закрыты и стабильны; всё остальное —
либо прогресс, либо free-form reason контроллера другого модуля, и новые могут появляться без
предупреждения. Считайте незнакомый reason «в процессе» только пока что-то реально меняется: меняется
reason или сообщение, появляется
status.boundSnapshotContentName, появляются дети. Если не меняется ничего — сработайте по собственному таймауту и сообщите об ошибке.
3. Скачать снимок
Скачивание — это обход дерева. Начните с корневого Snapshot и для каждого узла:
- прочитайте объект узла и возьмите его
status.childrenSnapshotRefs; - прочитайте собственные манифесты узла через
manifests-download; - повторите для каждой ссылки на ребёнка — в том же namespace.
manifests-download возвращает манифесты только этого узла и возвращает их как они были захвачены, включая status и другие runtime-поля.
Эндпоинт выбирается по виду узла:
| Узел | Запрос |
|---|---|
Узел Snapshot |
GET /apis/subresources.state-snapshotter.deckhouse.io/v1alpha1/namespaces/{namespace}/snapshots/{name}/manifests-download |
Узел VolumeSnapshot |
GET /apis/subresources.storage-foundation.deckhouse.io/v1/namespaces/{namespace}/volumesnapshots/{name}/manifests-download |
curl -sS -H "Authorization: Bearer ${TOKEN}" \
"https://{apiserver}/apis/subresources.state-snapshotter.deckhouse.io/v1alpha1/namespaces/{namespace}/snapshots/{name}/manifests-download"Узел, добавленный другим модулем Deckhouse (например, виртуальная машина и её диски), адресуется через
группу эндпоинтов того модуля, которому принадлежит его вид: subresources. плюс собственная группа API
узла, взятая из apiVersion в той ссылке на ребёнка, которая привела к узлу.
Узел VolumeSnapshot — исключение, и выводить его группу этим способом неверно. VolumeSnapshot живёт в
апстримной CSI-группе snapshot.storage.k8s.io, а его эндпоинты лежат в
subresources.storage-foundation.deckhouse.io/v1, как и указано во всех таблицах здесь. Вычислить это
правилом нельзя — берите из этого документа.
Данные тома
Манифесты описывают том, но не содержат его байтов. Чтобы прочитать байты узла VolumeSnapshot, создайте для него
DataExport и скачайте данные через HTTP-эндпоинт экспортёра:
apiVersion: storage-foundation.deckhouse.io/v1alpha1
kind: DataExport
metadata:
name: export-my-volume-node
namespace: my-app
spec:
ttl: 15m
publish: false
targetRef:
group: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: my-volume-nodeDataExport.spec.targetRef называет узел VolumeSnapshot, а не исходный том. Дождитесь готовности
объекта, возьмите из его статуса адрес и читайте данные по HTTP, предъявляя тот же токен
сервис-аккаунта, которым вы ходите в API-сервер (см. Права).
Какой адрес появится, зависит от DataExport.spec.publish. С выключенным, как выше, перенос
доступен только изнутри кластера, по адресу из status.url; сервер там предъявляет собственный
сертификат, и проверять его надо по status.ca — это PEM в base64. Агенту, работающему снаружи, нужен
publish: true: он читает status.publicURL, которое при выключенном publish остаётся пустым.
Публичный адрес поднимается на том же имени и с тем же сертификатом, что и опубликованный API-сервер,
поэтому снаружи проверяйте его обычным CA кластера — тем самым, что уже лежит у вас в кубконфиге.
Отдельный CA для этого не нужен, и status.ca на этом пути не нужен тоже.
У DataImport на пути загрузки publish работает точно так же: выключенный даёт адрес, доступный
только изнутри кластера, включённый — публичный.
DataExport.spec.ttl — окно простоя: оно начинает идти с готовности объекта и отодвигается, пока байты
действительно перемещаются. Открытое, но молчащее соединение окно не держит. У DataImport.spec.ttl на
пути загрузки механика та же, только считаются байты встречного направления — те, что заливаете вы.
Подробности этого пути — в документации модуля storage-foundation: эндпоинты,
файловый и блочный режимы, точные поля статуса. Сайт документации Deckhouse, страница примеров конфигурации,
раздел «Экспорт и импорт содержимого томов по HTTP»: «HTTP API: экспорт данных» — для
операции 3, «HTTP API: импорт данных» — для
операции 4.
Экспорт и импорт данных томов обеспечивает модуль storage-foundation. В кластере, где он не
включён, этого пути нет вовсе: видов DataExport и DataImport не существует, группы эндпоинтов,
обслуживающей узлы VolumeSnapshot, тоже нет, и у такого узла недоступны ни данные, ни манифесты.
Снимки namespace’ов при этом работают — но только те, в которых данных нет, а есть одни манифесты:
такой снимок скачивается и загружается обратно целиком. Упирается ограничение ровно в тома, и отказ
придёт на первом обращении к узлу VolumeSnapshot.
Размер тома
Размер тома — одно число, и берётся оно из статуса узла. Это ёмкость тома, который должен
возникнуть на приёмнике, в формате Kubernetes quantity; сравнивается она по значению, а не по
написанию — 10Gi и 10240Mi для всех проверок одно и то же. Лежит она в status.data.size узла,
несущего том: у листа VolumeSnapshot и у узла доменного снимка со своим томом. У узла без данных —
у корневого снимка namespace’а, у агрегатора — поля status.data нет вовсе.
Скачанный манифест PersistentVolumeClaim для этого не годится:
PersistentVolumeClaim.spec.resources.requests.storage — запрошенное, а не выделенное, и обычно оно
меньше.
Дальше это число едет неизменным и нигде не пересчитывается:
status.data.size узла → volumes[].size в snapshot.yaml → DataImport.spec.storageParams.sizeЕсли статуса узла у вас нет — например, вы собираете архив у себя, — берите число из нашего
архива, из volumes[].size, как есть. У блочного тома есть и второй источник: ту же величину сообщает
HEAD блочного эндпоинта в Content-Length. У файлового второго источника нет — экспортёр сообщает
размеры отдельных файлов, но ёмкость тома не публикует, а складывать файлы нельзя: том больше их
суммы.
На VolumeSnapshot поле status.data — расширение Deckhouse: в апстримном CSI его нет, и искать
величину в апстримной документации бесполезно. Оно зеркалит SnapshotContent, который вам по правам
недоступен, — чтобы весь описатель захваченного тома читался обычным GET в своём namespace.
4. Загрузить снимок в кластер
Загрузка зеркальна скачиванию: там вы обходили готовое дерево сверху вниз, здесь строите такое же дерево заново, объект за объектом, и заливаете манифесты каждого узла обратно. Ничего не выводится из содержимого — узел существует в кластере потому, что вы создали его объект.
-
Создайте корневой
Snapshotв целевом namespace сspec.mode: Import. Для него ничего не захватывается: он ждёт того, что вы загрузите.apiVersion: state-snapshotter.deckhouse.io/v1alpha1 kind: Snapshot metadata: name: my-namespace-snapshot namespace: my-app-restored spec: mode: Import -
Создайте объект для каждого некорневого узла — в том же namespace и тоже в режиме импорта. Узел с данными тома — это объект
VolumeSnapshot:apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: my-volume-node namespace: my-app-restored ownerReferences: - apiVersion: state-snapshotter.deckhouse.io/v1alpha1 kind: Snapshot name: my-namespace-snapshot uid: <uid корневого объекта Snapshot> spec: mode: ImportЗапись
ownerReferences, указывающая на родительский узел, обязательна, и нужна она для сборки мусора. Дерево одноразовое: удаление корневогоSnapshotуносит всё, что ниже, и освобождает залитые байты, без подтверждения и без отмены. Делает это штатный сборщик мусора Kubernetes, аownerReferences— единственное, по чему он видит связь; узел без этой записи переживёт удаление корня и останется в namespace сам по себе.Поле
VolumeSnapshot.spec.sourceу такого узла оставьте пустым — как в примере выше. У обычного CSI-объектаVolumeSnapshotоно обязательно и называет, откуда снимать: живойPersistentVolumeClaimлибо заранее подготовленное содержимое. У узла в режиме импорта снимать неоткуда: данные приезжаютDataImport‘ом (шаг 4), поэтому источник и не указывается. -
Залейте манифесты каждого узла — по одному запросу на узел:
Узел Запрос Узел SnapshotPOST /apis/subresources.state-snapshotter.deckhouse.io/v1alpha1/namespaces/{namespace}/snapshots/{name}/manifests-and-children-refs-uploadУзел VolumeSnapshotPOST /apis/subresources.storage-foundation.deckhouse.io/v1/namespaces/{namespace}/volumesnapshots/{name}/manifests-and-children-refs-uploadВ теле — собственные манифесты узла и ссылки на его прямых детей; сами дети заливаются своими запросами. Точная форма — в разделе Тела запросов и ответов.
Пока модуль не привязал узел, в который вы заливаете, запрос отвечает
409с reasonImportContentNotBound. Это нормальный ход, а не ошибка: привязка происходит асинхронно, поэтому повторяйте запрос с задержкой. Повторная загрузка безопасна (см. тот же раздел). -
Перенесите данные тома. Для каждого узла
VolumeSnapshot: создайте егоDataImport, дождитесь его готовности и появления адреса, загрузите данные через этот HTTP-эндпоинт, сообщите импортёру, что загрузка окончена — отдельным запросом, он описан ниже в этом шаге, — и дождитесь, покаDataImportсообщит о завершении. Завершение — этоstatus.phase: Completed. Не ждите при этомReady=True: у завершённого импортаReadyравенFalseс reasonCompleted, потому что эндпоинт уже закрыт.Создавайте каждый
DataImportнепосредственно перед записью в него: окноttlначинает идти с готовности импорта, а не с первого записанного байта, поэтому созданный заранее тратит окно, пока вы заняты предыдущим томом. Импорты разных узлов при этом можно вести параллельно. Запрещено другое: дваDataImportна один узел — тогда узел не связывается вовсе и данных не получает — и два одновременных пишущих в один эндпоинт: второй получает409без заголовков, и это не рассинхрон смещения, повтор с другого смещения не поможет.Окончание заливки — это явный
POSTна эндпоинтfinishedимпортёра. Из последнего записанного байта его никто не выводит: клиент, который залил данные и ждёт, что объект сам заметит окончание, будет ждать вечно. Сам запрос, оба режима переноса и используемые ими адреса описаны в разделе «HTTP API: импорт данных» документации модуляstorage-foundation.apiVersion: storage-foundation.deckhouse.io/v1alpha1 kind: DataImport metadata: name: my-volume-node-import-1 namespace: my-app-restored spec: ttl: 15m publish: false mode: PopulateData snapshotRef: apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot name: my-volume-node storageParams: storageClassName: my-storage-class size: 1Gi volumeMode: FilesystemDataImport.spec.snapshotRefназывает узелVolumeSnapshot, который этот импорт заполняет, и связь выводится только из этого поля:DataImport.metadata.nameв сопоставлении не участвует и может быть любым. Но имяDataImportзанимает имяPersistentVolumeClaim— транзитный том называется именем импорта и после захвата удаляется, поэтому имя, под которым в этом namespace уже есть или ещё появится чужойPersistentVolumeClaim, брать нельзя: импорт перехватит его и снесёт вместе с данными. Давайте импорту имя с уникальным для этой загрузки суффиксом и перед созданием проверяйте, чтоPersistentVolumeClaimс таким именем в namespace нет.DataImport.spec.storageParamsописывает транзитный том, в который байты складываются, прежде чем из них будет получено долговременное содержимое:Поле Откуда брать storageClassNameОбязательное. Класс транзитного тома в целевом кластере: выбирайте его по хранилищу, в котором будет лежать снимок тома для этого кластера и из которого том потом будут восстанавливать. Класс захваченного PersistentVolumeClaimиз его манифеста подходит, когда то же хранилище есть и в целевом кластере. Класс обязан существовать и поддерживать снимки. С исходным именем сверки нет: в узел запишется тот класс, который вы указали.sizeОбязательное. Ёмкость захваченного тома — см. Размер тома; занижать её нельзя, и нехватка места выглядит по-разному в разных режимах. У файлового тома место кончится посреди заливки: придёт 500, неотличимый от прочих сбоев сервера, а недописанный файл останется в хранилище. У блочного запись за конец устройства отбивается416, а превышение заявленного объёма —422.volumeModeНеобязательное: если не указать, то будет запущен импорт в режиме Filesystem. Указывайте тот режим, который был в захваченномPersistentVolumeClaim.DataImport.spec.ttl— это окно простоя, а не срок на весь перенос. Оно отсчитывается от последней активности этого импорта, и идущая заливка — это активность, поэтому перенос, который длится дольше указанной величины, не обрывается; расходует окно пауза. Когда окно истекает, импорт помечается просроченным, а его обслуживающая часть сносится: узел снимка, который он должен был заполнить, свои данные уже не получит, и путь дальше — создать новыйDataImportдля этого узла, который нужно заливать с начала (от частичной заливки просроченного импорта не сохраняется ничего). Подбирайте величину под самую долгую ожидаемую паузу и создавайте каждый импорт незадолго до записи в него. -
Дождитесь, пока корневой
Snapshotпридёт кReady=Trueс reasonCompleted, — ровно как в операции 2. Корень становится готов, только когда у каждого узла есть и манифесты, и данные.
Восстановление, коротко
manifests-with-data-restoration (GET) возвращает целое поддерево, подготовленное к применению: без
status и runtime-метаданных, с объектами, адресованными в namespace запрошенного Snapshot. У дерева,
загруженного обратно в кластер, это namespace, куда его загрузили, а не тот, из которого его объекты
были захвачены. Другой namespace вызывающий выбрать не может: такая попытка отклоняется с 400.
Эндпоинт даёт роль restore_snapshots из раздела Права. В четыре операции этого документа
он не входит, и остальной контракт его запроса здесь не задан.
Восстановление в другой namespace означает, что снимок надо сначала туда перенести. Маршрут — выгрузить снимок из того namespace, где он живёт
(операция 3), и загрузить его в целевой
(операция 4); только после этого в том namespace появляется Snapshot,
у которого можно запросить manifests-with-data-restoration. Дешёвого пути в обход нет: параметра
«отдай то же дерево, но адресованное в другой namespace» у этого эндпоинта не существует, а перенос —
это полноценная повторная загрузка, вместе с данными каждого тома.
Это чтение, а не применение. Обратно приходит список объектов в том виде, в каком их применяли бы. Применение — отдельное действие и выполняется правами того, кто применяет: ни одна роль набора не даёт создавать произвольные объекты в namespace, поэтому сервис-аккаунт вашего продукта — тот, которому выданы роли из раздела Права, — поддерево получит, но применить его не сможет.
Ответ отдаёт поддерево целиком, включая узлы других модулей Deckhouse. Ваши права спрашиваются один раз, на входе, за весь запрос; содержимое ответа против них уже не сверяется, и собирает его модуль своими правами, а не вашими. Поэтому роль, привязанная в namespace, — это доступ на чтение ко всему, что в этом namespace попало в снимок, включая объекты, читать которые напрямую аккаунту не позволено. Отсюда и требование привязывать её только в тех namespace’ах, которые продукту действительно отданы (см. Права).
Конфликты разбирает человек. Рекомендуемое место разобрать их — веб-консоль Deckhouse.
Тела запросов и ответов
manifests-download
Ответ. Content-Type: application/json; тело — обычный JSON-массив
объектов (не List Kubernetes и не поток YAML), содержащий собственные объекты адресованного узла
дословно как захвачено, вместе со status и runtime-метаданными:
[
{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "app-config", "uid": "...", "resourceVersion": "...", "managedFields": [ ... ] },
"data": { ... }
},
...
]manifests-and-children-refs-upload
Тело запроса. Content-Type: application/json:
{
"manifests": [ { "apiVersion": "v1", "kind": "ConfigMap", "metadata": {} } ],
"childRefs": [
{ "apiVersion": "snapshot.storage.k8s.io/v1", "kind": "VolumeSnapshot", "name": "my-volume-node" }
]
}manifestsобязателен и должен быть JSON-массивом — тем самым, которыйmanifests-downloadвернул для этого узла. Отсутствующее,nullили не-массив отвергается с400.childRefsперечисляет только прямых детей узла, у каждого обязательны все три поляapiVersion,kindиname; пропущенное поле отвергается с400. Namespace в ссылке не указывается: дети снимка namespace’а лежат в том же namespace, что и сам снимок. Список может быть пустым — детей у узла может и не быть.- У узла
VolumeSnapshotдетей не бывает: непустойchildRefsотвергается с400.
Ответ. HTTP 200 и объект Status Kubernetes — одинаково для узла Snapshot и узла
VolumeSnapshot:
{
"kind": "Status",
"apiVersion": "v1",
"status": "Success",
"manifestCheckpointName": "..."
}Названное в нём имя — внутренняя запись модуля о сохранённых манифестах; клиенту от неё не нужно ничего,
кроме самого "Success".
Повторы безопасны. Повторная загрузка с тем же телом безвредна, поэтому запрос, результата которого вы не увидели (оборванное соединение, таймаут), можно просто отправить снова. Небезопасно другое — отправить для узла другое тело после того, как одно уже сохранено; считайте содержимое узла неизменным с момента первой отправки.
Размер тела. Тело запроса на загрузку ограничено 64 MiB, большее отвергается с 413. Ограничение
относится к собственным манифестам одного узла, а не ко всему дереву.
Ошибки и повторы
Ошибку, сформированную API Kubernetes, возвращает объект Status с полями code, reason и
message, называющим задействованный объект; у транспортной ошибки такого тела может не быть.
Опирайтесь на reason, а не на текст сообщения: сообщения написаны для людей.
Таблица ниже описывает ответы агрегированных эндпоинтов узлов — тех, что перечислены в разделах
Скачать снимок и Загрузить снимок в кластер, плюс
чтение для восстановления. Обычная работа с объектами Kubernetes — создание Snapshot, опрос его
статуса, создание DataImport — идёт по общим правилам API-сервера. А данные томов едут не через
агрегированный API, а через отдельный HTTP-сервер модуля storage-foundation, адрес которого вы
берёте из статуса DataExport или DataImport. Кодов 416, 422 и 500 в этой таблице поэтому
нет: их контракт живёт в документации модуля storage-foundation (см. Данные тома).
| Код | Когда | Что делать |
|---|---|---|
400 |
Некорректное тело загрузки (manifests отсутствует или не массив, у ссылки на ребёнка нет поля, непустой список детей у узла VolumeSnapshot); неверный параметр запроса. |
Исправить тело или запрос. |
403 |
Запрос не покрыт правами: невыданная или ещё не заполненная роль, неверный HTTP-метод, любая ошибка в адресе — опечатка в имени эндпоинта, обращение через группу, которая его не обслуживает. Адрес отбивается здесь, а не как 404, потому что роли выданы на конкретные сабресурсы в конкретных группах, а проверка прав строится из URL и идёт до маршрутизации. Отдельно от прав тот же ответ даёт узел, который ссылается на SnapshotContent, а тот не ссылается на узел в ответ. |
Проверять в этом порядке: метод, адрес, привязки — по разделу Права и по таблицам эндпоинтов из раздела Скачать снимок. Если на только что установленном модуле отказывают подряд все запросы, повторить: агрегирующая роль заполняется сама. Случай с содержимым — отказ по устройству, повтор не поможет. |
404 |
Объекта с таким именем в этом namespace нет. Ошибка в самом адресе сюда не приводит: с ролями из раздела Права она даёт 403, см. строку выше. |
Проверить namespace и имя объекта. |
405 |
Неверный HTTP-метод (чтения — GET, загрузки — POST), запрошенный тем, кому на этом эндпоинте позволены оба. |
Исправить метод. С ролями из раздела Права та же ошибка приходит как 403, см. ниже. |
409 |
Узлу ещё не привязан SnapshotContent, то есть status.boundSnapshotContentName пуст: загрузке отвечают с reason ImportContentNotBound (ожидаемо в начале импорта), чтению — с reason Conflict. С reason Conflict приходят и остальные конфликты состояния: загрузка в объект не в режиме Import, узел, чьи сохранённые манифесты ещё не читаются, один и тот же объект дважды в манифестах узла. |
Всё, кроме дубликата, повторять с задержкой: состояния временные и уходят сами. Дубликат объекта — дефект содержимого, сам он не исчезнет. |
413 |
Тело загрузки больше 64 MiB. | В таком виде не повторяется. |
502 |
На эндпоинтах любого узла, кроме снимка namespace’а: и VolumeSnapshot, и доменные узлы обслуживает не state-snapshotter, а модуль соответствующего узла, и за манифестами он ходит в агрегированный API state-snapshotter. Этот код означает, что от агрегированного API state-snapshotter не пришло никакого ответа. |
Повторять с задержкой. |
503 |
Агрегированный API, к которому вы обратились, не обслуживает запросы: модуль запускается, перезапускается или его реестр снимков ещё не прогрет. У снимка namespace’а это state-snapshotter; у VolumeSnapshot и доменных узлов — либо обслуживающий их модуль, либо так ответил state-snapshotter за ним (ответ, в отличие от 502, есть). |
Повторять с задержкой. |
Неверный метод обычно приходит как 403, а не 405. Каждый запрос авторизуется глаголом, в
который отображается его метод (GET — в get, POST — в create), а роли из раздела
Права дают get на эндпоинты скачивания и create на эндпоинты загрузки — ровно их.
Поэтому с описанными правами 403 на адресе, который выглядит правильным, стоит начать проверять с
метода, а не с привязок.
Архив снимка
Снимок можно выгрузить из кластера архивом и тем же архивом загрузить обратно. Делают это наши
инструменты — клиент d8 и веб-консоль Deckhouse — через то же API, что описано выше, а формат
архива задан этим документом. Мы рекомендуем хранить снимки в этом формате. Тогда:
- архив, собранный вашим продуктом, может заливать в кластер
d8или веб-консоль; - архив, выгруженный из кластера нашими инструментами, ваш продукт может принять на хранение.
Это рекомендация, а не обязанность: четыре операции этого документа самодостаточны, и к операции 4 архив ничего не добавляет. Совпадение форматов даёт другое — снимок становится файлом, который можно передать носителем, файловым хранилищем или руками, не связывая ваш продукт и кластер сетью и не требуя, чтобы оба работали одновременно. Разойдясь с форматом, вы этого лишаетесь: обмениваться придётся только своими HTTP-запросами.
Дерево передают каталогом либо одним файлом .zip с тем же лейаутом внутри; имя .zip читается в
любом регистре. Других контейнеров нет.
Порядок сборки
- Разложите дерево: по каталогу на узел, прямые дети — в
snapshots/. - Положите в каждый узел его манифесты и, если у узла есть том, его единственный payload.
- Заполните в каждом
snapshot.yamlвсё, кроме дайджестов. - Посчитайте у каждого узла
checksum. - Идя от листьев к корню, посчитайте
childrenChecksum, затемmetadataChecksum, и запишитеsnapshot.yaml. Снизу вверх — потому что дайджесты ребёнка входят в дайджест родителя. - Проверьте каталог:
d8 snapshot local describe <каталог>. - Если нужен один файл — упакуйте проверенное дерево в
.zip.
Дерево
Ровно один каталог верхнего уровня; имя у него любое — это каталог корневого узла. Каждый узел, и корень, и потомок, — каталог одной и той же формы:
my-namespace-snapshot/ # корневой узел
├── snapshot.yaml # описание узла и три его дайджеста
├── manifests/ # собственные манифесты узла, по файлу на объект
│ └── configmap_app.yaml
└── snapshots/ # прямые дети; у узла без детей каталога нет
├── volumesnapshot_data-0/ # узел VolumeSnapshot с блочным томом
│ ├── snapshot.yaml
│ ├── manifests/
│ │ └── persistentvolumeclaim_data-0.yaml
│ └── data.bin
└── volumesnapshot_files-0/ # узел VolumeSnapshot с файловым томом
├── snapshot.yaml
├── manifests/
│ └── persistentvolumeclaim_files-0.yaml
└── data.tarСобирая архив:
- кладите
snapshot.yamlв каждый узел. У корня это жёстко: архив, в корне которого этого файла нет, отвергается целиком; - сохраняйте в
manifests/ровно тот массив, который вернул для этого узлаmanifests-download, — каждый объект отдельным файлом. Каталог есть у каждого узла, и пустым он не бывает: у корня в нём как минимум манифест самогоNamespace, у узлаVolumeSnapshot— манифест захваченногоPersistentVolumeClaim, ровно один; - называйте файл манифеста именем, оканчивающимся ровно на
.yaml. Ни.yml, ни любой другой суффикс манифестом не считается: такой файл не входит вchecksum, не грузится и в список пропущенного не попадает — он исчезает молча; - давайте файлу манифеста имя, уникальное в каталоге. Схемы из kind и имени объекта почти всегда
хватает — в одном namespace не бывает двух объектов с совпадающими группой, kind и именем, — но
одинаковый kind из разных API-групп возможен, и на нём такая схема даёт один файл на два объекта.
Наши инструменты пишут
<kind>_<имя>.yamlи в этом случае дописывают группу:<kind>.<группа>_<имя>.yaml; - кладите в файл ровно один непустой YAML- или JSON-документ (JSON здесь частный случай YAML).
Ни второго документа через
---, ни пустого документа, ни повторяющихся ключей в одном mapping: манифест при загрузке разбирается, а не переносится байтами; - не переименовывайте манифест после подсчёта
checksum— относительный путь входит в дайджест; - кладите каждого прямого ребёнка в свой подкаталог
snapshots/; у узла без детей каталога нет. Имя подкаталога читателю ничего не говорит — что за узел внутри, он берёт из егоsnapshot.yaml, — но среди соседей оно обязано быть уникальным. Наши инструменты держатся схемы<kind>_<имя>и, когда такое имя уже занято другим узлом, дописывают__<первые 8 знаков его checksum>; - не кладите в узел больше одного payload’а —
data.bin,data.bin.zstилиdata.tar. См. Payload тома; - не создавайте каталог
data/: он читается как узел с несколькими томами, и такой узел отвергается.
Читая чужой архив, не выводите идентичность из имён. Что за объект в манифесте, говорит только сам
манифест; что за узел в подкаталоге — только его snapshot.yaml. Подкаталог snapshots/ без этого
файла ребёнком не считается: он пропускается, а не отвергается, и попадает в список пропущенного,
который показывают после обхода. Но пропустить каталог и принять архив — разные вещи: если этот
ребёнок учтён в childrenChecksum родителя, дайджест перестанет сходиться, и архив будет отвергнут
всё равно. Вынуть узел из готового дерева, просто удалив его snapshot.yaml, нельзя. А data.bin.d/, data.tar.d/ и
identity.json — следы незавершённого скачивания нашими утилитами, на содержимое узла они не влияют.
Лишнее игнорируется молча. Неизвестное поле в snapshot.yaml и посторонний файл в каталоге узла
не отвергают архив, но и не попадают ни в какой отчёт — рассчитывать пронести ими что-то своё нельзя.
Исключение — имена из пространства payload’а (data.bin*, data.tar*, data): они разбираются
строго и могут узел отвергнуть.
Эти нарушения отвергают весь архив, ещё не обратившись к кластеру:
| Нарушение | Правило |
|---|---|
| Корень не снимок namespace’а | При заливке целиком корень обязан нести apiVersion: state-snapshotter.deckhouse.io/v1alpha1, kind: Snapshot. Корень VolumeSnapshot даёт archive root VolumeSnapshot/<имя> is not a core Snapshot. Это ограничение режима, а не формата — см. ниже. |
Узел VolumeSnapshot без байтов |
Узел с apiVersion: snapshot.storage.k8s.io/v1, kind: VolumeSnapshot обязан нести data.bin, data.bin.zst или data.tar. |
volumes не сходится с payload’ом |
С payload’ом — ровно одна запись, и описывает она этот самый payload; без payload’а — ни одной. Двух не бывает ни у кого. |
Один том можно залить и отдельно — назвав узел. Требование к корню из таблицы выше относится
только к заливке архива целиком. Любой узел VolumeSnapshot из архива заливается сам по себе, если
указать инструменту, какой именно узел брать:
d8 snapshot upload -n <namespace> -i <каталог> --node VolumeSnapshot/<имя>, а в веб-консоли — выбрать
этот узел в дереве вместо «весь архив». Проверки snapshot.yaml, payload’а и дайджестов те же, что
при заливке целиком.
Узел приезжает в кластер новым самостоятельным объектом: присоединить его к дереву, которое уже
живёт в кластере, нельзя. Предки, оставшиеся за пределами выбранного узла, сверяются по дайджестам из
их snapshot.yaml, но в кластере не ищутся и не создаются.
Payload тома
Узел несёт не больше одного payload’а, и запись volumes в его snapshot.yaml описывает ровно этот
payload:
| Узел | Payload | volumes |
|---|---|---|
| Без тома | нет | поля нет либо [] — это одно и то же |
| Блочный том | data.bin или data.bin.zst |
ровно одна запись, volumeMode: Block |
| Файловый том | data.tar |
ровно одна запись, volumeMode: Filesystem |
Узлу VolumeSnapshot доступны только две последние строки. На metadataChecksum пустой список
volumes: [] тоже не влияет: из канонической формы он выбрасывается наравне с отсутствующим полем.
Блочный том — это data.bin, байты тома ровно в том виде, в каком их отдал экспорт; для сырого
блочного экспорта это устройство целиком. У сжатого payload’а кодек остаётся в имени: data.bin.zst.
Сжатие — либо zstd, либо никакого: других кодеков в архиве быть не должно.
У блочного узла size в snapshot.yaml обязан в точности равняться числу развёрнутых байт
payload’а. Экспорт отдаёт устройство целиком, поэтому ёмкость тома и длина payload’а — одно и то
же число. Проверяется это при заливке: развёрнутая длина считается по размеру файла, если payload не
сжат, и по объявленному размеру содержимого потока для data.bin.zst, — расхождение даже в один байт
роняет архив.
Откуда брать саму величину, сказано в разделе Размер тома. У файлового узла этого
равенства нет: payload с ёмкостью тома не связан.
Правила потока zstd
Они одни и те же для data.bin.zst и для сжатых записей внутри data.tar:
- каждый frame объявляет свой
Frame_Content_Size; - ни skippable-frame’ов, ни словаря;
- разметка всех frame’ов цела;
- сумма объявленных размеров равна
sizeизsnapshot.yaml, а для записи в tar’е — её собственномуD8.snapshot.fs.rawSize.
Многофреймность нужна для возобновления переноса. Прерванные скачивание и заливка продолжаются с сырого смещения, а найти его в сжатом файле можно только обходом заголовков фреймов: объявленные размеры дают границы, и распаковать приходится один фрейм, а не весь префикс до места обрыва. Один фрейм на весь том формат не нарушает, но каждое возобновление тогда распаковывает поток с начала. Размер фрейма контрактом не задан — берите свой.
Первое правило проще всего нарушить пайпом. Читая вход из пайпа — cat data.bin | zstd и любой
подобный конвейер, — компрессор не знает длину заранее и оставляет Frame_Content_Size незаписанным.
Передайте файл именем, zstd data.bin: длина известна, и поле попадает в заголовок frame’а.
Frame’ы обходятся прежде, чем байты уйдут с машины, поэтому нарушение роняет заливку: часть этих
правил проверяется до её начала, часть — уже посреди переноса. Три дайджеста и
d8 snapshot local describe такой payload при этом проходит: байты на диске целы, они просто
неизмеримы.
Файловый том — data.tar
Сам tar-контейнер никогда не сжимается, и то, что внутри него, — тоже контракт. Файловый том экспорт
отдаёт по файлу на запрос, то есть контейнер собирает тот, кто пишет архив, — и обычный tar из
скачанных файлов отвергается, потому что в нём нет описанных ниже записей.
Каждая запись обычного файла несёт три PAX-записи:
| PAX-запись | Значение |
|---|---|
D8.snapshot.fs.codec |
Кодек, применённый к этой записи: none или zstd. Кодек обязан быть один и тот же во всём tar’е — смешанный отвергается. |
D8.snapshot.fs.originalPath |
Путь файла внутри тома: относительный и переносимый — без ведущего /, без обратного слеша, без буквы диска, без пустых сегментов и сегментов . и .., без символов ниже 0x20 и символа 0x7f, никогда не начинающийся зарезервированным сегментом .d8-meta и уже нормализованный, то есть не меняющийся от схлопывания . и ... |
D8.snapshot.fs.rawSize |
Число байт записи до сжатия, десятичным числом без ведущих нулей. Для кодека none собственный размер записи в tar’е обязан ему равняться; у сжатой записи развёрнутые байты пересчитываются и обязаны дать ровно это число. |
Имя записи тоже не произвольно: originalPath плюс расширение кодека — ничего для none и .zst
для zstd. Сам originalPath обязан быть уникальным среди обычных файлов: две записи с одним и тем
же путём отвергаются. Дополнительные PAX-поля сверх этих трёх разрешены, но никакой гарантированной
семантики у них нет — читатель их не интерпретирует.
Каталоги едут обычными tar-записями каталога и PAX-записей не несут, но на их имена действуют те же
правила, что на originalPath выше, — не считая завершающего /. Из-за этого не годится
tar -C /volume -cf data.tar .: он пишет записи как ./, ./sub/ и далее, и каждая из них
отвергается на сегменте .. Имена записей надо строить от корня тома, без ведущего ./.
Порядок записей в tar произволен, и явные записи родительских каталогов не нужны: запись a/b/file
принимается, даже когда записей a/ и a/b/ в tar’е нет, — недостающие каталоги создаются при записи
самого файла.
Что доезжает до восстановленного тома, а что нет. У обычного файла восстанавливаются числовые
mode, uid, gid и mtime. Имена владельца и группы (uname, gname) не переносятся. Атрибуты
каталогов не переносятся тоже: права, владелец и время теряются даже у явной записи каталога — каталог
создаётся как родитель для лежащего в нём файла, и ничего сверх этого из записи не берётся.
Всё, что не обычный файл и не каталог, отвергается — симлинк, жёсткая ссылка, устройство, fifo, — и
пустой каталог тоже: протокол заливки переносит файлы, и каталог, под которым нет ни одного файла,
воссоздать на приёмнике нечем. Обойти это правило может только явное действие того, кто заливает: у
клиента d8 это флаг --skip-unsupported-fs-entries, у веб-консоли — флажок в диалоге загрузки, по
умолчанию снятый. Пропущенное перечисляется после заливки, потому что пропуск — это потеря данных:
того, что стояло за такими записями, в восстановленном томе не будет.
Единственное исключение — lost+found: пустой каталог с таким именем пропускается всегда, разрешения
заливающего на это не требуется, и потерей он не считается. Такой каталог создаётся при разметке любой
файловой системы ext4 и в исправном томе пуст, так что без исключения ни один такой том не залился бы
вовсе. Сверяется последний сегмент имени, поэтому исключение действует на любой глубине, а не только в
корне тома.
Файл snapshot.yaml
Этот файл записывает, что за узел перед читателем, и заверяет его содержимое. Он проверяется до первой записи в кластер, и архив, в котором он не сошёлся, отвергается целиком.
formatVersion: 2
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
name: data-0
namespace: my-app
uid: 5f6bd1ca-2c1a-4a2f-9f3f-000000000001
checksum:
algorithm: sha256
hex: 311996ce65e34f57fb9748e53a35c1f31df523ed533436ebce32a13f02f0f984
short: 311996ce
childrenChecksum:
algorithm: sha256
hex: 926e82507890b7d5c7ad50f8cb7fd2172f81bfd2cf1d2ca86e3b644f66b02701
short: 926e8250
metadataChecksum:
algorithm: sha256
hex: 46b8d72137d6cd0d149ca2455cd2f9809a590fdabe6e0362554ccad84fa00734
short: 46b8d721
volumes:
- target:
apiVersion: v1
kind: PersistentVolumeClaim
name: data-0
namespace: my-app
uid: 5f6bd1ca-2c1a-4a2f-9f3f-000000000002
artifact:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotContent
name: snapcontent-1
volumeMode: Block
storageClassName: my-storage-class
size: 10GiПишите простые YAML-скаляры, а поле, для которого значения нет, опускайте:
formatVersion— число без кавычек:formatVersion: 2, а не"2";- необязательное поле, для которого значения нет, опускается целиком — ни
null, ни~, ни пустой строки.metadataChecksumсчитается по канонической форме файла, а в ней пустого значения не существует: явно написанный null дайджест не воспроизводит, и архив отвергается; - порядок ключей произволен — дайджестами покрыт не сам YAML-документ, а значения в нём.
| Поле | Правило |
|---|---|
formatVersion |
2, обязателен. Любое другое значение отвергается, поэтому на архив «из будущего» не рассчитывайте: незнакомую версию читатель не пропускает вперёд, а отбивает. Файл без этого поля — архив доверсионного формата, метаданные которого ничем не заверены; такой архив не собирайте. |
apiVersion, kind, name |
Собственная идентичность объекта узла. У ребёнка — в том виде, в каком она стоит в приведшей к нему ссылке родителя; у корня, к которому ссылки нет, — идентичность самого объекта Snapshot, чей это снимок. Все три обязательны. Для видов Snapshot и VolumeSnapshot они вдобавок решают, что узел обязан нести, — см. таблицу отказов в разделе Дерево. |
namespace, uid |
Namespace и UID объекта узла. Необязательны, но всё, что здесь написано, входит в childrenChecksum родителя (см. Как считаются дайджесты), поэтому писать надо согласованно. |
sourceName, sourceObjectRef |
Происхождение узла: имя захваченного исходного объекта и его тройка apiVersion, kind, name. Оба необязательны — в примере выше их поэтому нет; sourceObjectRef либо задаёт все три поля, либо отсутствует целиком. Собирая архив сами, можете их не писать; перезаливая наш архив, сохраните как есть — они входят в metadataChecksum. |
checksum, childrenChecksum, metadataChecksum |
Дайджесты собственного содержимого узла, точного набора его прямых детей и самого этого файла. Все три обязательны — childrenChecksum в том числе у узла без детей. |
volumes |
Следует за payload’ом: см. матрицу в разделе Payload тома. |
Все три дайджеста записываются одинаково: algorithm — sha256, hex — 64 символа hex нижнего
регистра, short — ровно первые 8 символов hex.
Запись volumes описывает том, который лежит в payload’е, и именно из неё путь загрузки собирает
DataImport:
| Поле | Правило |
|---|---|
target |
Идентичность захваченного claim’а: apiVersion, kind, name обязательны, namespace и uid — как у claim’а. Это тот же объект, чей манифест лежит в manifests/ этого узла. |
artifact |
Идентичность сохранённого контента, к которому узел был привязан на момент захвата: у узла VolumeSnapshot это VolumeSnapshotContent, названный в status.boundVolumeSnapshotContentName. apiVersion, kind и name обязаны быть непустыми; их значения ни с чем не сверяются, и путь загрузки их не читает. Структура у artifact общая с target, поэтому namespace и uid в нём тоже разбираются и попадают в metadataChecksum — но контент кластерный, смысла у этих полей нет, и писать их не надо. |
volumeMode |
Обязателен, и ровно Block для payload’а data.bin или data.bin.zst либо Filesystem для data.tar. Пустое значение — такое же расхождение, как любое другое. |
storageClassName |
Обязательно и непусто. |
size |
Ёмкость захваченного тома: обязателен и положителен — см. Размер тома. У блочного узла он вдобавок обязан равняться точному числу развёрнутых байт payload’а — см. Payload тома. У файлового узла его не сверяет никто, но именно это число берут при загрузке как размер создаваемого тома, поэтому занижать его нельзя. |
Как считаются дайджесты
Все три дайджеста — SHA-256. Ниже — ровно то, что инструменты пересчитывают и сверяют.
checksum — содержимое узла. Покрыт каждый файл manifests/*.yaml и единственный payload
(data.bin, data.bin.zst или data.tar). Не покрыты сам snapshot.yaml, весь подкаталог
snapshots/ со всеми детьми и всё в manifests/, чьё имя не оканчивается на .yaml.
- Для каждого покрытого файла посчитайте
sha256(<путь> + 0x00 + <байты файла>), где путь — путь файла относительно каталога самого узла (manifests/configmap_app.yaml,data.bin) в UTF-8, затем один нулевой байт, затем байты файла без изменений. - Отсортируйте пути покрытых файлов побайтово.
- Скормите финальному SHA-256 сырые 32 байта каждого файлового дайджеста в этом порядке.
- Запишите hex нижнего регистра в
checksum.hex.
childrenChecksum — точный набор прямых детей. В один SHA-256 подаются по порядку:
- ASCII-строка
d8-snapshot-children-checksum-v1и один нулевой байт; - число прямых детей десятичной строкой, с префиксом длины;
- для каждого ребёнка, в каноническом порядке (см. ниже), семь строк с префиксом длины: его
apiVersion,kind,namespace,name,uid, затем его собственныеchecksum.hexиchildrenChecksum.hex.
С префиксом длины означает: длина строки в байтах UTF-8 восьмибайтовым целым big-endian, следом
сами байты. Отсутствующие namespace или uid дают пустую строку — нулевую длину и ни одного байта.
Канонический порядок сортирует детей по кортежу apiVersion, kind, namespace, name, uid,
соединённому байтом 0x1F, сравнение побайтовое; два ребёнка с одинаковым кортежем отвергаются.
Узел без детей коммитит дайджест пустого набора, а он всегда равен
926e82507890b7d5c7ad50f8cb7fd2172f81bfd2cf1d2ca86e3b644f66b02701. Воспроизвести эту константу —
самая дешёвая проверка того, что ваша реализация дайджеста верна.
Дайджесты ребёнка входят в дайджест родителя, поэтому файлы snapshot.yaml пишутся снизу вверх:
сначала листья, потом их родители, потом корень.
metadataChecksum — сам snapshot.yaml. Он сериализуется в компактный JSON, без пробелов между
токенами и БЕЗ поля metadataChecksum — оно выбрасывается целиком, — и от этих байтов берётся
SHA-256. Порядок ключей фиксирован, и он не алфавитный:
| Уровень | Порядок ключей |
|---|---|
| Верхний уровень файла | formatVersion, apiVersion, kind, name, namespace, uid, sourceName, sourceObjectRef, checksum, childrenChecksum, volumes |
| Любой дайджест | algorithm, hex, short |
sourceObjectRef |
apiVersion, kind, name |
Запись volumes |
target, artifact, volumeMode, storageClassName, size |
target, artifact |
apiVersion, kind, name, namespace, uid |
Ещё два правила:
- поле, которое разрешено опускать, выбрасывается целиком, когда оно пусто, а не пишется как
null. Полный набор такой:formatVersion,namespace,uid,sourceName,sourceObjectRef,childrenChecksum,volumes, затемvolumeMode,storageClassNameиsizeвнутри записиvolumes, а такжеnamespaceиuidвнутриtargetиartifact. Это правило сериализации, а не список необязательных полей:formatVersionиchildrenChecksumв файле обязаны быть, и их отсутствие отвергается ещё до подсчёта дайджеста. Реально из корректного файла исчезают толькоvolumes— у узла без payload’а — и необязательные поля идентичности. Любой другой ключ пишется, даже если значение пусто; - в строках экранируются
"и\, и — вот про что забывают —<,>и&экранируются как\u003c,\u003eи\u0026. Всё остальное, включая не-ASCII, пишется как UTF-8.
Пример из раздела Файл snapshot.yaml хешируется ровно в тот
metadataChecksum, который в нём написан, — то есть годится как тестовый вектор для реализации этой
сериализации.
Проверка архива до отгрузки
d8 snapshot local describe <каталог> обходит архив: кластер ему не нужен, архив он не меняет.
Проверяет: все три дайджеста у каждого узла, поля snapshot.yaml по правилам выше и соответствие
volumes тому payload’у, который лежит на диске. Каждый байт payload’а при этом читается — дайджест
содержимого его покрывает, — поэтому команда стоит полного прохода SHA-256 по всему архиву, включая
многотерабайтный том.
Не проверяет: size против длины payload’а, содержимое data.tar, frame’ы zstd, обязательность
payload’а у узла VolumeSnapshot, вид корня архива и упаковку в .zip — команда смотрит каталог на
диске. Поэтому её успех не обещает, что архив зальётся: архив с округлённым size, с обычным tar
или с data.bin.zst, сжатым через пайп, эту команду проходит и отвергается на заливке.
Упаковка дерева в .zip
.zip — то же дерево, упакованное в один файл; лейаут внутри не меняется: путь записи равен пути файла
в дереве выше, разделитель только /. Большинство требований ниже проверяется при открытии файла, и
архив, нарушивший любое из них, отвергается целиком:
- Имя файла оканчивается на
.zip, в любом регистре. - В архиве ровно один каталог верхнего уровня — каталог корневого узла. Правило для того, кто
пишет архив: дайте ему собственную запись каталога. Читатель выводит корень и из пути
<корень>/snapshot.yaml, но рассчитывать на это не надо. - Пути записей уникальны. Правило для того, кто пишет архив: повтор пути читатель не отвергает, а молча схлопывает, оставляя последнюю запись центрального каталога, — то есть подменённый файл пройдёт незамеченным.
- Каждая запись stored: метод сжатия
0и в локальном заголовке, и в центральном каталоге, а размер в архиве равен размеру данных. Сжатая запись отвергается, какой бы маленькой она ни была. - Ни одна запись не зашифрована — ни обычным шифрованием, ни усиленным.
- Архив состоит из одной части: номера дисков
0, число записей в записи конца центрального каталога равно общему, и нет локатора ZIP64, объявляющего вторую часть. - Имена записей — корректный UTF-8, и у любого имени с байтом
0x80и выше стоит флаг UTF-8 (бит 11). Ставить его у всех записей, включая ASCII-имена, всегда правильно. - В путях записей нет
..,.и пустых сегментов, путь не начинается с/, не содержит буквы диска и обратного слеша и не содержит символов ниже0x20и символа0x7f. - Локальный заголовок записи сходится с её записью в центральном каталоге: те же байты имени, тот же
метод сжатия, то же состояние флага data descriptor’а (бит 3). Размеры и
crc32могут ехать в data descriptor’е после записи — тогда настоящие размеры обязан нести центральный каталог, потому что читаются они оттуда и больше ниоткуда. - У записи каталога завершающий
/и нулевая длина — и в архиве, и в данных. Правило для того, кто пишет архив: каталог, в котором что-то лежит, восстанавливается и по путям того, что в нём лежит, поэтому отдельная запись — единственный способ существования пустого каталога; отсутствующую запись читатель от никогда не существовавшего каталога не отличит. - Там, где 32-битное поле насыщено (
0xFFFFFFFF, а для числа записей0xFFFF), настоящее значение даёт соответствующая запись ZIP64: extra-поле0x0001у записи плюс запись ZIP64 конца центрального каталога с локатором в хвосте. - В центральном каталоге ровно столько записей, сколько он объявляет, данные записей в него не заходят, и запись конца центрального каталога завершает файл: объявленная в ней длина комментария доходит ровно до конца — непустой корректно объявленный комментарий читатель принимает. Правило для того, кто пишет архив: комментарий не писать, после этой записи ничего не дописывать — и перед первой тоже, ни самораспаковывающегося стаба, ни любого другого префикса.
crc32 в центральном каталоге ни с чем не сверяется: целостность архива держат три дайджеста SHA-256
выше, а не контейнер.
Где определён этот формат
Формат принадлежит двум клиентским инструментам, которые читают архив, — клиенту d8 и веб-консоли
Deckhouse, — а не HTTP API модуля, которому посвящён остальной документ. При изменении формата
меняется formatVersion в snapshot.yaml. Считайте авторитетом d8 snapshot local describe, прогнанный на
настоящем архиве, плюс одну заливку через консоль, и повторяйте и то и другое после обновления
инструментов.
Что этот документ не покрывает
- Разрешение конфликтов при восстановлении. Чтение, отдающее поддерево в форме для восстановления, выдаётся и описано в разделе Восстановление, коротко; что делать с объектом, который уже есть, здесь не описано, а рекомендуемое место, где это решают, — веб-консоль.
- Проводной протокол, которым двигаются байты тома. Создание переноса и наблюдение за ним — здесь;
сами запросы, несущие байты, — эндпоинты, два режима переноса, явный вызов
finishedи поля статуса, публикующие адрес, — принадлежат модулюstorage-foundationи описаны в его документации, на странице, названной в разделе Данные тома. Для дерева изSnapshotиVolumeSnapshotэто единственный документ, который нужен помимо этого. Два других понадобятся по обстоятельствам: пользовательское руководство модуля — если вы исключаете объекты из захвата, и документация модуля доменных узлов — если в дереве есть его узлы (следующий пункт). - Узлы, добавленные другими модулями. Поддерево, созданное другим модулем Deckhouse (например, виртуальные машины и их диски), обходится через группу эндпоинтов своего вида; что содержат такие узлы и как переносятся их данные, описывает модуль, который их создаёт.
- Перенос снимка между кластерами с переименованием узлов. Загрузка воссоздаёт дерево под теми именами узлов, с которыми оно было захвачено. Переименование узлов по дороге в этот контракт не входит.
- Внутренний слой модуля. Cluster-scoped артефакты с захваченными байтами, их формат и их сборка мусора — внутренности; роли из раздела Права их не адресуют, и их устройство не является обещанием совместимости.
- Каталог, в который
d8ещё качает. Пока скачивание не закончено, рядом с готовыми файлами лежат рабочие файлы клиента — стейджингиdata.bin.dиdata.tar.d, служебные.partи.tmp, по которым работает докачка. Форматом они не описаны и могут смениться в любой версии, а дерево с ними архивом ещё не является: дайджесты на нём не сойдутся. Архив — то, что осталось после успешного завершения, и описан он в разделе Архив снимка.