← Заметки

siphon: как довезти WAL из CloudNativePG до Proxmox Backup Server

· backup, proxmox, cloudnativepg, postgres, s3, ceph, rust, kubernetes

Всё в моём хоумлабе бэкапится в 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 умеет бэкапить только файлы, и у каждого очевидного обходного пути был подвох:

Поэтому — небольшая утилита на Rust поверх крейтов самого Proxmox: тот же чанкер, тот же формат индексов и тот же код протокола, что и в официальном клиенте. Содержимое объектов идёт потоком из S3 прямо в PBS, на локальный диск ничего не пишется. Заодно и CronJob получается простым: по одному на бакет, после base backup, с пользователем, которому бакет доступен только на чтение.

Сборка на крейтах Proxmox

Нужных мне pbs-client и pbs-datastore на crates.io нет — они лежат в workspace proxmox-backup на git.proxmox.com. С этим я провозился дольше, чем с самой логикой бэкапа:

Ещё я смотрел на сборку из дебиановских пакетов 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.