Переезд S3 без простоя и без привязки к одному облаку: rclone union + serve s3 на практике

Задача: сделать объектное хранилище, которое переживает отказ провайдера целиком, а не отдельного диска или зоны. «Три зоны доступности» внутри одного облака и два разных провайдера — две большие разницы. Ситуаций две. В обычной текущий провайдер работает, и к нему нужно добавить второй. В аварийной облако после сбоя отдаёт данные медленно и только на чтение, и из него надо уезжать. В обоих случаях — без простоя и без правок в коде приложения.
Решение целиком собрано из rclone. Его обычно знают как «rsync для облаков»: rclone copy и rclone sync между диском и бакетом. Но у rclone есть ещё два слоя, о которых вспоминают редко.
Первый — виртуальные бэкенды. Это remote’ы, которые сами ничего не хранят, а собираются из других: union склеивает несколько хранилищ в одно, combine раскладывает разные хранилища по каталогам, а crypt, chunker и compress шифруют, режут и сжимают на лету. Из них, как из конструктора, складывается одно логическое хранилище поверх любых из примерно семидесяти поддерживаемых бэкендов: S3 любого провайдера, SFTP, WebDAV, локального диска.
Второй — rclone serve. Любой remote, включая собранный из виртуальных, rclone умеет отдавать наружу как сервер: по HTTP, WebDAV, FTP, SFTP, NFS, для restic — и по S3 API. rclone serve s3 превращает rclone в S3-совместимый прокси: приложение ходит в него обычным SDK и не знает, что за ним несколько провайдеров.
Отсюда схема: один S3-endpoint для приложения, за ним несколько провайдеров. Конфигурация у обеих ситуаций общая, различаются состав union и перенос данных.
Схема
[webshield]
type = s3
provider = Other
endpoint = https://s3.webshield.pro
force_path_style = true
no_check_bucket = true
access_key_id = ...
secret_access_key = ...
[sel]
type = s3
provider = Selectel
endpoint = https://s3.ru-1.storage.selcloud.ru
region = ru-1
access_key_id = ...
secret_access_key = ...
[old]
type = s3
provider = Other
endpoint = https://storage.old-cloud.example
access_key_id = ...
secret_access_key = ...
Имена бакетов у провайдеров разные, поэтому у каждого сначала собирается одинаковое дерево через combine:
[webshield_all]
type = combine
upstreams = media=webshield:u42-media dumps=webshield:u42-dumps
[sel_all]
type = combine
upstreams = media=sel:acme-media dumps=sel:acme-dumps
[old_all]
type = combine
upstreams = media=old:media dumps=old:dumps
А поверх — union, который делает из нескольких деревьев одно. В обычном режиме это текущий провайдер и новый, оба полноправные:
[s3all]
type = union
upstreams = old_all: webshield_all:
create_policy = all
action_policy = all
search_policy = ff
В аварийном старое облако уходит последним и только на чтение, а писать приходится в два новых провайдера:
upstreams = webshield_all: sel_all: old_all::ro
create_policy = all и action_policy = all пишут и удаляют во всех upstream’ах, кроме помеченного :ro. search_policy = ff читает с первого, где объект нашёлся: порядок в upstreams — это приоритет.
Порядок слоёв важен. Первая версия была наоборот — union для каждого бакета и combine сверху. Она работала, пока не понадобилось перезаписать объект, который ещё лежал только в старом облаке: новая версия уезжала по неверному пути, а читалась старая.
Приложение не меняется
rclone serve s3 поднимает поверх s3all: обычный S3 API. Каталоги верхнего уровня — media и dumps — становятся бакетами с прежними именами. Прокси запускается на каждой машине с приложением и слушает только localhost: у каждого сервера свой экземпляр, поэтому ни балансировщик, ни TLS не нужны, а сам прокси не становится единой точкой отказа.
rclone serve s3 s3all: \
--addr 127.0.0.1:8333 \
--auth-key "$APP_ACCESS_KEY,$APP_SECRET_KEY" \
--contimeout 3s --timeout 30s --low-level-retries 2 \
--dir-cache-time 5s
У приложения своя пара ключей, ключи провайдеров есть только в rclone.conf. Таймауты обязательны: с умолчаниями rclone повторяет запросы к упавшему провайдеру минутами, и прокси на это время зависает.
--dir-cache-time важен не меньше. Прокси кеширует каталоги, по умолчанию на 5 минут, и для уже найденного объекта помнит, у какого провайдера он лежит. Если упал именно этот провайдер, чтение не переключается на второй, а обрывается ошибкой — до конца кеша. С 5 секундами первое чтение после отказа занимает несколько секунд, дальше всё идёт со второго провайдера.
В приложении меняются адрес и ключи. Если адрес берётся из окружения, достаточно поменять значение — AWS_ENDPOINT_URL читают свежие boto3, AWS CLI v2, aws-sdk-js v3 и Go SDK v2:
AWS_ENDPOINT_URL=http://127.0.0.1:8333
AWS_ACCESS_KEY_ID=$APP_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=$APP_SECRET_KEY
rclone — не ниже 1.75.2: механизм serve достаточно новый и сейчас активно дорабатывается.
С этого момента приложение работает с новой схемой. Новые файлы сразу ложатся ко всем провайдерам, открытым на запись, а старые читаются оттуда, где лежат.
Перенос данных: обычный режим
Когда текущий провайдер работает и открыт на запись, всё просто. Приложение через union пишет и удаляет у обоих, поэтому обычный rclone copy от старого к новому ничего не откатит и не вернёт удалённое — повторять его можно сколько угодно:
rclone copy old:media webshield:u42-media -M --transfers 16
rclone copy old:dumps webshield:u42-dumps -M
rclone check old:media webshield:u42-media
Пока копия не закончена, перезапись файла, который есть только у старого провайдера, обновит только его, а новый получит свежую версию при следующем проходе. После нулевого check оба провайдера равноправны.
Перенос данных: аварийный режим
Пока старое облако подключено, копирование идёт параллельно с работой приложения, и обычный rclone copy здесь опасен. Повторный проход откатывает файлы, которые приложение уже обновило через прокси, к старым версиям и возвращает удалённые. Удаление через прокси вообще не держится: из новых бакетов файл уходит, но после перезапуска прокси снова читается из старого облака.
Поэтому копировали не всё подряд, а по списку живых ключей из базы приложения — удалённых в нём нет:
psql -At -c "select storage_key from attachments" > keys.txt
rclone copy old:media webshield:u42-media \
--files-from keys.txt --no-traverse --ignore-existing \
--transfers 8 --checkers 16 --tpslimit 20 \
--retries 10 --low-level-retries 20 \
-M --log-file copy.log
rclone copy webshield:u42-media sel:acme-media \
--files-from keys.txt --no-traverse --ignore-existing -M --transfers 32
Дампы базы в приложении никто не удаляет и не перезаписывает, их копировали целиком теми же двумя шагами: old:dumps → webshield:u42-dumps → sel:acme-dumps с --ignore-existing.
--ignore-existing не трогает то, что у приёмника уже есть: если файл там есть, значит, его записало приложение, и эта версия новее. --files-from с --no-traverse к тому же избавляет от листинга медленного источника. Копировать в s3all: нельзя: union найдёт каждый объект в старом облаке и решит, что копировать нечего. Поэтому сначала копия к одному провайдеру, а уже от него — ко второму: медленный источник читается один раз.
--tpslimit ограничивает число запросов в секунду: старое облако отвечало на всплески ошибкой SlowDown. -M переносит Cache-Control и Content-Disposition — без него Content-Type rclone сохраняет, а остальные заголовки теряются. Файлы, которых нет в старом облаке, rclone пропускает молча и с нулевым кодом выхода, поэтому потери считали отдельно: список ключей против rclone lsf -R приёмника.
Когда всё скопировано, old_all::ro убрали из upstreams и перезапустили прокси. Затем сверили провайдеров между собой (rclone check webshield:u42-media sel:acme-media) и убрали «сирот» — файлы, которые пользователи удалили, пока шло копирование. Их нет в свежей выгрузке из базы, а --min-age не даёт задеть только что загруженные, которые в базу ещё не попали:
psql -At -c "select storage_key from attachments" | sort > live.txt
rclone lsf -R --files-only --min-age 1h webshield:u42-media | sort | comm -23 - live.txt > orphans.txt
rclone delete webshield:u42-media --files-from orphans.txt
rclone delete sel:acme-media --files-from orphans.txt
Чего схема не умеет:
ACL, теги, retention и политики бакетов —
serve s3поддерживает только объекты, листинг, multipart и копирование;метаданные при записи через прокси теряются,
Content-Typeугадывается по расширению;ссылки для браузера через локальный прокси не работают — раздача клиентам остаётся задачей CDN;
строгой консистентности нет: сбой посреди записи даёт расхождение до сверки.
Для медиа, документов, артефактов и бэкапов схема подходит. Для частых перезаписей и нагрузки, похожей на базу данных — нет.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.