siphon: как довезти WAL из CloudNativePG до Proxmox Backup Server
Всё в моём хоумлабе бэкапится в Proxmox Backup Server: с ротацией, верификацией и копией на ленту. В начале октября для баз данных это перестало быть правдой, и на исправление ушла неделя и небольшая утилита на Rust. siphon переносит S3-бакет в снапшот PBS и обратно; подробности — в README. А здесь — зачем он понадобился и что мешало.
Как базы выпали из PBS
3–7 октября. Я переносил сервисы с виртуалок и compose в небольшие кластеры Kubernetes: сначала Forgejo, потом Healthchecks, Woodpecker, Immich, Paperless, Open Archiver. Файлы по-прежнему уезжали в PBS: общий CronJob монтирует том рядом с работающим подом в режиме только для чтения и запускает клиент бэкапа, а при необходимости сначала выполняет подготовку (Paperless, например, перед этим выгружает документы).
С базами вышло иначе. Каждое приложение получило кластер CloudNativePG, а
он бэкапится через плагин Barman Cloud: WAL архивируется непрерывно, раз в
сутки делается base backup, и всё это складывается в один общий бакет на
Ceph RGW — по префиксу на сервис, с хранением 30 дней. Инструмент правильный:
он даёт восстановление на любой момент времени, чего ночной pg_dump не
умеет. Но в старых бэкапах в каждом снапшоте PBS лежал postgresql.pxar с
дампом, а в новых его не стало. Коммит за коммитом базы переезжали из PBS в
бакет, который живёт на том же Ceph, что и сами базы. От сломанной базы это
спасает, от сломанного Ceph — нет, и на ленту это не попадает.
Почему не rclone
8 октября. Значит, бакет надо было как-то довезти до PBS. PBS умеет бэкапить только файлы, и у каждого очевидного обходного пути был подвох:
rclone syncна CephFS, а оттудаproxmox-backup-client— бакет дважды лежит на одном и том же Ceph.rclone mountи бэкап примонтированного каталога — FUSE требует от пода лишних привилегий, а долгое чтение через FUSE ненадёжно.- Есть сторонняя реализация протокола PBS на Go, но насколько она зрелая, я оценить не смог.
Поэтому — небольшая утилита на Rust поверх крейтов самого Proxmox: тот же чанкер, тот же формат индексов и тот же код протокола, что и в официальном клиенте. Содержимое объектов идёт потоком из S3 прямо в PBS, на локальный диск ничего не пишется. Заодно и CronJob получается простым: по одному на бакет, после base backup, с пользователем, которому бакет доступен только на чтение.
Сборка на крейтах Proxmox
Нужных мне pbs-client и pbs-datastore на crates.io нет — они лежат в
workspace proxmox-backup на git.proxmox.com. С этим я провозился дольше,
чем с самой логикой бэкапа:
- Не к чему привязаться. Proxmox ставит теги не на каждый релиз, есть
только коммиты «bump version to 4.2.x-1». siphon фиксирует конкретный коммит
через
rev— той же версии, что сервер PBS, или старше. - Одна ревизия на всё.
pbs-clientтянет за собой ещё с десяток крейтовproxmox-*, плюсpxarиpbs-api-types. Чтобы мои прямые зависимости и его собственные указывали на один и тот же код, каждый репозиторий закреплён один раз через[patch.crates-io]. Работает это только в корневом workspace, поэтому живёт вCargo.tomlсамого siphon. - Нативные библиотеки.
pbs-clientлинкуется с libacl, libcrypt, libssl, libsystemd, libuuid и libzstd, а при сборке нужен bindgen. Статического бинарника на musl не будет — сборка под glibc на Debian trixie, в контейнере. - AGPL.
proxmox-backupпод AGPL-3.0, значит, и siphon тоже.
Ещё я смотрел на сборку из дебиановских пакетов librust-*-dev и на
git-сабмодуль. Оба варианта добавляли сложности и ничего не давали по
сравнению с git-зависимостью, закреплённой на коммите.
S3-клиент, который теряет заголовки
Сначала я взял proxmox-s3-client — он и так уже был в зависимостях. Не
подошёл: из ответа он сохраняет только размер, ETag, Content-Type и
Last-Modified, так что никаких x-amz-meta-*; метаданные при загрузке
задавать не умеет; multipart upload у него нет. Для восстановления нужны все
три, поэтому siphon работает через aws-sdk-s3. С RGW пригодились две
настройки: контрольные суммы — только там, где операция их требует (новые
умолчания SDK понимает не каждое S3-совместимое хранилище), и сертификаты
УЦ из системного хранилища.
Метаданные объекта (Content-Type и компания, x-amz-meta-*, ETag, version ID)
кладутся в архив как xattr user.s3.*. Именно в user. — чтобы их сохранил
даже обычный proxmox-backup-client restore в каталог.
Ключи, которые не пути
Дерево архива повторяет бакет: ключ a/b/c — это файл c в каталоге a/b.
Только ключи в S3 — просто строки. a//b, маркер каталога dir/, .. в
пути, компонент длиной 300 байт или a рядом с a/b — файлом такое не
станет. Подобные объекты кладутся плоско в .siphon/keys/<sha256>, а
настоящий ключ — в xattr. rustfs, на котором гоняется end-to-end тест,
большинство таких ключей сам не принимает, так что они покрыты только
юнит-тестами.
К вечеру 8 октября бэкап и восстановление работали от начала до конца в CI: rustfs и PBS 4.2 в контейнере, два бэкапа, verify в PBS, восстановление и сравнение всех объектов и заголовков. CronJob в тот же вечер появился в GitOps-репозитории и ждал секретов.
Снапшот, который не открывался в веб-интерфейсе
9 октября. Первый настоящий снапшот появился в PBS, но веб-интерфейс
отказался его показывать: unable to read dynamic index …
catalog.pcat1.didx. Интерфейс не читает .pxar-архив напрямую, он ходит по
каталогу, который proxmox-backup-client загружает рядом, — а siphon его не
писал. Теперь каталог собирается в памяти по ходу упаковки (десятки байт на
объект) и загружается после архива. End-to-end тест получает список из
каталога и скачивает отдельный файл через API PBS, так что незаметно это уже
не сломается. (Что ошибку нашёл именно первый боевой запуск — мой вывод по
времени коммитов: между деплоем и исправлением два часа.)
Не загружать одни и те же чанки заново
В тот же день: как и официальный клиент, siphon теперь передаёт при загрузке манифест предыдущего снапшота, и чанки, которые уже есть в PBS, повторно не отправляются. PBS и так дедуплицирует на диске, но так экономится передача.
Хотелось видеть долю повторно использованного в итоговой строке —
archive 3.1 GiB, reused 3.09 GiB (99.6%), — но pbs-client держит свои
счётчики при себе. Поэтому siphon ещё раз хэширует каждый чанк и считает
сам, по тому же правилу.
Тест на это сначала был нестабильным. Третий бэкап добавляет один файл WAL и должен переиспользовать часть чанков, но на случайных тестовых данных результат прыгал от 0% до 69% — в зависимости от того, где чанкер резал поток. Теперь данные генерируются из фиксированных seed, а базовый объект весит 40 МиБ — больше двух максимальных чанков по 16 МиБ, — так что чанки до нового объекта переиспользуются всегда.
И напоследок siphon научился сам отчитываться в Healthchecks: пинг на старте, потом успех с итоговой строкой или ошибка со всей цепочкой причин. Если запуск завис или упал, это видно там, и уведомление приходит туда же, куда и все остальные.
Что дальше
Вторая половина — восстановление: siphon restore возвращает снапшот в
новый бакет вместе с метаданными, и кластер CloudNativePG может
восстановиться из этого бакета так же, как из исходного.
Пока что каждый запуск скачивает весь бакет целиком, инкрементальна только загрузка в PBS. Следующий шаг — разделённый архив, который официальный клиент использует для поиска изменений: объекты, у которых совпадают ключ, размер, ETag и время изменения с предыдущим снапшотом, будут копироваться из него без единого запроса к S3, и читать придётся только новые WAL и base backup.