Содержание статьи
Кому-нибудь пятничной духоты? Ее есть у нас.
Скрытый текст
Все происходило на изолированном (частично) стейдже и вообще три дня назад. Ни один прод не пострадал.
У нас перестали загружаться четыре файла в резервную копию. Дамп PostgreSQL при этом продолжал загружаться. Использовались тот же S3-совместимый сервис, тот же сервисный аккаунт и та же команда aws s3 cp.
Ошибка выглядела достаточно определенно:
CompleteMultipartUpload: AccessDenied HTTP 403
Мы начали проверять права. Потом воспроизвели ошибку другим способом. Потом обнаружили, что второй способ повторяет существенную часть первого и почти ничего не добавляет к пониманию. В итоге один и тот же зашифрованный файл с одними и теми же учетными данными не загрузился через multipart, но загрузился через PutObject. После этого мы догрузили четыре файла и восстановили выбранную резервную копию в отдельном окружении. Причину, по которой сервер отклонял CompleteMultipartUpload, мы не установили. Зато выяснили, какое поведение клиента пропустили в тестах, проверили ограниченное исправление и нашли еще одну проверку, которая успешно проходила на пустых данных.
Что именно мы сохраняли
Для восстановления приложения требовались база PostgreSQL и вложения из объектного хранилища. Внешняя резервная копия должна была содержать оба набора данных.
Дамп проходил такую цепочку:
pg_dump custom format → gzip → GPG → внешнее хранилище
Вложения шифровались GPG и отправлялись туда же. Здесь сразу стоит признаться в небольшой странности: pg_dump уже запускался с —format=custom —compress=9, после чего результат дополнительно проходил через gzip -9. Двойное сжатие было частью существовавшего скрипта. Это не рекомендация по устройству бэкапов и не объяснение отказа. Дамп после сжатия и шифрования занимал около 4,6 MB и загружался успешно. Четыре исходных вложения, на которых возникал отказ, занимали примерно от 8,7 до 9,2 MB каждое. Это размеры исходников. Клиент выбирал способ загрузки по размеру уже подготовленного шифротекста. Между этими величинами нельзя ставить знак равенства: сжатие и шифрование меняют размер. Исходные вложения оставались доступны. Потерю исходников мы не обнаружили. Проблемой были отсутствующие внешние копии, которые должны были пережить потерю основного окружения.
Одна команда скрывала два способа загрузки
В скрипте использовался высокоуровневый вызов:
aws —endpoint-url «$endpoint» s3 cp «$file» «s3://$bucket/$key»
Мы воспринимали его как операцию отправки файла. Клиент воспринимал его как задачу, для которой нужно выбрать способ передачи. В AWS CLI за переключение отвечает multipart_threshold. Параметр относится к высокоуровневым командам aws s3, включая (угадайте что) cp.
Для расследования мы зафиксировали AWS CLI 2.36.33 и проверили его запросы на локальном HTTPS-приемнике с тестовыми учетными данными. Приемник показывал, какие запросы строит клиент. Совместимость настоящего хранилища этот эксперимент не проверял.
Размер передаваемого файла
Что
Наблюдения
8 388 607 байт
s3 cp
PutObject
8 388 608 байт
s3 cp
Create, одна часть, Complete
9 445 198 байт
s3 cp
Create, две части, Complete
9 445 198 байт
s3api put-object
PutObject
Ровно на 8 MB клиент уже выбирал multipart. Причем с одной частью. Multipart здесь определяется последовательностью API-вызовов, а не обязательным наличием нескольких частей. В запросах также встречались Transfer-Encoding: chunked и Content-Encoding: aws-chunked. Они присутствовали и у одиночного PutObject. Поэтому искать чанки в отладочном выводе и по нему определять мультипарт было бы ошибкой. Одна строка скрипта скрывала переход на другую последовательность запросов. Наши маленькие тестовые файлы этот переход не пересекали. В доступной истории внешнего хранилища мы нашли 88 зашифрованных версий. Самая большая занимала 7 897 419 байт. Среди них не было ни одной размером > 8 MB. Это не полная история всех когда-либо выполненных загрузок. Но сохраненные версии не подтверждали, что проблемный способ загрузки вообще работал раньше.
Привет, 403
Ответ AccessDenied делает проверку прав вполне разумным первым действием.
Мы проверяли права сервисного аккаунта и добавили еще одну профильную роль без повышения до администратора (по сути, точечный и временный тык с бакет юзера на юзера чуть повыше). Новая попытка multipart закончилась тем же отказом. Этот результат сам по себе не исключал проблему авторизации: полного объяснения эффективных прав и серверного решения у нас не было. Но продолжать расширять права без нового проверяемого предположения уже было трудно оправдать даже у себя в голове.
Следующим шагом воспроизвели загрузку явными вызовами multipart API:
Что
Ответ
CreateMultipartUpload
200
Первый UploadPart
200
Второй UploadPart
200
CompleteMultipartUpload
403
AbortMultipartUpload
204
HEAD итогового объекта
404
На первый взгляд появилось подтверждение двумя способами: через s3 cp и через ручную сборку мультипарта.
Но оба способа использовали тот же клиент и тот же multipart-путь. Второй эксперимент помог локализовать отказ на завершении загрузки. Он не позволял отделить особенности клиента, multipart-запросов и реализации сервера.
Мы повторили существенную часть первого эксперимента, хотя сначала восприняли результат как более независимое подтверждение. Полезнее оказалось изменить способ передачи, сохранив сам файл. Один раз подготовили зашифрованный файл размером 9 445 177 байт, посчитали SHA-256 и отправили его двумя способами под разными ключами. Клиент, endpoint и учетные данные оставались одинаковыми. Проверки checksum специально не отключали.
Что
Результат
s3 cp
Части приняты, CompleteMultipartUpload вернул 403
s3api put-object
Успех
После успешного PutObject отдельным читателем проверили конкретную версию: метаданные, retention и полное скачивание. SHA-256 скачанного энкриптед-текста совпал с локальным. Это был результат, на который уже можно было опереться при исправлении, ибо он доказывал, что данный файл с этими учетными данными проходит через PutObject и не проходит через проверенный multipart-путь. Он не объяснял серверный 403. Между этими способами отличаются запросы, передача checksum и завершение операции.
По нашему эксперименту нельзя честно выбрать между ошибкой клиента, особенностью совместимости, политикой доступа или иной серверной причиной.
Как повторить сравнение без настоящих данных
Для проверки выбора способа передачи достаточно файла из рандомных байтов. GPG здесь не требуется: клиент принимает решение по размеру подготовленного файла. Ниже учебный пример. Для запуска вам, очевидно, нужны собственный тестовый бакет с versioning и retention, настроенные профили demo-writer и demo-reader, AWS CLI v2 и jq. В профиле записи предполагаются стандартные настройки мультипарт для проверяемого клиента.
Обе загрузки выполняются одной версией CLI. На другом сервисе обе могут пройти успешно: пример показывает способ сравнения, а не гарантированное воспроизведение нашего 403.
set -euo pipefail umask 077 export AWS_PAGER=» demo_dir=$(mktemp -d) endpoint=’https://s3.example.invalid’ bucket=’backup-demo’ run_id=$(python3 -c ‘import uuid; print(uuid.uuid4().hex)’) cp_key=»article-demo/$run_id/cp.bin» put_key=»article-demo/$run_id/put.bin» head -c 9437184 /dev/urandom > «$demo_dir/payload.bin» aws —version stat -c ‘%s bytes’ — «$demo_dir/payload.bin» if aws —profile demo-writer —endpoint-url «$endpoint» s3 cp «$demo_dir/payload.bin» «s3://$bucket/$cp_key» —only-show-errors; then printf ‘%sn’ ‘s3 cp: success’ else printf ‘%sn’ ‘s3 cp: failed; inspect the error above’ fi aws —profile demo-writer —endpoint-url «$endpoint» s3api put-object —bucket «$bucket» —key «$put_key» —body «$demo_dir/payload.bin» —output json > «$demo_dir/put.json» version_id=$(jq -er ‘.VersionId | select(type == «string» and length > 0 and . != «null»)’ «$demo_dir/put.json»)
Здесь есть несколько существенных деталей.
Один файл используется в обеих попытках. Ключи объектов различаются. Ожидаемый отказ первой команды обработан явно, поэтому set -e не прервет сравнение. В —body передается обычный путь к файлу. Для этого параметра AWS CLI не нужны префиксы file:// или fileb://. Если PutObject прошел, продолжаем в той же оболочке:
aws —profile demo-reader —endpoint-url «$endpoint» s3api head-object —bucket «$bucket» —key «$put_key» —version-id «$version_id» —query ContentLength —output json aws —profile demo-reader —endpoint-url «$endpoint» s3api get-object-retention —bucket «$bucket» —key «$put_key» —version-id «$version_id» —query Retention —output json aws —profile demo-reader —endpoint-url «$endpoint» s3api get-object —bucket «$bucket» —key «$put_key» —version-id «$version_id» —output json «$demo_dir/readback.bin» > «$demo_dir/get.json» cmp — «$demo_dir/payload.bin» «$demo_dir/readback.bin» printf ‘%sn’ ‘Readback matches the uploaded file byte for byte’
Сначала выводятся размер и retention выбранной версии. Затем скачивается ее содержимое и выполняется побайтовое сравнение с локальным файлом. У get-object выходной файл передается последним позиционным аргументом. Перенаправление > «$demo_dir/get.json» сохраняет JSON ответа, а содержимое объекта записывается в readback.bin.
Во всех запросах используется один VersionId. Проверка просто текущего объекта по ключу была бы слабее: между загрузкой и чтением под тем же ключом может появиться другая версия.
Почему тесты этого не заметили
В тестах мы подменяли функцию, которая вызывала удаленное хранилище. Проверка проходила подготовку файла и шифрование. Вместо настоящего вызова AWS CLI подстановка сохраняла результат в локальный каталог. Для своей задачи это было полезно.
Тест мог обнаружить неправильного получателя шифрования или отсутствие подготовленного файла. Но AWS CLI внутри него не запускался. Автоматический выбор multipart в модели теста отсутствовал. Проблема была в выводе из зеленого результата.
Проверка подготовки бэкапа постепенно стала восприниматься как проверка его загрузки. Мы добавили отдельный уровень: настоящий закрепленный клиент, локальный приемник запросов и размеры по обе стороны границы. Затем сделали отрицательный контроль. В исправленный код временно вернули старый вызов s3 cp. Новая проверка должна была заметить возвращение multipart. Получили вполне ожидаемую последовательность: исправление проходит, возврат старого поведения ломает проверку, восстановление исправления снова проходит. Так стало понятно, что тест различает нужное поведение. Еще одна проблема воспроизводимости обнаружилась в способе запуска клиента: исходный скрипт использовал образ с тегом latest.
Мы не восстановили исторический digest клиента для каждой прежней загрузки. Поэтому утверждать, что инцидент вызвало обновление образа, нельзя. Зато теперь было понятно, чего нам не хватало для такого расследования. В исправлении образ по умолчанию закрепили по digest.
Исправление с явным пределом
Для текущих размеров объектов мы выбрали явный PutObject. Можно было поднять multipart_threshold, но тогда клиент продолжал бы выбирать способ передачи, просто на другой границе. Для небольшого проверенного диапазона размеров явный API-вызов оказался проще. Общий загрузчик получил диапазон:
1 <= ciphertext_bytes <= 16 777 216
Предел в 16 MB был нашим эксплуатационным ограничением. Это, очевидно, не лимит S3 и не прогноз будущего размера базы. Слишком большой энкриптед отклонялся до записи. Автоматического возврата к multipart не было. Когда объект вырастет за предел, задача потребует нового решения, а не незаметного переключения на непроверенный способ загрузки.
Успешный ответ сервера тоже перестал быть последней проверкой. После записи сохранялся VersionId, а отдельный читатель проверял размер, метаданные, тип содержимого и retention этой версии. При штатной тридцатидневной retention для новой версии требовали не менее 29 оставшихся дней. Нас интересовала защита загруженной версии, а не только настройка бакета: в модели S3 Object Lock retention относится к версиям объектов. В метаданные записывались размер и SHA-256 локального энкрипшна. Но хеш, который загрузчик сам положил в метаданные, не стал доказательством целостности удаленного содержимого. Он полезен для сверки, но не заменяет скачивание и вычисление хеша. При приемке исправления полное скачивание было отдельной проверкой. В штатном скрипте каждый объект сразу обратно не скачивался. Между этими уровнями проверки осталась явная граница.
Изменение прошло общий набор из 3382 тестов. Поведение на нужной границе подтверждали конкретные проверки загрузчика и клиента. Само количество зеленых тестов ничего не объясняло про CompleteMultipartUpload.
Догрузить != восстановить
Для четырех исходных вложений подготовили новые зашифрованные копии и загрузили их через PutObject. Проверили стабильность исходников. У новых версий проверили retention, скачали энкриптед и сравнили SHA-256. Позже при восстановлении сравнили и расшифрованные данные этих четырех файлов с исходными.
Это закрыло конкретные четыре пропуска. Но приложение восстанавливается из базы и файлов вместе. Успешно загруженный дамп может ссылаться на вложения, которых во внешней копии нет. Для проверки выбрали дамп и зафиксировали версии объектов, из которых собирали восстановление. Для базы последовательность была такой: скачать выбранную версию, расшифровать, снять внешний gzip и выполнить настоящий pg_restore.
Ниже показана только эта часть. archive.dump.gz.gpg обозначает локальный файл дампа, а restore-demo представляет заранее настроенный PostgreSQL connection service, указывающий на отдельную пустую тестовую базу, которую вы можете шатать-ломать-копировать. Если вы дочитали до этого момента, то параметры подключения и как использовать пароль мне вам рассказывать смысла нет.
( set -euo pipefail umask 077 restore_dir=$(mktemp -d) trap ‘rm -rf — «$restore_dir»‘ EXIT gpg —batch —output «$restore_dir/archive.dump.gz» —decrypt ./archive.dump.gz.gpg gzip -dc — «$restore_dir/archive.dump.gz» > «$restore_dir/archive.dump» pg_restore —list «$restore_dir/archive.dump» > /dev/null pg_restore —exit-on-error —no-owner —dbname=’service=restore-demo’ «$restore_dir/archive.dump» )
Команды выполняются последовательно. Ошибка расшифровки или распаковки останавливает сценарий до восстановления.
pg_restore —list читает оглавление архива. Это полезная предварительная проверка, но она не выполняет восстановление. Следующая команда уже отправляет SQL в тестовую базу.
Флаг —exit-on-error нужен явно: по умолчанию pg_restore продолжает работу после ошибок SQL и сообщает их количество в конце.
—no-owner пропускает восстановление исходных владельцев объектов. Команды назначения прав продолжают выполняться, поэтому необходимые роли должны существовать в тестовом окружении. Убирать ошибки через добавление —no-acl означало бы изменить состав восстанавливаемых настроек доступа.
После восстановления базы мы выполнили проверки схемы и миграций, получили список необходимых вложений, скачали и расшифровали файлы, а затем проверили их запись и чтение во временном объектном хранилище. В выбранной базе оказалось 73 необходимых вложения. Набор файлов содержал 92 версии вложений. Все 73 требуемых файла нашлись, размеры расшифрованных данных совпали с записанными в базе.
Оставшиеся 19 файлов были дополнительными относительно этого дампа. Из этого, конечно же, не следовало, что их можно удалять.
Что
Результат
Объекты внешнего хранилища + дамп
93
Полные скачивания
93
Проверки HEAD
93
Проверки retention
93
Скачано зашифрованных данных
113 147 452 байта
Найдено необходимых вложений
73 из 73
Весь прогон
32 мин 14 с
Эти 32 минуты включали расшифровку, восстановление базы, миграции, работу с файлами и обслуживание временного окружения. Безусловно, это не измеряет скорость Постгреса. И это еще не RTO приложения: мы не переключали пользовательский трафик и не восстанавливали всю инфраструктуру после полной потери площадки.
Для старых вложений также не было исторических SHA-256 исходного содержимого. Мы подтвердили чтение выбранных версий, расшифровку, размеры и последующий локальный цикл записи и чтения. Утверждать побайтовое совпадение каждого старого файла с его первоначальным состоянием было бы сильнее наших доказательств.
Еще один зеленый тест и нули
При ревью восстановления нашли неприятную деталь в проверке изоляции арендаторов. Для двух арендаторов сравнивали количество строк, доступных привилегированной проверке и прикладной роли. По двум таблицам получили четыре совпадения:
Ожидалось
Доступно приложению
Арендатор A
0
0
Арендатор B
0
0
Арендатор A
0
0
Арендатор B
0
0
Нравится? Совпало же?
Для выбранных арендаторов в этих таблицах не было данных.
Проверка не доказывала, что прикладная роль видит собственные строки. Если бы доступ был запрещен ко всем строкам, этот положительный сценарий все равно прошел бы.
Наличие политик, настройки RLS и отдельные отрицательные проверки оставались полезными фактами. Но положительной проверки чтения собственных непустых данных не получилось. Мы не обнаружили этим ошибку изоляции в рабочем приложении. Мы обнаружили недостаточность приемки. После истории с multipart особенно неприятно было снова увидеть ту же конструкцию: тест проходил, потому что выбранные данные не заставляли его проверить нужное поведение.
Что я теперь потребовал бы от такого бэкапа
В начале у нас была ошибка загрузки четырех файлов. Для исправления хватило сменить API-вызов и ограничить размер. Для приемки понадобилось значительно больше работы, потому что слово «бэкап» склеивало несколько разных результатов. Зашифрованный файл может быть корректным, но не попасть в хранилище. Объект может попасть в хранилище, но оказаться без ожидаемой защиты версии. Дамп может восстановиться, но ссылаться на отсутствующие вложения. Проверка доступа может пройти, не прочитав ни одной строки. Теперь для такой системы я хотел бы видеть отдельные доказательства каждого перехода: подготовленный артефакт, загруженную версию, ее защиту, возможность чтения и расшифровки, восстановленную базу и наличие нужных ей файлов.
Тест загрузчика должен пересекать границы, на которых клиент меняет поведение. Тест восстановления должен действительно восстанавливать. Положительный тест доступа должен читать существующие данные. На момент завершения описанного этапа четыре файла были догружены, выбранная копия восстановлена, а исправление загрузчика проверено отдельно. Это еще не означало, что исправление уже развернуто в штатном расписании или что все исторические копии проверены. Причина серверного 403 осталась открытым вопросом. Мы перестали ждать ответа на него, чтобы восстановить нужные копии, но не стали подменять отсутствие ответа удобным объяснением.
К моку претензий нет
Ни одного 403, никаких проблем с правами, все файлы на месте. Он просто копировал их в локальный каталог и с этой задачей справлялся лучше любого облака. Проблемы начались, когда мы потребовали того же от настоящего хранилища. Теперь проверяем и настоящий клиент. Перенести прод в мок, к сожалению, оказалось сложнее.
