TL;DR — Сегодня я занимался созданием подсистемы резервного копирования, и практически каждое важное архитектурное решение сводилось к одному и тому же вопросу: может ли этот компонент дать сбой таким образом, что система все равно покажет зеленую галочку успешного выполнения? Громко падающий бэкап — это просто досадная неприятность. Бэкап, который падает молча — это причина, по которой в нужный момент у вас не окажется резервной копии для восстановления.


Сценарий отказа, о котором никто не думает при проектировании

У резервного копирования есть уникальное свойство, отличающее его от других фич: момент, когда вы узнаете, работает ли оно, — это самый худший момент для такого выяснения. Все, что происходит до этого — лишь предположения. А предположения — это как раз то поле, где система может вежливо вам лгать.

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

Вот как это выглядело на практике.

Синхронизация — это не бэкап

Это первая и самая частая ошибка, которую совершают с максимальной уверенностью, потому что инструмент, выполняющий эту задачу неправильно, сам по себе превосходен.

Инструмент синхронизации зеркалирует источник в целевую папку. Если ключ исчезает в источнике, он исчезает и в целевой папке. Это правильное поведение для зеркала и катастрофическое для бэкапа. Ведь вы делаете бэкап на случай, если кто-то в два часа ночи случайно удалит не ту папку. Зеркало же распространит это удаление на единственную копию, которая могла бы все исправить (обычно в течение часа), а затем бодро отрапортует об успешном завершении.

Поэтому написанный мной сегодня драйвер бакета фиксирует удаления, но никогда не воспроизводит их в целевом хранилище:

 /**
 * A key that vanished at the source is marked deleted in the manifest.
 * Its object is LEFT WHERE IT IS until retention expires it.
 * Nothing in this driver deletes an object at a destination.
 */
 

Два теста фиксируют эти два следствия, причем второе важно ничуть не меньше первого:

  • объект, удаленный в источнике, все еще можно восстановить из последней резервной копии, где он присутствовал;
  • восстановление из более поздней копии не воскрешает его обратно.

Про второй тест часто забывают. Восстановление воссоздает состояние бакета на момент конкретного запуска. Администратор, восстанавливающий одну папку, вовсе не просил отменить все намеренные удаления, сделанные после этого. И если ваше восстановление делает именно это, вы подкидываете ему второй инцидент прямо во время устранения первого.

«Перемещение» — это копирование, проверка и только потом удаление. Всегда именно в таком порядке

Перемещение бэкапа между хранилищами — это самая очевидно опасная операция во всей подсистеме, потому что существует окно, когда артефакт существует ровно в одном месте, а какой-то процесс активно пытается его удалить.

Правило очередности банально. Интересно то, как сделать это правило проверяемым:

 // source_deleted_at stays null until the destination copy is Verified,
// and is written in its own statement, after verified_at.
 

Две отдельные записи в базу, намеренно. Если бы они отправлялись вместе, можно было бы бесконечно спорить, была ли некорректная строка результатом состояния гонки или багом. Когда они разделены, запрос легко может обнаружить нарушение:

 test('a transfer never deletes the source before the destination verifies', function () {
    expect(BackupTransfer::deletedSourceBeforeVerification())->toBeEmpty();
});
 

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

Верификация, которая честно признает, что она не проверяла

Это мое любимое решение, потому что честный вариант выглядит менее красиво, чем нечестный, и в этом вся суть.

Правильная верификация копии бакета означает повторное хеширование каждого объекта. Для этого управляющий слой должен прочитать каждый объект обратно через себя — и это во время верификации передачи, вся цель которой состояла в том, чтобы не гонять эти данные через управляющий слой. Одно исключает другое.

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

 'verification' => [
    'objects_checked' => 1_284,
    'bytes_expected'  => 4_118_233_712,
    'etags_rehashed'  => false,
],
 

Значение  etags_rehashed: false  остается в записи навсегда. Успешная верификация не может быть истолкована позже кем-то другим как доказательство того, что на самом деле никогда не проверялось. Тот же инстинкт побудил меня написать тест, который сканирует все видимые оператору строки в интерфейсе на наличие слов «point-in-time», «versioning» и «snapshot» — потому что драйвер этого не делает, а текст, который просто не заявляет о наличии фичи, — это не то же самое, что текст, прямо утверждающий обратное. Тот, кто будет смотреть на экран через два года, додумает все, что позволяют формулировки.

Целостность — это не восстанавливаемость

Каждый запуск проверяет, что артефакт на месте, имеет правильный размер, правильную контрольную сумму и открывается. Все это дешевые проверки, их полезно иметь, но  ни одна из них не доказывает, что данные восстановятся.

