Учебник веб-разработки
Разделы учебника
На этой странице

XI. DevOps

Эксплуатация, резервные копии и восстановление

Оглавление · Жизненный цикл PostgreSQL · Миграции схемы · Наблюдаемость

Штатные production-команды выполняются на VPS уполномоченным deploy-пользователем. Локальный docker compose обслуживает локальную среду. Production принадлежит SourceCraft; активные приложения выбираются из production/active.json. Старый указатель current не выбирает активный blue-green цвет.

Owner-aware backup и recovery

После установки проверенного комплекта trusted tools используйте штатный helper:

tools=$(readlink -e /opt/gheilt/preview-runtime/current)
test -f "$tools/scripts/deploy/production-backup.py"
python3 "$tools/scripts/deploy/production-backup.py" --active

Команда меняет состояние и требует согласованного окна обслуживания. Под одним exclusive deploy.lock helper проверяет SourceCraft owner, отсутствие незавершённого promotion, фактические proxy/containers и active receipt. CMS/web выбираются из active.apps внутри lock, поэтому promotion не может сменить процессы записи между выбором и копированием. Legacy fallback для owned production запрещён.

Helper останавливает активную CMS и Garage по зафиксированным container IDs, сохраняет приватную копию и выполняет restore drill на отдельных scratch DB/volumes. PostgreSQL, Caddy и web не останавливаются; CMS, media и новые чтения CMS временно недоступны. При прерывании не удаляйте journal и не выполняйте blanket up:

tools=$(readlink -e /opt/gheilt/preview-runtime/current)
python3 "$tools/scripts/deploy/production.py" --recover

Recovery под тем же lock проверяет owner, завершает адресное восстановление backup writers/scratch, затем восстанавливает production-транзакцию. Ошибка сохраняет intent для дальнейшего расследования. Это не откат на произвольный тег и не восстановление рабочей базы из старого dump.

Новый --active описывает контракт кода этого этапа. До установки соответствующих trusted tools команда на прежнем VPS может быть недоступна; локальные тесты не подтверждают живой production drill.

Примеры с dc ниже относятся к прежнему legacy Compose или изолированной учебной среде. Не применяйте их к SourceCraft-owned production: запуск всего atmanki может вернуть прежние приложения вместо активного цвета. Действующий порядок выпуска и восстановления описан в релизном контракте.

Удобная оболочка для текущего релиза

release_dir=$(readlink -f /opt/gheilt/current)
test -n "$release_dir" && test -f "$release_dir/release.env"
compose=(docker compose --project-name atmanki \
  --env-file /opt/gheilt/.env --env-file "$release_dir/release.env" \
  -f "$release_dir/compose.yaml" -f "$release_dir/compose.production.yaml")
dc() { "${compose[@]}" "$@"; }

Дальнейшие команды dc предполагают эту оболочку. Если current ещё нет, используйте конкретный release directory из неудачного первого запуска.

Статус и логи

dc ps
dc logs --tail=100 web
dc logs --tail=100 strapi postgres
docker stats --no-stream
free -m
df -h

Логи могут содержать payload CMS. Не отправляйте их целиком в публичные issue. docker stats помогает увидеть рост памяти, но не заменяет длительные метрики: Prometheus/Grafana/alerting в проекте не настроены.

curl --fail https://gheilt.mxsource.xyz/api/health
curl --fail https://gheilt.mxsource.xyz/api/trpc/news.list

Аналитика и мониторинг

Для аналитики посещений, ошибок и мониторинга подготовлена self-hosted конфигурация Umami, GlitchTip, Prometheus и Grafana на том же VPS, что и приложения. Файлы в checkout не подтверждают выпуск или проверку сервисов на VPS; практическая глава описывает отдельные критерии этой проверки.

Для учебного проекта один VPS — осознанное упрощение. Мониторинг на нём помогает изучать метрики и разбирать сбои отдельных сервисов, пока сам мониторинг продолжает работать. При отказе всего VPS мониторинг также становится недоступен и не может отправить уведомление об этом отказе. Сбой сети или нехватка ресурсов хоста могут одновременно затронуть приложения и доставку уведомлений.

Независимую внешнюю проверку доступности и отдельный сервер мониторинга сейчас не добавляем: высокая доступность не является целью учебного проекта. Отсутствие уведомлений само по себе не подтверждает исправность сайта.

Применение новых настроек

Для изменения только кода используйте SourceCraft CI. После изменения environment нужно пересоздать контейнеры: restart не перечитывает environment.

dc up -d --no-build --pull never --wait web

Команда использует уже загруженные образы. Наличие отсутствующего образа — повод восстановить доступ к registry через штатный pipeline, а не собирать всё на VPS. Перезапуск CMS и базы должен учитывать активные операции и доступность приложений.

Что резервировать

