Стадия жизненного цикла модуля: Общедоступная версия

У модуля есть требования для установки

Кому адресован этот документ

Это клиентский контракт для внешней системы резервного копирования, которая хранит снимки namespace’ов Deckhouse за пределами кластера. Он покрывает ровно четыре операции:

  1. создать снимок;
  2. убедиться, что снимок успешен;
  3. скачать снимок;
  4. загрузить снимок обратно в кластер.

Эти четыре операции — весь 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 и для каждого узла:

  1. прочитайте объект узла и возьмите его status.childrenSnapshotRefs;
  2. прочитайте собственные манифесты узла через manifests-download;
  3. повторите для каждой ссылки на ребёнка — в том же 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-node

DataExport.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. Загрузить снимок в кластер

Загрузка зеркальна скачиванию: там вы обходили готовое дерево сверху вниз, здесь строите такое же дерево заново, объект за объектом, и заливаете манифесты каждого узла обратно. Ничего не выводится из содержимого — узел существует в кластере потому, что вы создали его объект.

  1. Создайте корневой 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
  2. Создайте объект для каждого некорневого узла — в том же 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), поэтому источник и не указывается.

  3. Залейте манифесты каждого узла — по одному запросу на узел:

    Узел Запрос
    Узел Snapshot POST /apis/subresources.state-snapshotter.deckhouse.io/v1alpha1/namespaces/{namespace}/snapshots/{name}/manifests-and-children-refs-upload
    Узел VolumeSnapshot POST /apis/subresources.storage-foundation.deckhouse.io/v1/namespaces/{namespace}/volumesnapshots/{name}/manifests-and-children-refs-upload

    В теле — собственные манифесты узла и ссылки на его прямых детей; сами дети заливаются своими запросами. Точная форма — в разделе Тела запросов и ответов.

    Пока модуль не привязал узел, в который вы заливаете, запрос отвечает 409 с reason ImportContentNotBound. Это нормальный ход, а не ошибка: привязка происходит асинхронно, поэтому повторяйте запрос с задержкой. Повторная загрузка безопасна (см. тот же раздел).

  4. Перенесите данные тома. Для каждого узла VolumeSnapshot: создайте его DataImport, дождитесь его готовности и появления адреса, загрузите данные через этот HTTP-эндпоинт, сообщите импортёру, что загрузка окончена — отдельным запросом, он описан ниже в этом шаге, — и дождитесь, пока DataImport сообщит о завершении. Завершение — это status.phase: Completed. Не ждите при этом Ready=True: у завершённого импорта Ready равен False с reason Completed, потому что эндпоинт уже закрыт.

    Создавайте каждый 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: Filesystem

    DataImport.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 для этого узла, который нужно заливать с начала (от частичной заливки просроченного импорта не сохраняется ничего). Подбирайте величину под самую долгую ожидаемую паузу и создавайте каждый импорт незадолго до записи в него.

  5. Дождитесь, пока корневой Snapshot придёт к Ready=True с reason Completed, — ровно как в операции 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 читается в любом регистре. Других контейнеров нет.

Порядок сборки

  1. Разложите дерево: по каталогу на узел, прямые дети — в snapshots/.
  2. Положите в каждый узел его манифесты и, если у узла есть том, его единственный payload.
  3. Заполните в каждом snapshot.yaml всё, кроме дайджестов.
  4. Посчитайте у каждого узла checksum.
  5. Идя от листьев к корню, посчитайте childrenChecksum, затем metadataChecksum, и запишите snapshot.yaml. Снизу вверх — потому что дайджесты ребёнка входят в дайджест родителя.
  6. Проверьте каталог: d8 snapshot local describe <каталог>.
  7. Если нужен один файл — упакуйте проверенное дерево в .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.

  1. Для каждого покрытого файла посчитайте sha256(<путь> + 0x00 + <байты файла>), где путь — путь файла относительно каталога самого узла (manifests/configmap_app.yaml, data.bin) в UTF-8, затем один нулевой байт, затем байты файла без изменений.
  2. Отсортируйте пути покрытых файлов побайтово.
  3. Скормите финальному SHA-256 сырые 32 байта каждого файлового дайджеста в этом порядке.
  4. Запишите hex нижнего регистра в checksum.hex.

