Бэкапы 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 из двух шагов в одном поде:

  1. initContainer dump (образ postgres) делает pg_dump во временный emptyDir и проверяет, что архив читается.
  2. Контейнер 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:* для самого владельца — доступ возвращается.

То есть при наличии политики доступ — пересечение роли и политики: роль даёт право, а политика должна его не запретить, и кого в политике нет, тот не проходит, будь он хоть владельцем облака. В прочитанной документации Яндекса я этого явно не нашёл, так что это вывод из наблюдений; но ведёт себя оно именно так.

Из этого два практических правила:

  1. Если политика не нужна — не заводите её. Роль на бакет закрывает задачу целиком, а политика добавляет второй слой, о котором легко забыть.
  2. Если политика нужна, впишите в неё себя. Иначе доступ к бакету останется только у тех, кто в ней перечислен.

И ещё одно: убрать политику через 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 и ключом на чтение, проверенный до того, как он понадобился.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.