ОбъектЗачем
/opt/gheilt/.envDB passwords, signing/encryption keys, token
База strapiЗаписи CMS и администраторы
atmanki_s3_metadataКаталог Garage: buckets, keys, объектные ссылки
atmanki_s3_dataБайты S3 объектов
atmanki_strapi_uploadsПрежние local uploads, на которые ещё может ссылаться CMS
PG globalsРоли; файл содержит чувствительные password hashes
Тома CaddyTLS-ключи/конфигурация; можно перевыпустить, но сохранение полезно
Release metadataКакая версия образов работала вместе с копией

Репозиторий не содержит автоматического backup scheduler, offsite-хранилища или retention policy; одноразовый migration backup проверяет PostgreSQL/Garage restore. Копия на том же VPS не спасает от потери VPS. В учебном проекте регулярное и внешнее хранение копий не включаем: отрабатываем процедуру и проверяем восстановление на disposable worker. Существующий production promotion backup и исторические копии сохраняются по своему контракту.

Учебный disposable drill в CI

Повторяемый сценарий уже входит в SourceCraft workflow checks: task check-task, cube test-data-drill вызывает из корня checkout:

python3 scripts/tests/integration_test_refresh.py

Запускайте workflow checks на точном SHA в SourceCraft. Внутри выделенного CI worker установлен SOURCECRAFT_CI=true и доступен Docker; скрипт отказывается работать без этого признака. Это ограничитель от случайного локального запуска, а не проверка доверия: не выставляйте его на VPS или обычном рабочем компьютере. Worker должен быть одноразовым, без live volumes, production env и токенов. Feature --cfg-commit допустим только для checks, как описано в контракте SourceCraft. Образы собираются только в CI.

  1. Скрипт проверяет отсутствие проекта atmanki-test и его прежних volumes/networks, создаёт временный каталог и уникальные теги CMS/HTTP-фикстуры. При конфликте завершает работу до создания данных; повтор выполняют на новом worker.
  2. Создаёт вымышленный published snapshot и наполняет настоящие PostgreSQL 17, Garage и Strapi. Reset использует штатный test-refresh.py: сохраняет пять backend volumes, восстанавливает их в scratch volumes, сравнивает деревья, запускает восстановленный PostgreSQL, читает таблицы и создаёт dump, запускает восстановленный Garage и читает media probe. Проверяется verified receipt.
  3. После успешного reset искусственная ошибка после следующего импорта должна вернуть прежние state и полный набор данных. Повторная проверка контента и media выполняется также после холодного перезапуска.
  4. finally перечисляет ресурсы только по точным labels своего проекта/attempt и уникальным тегам образов. Перед удалением сверяет ownership через inspect; снимает только собственные image tags. Отказ одного удаления не отменяет остальные попытки. Затем новое перечисление должно дать ноль собственных containers, volumes, networks и tagged images. Временный каталог с env, snapshot, архивами и dump удаляется при выходе, в том числе при ошибке.

Успех — exit 0, сообщение Drill cleanup verified с нулевыми остатками и Drill temporary files removed. Одного сообщения о restore недостаточно. При ошибке inventory/inspect/removal или оставшихся ресурсах run завершается с ненулевым exit; отчёт содержит только категории и количества, без Docker output, секретов и контента. Если worker был принудительно убит и finally не исполнился, очистка не подтверждена: удаляется весь одноразовый worker штатными средствами CI. Не выполняйте глобальный prune на общем Docker host ради этого упражнения. Общие base images и build cache сценарий не удаляет.

HTTP companions заменяют web, Storybook и docs: этот drill не проверяет настоящий Next.js, Caddy/TLS или восстановление всего VPS. Он не читает production snapshot и не создаёт постоянное backup-хранилище. Ошибки backup/recovery и ownership отдельно проверяются быстрыми контрактными тестами. Production promotion backup и исторические копии вне worker остаются под своим релизным контрактом; эта процедура их не удаляет.

Пример согласованной копии в окно обслуживания

Это процедура с временной недоступностью CMS/очереди. Сначала отрепетируйте её локально. Не выполняйте команды остановки автоматически ради чтения документации. Далее предполагаются Bash, определённый dc и уже работающий production:

set -euo pipefail
umask 077
exec 9>/opt/gheilt/deploy.lock
flock -n 9
backup_dir="/opt/gheilt/backups/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$backup_dir"
# EXIT пытается вернуть сервисы и при ошибке копирования.
trap 'dc up -d --no-build --pull never --wait --wait-timeout 240' EXIT
dc stop --timeout 60 caddy web strapi s3
cp /opt/gheilt/.env "$backup_dir/environment.env"
cp "$release_dir/release.env" "$backup_dir/release.env"
printf '%s\n' "$release_dir" > "$backup_dir/release-path.txt"
dc exec -T postgres pg_dump -U atmanki -d strapi -Fc > "$backup_dir/strapi.dump"
dc exec -T postgres pg_dumpall -U atmanki --globals-only > "$backup_dir/globals.sql"
for volume in s3_metadata s3_data strapi_uploads caddy_data caddy_config; do
  docker volume inspect "atmanki_$volume" >/dev/null
  docker run --rm -v "atmanki_$volume:/source:ro" -v "$backup_dir:/backup" \
    alpine:3.23 tar -czf "/backup/$volume.tgz" -C /source .
