Перейти к основному содержанию Перейти к навигации Перейти к нижнему колонтитулу
Ограниченное время: Программа Design Partner. План BUSINESS бесплатно на всю жизнь.

git diff для зашифрованных образов дисков: сравниваем форки без их расшифровки

rdc repo diff сравнивает зашифрованные образы на уровне блоков и выдаёт список A/M/D/R. Ни один ключ не используется. Стоимость зависит от числа изменённых блоков, а не от размера репозитория.

Кратко. rdc repo diff показывает файловые различия между двумя форкнутыми репозиториями в грамматике git status --short (A/M/D/R) и никогда не расшифровывает ни один из них.

  • Он сравнивает два LUKS-образа на уровне блоков с помощью ioctl FIEMAP, который читает только метаданные карты экстентов. Ни один ключ не загружается, ни один открытый текст не читается.
  • aes-xts сохраняет длину и шифрует каждый 512-байтный сектор независимо, поэтому изменённый сектор открытого текста соответствует изменённому сектору зашифрованного текста по тому же смещению (сдвинутому на 16 МБ смещения данных LUKS). Вычтите смещение, отобразите диапазоны устройства на имена файлов через карту экстентов ext4 и через inode-проход, и вы получаете список файлов.
  • Стоимость зависит от числа изменённых блоков, а не от размера репозитория. Форки в 1 ГБ и 100 ГБ сравниваются за одинаковое время в миллисекундах, потому что сравнение работает только с метаданными.

Итак, форк в Rediacc выполняется как cp --reflink=always LUKS-образа репозитория. Мгновенный, и ему всё равно, какой размер. Репозиторий 100 ГБ форкается так же быстро, как и репозиторий 1 ГБ. Знаю, звучит как маркетинг, но это просто то, как работают reflink: btrfs копирует карту экстентов и разделяет блоки под ней. Мы активно используем это. Форки применяются как тестовый sandbox, временная ветка, копия стейджинга, которую выбрасывают после завершения.

Чего у нас не было, так это дешёвого ответа на очевидный следующий вопрос: что именно изменил этот форк. Наивный путь: смонтировать форк, разблокировать контейнер LUKS, обойти внутренний ext4, хешировать каждый файл относительно родителя. Это масштабируется с размером репозитория и по чтению, и по расшифровке. Для этого нужны живые ключи в цепочке сравнения. И это выбрасывает единственное, что уровень хранения уже знает бесплатно: какие блоки разошлись. rdc repo diff идёт другим путём. Масштабируется с изменёнными блоками. Не загружает ни одного ключа. Получает список файлов, сравнивая два зашифрованных образа.

Что именно вы сравниваете

Давайте точно скажем, что означает «два репозитория» на диске. Весь трюк зависит от этого. Снизу вверх: SSD, хранилище хоста, пул btrfs. На нём по одному LUKS2-образу на репозиторий. Разблокируйте его, и получите dm-crypt устройство. Внутри живёт файловая система ext4, которую используют контейнеры. Один репозиторий соответствует одному файлу в пуле btrfs.

Форк является reflink-копией этого файла. Сразу после форка два образа побайтово идентичны. Они совместно используют каждый физический блок. Родитель и форк не являются двумя копиями данных. Это две карты экстентов, указывающих на одни и те же блоки. Когда вы пишете внутри форка, уровень хранения выделяет новый блок для изменённой области. Только карта экстентов форка переписывается. Блоки родителя остаются нетронутыми.

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

FIEMAP: спрашиваем ядро об изменениях без чтения данных

Ioctl FIEMAP возвращает карту экстентов файла: список кортежей (логическое смещение, физическое смещение, длина). Каждый кортеж говорит, где находится часть файла на диске. Это чистые метаданные файловой системы. Данные файла не читаются. Для зашифрованного образа не нужен никакой ключ. Зашифрованный текст представляет собой просто байты, которые ядру никогда не нужно интерпретировать.

Сравниваем две карты экстентов. Любой логический диапазон, где оба форка указывают на один и тот же физический блок, является общим. Общий означает идентичный, потому что это буквально один и тот же блок на устройстве. Диапазоны, где у форка есть собственный приватный блок, являются записями. Это изменённые блоки. Мы получили их из метаданных, которые уровень хранения хранит и так.