childrenChecksum — точный набор прямых детей. В один SHA-256 подаются по порядку:

  1. ASCII-строка d8-snapshot-children-checksum-v1 и один нулевой байт;
  2. число прямых детей десятичной строкой, с префиксом длины;
  3. для каждого ребёнка, в каноническом порядке (см. ниже), семь строк с префиксом длины: его 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 — то же дерево, упакованное в один файл; лейаут внутри не меняется: путь записи равен пути файла в дереве выше, разделитель только /. Большинство требований ниже проверяется при открытии файла, и архив, нарушивший любое из них, отвергается целиком:

  1. Имя файла оканчивается на .zip, в любом регистре.
  2. В архиве ровно один каталог верхнего уровня — каталог корневого узла. Правило для того, кто пишет архив: дайте ему собственную запись каталога. Читатель выводит корень и из пути <корень>/snapshot.yaml, но рассчитывать на это не надо.
  3. Пути записей уникальны. Правило для того, кто пишет архив: повтор пути читатель не отвергает, а молча схлопывает, оставляя последнюю запись центрального каталога, — то есть подменённый файл пройдёт незамеченным.
  4. Каждая запись stored: метод сжатия 0 и в локальном заголовке, и в центральном каталоге, а размер в архиве равен размеру данных. Сжатая запись отвергается, какой бы маленькой она ни была.
  5. Ни одна запись не зашифрована — ни обычным шифрованием, ни усиленным.
  6. Архив состоит из одной части: номера дисков 0, число записей в записи конца центрального каталога равно общему, и нет локатора ZIP64, объявляющего вторую часть.
  7. Имена записей — корректный UTF-8, и у любого имени с байтом 0x80 и выше стоит флаг UTF-8 (бит 11). Ставить его у всех записей, включая ASCII-имена, всегда правильно.
  8. В путях записей нет .., . и пустых сегментов, путь не начинается с /, не содержит буквы диска и обратного слеша и не содержит символов ниже 0x20 и символа 0x7f.
  9. Локальный заголовок записи сходится с её записью в центральном каталоге: те же байты имени, тот же метод сжатия, то же состояние флага data descriptor’а (бит 3). Размеры и crc32 могут ехать в data descriptor’е после записи — тогда настоящие размеры обязан нести центральный каталог, потому что читаются они оттуда и больше ниоткуда.
  10. У записи каталога завершающий / и нулевая длина — и в архиве, и в данных. Правило для того, кто пишет архив: каталог, в котором что-то лежит, восстанавливается и по путям того, что в нём лежит, поэтому отдельная запись — единственный способ существования пустого каталога; отсутствующую запись читатель от никогда не существовавшего каталога не отличит.
  11. Там, где 32-битное поле насыщено (0xFFFFFFFF, а для числа записей 0xFFFF), настоящее значение даёт соответствующая запись ZIP64: extra-поле 0x0001 у записи плюс запись ZIP64 конца центрального каталога с локатором в хвосте.
  12. В центральном каталоге ровно столько записей, сколько он объявляет, данные записей в него не заходят, и запись конца центрального каталога завершает файл: объявленная в ней длина комментария доходит ровно до конца — непустой корректно объявленный комментарий читатель принимает. Правило для того, кто пишет архив: комментарий не писать, после этой записи ничего не дописывать — и перед первой тоже, ни самораспаковывающегося стаба, ни любого другого префикса.

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, по которым работает докачка. Форматом они не описаны и могут смениться в любой версии, а дерево с ними архивом ещё не является: дайджесты на нём не сойдутся. Архив — то, что осталось после успешного завершения, и описан он в разделе Архив снимка.