done

Проверьте exit status и наличие архивов. pg_dump берёт согласованный snapshot каждой базы, но два отдельных dump не являются одним межбазовым snapshot. Остановка приложений уменьшает расхождения; таймаут остановки не гарантирует завершение произвольно долгой задачи. Caddy и Garage остановлены на время копирования: metadata SQLite и object files копируются согласованно. Изменяемые файлы работающего Garage архивировать нельзя.

EXIT trap поднимает сервисы. После завершения процесса/скрипта освобождается lock. Если выполняли пример интерактивно, выйдите из этого shell, чтобы освободить fd 9. Затем проверьте доступность приложений и доставьте зашифрованную копию вне хоста. Нельзя копировать активную папку PostgreSQL обычным tar вместо pg_dump.

Учебное восстановление базы без изменения production

На локальном Docker-стеке создайте новую базу с отдельным именем:

docker compose exec -T postgres createdb -U atmanki -O strapi learning_restore
docker compose exec -T postgres pg_restore -U atmanki --no-owner --role=strapi \
  --exit-on-error --single-transaction -d learning_restore < /path/to/strapi.dump
docker compose exec -T postgres psql -U atmanki -d learning_restore -c '\dt'

/path/to/strapi.dump — доступная локальная учебная копия, не приватная production- база. Если база уже существует, выберите другое имя. Не применяйте --clean к рабочей базе для обхода конфликта. Проверка \dt доказывает только наличие таблиц; нужна проверка данных и запуск отдельного экземпляра CMS на восстановленной базе.

Восстановление всего стека на новом изолированном хосте

Восстановите секреты и файлы релиза на изолированном хосте. Запустите только PostgreSQL, восстановите базу Strapi и оба тома Garage, затем запускайте CMS и сайт. Проверьте записи и изображения до переключения трафика.

# На новом хосте, когда текущий release и dc уже подготовлены:
dc up -d --no-build --pull never --wait postgres
dc exec -T postgres pg_restore -U atmanki --no-owner --role=strapi \
  --exit-on-error --single-transaction -d strapi < "$backup_dir/strapi.dump"
docker volume create atmanki_strapi_uploads
docker run --rm -v atmanki_strapi_uploads:/restore -v "$backup_dir:/backup:ro" \
  alpine:3.23 tar -xzf /backup/strapi_uploads.tgz -C /restore

Очистка контейнеров и образов стендов

Статус: очистка реализована в контроллере; выкладка и проверка на VPS — отдельный этап. Сохраняем полные SourceCraft-стеки; shared-хранилища и light/full для этой задачи не нужны. Цель очистки — освобождать место после обновления или удаления стенда, сохраняя возможность холодного запуска и предусмотренного отката без pull.

Что уже делает код

scripts/deploy/preview.sh remove проверяет состояние PR под deploy.lock, удаляет маршруты, контейнеры, сеть и тома его Compose project. При отсутствии current используются точные project labels. Затем удаляются каталог стенда и power state. Перед удалением каталога образы попадают в отдельную очередь /opt/gheilt/image-cleanup; следующий poll удаляет только свободные ссылки. Холодный режим выполняет stop, оставляя контейнеры и их образы; пробуждение использует --pull never.

При обновлении Compose с --remove-orphans заменяет изменившиеся сервисы; неизменившиеся контейнеры может сохранить. Каталог релиза неизменяем: identity связывает merge-SHA и hash descriptor с четырьмя digest. Перед provisioning/import проверяется совместимость CMS. При сбое финального smoke возвращается весь прежний готовый стек, включая CMS; current/state/power восстанавливаются после health. Ошибка восстановления сохраняет закрытый стенд и pending images. Возврат образов не отменяет миграцию данных: неизвестная схема требует отдельного плана. До подтверждения восстановления автоматическое удаление образов запрещено.

Сценарии и поведение