Отсюда следует история о стоимости. Сравнение FIEMAP читает записи экстентов, а не данные. Работа масштабируется с количеством изменённых экстентов, а не с размером репозитория. Форк 1 ГБ и форк 100 ГБ возвращают одинаково короткий список приватных экстентов. Одинаковые миллисекунды, если они изменили одинаковые файлы. Честная оговорка: время обхода экстентов масштабируется с фрагментацией образа, а не с размером. Образ с copy-on-write при интенсивных случайных записях накапливает экстенты. Полный проход filefrag занял 3,19 секунды на самом фрагментированном продакшен-образе, который я замерял. Смотрите пост о бенчмарке фрагментации. Это потолок для стороны метаданных. Это фоновое сканирование, а не чтение данных.

От изменённого блока к имени файла через два зашифрованных слоя

Список изменённых диапазонов байтов в зашифрованном образе пока не полезен. Диапазоны являются позициями в зашифрованном тексте. Имена, которые вам нужны, находятся на два слоя выше, в внутреннем ext4. Мост между ними строится через адресную арифметику, а не через расшифровку.

LUKS шифрует с помощью aes-xts. Это шифрование с сохранением длины, которое шифрует каждый 512-байтный сектор самостоятельно. Изменённый сектор открытого текста производит изменённый сектор зашифрованного текста по тому же смещению. Единственный сдвиг задаётся смещением данных LUKS. Это 16 МБ заголовка и слотов ключей перед зашифрованной нагрузкой. Вычтите это смещение из каждого изменённого диапазона образа. Теперь у вас есть соответствующий диапазон на устройстве dm-crypt. Это блочное устройство, на котором находится внутренний ext4. Никакой ключ не использовался. Это вычитание.

Теперь отобразим диапазоны устройства на файлы. ext4 также хранит карту экстентов для каждого inode. Та же структура (логическое, физическое, длина). До неё можно добраться через FIEMAP на смонтированной внутренней файловой системе. Один раз обходим inode, чтобы построить индекс блок-к-файлу. Затем ищем каждый изменённый диапазон устройства в этом индексе. Диапазон, пересекающийся с данными inode 1234, принадлежит пути этого inode. Это файл, который изменился.

Скажу прямо, что этот процесс никогда не делает. Он никогда не извлекает открытый текст из изменённого образа. Он читает структуру файловой системы по известным смещениям. Делает это как на зашифрованной стороне, так и на расшифрованной. Затем соединяет их по адресу. Блочный фильтр говорит, какие области устройства сдвинулись. Карта экстентов ext4 говорит, какой файл владеет каждой областью. Ни один шаг не проверяет содержимое изменённого блока, чтобы решить, что он изменился.

Добавления, удаления и переименования: обход inode-идентичности

Изменения напрямую следуют из сравнения блоков. Добавления, удаления и переименования требуют ещё одного наблюдения. Reflink даёт его нам бесплатно: форк сохраняет номера inode. Reflink всего образа клонирует всю внутреннюю файловую систему побайтово до того, как что-либо разойдётся. Поэтому inode, существовавший у родителя, имеет тот же номер в форке.

Это делает идентичность операцией сравнения множеств. Inode, существующий с обеих сторон, но с разными путями, является переименованием. Inode только на новой стороне является добавлением. Inode только на старой стороне является удалением. Переименование подтверждается совпадением экстентов устройства. Блоки данных переименованного файла находятся по тем же смещениям устройства в обоих форках. Два форка используют одну систему координат. Это совпадение также исключает случай повторного использования номера inode для несвязанных данных. Чистое переименование тогда отображается с неизменёнными блоками данных файла. Только запись каталога переместилась.

Вот форма по умолчанию: имя-статус, та же грамматика A/M/D/R, которую вы уже читаете из git status --short:

$ rdc repo diff --name test-1gb:fork1 -m hostinger
M  hello.txt

1 file changed: 0 added, 1 modified, 0 deleted, 0 renamed

Один изменённый файл в репозитории 1 ГБ. Получен из блочного сравнения, не читавшего данные файла. Ничего не было разблокировано.

По умолчанию делается ещё одна вещь для корректности. Блочный фильтр является надмножеством. Экстент btrfs может покрывать больше байт, чем реально изменилось. Поэтому запись в один файл может пометить соседний, разделяющий экстент. Чтобы не сообщать о файле, который не изменился, по умолчанию выполняется подтверждение каждого кандидата, помеченного блоком. Хешируется только этот файл с обеих сторон. Хешируются кандидаты, а не весь репозиторий. Поэтому стоимость подтверждения всё равно отслеживает набор изменений. --fast доверяет блочному фильтру и пропускает подтверждение. Используйте, если хотите быстрый ответ и допускаете редкие ложноположительные результаты.

