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

XI. DevOps

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

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

Примеры production-команд ниже выполняются на VPS в Bash пользователем atmanki-deploy или уполномоченным администратором. Нельзя заменить их локальным docker compose и ожидать, что команда затронет сервер.

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

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 мониторинг также становится недоступен и не может отправить уведомление об этом отказе. Сбой сети или нехватка ресурсов хоста могут одновременно затронуть приложения и доставку уведомлений.

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

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

Для изменения только кода используйте GitHub Actions. После изменения 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. Храните шифрованную копию отдельно и проверяйте восстановление.

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

Это процедура с временной недоступностью 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 заменяет изменившиеся сервисы; неизменившиеся контейнеры может сохранить. Старые release directories и образы остаются. Текущий обработчик ошибки пытается вернуть предыдущие web/Storybook/docs, но не восстанавливает Strapi и данные. Поэтому ошибка деплоя не является доказательством завершённого отката: до подтверждения восстановления автоматическое удаление образов запрещено.

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

СобытиеКонтейнерыОбразы и сохранённые поколения
Успешное обновление A → BCompose заменяет изменившиеся сервисы; удаляем только доказанные orphan-контейнеры этого projectСохраняем B и один предыдущий успешный набор A из четырёх digest
Следующее успешное обновление B → CПроверяем точные labels и ID оставшихся контейнеровСохраняем C/B; A становится кандидатом, если нигде больше не нужен
Повтор доставки того же поколенияНе пересоздаём исправный стек ради уборкиНе создаём лишнюю запись предыдущей версии; повтор очистки идемпотентен
Неудачный pull или отказ по ресурсамРабочую версию сохраняемСохраняем образы незавершённого кандидата до завершения или явного отказа от попытки
Неудачный первый deployОстанавливаем доказанные частичные application-контейнеры; сохраняем данные для повтораОбразы закреплены за незавершённой попыткой; полное удаление выполняется при закрытии/TTL
Неудачное обновление и откатЗакрываем доступ; останавливаем CMS кандидата. Возврат web/Storybook/docs остаётся частичным; данные/CMS проверяются отдельноТекущий и 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 или повреждённое состояниеНе удаляем неизвестные контейнерыАвтоматическая очистка пропускается с диагностикой; требуется аудит

«Предыдущий набор» закрепляет только head и image digests. Старые release directories не очищаются, но повторная сборка того же SHA может перезаписать его release.env. Журнал не является готовой копией всей прежней конфигурации. Это не копия базы и медиа и не гарантия совместимости старой CMS с изменённой схемой. Не добавляем автоматический rollback данных ради очистки. Сохранение набора относится к поколению и digest, а не только к SHA: повторная сборка того же commit может получить другие образы.

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

Кандидаты ограничены четырьмя 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 и удаляется при восстановлении.