СобытиеКонтейнерыОбразы и сохранённые поколения
Успешное обновление A → BCompose заменяет изменившиеся сервисы; удаляем только доказанные orphan-контейнеры этого projectСохраняем B и один предыдущий успешный набор A из четырёх digest
Следующее успешное обновление B → CПроверяем точные labels и ID оставшихся контейнеровСохраняем C/B; A становится кандидатом, если нигде больше не нужен
Повтор доставки того же поколенияНе пересоздаём исправный стек ради уборкиНе создаём лишнюю запись предыдущей версии; повтор очистки идемпотентен
Неудачный pull или отказ по ресурсамРабочую версию сохраняемСохраняем образы незавершённого кандидата до завершения или явного отказа от попытки
Неудачный первый deployОстанавливаем доказанные частичные application-контейнеры; сохраняем данные для повтораОбразы закреплены за незавершённой попыткой; полное удаление выполняется при закрытии/TTL
Неудачное обновление и откатЗакрываем доступ; возвращаем прежний полный стек и проверяем health. Неизвестная схема блокирует попытку до изменения данныхТекущий и pending-наборы защищены; новый успешный deploy завершает попытку. Ошибка восстановления оставляет образы для диагностики
PR закрыт или истёк TTLУдаляем только этот project, его маршруты и данные по существующему контрактуВсе известные поколения этого PR становятся кандидатами после удаления контейнеров
Новый push во время уборкиПод lock повторно проверяем provider/head/generation; устаревшая уборка прекращаетсяПеред каждым удалением заново проверяем актуальные ссылки
Холодный стенд или LRU-охлаждениеТолько stop; контейнеры сохраняютсяТекущий и предусмотренный предыдущий наборы остаются локально
Один образ нужен нескольким стендамЧужие контейнеры сохраняются, в том числе остановленныеОбраз остаётся, пока на него ссылается хотя бы один защищённый набор или контейнер
Смена release-линии stagingОбновляем существующий стек с сохранением редакторских данныхПрежнее поколение защищаем как предыдущий успешный набор, даже при одинаковом SHA
Production blue-greenАктивный и сохранённый резервный цвет исключаем из уборки PR/test/stagingОбразы production и незавершённого promotion/recovery всегда защищены
Сбой уборки после удаления PRНе повторяем удаление по одному имени без проверки актуального поколенияЗакрытая запись кандидатов сохраняется отдельно от каталога PR; следующий poll повторяет только оставшиеся операции
Неизвестные labels, manifest, image ID или повреждённое состояниеНе удаляем неизвестные контейнерыАвтоматическая очистка пропускается с диагностикой; требуется аудит

«Предыдущий набор» закрепляет digest и immutable release identity, а deployment-previous.json — прежний ready state, runtime power и release path для восстановления. Новые release.env/manifest/config не перезаписываются по SHA. Legacy SHA-каталоги остаются читаемыми, но наличие каталога не доказывает успешный smoke. Это не backup БД/media; безопасный возврат CMS требует совпадения контракта. После health возвращается прежний current/state/power; если стенд был холодным, он снова останавливается. Observed head и lease сохраняются. Pending attempts защищены до подтверждённого завершения, независимо от результата cleanup.

Как отличать свободный образ от нужного

Кандидаты ограничены четырьмя application images, явно записанными lifecycle этого проекта. PostgreSQL, Garage, Caddy, observability, backup/restore helpers, build cache и неизвестные локальные образы исключены. Перед удалением собираем ссылки текущих и предыдущих успешных поколений всех стендов, pending attempts, production active/recovery и всех контейнеров Docker, включая остановленные. Ссылки на digest сопоставляем с локальными image ID: разные ссылки могут вести на один и тот же образ. При неоднозначности сохраняем весь образ.

Защиту кандидата записываем до первого pull: сейчас receive.pull_images вызывается раньше locked deploy, и одна блокировка уборки без учёта скачивания не предотвращает удаление нового образа между pull и запуском. Запись завершённого успеха и выбор предыдущего набора выполняются только после smoke и ready. Если HTTP smoke прошёл, но запись ready прервалась, попытка остаётся защищённой.

Перед удалением каталога PR сохраняем его кандидаты в закрытую очередь с id и lastRemoved head/generation; pending/current содержат head и полный набор digest. Применение использует общий deploy.lock, повторную проверку ссылок и точные ID. Контейнеры убираются раньше образов. Ошибка очистки фиксируется отдельно от результата деплоя; очередь позволяет повторить уборку следующим циклом существующего контроллера. Нехватка места не отменяет защиту холодных стендов или rollback-набора.

При первой установке журналов уже работающий стенд сохраняет всю известную историю образов: manifests не доказывают, какое прежнее поколение прошло smoke. Первое новое успешное обновление записывает предыдущим доказанный ready-набор и разрешает обычную уборку более старых ссылок. Неудачная первая попытка обновления эту защиту не снимает.

Глобальные docker system prune, container prune и image prune -a не подходят: Docker не знает о сохранённых release manifests. Удаление делается адресно через docker image rm <repository@digest> без --force. В dry-run отчёте перечисляются точные кандидаты, защищающие ссылки и причины отказа без env и секретов. Общие слои могут оставаться после удаления ссылки; освобождённое место измеряется, а не вычисляется суммированием размеров образов. Registry retention — отдельная задача: локальное удаление образа не удаляет его из YC Registry. Поведение image rm, границы image prune.

Что проверить до включения

Контрактные тесты должны воспроизвести все строки таблицы: особенно холодный стенд без доступа к registry, одинаковый image ID у разных digest, обновление head во время уборки, повтор одной попытки, частичный pull, ошибку отката и повреждённое состояние. В изолированном CI нужны два полноценных стенда с общим application image, обновления A → B → C, принудительная ошибка обновления, холодный запуск с --pull never, удаление одного PR и повтор уборки после сбоя. Сверяем точные container/image ID и сохранность второго стенда и его volumes. Живая приёмка сначала выполняется на отдельном проверочном PR; глобальная уборка на VPS в эту проверку не входит.

Production → testing: процедура обновления данных