Зачем это нужно AI-агенту

Причина, по которой эта команда вообще существует, кроется в рабочем процессе агента. Я раз за разом наблюдал, как агенты форкают продакшен, вносят изменения, и затем не имеют чёткого способа сообщить, что именно они затронули. AI-агент может мгновенно форкнуть продакшен. Он запускает рискованное изменение внутри изолированного форка. Затем ему нужно точно знать, чего он коснулся, прежде чем что-либо продвигать обратно. Форк является веткой. Diff является проверкой.

Агент читает не имя-статус, а --json:

$ rdc repo diff --name prod:experiment --json -m hostinger

Структурированный вывод даёт агенту точный набор изменений: какие пути он изменил, создал, удалил. С --stat доступен размер изменений на файл в байтах и блоках. Агент, видящий свой diff перед продвижением, является агентом, которому можно доверить работу рядом с продакшеном. Радиус поражения поддаётся инспекции, а не просто утверждается. Другие режимы обслуживают тот же цикл проверки. --name-only для простого списка путей. --content <path> для единого текстового diff одного файла (только текст; бинарный файл выдаёт Binary files differ). --stat, когда агенту нужно знать, что изменилось и насколько.

Зачем это нужно для DR-тестирования

Тот же примитив отвечает на DR-вопрос, который раньше было неловко задавать без риска. Форкните продакшен. Восстановите резервную копию в форк. Сравните форк с продакшеном. Diff покажет, воспроизвёл ли восстановленный набор файлов ожидаемое. Без остановки продакшена. И без расшифровки чего-либо в цепочке diff.

Это репетиция, которую можно запускать по расписанию. Восстановление приземляется в изолированный форк. Diff выдаёт дельту в грамматике git. Чистая репетиция: изменённый набор совпадает с тем, что должна была содержать резервная копия. Вы валидируете восстановление против живого продакшена. Копия ничего не стоит для создания и выброса.

Честные ограничения

Diff содержимого только для текста. --content создаёт единый diff для текстовых файлов. Для всего остального выдаёт Binary files differ, как делает git. Строково-ориентированный diff зашифрованного, а затем сжатого блоба является шумом.

Он сравнивает связанные форки, а не произвольные репозитории. Весь механизм зависит от общей системы координат. Общие экстенты доказывают равенство. Сохранённые номера inode фиксируют идентичность. Общее смещение данных связывает всё вместе. Два репозитория, никогда не форкнутых из общего предка, не разделяют ничего из этого. Дешёвого diff между ними нет. Это особенность, а не ошибка. Точно так же, как git diff между двумя несвязанными историями не имеет смысла.

Обнаружение переименования основано на inode. Оно точно для переименований, которые файловая система реально записывает как переименования. Удаление и создание идентичного содержимого под новым именем? Две операции в таблице inode. Поэтому выдаётся одно удаление и одно добавление, а не переименование. Эвристика схожести содержимого в git назвала бы это переименованием. Проход по inode не назовёт. Это правильный ответ о том, что сделала файловая система. Даже если это не ответ о том, что намеревался человек.

И проход по метаданным масштабируется с фрагментацией. На сильно фрагментированном образе перечисление экстентов занимает секунды, а не миллисекунды. Это всё равно не зависит от размера репозитория. Это всё равно не требует чтения данных. Но это не буквально мгновенно на самых фрагментированных образах.

Выводы

rdc repo diff добавляет эргономику контроля версий к зашифрованной, работающей инфраструктуре. Интерфейс намеренно git-подобный. A/M/D/R, единые diff, --stat. Ничего нового для изучения. Если вы умеете читать git status --short, вы умеете читать diff между двумя LUKS-образами. Инженерия под капотом заслуживает внимания. Она сводится к двум отказам. Никогда не расшифровывать: aes-xts позволяет блочному FIEMAP-сравнению находить каждый изменённый сектор по адресу. И никогда не платить за данные, которые не изменились: уровень хранения уже записал, какие блоки разошлись. Форк является веткой. Diff является проверкой. Проверка стоит столько, сколько стоит изменение, а не столько, сколько весит репозиторий.