Бэкапы PostgreSQL из Kubernetes в Object Storage: без CSI, с ключом, который не умеет удалять
В серии про отказоустойчивый Kubernetes на BGP я ни слова не сказал о бэкапах. Исправляюсь. В этой статье — как ночной pg_dump из кластера попадает в закрытый бакет Yandex Object Storage, почему я не стал монтировать бакет через CSI, какие права нужны ключу и на какие грабли я наступил с политикой бакета.
Всё ниже я сделал для двух сервисов, у каждого из которых своё облако и свой бакет. Поэтому схема с самого начала рассчитана на то, что никакого общего ключа «на всё» нет.
Как было и чем это плохо
Исходная схема была простой и привычной: CronJob раз в ночь запускал pg_dump --format=custom и клал файл на PersistentVolume, а потом удалял дампы старше 14 дней командой find -mtime +14 -delete.
Том был на SMB-шаре. Проблема в том, что дампы лежали на той же площадке, что и сама база, а у файлового сервера своих резервных копий не было. Пропадёт площадка — пропадут и данные, и их копии. Бэкап, который хранится рядом с оригиналом, страхует от DROP TABLE, но не от пожара.
Object Storage в облаке — другая площадка, дешёвое хранение и правила жизненного цикла из коробки. Оставалось решить, как туда писать.
Вариант с CSI и почему я его отбросил
Первая мысль — смонтировать бакет как том и ничего в CronJob не менять. У Яндекса есть официальный драйвер k8s-csi-s3 (форк ctrox/csi-s3, монтирует через GeeseFS), и он действительно работает. Но для бэкапов он даёт больше хлопот, чем пользы:
- FUSE на узле. Бакет монтируется на хосте, драйверу нужны привилегированные поды и
mountPropagation. Ещё один компонент с правами на узлы, который надо обновлять. - Ротация по сети.
find -deleteпо смонтированному бакету — это листинг и удаление объектов через FUSE, медленно и с сюрпризами. - Зависимость от узла. Зависшее монтирование — и CronJob зависает вместе с ним. Я уже проходил это с SMB.
- Один StorageClass — один секрет. Для двух облаков пришлось бы заводить статические PV со своими секретами, а чарт драйвера по умолчанию ещё и создаёт бакеты сам с
reclaimPolicy: Delete: удалил PVC — удалил бакет с бэкапами.
Для задачи «раз в сутки положить один файл» монтировать файловую систему не нужно. Достаточно загрузить объект.
Схема
Job из двух шагов в одном поде:
- initContainer
dump(образpostgres) делаетpg_dumpво временныйemptyDirи проверяет, что архив читается. - Контейнер
upload(образrclone/rclone) заливает файл в бакет.
Init-контейнер даёт порядок и короткое замыкание: если дамп не удался, загрузка не запустится, Job упадёт, а упавший Job останется в истории. Собирать свой образ с pg_dump и rclone внутри не нужно.
apiVersion: batch/v1
kind: CronJob
metadata:
name: blog-backup
spec:
schedule: "30 3 * * *"
timeZone: "Europe/Minsk"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 7
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 70 # postgres в образе postgres:*-alpine
runAsGroup: 70
fsGroup: 70
initContainers:
- name: dump
image: postgres:18-alpine
env:
- { name: PGHOST, value: pg.example.com }
- { name: PGPORT, value: "5432" }
- { name: PGDATABASE, value: blog }
- { name: PGUSER, value: blog }
- name: PGPASSWORD
valueFrom:
secretKeyRef: { name: blog-backup, key: PGPASSWORD }
command:
- sh
- -ec
- |
ts="$(date +%Y%m%d-%H%M%S)"
out="/backups/${PGDATABASE}-${ts}.dump"
pg_dump --format=custom --compress=6 --file="${out}"
[ -s "${out}" ]
pg_restore --list "${out}" > /dev/null # архив должен читаться до того, как уйдёт из пода
ls -lh /backups
volumeMounts:
- { name: backups, mountPath: /backups }
containers:
- name: upload
image: rclone/rclone:1.75.1
env:
- { name: RCLONE_CONFIG_YC_TYPE, value: s3 }
- { name: RCLONE_CONFIG_YC_PROVIDER, value: Other }
- { name: RCLONE_CONFIG_YC_ENDPOINT, value: https://storage.yandexcloud.net }
- { name: RCLONE_CONFIG_YC_REGION, value: ru-central1 }
- name: RCLONE_CONFIG_YC_ACCESS_KEY_ID
valueFrom:
secretKeyRef: { name: blog-backup, key: S3_ACCESS_KEY }
- name: RCLONE_CONFIG_YC_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef: { name: blog-backup, key: S3_SECRET_KEY }
- { name: S3_DEST, value: "yc:blog-db-backups/postgres" }
args:
- copy
- /backups
- $(S3_DEST)
- --s3-no-check-bucket
- --s3-no-head
- --no-check-dest
- -v
volumeMounts:
- { name: backups, mountPath: /backups, readOnly: true }
volumes:
- name: backups
emptyDir:
sizeLimit: 512Mi
Пара замечаний к манифесту.
rclone без файла конфигурации. Ремоут yc целиком описан переменными RCLONE_CONFIG_YC_*: тип, провайдер, endpoint, регион и ключи. Ни ConfigMap, ни rclone config не нужны, а ключи приходят из Secret, как и пароль базы.
Три флага у copy. --s3-no-check-bucket — не пытаться создать бакет (прав на это у ключа нет, и это правильно). --s3-no-head и --no-check-dest — не проверять, есть ли объект в назначении: имя содержит метку времени, а листинг ключу может быть и не разрешён. Без этих флагов rclone на ограниченном ключе падает ещё до загрузки.
emptyDir с sizeLimit. Дамп живёт в поде ровно столько, сколько нужно для загрузки. Лимит берите с запасом вдвое от размера дампа: при переполнении под будет вытеснен.
Проверка архива. pg_restore --list читает оглавление и падает на битом файле. Дешёвая проверка, которая ловит оборванный дамп раньше, чем он окажется единственной копией.
В Helm-чарте всё это параметризовано: имя бакета, префикс, образы и ключи приходят из values, а ключи — из зашифрованного sops файла секретов.
Бакет
Для каждого сервиса — отдельный бакет под бэкапы в его облаке, не рабочий бакет приложения. Тогда срок хранения и права на удаление не заденут файлы приложения.
yc storage bucket create --name blog-db-backups \
--default-storage-class standard --max-size $((10 * 1024**3)) \
--public-read=false --public-list=false --public-config-read=false
Срок хранения — правило бакета, а не скрипт
Раньше ротацию делал find в CronJob. Теперь это правило жизненного цикла бакета: объекты под префиксом postgres/ удаляются через 14 дней, незавершённые multipart-загрузки — через день.
{"lifecycleRules": [{
"id": "expire-dumps", "enabled": true,
"filter": {"prefix": "postgres/"},
"expiration": {"days": "14"},
"abortIncompleteMultipartUpload": {"daysAfterExpiration": "1"}
}]}
yc storage bucket update --name blog-db-backups --lifecycle-rules-from-file lifecycle.json
Правило работает, даже если CronJob не запускается. И, что важнее, ключу из пода теперь не нужно право удалять.
Одна странность: yc storage bucket get --full показывает у правила expiration.date: 0001-01-01T00:00:00Z рядом с days: 14. Это нулевое значение незаполненного поля, а не дата в прошлом; правило работает по days.
Права: роль на бакет
Сервисный аккаунт на загрузку, один на бакет:
yc iam service-account create --name db-backup-uploader
yc iam access-key create --service-account-name db-backup-uploader
Роль ему нужна только на этот бакет, не на каталог: storage.uploader. Она разрешает загружать, листать и читать объекты, но не удалять и не создавать бакеты. Утечка ключа из кластера не даст стереть бэкапы, а читать дампы такой ключ и так может — в том же Secret лежит пароль от базы.
В консоли роль на бакет назначается на вкладке «Безопасность». В yc команды для этого я не нашёл, но есть REST API, причём с двумя особенностями: в пути нужен resource_id бакета, а не имя (по имени отвечает «The specified bucket does not exist»), и метод — PATCH (на POST приходит Method Not Allowed):
RID=$(yc storage bucket get --name blog-db-backups --full --format json | jq -r .resource_id)
SA=$(yc iam service-account get --name db-backup-uploader --format json | jq -r .id)
curl -s -X PATCH "https://storage.api.cloud.yandex.net/storage/v1/buckets/$RID:updateAccessBindings" \
-H "Authorization: Bearer $(yc iam create-token)" -H "Content-Type: application/json" \
-d "{\"accessBindingDeltas\":[{\"action\":\"ADD\",\"accessBinding\":{\"roleId\":\"storage.uploader\",\"subject\":{\"id\":\"$SA\",\"type\":\"serviceAccount\"}}}]}"
Для восстановления — второй аккаунт db-backup-reader с ролью storage.viewer на тот же бакет. Он читает и листает, но не пишет. Ключ загрузки в поде восстановления не используется.
Проверка, которую стоит сделать до того, как положиться на схему, — прогнать все четыре операции обоими ключами. У меня вышло так:
| Операция | uploader | reader |
|---|---|---|
| загрузить | да | 403 |
| листать | да | да |
| скачать | да | да |
| удалить | 403 | 403 |
Грабли: политика бакета сужает права и закрывает доступ владельцу
Сначала я выдал доступ не ролью, а политикой бакета: Principal: {CanonicalUser: <id аккаунта>}, Action: s3:PutObject. Политика записалась, читалась обратно, а загрузка двенадцать минут подряд отвечала AccessDenied — и через rclone, и через boto3. Одной политики аккаунту без роли на бакет не хватило.
Я добавил роль storage.uploader, загрузка заработала — и я на этом остановился. Через час выяснилось, что владелец облака (роль resource-manager.clouds.owner) не может ни листать, ни читать, ни удалять объекты в новых бакетах: AccessDenied на всё. В старых бакетах, где политики не было, всё работало.
Опыт на тестовом бакете расставил всё по местам:
- бакет без политики — владелец видит объекты;
- добавляем политику с
Allowдля другого субъекта — владелец получает 403; - добавляем в ту же политику
Allow s3:*для самого владельца — доступ возвращается.
То есть при наличии политики доступ — пересечение роли и политики: роль даёт право, а политика должна его не запретить, и кого в политике нет, тот не проходит, будь он хоть владельцем облака. В прочитанной документации Яндекса я этого явно не нашёл, так что это вывод из наблюдений; но ведёт себя оно именно так.
Из этого два практических правила:
- Если политика не нужна — не заводите её. Роль на бакет закрывает задачу целиком, а политика добавляет второй слой, о котором легко забыть.
- Если политика нужна, впишите в неё себя. Иначе доступ к бакету останется только у тех, кто в ней перечислен.
И ещё одно: убрать политику через yc мне не удалось. --policy '{}' и политика с пустым списком Statement записываются как {"Version": "2012-10-17"} и доступ не возвращают. Удалилась она только из консоли.
Что касается «ключ только на запись», в которое я поверил на время, — это тоже была политика. Она резала права storage.uploader до одного PutObject. Без политики роль работает так, как в таблице выше.
Восстановление
Пока всё лежало на томе, для восстановления был Deployment с нулём реплик: образ postgres, тот же том в /backups, переменные PG* уже выставлены. Поднял реплику, зашёл внутрь, запустил pg_restore.
С бакетом том заменяется на emptyDir, а рядом появляется второй контейнер fetch — тот же rclone, но с ключом на чтение:
kubectl scale deployment/blog-restore --replicas=1
kubectl exec deploy/blog-restore -c fetch -- sh -c 'rclone ls "$S3_DEST"'
kubectl exec deploy/blog-restore -c fetch -- sh -c 'rclone copyto "$S3_DEST/blog-20261006-185006.dump" /backups/blog-20261006-185006.dump'
kubectl exec -it deploy/blog-restore -c restore -- sh
# внутри:
pg_restore --list /backups/blog-20261006-185006.dump | head
pg_restore --clean --if-exists --no-owner -d "$PGDATABASE" /backups/blog-20261006-185006.dump
kubectl scale deployment/blog-restore --replicas=0
Перед настоящим восстановлением остановите всё, что пишет в базу, иначе --clean будет воевать с живыми соединениями.
Под каким пользователем подключаться, зависит от того, кому принадлежат объекты. Если роль приложения владеет базой и всеми таблицами (так у блога), ей и восстанавливайте. Если объекты принадлежат разным ролям или в базе есть расширения — нужны права администратора, и это отдельный Secret, которого у пода бэкапа быть не должно.
Проверить схему восстановления стоит сразу, не дожидаясь аварии: поднять под, скачать вчерашний дамп, прочитать pg_restore --list, подключиться psql. Саму базу трогать не обязательно.
Что не стал делать
- Версионирование бакета. Имена файлов с меткой времени, перезаписи нет, а ключ пода удалять не умеет. Версионирование только удвоило бы объём.
- Шифрование на стороне клиента. Бакет закрыт, трафик по TLS, ключи на чтение — отдельные. Для моих данных этого достаточно; для чужих персональных данных я бы шифровал дамп перед загрузкой.
- Оповещения. Kubernetes не покажет, что дамп не залился, пока Job не упал, а упавший Job только попадёт в историю. У меня алерт на упавшие Job'ы есть в Prometheus; если у вас нет — самое время.
Итог
- CronJob из init-контейнера
pg_dumpи контейнераrcloneвместо CSI-драйвера: ничего не монтируется, нет привилегированных подов, секрет живёт в неймспейсе сервиса. - Отдельный закрытый бакет на сервис, в его облаке. Разные облака получаются сами собой — у каждого свой Secret.
- Роль
storage.uploaderна бакет: ключ пода не умеет удалять. Срок хранения — правило бакета. - Политика бакета, если она есть, сужает права поверх ролей и закрывает доступ всем, кого в ней нет, включая владельца облака. Не заводите её без нужды.
- Deployment для восстановления с контейнером
fetchи ключом на чтение, проверенный до того, как он понадобился.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.