При импорте snapshot оригинальные медиа не оптимизируются повторно: Strapi обычно перекодирует изображения при загрузке, что меняет байты и SHA-256. Исключение действует только внутри отдельного процесса импорта; обычная загрузка через CMS сохраняет свои настройки.

Статус: реализована отдельная команда reset с dry-run и восстановлением. Перед переносом живых данных обязательны успешный контейнерный drill точного commit и проверка на VPS. Код и CI не заменяют подтверждение живого переноса. Здесь testing — окружение test. Staging и PR не обновляются этой операцией. Используем уже принятый published snapshot текущей production CMS/Garage: опубликованные документы, их связи, используемые файлы и форматы изображений. Это перенос контента приложения, а не PostgreSQL streaming replication. Черновики, администраторы, сессии, API-токены, webhook, production-пароли и неиспользуемые S3-объекты в test не переносятся. Полный clone production БД — отдельная задача с проверкой закрытых данных и доступов, а не режим этого импортера.

Что уже работает и где граница

При первом запуске preview.sh prepare выбирает CMS из production active.json, экспортирует опубликованный контент и затем импортирует его в пустой выделенный стек. Обычные обновления кода сохраняют test-контент. Импортер хранит checksum снимка и прогресс, позволяет повторить тот же импорт после прерывания и отказывается принимать другой снимок поверх существующего контента. Удаление файла imported не превращает этот путь в обновление данных.

Exporter сравнивает несколько чтений документов, в том числе после скачивания media, и отклоняет обнаруженные изменения источника. Файлы проверяются по SHA-256, размеру и ссылкам; модели — по fingerprint. Это защита от обнаруживаемых изменений, не атомарный snapshot общей транзакции PostgreSQL/S3. Для воспроизводимого переноса согласуйте окно без редакторских изменений и удалений media; чтение production сайта продолжает работать. Нестабильный источник требует нового экспорта.

Политика тестовых правок

РежимРезультатПоддержка сейчас
Сохранить редакторские правкиСуществующий test продолжает работать; новый снимок не накладывается поверх негоОтказ при конфликте уже реализован; merge новых production-правок не реализован
Заменить контент testПосле backup test получает новый опубликованный production-контент; прежние тестовые правки остаются только в backup/сохранённых томахКоманда test-refresh.py --mode reset --apply; требуется согласовать замену test

Режим должен быть явно указан перед запуском. Reset не запускается по расписанию, при push, обновлении образов или повторе CI. Согласование режима не означает разрешения удалить текущие тестовые данные без готового backup и проверки отката.

Порядок для reset

  1. Зафиксировать источник и цель. Под общим deploy lock проверить SourceCraft provider, id=test, текущие head/generation, production active receipt и доступные RAM/диск. Записать image digests, пути конфигурации и точные ID томов test. Источник выбирается из active receipt, не по предположению о blue/green. Тестовые образы не заменяются production-образами.
  2. Получить новый неизменяемый снимок. Экспортировать в отдельный закрытый каталог, сохранив production receipt, время, checksum manifest и количества документов/файлов. Read-only production token используется только exporter; его временный env удаляется до запуска target importer. Снимок не попадает в публичный CI artifact. Нельзя переиспользовать старый snapshot только потому, что в нём уже есть manifest.
  3. Проверить до изменения test. Проверить файлы и fingerprint моделями текущего test Strapi image. CLI node scripts/import-published.mjs --snapshot /snapshot --dry-run проверяет сам снимок без загрузки target CMS; он не проверяет владельца test, конфликты живой БД, ресурсы или план reset. Эти проверки нужны отдельно. Несовместимые модели требуют отдельного изменения схемы; процедура не продвигает новые образы автоматически.
  4. Закрыть записи и пробуждение test. Остановить обслуживаемые test-приложения, закрыть его маршруты на время обслуживания и запретить HTTP wake/CI deploy этого поколения. Повторно сверить lease под lock. Production, другие PR, staging и их данные продолжают работать. Простого удаления контейнера недостаточно: обычный runtime может попытаться поднять его снова.
  5. Сохранить восстановимый test. Сохранить архивы всех пяти тестовых томов, dump БД из восстановленной копии, test env, исходный state, указатель data-volumes и image digests. Current, routes и существующий import marker эта операция не заменяет; конфигурация текущего релиза остаётся в его каталоге. Проверить restore в изолированной БД/хранилище, затем удалить только scratch ресурсы проверки. Backup включает тестовые черновики и пользователей; права каталога 0700, файлов 0600. При отказе backup восстановить старый test и завершить операцию до reset. Сохранённый backup закрепляет образы от уборки.
  6. Импортировать в пустое тестовое хранение. Подготовить новые выделенные тома этого test, сохранив старые тома для отката. Target importer получает только test DB/S3 credentials и read-only mount нового снимка. Тестовые signing keys и настройки доступа не копируются с production. Provisioning создаёт собственные test admin/token/webhook; URLs и связанные media переписываются на test. Только после успешного импорта обновить его markers.
  7. Проверить и открыть test. Сверить полный отчёт импорта с manifest, опубликованные коллекции, Site и связи; ID target могут отличаться от source. Проверить отсутствие ссылок на production CMS/media, Content API read-only, /api/health с прежним test SHA, /api/content-health, CMS, Storybook/docs и media Range. Сбросить кеш web штатной ревалидацией. Затем сохранить новый generation/state и receipt обновления данных, восстановить маршруты и cold lifecycle. Снимок описывает состояние на время экспорта, не текущую копию production в реальном времени.
  8. При сбое вернуть весь прежний test. Остановить новые контейнеры, вернуть старые тома/config/env/markers/current/routes и проверить прежний контент, CMS и web до открытия маршрутов. Не смешивать старую БД с новыми media или наоборот. Если восстановление не удалось, сохранить закрытые маршруты, обе версии и журнал для оператора. Незавершённая попытка и её образы защищены от уборки; повтор использует тот же проверенный снимок и сохранённый прогресс.