Архив, который успешно проходит  pg_restore --list , а затем падает на середине реального восстановления — из-за отсутствующего расширения, несовместимой версии сервера или несовпадения кодировок — вплоть до 3 часов ночи ничем не отличается от идеального бэкапа.

Поэтому нужны тренировочные восстановления (restore drills). Политика может включать уровень  verify_level = restorability  со своим расписанием и курсором ( drill_cron ,  next_drill_at  — для ежедневного бэкапа не нужна ежедневная тренировка; тренировка требует реального целевого сервера и реального восстановления).

Здесь есть два архитектурных решения, которые я готов защищать где угодно:

 Тренировка использует стандартный путь восстановления. Не какой-то специальный тестовый путь. Тренировка со своим собственным кодом доказывает лишь то, что работает сам механизм тренировки, в чем как раз никто и не сомневался. Она создает запись о восстановлении в режиме нового целевого сервера и передает ее тому же методу  restore() , который вызвал бы администратор.

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

И бонус, который тренировка дала бесплатно:  время восстановления — это показатель, который больше нигде в системе не измеряется. Администратору при планировании нужно знать, подписывается ли он на 10 минут или на 6 часов. Нет другого честного способа узнать это, кроме как запустить процесс. Поэтому время восстановления записывается по часам самой тренировки или реального восстановления, а если ни того, ни другого не было, на экране пишется «never measured» (ни разу не измерялось). Никаких оценочных расчетов. Оценка здесь — это просто выдуманная зеленая галочка с цифрой с потолка.

Кстати, удаление тестового окружения происходит в блоке  finally  и работает максимально тихо. Ошибка удаления не должна перекрывать реальную причину сбоя тренировки. А тренировка, которая оставляет после себя мусор, быстро забьет сервер тестовыми базами данных — упасть из-за фичи бэкапа было бы крайне глупо.

Тихие убийцы: пагинация и фейки

Две мелочи из того же семейства.

  ListObjectsV2  возвращает 1000 ключей за один вызов. Сделайте бэкап первой тысячи объектов в алфавитном порядке из каждого бакета, сообщите об успехе — и вы получите худшую реализацию этой фичи. Она будет неотличима от правильной до тех пор, пока кому-то не понадобится файл на букву «z». Используйте пагинацию и зафиксируйте это тестом, который создает более тысячи объектов.

 Фейк (fake) выдается только с указанием причины. Резолвер, выбирающий клиент объектного хранилища, может откатываться к заглушке (no-op) в окружениях, где нет демона. Но он никогда не делает это молча:

 $store = $resolver->for($destination);

if ($store->isFake()) {
    return BackupOutcome::refused($store->reason());
}
 

Драйвер превращает эту причину в отказ. Молчаливый фейк запишет прекрасный, успешный бэкап «ничего» — что опять же является тем самым багом: зеленая галочка без реальной работы за ней.

Базовый бэкап для инкрементального копирования должен быть верифицирован, а не просто завершен

Объекты сравниваются по тройке  (key, etag, size)  с последним запуском, который  одновременно и завершился успешно, и имеет верифицированную копию.

Если брать за основу просто завершенный запуск, вы молча и навсегда унаследуете его пробелы — каждый будущий инкремент будет сверяться с базой, которая уже была неполной, и эта проблема всплывет только при восстановлении, когда данные действительно понадобятся.

Параметр  lastModified  намеренно исключен из этой тройки идентификации. Повторно загруженный объект получает новую метку времени при идентичных байтах, и сравнение по времени молча превратило бы инкрементальный бэкап в полный — противоположный сбой, но все еще указывающий на то, что система неверно оценивает собственную работу.

Выводы

Каждая из этих проблем имеет одинаковую природу. Везде, где есть путь, по которому система может сообщить о незаслуженном успехе, решением никогда не является «добавить еще одну проверку» — обычно нужно сделать нечестный путь невозможным на уровне архитектуры:

  • удаление после верификации отдельными записями, чтобы нарушение можно было найти запросом, а не спорить о нем;
  • верификация, которая фиксирует то, что она не проверяла;
  • тренировка восстановления, использующая тот же путь, что и продакшн;
  • фейк, который обязан содержать причину своего создания;
  • базовый бэкап, который должен быть верифицирован, а не просто завершен.

Если вы создаете что-то в этой сфере, вопрос, с которого стоит начать, звучит не как «работает ли оно?». Он звучит так:  «если это тихо перестанет работать, как быстро кто-то заметит и что в это время будет написано на экране?»

Если ответом на вторую часть будет «успешно», то это баг. Все остальное — детали.

Следующий шаг с моей стороны: селектор источника восстановления — когда существует несколько верифицированных копий, выбор наиболее дешевой для восстановления представляет собой отдельный набор компромиссов.