Перед применением нужен dry-run отчёт с источником, целью, поколением, числами документов/media, результатом проверки моделей и списком заменяемых test-ресурсов. В отчёте нет токенов, паролей и содержимого документов. Законченный перенос сохраняет snapshot checksum, backup path, прежние/новые resource ID и результаты проверок; старые тома не удаляются автоматически вместе с application images.

Команды и подключение

Команды выполняет deploy-пользователь после установки проверенных trusted tools:

tools=$(readlink -e /opt/gheilt/preview-runtime/current)
# Только план: Docker и журналы не меняются.
python3 "$tools/scripts/deploy/environment-cleanup.py" collect
# Адресное применение того же алгоритма под deploy.lock.
python3 "$tools/scripts/deploy/environment-cleanup.py" collect --apply

Отчёт содержит digest, причину protected, container или unused и protectedBy — защищающие ссылки на тот же image ID. unused может означать, что ссылка уже отсутствует локально: повтор в этом случае завершает очередь. Контроллер вызывает сборку мусора после обработки стендов; ошибка уборки не отменяет успешный deploy. Отдельный standalone receiver тоже закрепляет candidate до pull и требует установленные trusted lifecycle tools.

Команды оператора

Запускайте из того же immutable каталога tools, которым обслуживается runtime. Перед первым reset обновите работающий dispatcher: проверка /capabilities на его Unix socket не позволит старому процессу запустить reset без поддержки новых томов и блокировки запросов. Обычный deploy не вызывает эту команду.

tools=$(readlink -e /opt/gheilt/preview-runtime/current)
# Экспорт и проверка нового published snapshot; test продолжает работать.
python3 "$tools/scripts/deploy/test-refresh.py"
# После согласования замены редакторских данных test:
python3 "$tools/scripts/deploy/test-refresh.py" --mode reset --apply
# Если операция прервана, восстановление запускается отдельно:
python3 "$tools/scripts/deploy/test-refresh.py" --recover --apply

Можно передать заранее проверенный закрытый каталог через --snapshot /absolute/path. Режим preserve отказывается изменять test: автоматического merge контента нет. Dry-run никогда не запускает recovery; при незавершённой операции он останавливается.

Операция держит /opt/gheilt/deploy.lock. Это также временно задерживает lifecycle других стендов и promotion, хотя работающие контейнеры продолжают обслуживать запросы. Журнал /opt/gheilt/test-refresh/intent.json дополнительно закрывает test для HTTP и новых deploy после падения процесса. Backup сохраняет все пять backend томов, env и исходный state. Проверяются извлечённые деревья, запуск PostgreSQL с чтением таблиц/dump и запуск Garage; если в прежнем test есть media, сравнивается один реальный объект. При пустой библиотеке проверяется запуск и bucket.

Новые тома закрепляются в /opt/gheilt/previews/test/data-volumes.json; эту настройку используют дальнейшие deploy и холодное пробуждение. После импорта проверяются опубликованные документы/связи, read-only token и все snapshot-файлы, включая Range. Compose healthchecks проверяют CMS/Storybook/docs, web проверяет SHA и content-health; кеш сбрасывается через штатный webhook. Маршруты не пересоздаются: до удаления intent dispatcher отвечает 503. После открытия выполните публичный smoke по доменам test — внутренние проверки не подтверждают TLS и весь путь через Caddy.

При сбое до commit возвращаются старые тома/env/state. Если state уже переключён на записанный новый generation/checksum, recovery проверяет и завершает новое поколение, а не затирает его. Неудачный recovery оставляет intent и закрытый test. Старые и неудачные новые тома остаются для аудита; эта задача автоматически удаляет только application images, а не сохранённые данные.

Обязательные проверки процедуры

Первое наполнение пустого test; повтор того же снимка; новый снимок при наличии test-правок в обоих режимах; публикация/удаление source media во время экспорта; несовместимые модели; повреждённый или неполный snapshot; нехватка места до reset; ошибка импорта после части документов/media; отказ ревалидации или открытия маршрутов; прерывание после переключения до receipt; попытка wake/deploy/cleanup во время reset. Контрактные тесты проверяют порядок backup/import/commit, dry-run, восстановление и блокировку wake; это не заменяет контейнерный drill. После каждого искусственного сбоя проверяются прежний test, его drafts/media и неизменность production и соседнего PR. SourceCraft check-task/test-data-drill запускает scripts/tests/integration_test_refresh.py: настоящие PostgreSQL, Garage и Strapi, фиктивные HTTP companions, успешный reset, отказ после импорта и холодный запуск. У него нет production secrets или данных; локальные production-сборки запрещены. Этот drill не подтверждает TLS/Caddy и поведение настоящего Next.js — публичный smoke остаётся отдельным шагом.

Код процедуры: scripts/deploy/test-refresh.py, scripts/deploy/preview-data.py, infra/strapi/scripts/verify-published.mjs; существующие части: scripts/deploy/preview-source.py, infra/strapi/scripts/lib/published-snapshot.mjs, infra/strapi/scripts/lib/import-published.mjs.

Ручной откат образов

Для отката только web/Caddy на VPS в Bash выберите существующий release. CMS и S3 сохраняются; их откат выполняется отдельно после проверки данных:

rollback_dir=/opt/gheilt/releases/REPLACE_WITH_COMMIT_SHA
old_compose=(docker compose --project-name atmanki \
  --env-file /opt/gheilt/.env --env-file "$rollback_dir/release.env" \
  -f "$rollback_dir/compose.yaml" -f "$rollback_dir/compose.production.yaml")
exec 9>/opt/gheilt/deploy.lock
flock -n 9
# Use the current renderer even when the selected release predates monitoring.
fragment=/opt/gheilt/observability/current/infra/observability/proxy.caddy
if [[ -f "$fragment" ]]; then
  python3 /opt/gheilt/observability/current/scripts/deploy/render-caddy.py --replace-observability \
    "$rollback_dir/infra/Caddyfile.production" "$fragment" > "$rollback_dir/Caddyfile.combined"
  cat "$rollback_dir/Caddyfile.combined" > "$rollback_dir/infra/Caddyfile.production"
fi
"${old_compose[@]}" config --quiet
mapfile -t images < <("${old_compose[@]}" config --images)
docker image inspect "${images[@]}" >/dev/null
"${old_compose[@]}" up -d --no-deps --no-build --pull never --wait --wait-timeout 240 web caddy
curl --fail https://gheilt.mxsource.xyz/api/health
if [[ -f "$fragment" ]]; then
  curl --fail https://analytics.gheilt.mxsource.xyz/api/heartbeat
  curl --fail https://errors.gheilt.mxsource.xyz/_health/
  curl --fail https://monitoring.gheilt.mxsource.xyz/api/health
fi
ln -sfn "$rollback_dir" /opt/gheilt/current.next
mv -Tf /opt/gheilt/current.next /opt/gheilt/current

Сначала замените SHA, проверьте совместимость и наличие образов. Это не откат базы. Для постоянного исправления измените Git/branch: следующий push main снова применит содержимое main. Не воспринимайте удачный health как полную проверку старого релиза.

Обновление зависимостей и освобождение диска

Обновляйте небольшими группами: workspace через pnpm, CMS через её npm lockfile, инфраструктуру через image tags. При major-обновлении PG нужен план миграции данных; замена 17-alpine на новый major поверх старого volume не является таким планом.

Сначала df -h, docker system df и список релизов. Не запускайте глобальный prune с volumes: можно потерять данные и образы для rollback. Для известных application digests реализована адресная очистка, описанная выше. Retention резервных копий, сохранённых томов и образов в registry автоматически не выполняется.

Дополнение для Garage

Прежний пример backup покрывает PostgreSQL и local uploads, но после добавления S3 его недостаточно: отдельно нужны объекты atmanki_s3_data и metadata atmanki_s3_metadata, а также ключи из .env. Для согласованной cold-копии остановите Strapi и Garage; на это время media недоступны. Скопируйте оба тома, затем поднимите Garage, дождитесь health и поднимите CMS. Проверьте восстановление на отдельном экземпляре. Не снимайте работающую SQLite metadata обычным tar и не считайте копию на том же VPS защитой от потери VPS.

Проверяемая предмиграционная копия

release.sh вызывает scripts/deploy/backup-content.sh один раз перед первой новой CMS под deploy flock. Он сохраняет SQL, оба Garage volumes, old uploads, env и release refs в каталог 0700, восстанавливает SQL в отдельную scratch DB и Garage в отдельную сеть с отдельными volumes. Cleanup удаляет только созданные scratch объекты и возвращает прежние приложения. Маркер /opt/gheilt/content-migration.backup указывает на копию. Это проверка миграции, не планировщик ежедневных backups и не offsite backup.

Ручной повтор выполняйте в согласованное окно обслуживания с deploy lock. Новую CMS/data не откатывайте запуском старого Strapi image: используйте проверенную копию на изолированном стенде и отдельный план восстановления. Rollback web использует --no-deps и сохраняет CMS/S3. Не удаляйте volumes исходного проекта ради restore drill.

current хранит релиз web, cms-current — фактически работающий релиз CMS. Во время подготовки и частичного rollback они могут различаться; backup сохраняет обе ссылки и возвращает каждую компоненту по её собственной версии. Garage restore drill также читает контрольный объект и сравнивает байты, а не только проверяет bucket info.

Эксплуатация стека наблюдаемости

Конфигурация находится в infra/observability; до первого выпуска ограничения из раздела о планируемом мониторинге сохраняются. После выпуска используется отдельный project, который можно диагностировать без пересоздания приложений:

obs_release=$(readlink -f /opt/gheilt/observability/current)
obs=(docker compose --project-name atmanki-observability \
  --env-file /opt/gheilt/observability/.env \
  --env-file "$obs_release/infra/observability/images.env" \
  -f "$obs_release/infra/observability/compose.yaml")
"${obs[@]}" ps
docker stats --no-stream

Секреты не выводите через docker inspect environment или compose config. Для проверки синтаксиса используйте config --quiet.

В резервные копии добавьте базы umami и glitchtip, секреты observability, atmanki-observability_grafana_data (аккаунты и настройки Grafana) и atmanki-observability_glitchtip_uploads (артефакты source maps). Изменяемую SQLite-базу Grafana копируют согласованно с остановкой её контейнера либо штатным способом backup, а не произвольным tar работающего volume.

После определения dc из начала главы можно получить отдельные SQL snapshots:

dc exec -T postgres pg_dump -U atmanki -d umami -Fc > "$backup_dir/umami.dump"
dc exec -T postgres pg_dump -U atmanki -d glitchtip -Fc > "$backup_dir/glitchtip.dump"

backup_dir должен быть заранее подготовленным защищённым каталогом. Два dump — два snapshots, а не общая транзакция. Резервируйте роли вместе с остальными PostgreSQL globals и проверяйте restore на изолированном экземпляре. Prometheus history — временные измерения, она не нужна для повторного запуска метрик; её потеря оставляет разрыв на графике, а не повод показывать нули.

Проверяемый backup для будущего blue-green

scripts/deploy/production-backup.py — отдельный путь под deploy.lock. Он сверяет реальные backend mounts, останавливает только CMS/S3 писателей и сохраняет PostgreSQL dump, Garage metadata/data и uploads. Caddy не пересоздаётся. Restore проверяется в scratch database по counts всех текущих public tables и в отдельных Garage volumes/network по чтению контрольного объекта. Старое имя таблицы news не используется как предположение о текущих моделях.

Private backup (directory700/files600) остаётся в /opt/gheilt/backups. verified.json появляется только после restore и восстановления здоровья исходных писателей. Durable production/backup-intent.json позволяет вернуть контейнеры по прежним ID после прерывания, без старого Compose. При ошибке cleanup intent и backup сохраняются; production backup не отправляется CI. Скрипт пока проверен контрактными тестами в рабочей ветке; живой restore drill входит в приёмку релизной доставки. Копия на этом VPS не заменяет внешнюю backup.

Restore drill также извлекает strapi_uploads.tar в отдельный временный volume. Дерево сверяется по типам, правам, владельцам, link targets и хешам файлов; повреждённый архив или расхождение запрещает verified receipt. Volume включён в crash journal и удаляется при восстановлении.

Восстановление прерванного обновления CMS своего PR

Незавершённый preview-upgrades/pr-N/intent.json закрывает PR для HTTP и lifecycle. Не удаляйте journal вручную и не запускайте прежний image на новых томах. Из установленного immutable tools запустите отдельное восстановление:

tools=$(readlink -e /opt/gheilt/preview-runtime/current)
python3 "$tools/scripts/deploy/preview-deployment.py" recover pr-42

Команда сама берёт общий deploy.lock; внешний flock вокруг неё не нужен. До commit восстанавливается прежнее поколение, включая холодное состояние, если оно было сохранено. После durable binding.json восстанавливается только новый release с его данными. Повторный recovery не запускает миграцию ещё раз. Если проверка или запуск не удались, journal остаётся, маршруты остаются закрытыми.

Приватные архивы всех пяти томов, прежний env, PostgreSQL dump и хеши строк лежат в preview-upgrades/pr-N/<attempt>/; файлы не публикуются как CI artifacts. previews/pr-N/data/<attempt>/ хранит environment и Compose pointer клона. Эти каталоги содержат чувствительные данные собственного PR и требуют закрытых прав. Пока intent не завершён или восстановление не удалось, приватные архивы и связанные образы остаются защищёнными. После подтверждённого resume или rollback штатная адресная cleanup удаляет завершённые scratch-каталоги с архивами, dump и хешами; действующий и предыдущий Compose pointer сохраняются. Удаление PR проверяет его ownership и не затрагивает чужие или незавершённые поколения. Регулярное и внешнее backup-хранилище эта процедура не создаёт.

Проверьте после recovery прежние draft/published документы, медиа и вход редактора, затем публичные адреса через Caddy. Внутренний content-health не доказывает TLS. Сама возможность исполнить recovery не означает, что этот этап уже опубликован и проверен на живом VPS.