Бэкапы, обновление, разбор падения

выкладка≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

Данные базы лежат в томе, и том переживает пересоздание контейнера. Отсюда легко сделать вывод, что о сохранности можно больше не думать, — и вывод неверный. Том не спасает ни от команды, которая его стирает, ни от DELETE без WHERE, ни от диска, который кончился, ни от VPS, выключенного хостингом за неоплату. Во всех этих случаях данные пропадают вместе с томом или внутри него. Бэкап — это отдельная копия, которая лежит не там же.

У PostgreSQL копия бывает двух родов. Логическая — текст SQL, который восстанавливает схему и строки обычными командами: её снимает pg_dump, после сжатия она невелика, читается глазами и переносится между мажорными версиями. Физическая — побайтная копия каталога данных вместе с журналом упреждающей записи; она умеет «на минуту до аварии», но требует того же мажора сервера и места под журналы. Одному VPS с одной базой хватает первой, и весь раздел про неё.

Первая ловушка ждёт там, где её не ищут, — в версии клиента. Сервер у нас 18-й, а pg_dump на хосте приехал с каким-то другим пакетом и оказался 17-м. Прогноз: что напечатает каждый из двух клиентов?

запись с машины: macOS arm64, временный кластер PostgreSQL 18.4 (Homebrew); клиенты pg_dump из пакетов postgresql@18 (18.4) и libpq (17.5), каталоги переданы в CLIENTS · 28 сентября 2026
два клиента pg_dump разных мажорных версий на одном и том же сервере
# Один и тот же сервер, два клиента pg_dump разных мажорных версий.
# Каталоги клиентов приходят в CLIENTS: на хосте это тот postgresql-client,
# который поставил пакетный менеджер, в контейнере — клиент самого образа.
set -eu
export PGUSER=${POSTGRES_USER:-app}
DB=${POSTGRES_DB:-app_db}

echo "сервер: $(psql -tAc 'show server_version' -d "$DB")"
for DIR in $CLIENTS; do
  echo "--- клиент pg_dump $("$DIR/pg_dump" --version | cut -d' ' -f3)"
  if "$DIR/pg_dump" -d "$DB" > dump.sql 2> err.txt; then
    echo "дамп снят, строк SQL $(wc -l < dump.sql | tr -d ' ')"
  else
    echo "код выхода $?"
    cat err.txt
  fi
done
rm -f dump.sql err.txt
sh dump-major.sh

Клиент 17-й версии не знает каталогов 18-й и отказывается сам, кодом 1, не сняв ни строки. Отказ громкий и честный: половина дампа была бы хуже. Отсюда правило боевого сценария — pg_dump запускают внутри контейнера базы, а не на хосте: там клиент и сервер приехали одним образом и разъехаться не могут.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка set -euo pipefail несёт больше, чем кажется. Код выхода трубы — это код последней команды, а последняя тут gzip: упавший pg_dump отдал бы ему пустой поток, тот записал бы 20-байтовый архив и вернул ноль. Файл есть, дата свежая, сценарий доволен. Спасает pipefail — с ним сценарий обрывается до ротации и не успевает удалить рабочий дамп в пользу пустого.

Но проверять всё равно надо не «файл появился», а сам файл, и делать это должен не человек. Минимальный монитор — две проверки: свежесть и размер против того, каким этот дамп обычно бывает. Прогноз: сколько файлов из пяти он признает годными и что скажет про последний?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Свежесть у последнего файла в порядке, а размер — нет, и это самый частый вид мёртвого бэкапа: снимается каждую ночь, файл появляется, дата новая. Порог «не меньше половины медианы» груб, зато не требует знать, сколько весит база, растёт вместе с ней и ловит ровно тот случай, когда дамп не снялся вовсе.

Проверка, которая кажется разумной и не работает: сравнить два дампа побайтно. У PostgreSQL 18 в начале и конце файла стоит \restrict со случайным ключом, и два дампа одних и тех же данных не совпадают. Сравнивают содержимое базы, а не файлы.

Теперь о том, чего в дампе базы нет. Роли и их пароли — объекты кластера, а не базы, и pg_dump их не берёт; при этом строки вида ALTER TABLE … OWNER TO app в дампе есть, то есть роль app обязана существовать раньше восстановления. Фотографии лежат не в базе. Файла с секретами в базе нет вовсе.

ЧтоЧем спасаетсяКак это устроено в стеке учебника
Схема и строки базыpg_dump внутри контейнера базыночной deploy/backup.sh, дампы в deploy/backups/, последние четырнадцать
Роли и пароли кластераpg_dumpall --globals-onlyне снимается: роль заводит сам образ по переменным стека
Фотографиизеркало объектного хранилищаодноразовый контейнер с mc mirror, туда же в backups/
Файл с секретамикопия вне сервера.env.prod живёт на VPS и в вашем хранилище паролей
Образыпересборка из репозиторияни в дампе, ни в томе их нет — и не нужно

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

Лишние дампы убирает ls -1t — список по времени изменения, свежие первыми, — а tail -n +15 оставляет четырнадцать. Сортировка по имени дала бы то же самое при одном условии: дата в имени записана от крупного к мелкому, 2026-09-28. С другим форматом порядок имён перестаёт быть порядком дат, и ротация начинает удалять самое свежее. Это первая задача статьи.

И второе: четырнадцать дампов на том же VPS переживут ошибку в приложении, но не смерть сервера. Копию периодически забирают к себе — тем же scp, которым увозят зеркало фотографий.

Дамп, который ни разу не поднимали, — это не копия, а файл. Между «pg_dump вышел с нулём» и «из этого файла встаёт работающая база» лежит целый класс поломок: прав не хватило на часть объектов, дамп снимали с фильтром по схеме, роль владельца в новой базе называется иначе, дамп снимали не с той базы. Каждая из них видна только при восстановлении.

Проверка — три шага: снять дамп, поднять его в чистой базе, сверить содержимое с тем, откуда снимали. Третий обычно и пропускают, ограничившись «команда не отказала». Прогноз: что напечатает сверка и какие таблицы попадут в её список?

запись с машины: macOS arm64, временный кластер PostgreSQL 18.4 (Homebrew) на свободном порту; база app_db со схемой packages/db (три пользователя, 28 заметок) и журналом drizzle. На боевой машине те же команды идут внутри контейнера postgres · 28 сентября 2026
дамп, восстановление в чистую базу и сверка содержимого таблица за таблицей
# Проверка бэкапа: снять дамп, поднять его в ЧИСТОЙ базе и сверить содержимое.
# На боевой машине это идёт внутри контейнера с базой — клиент там заведомо
# той же мажорной версии, что и сервер:
#   docker compose exec -T postgres sh /backup/restore-check.sh
# Хост и порт libpq берёт из окружения; внутри контейнера это локальный сокет.
set -eu

DB=${POSTGRES_DB:-app_db}
FRESH=${DB}_check
export PGUSER=${POSTGRES_USER:-app} PGDATABASE=$DB

echo "сервер: $(psql -tAc 'show server_version'), pg_dump: $(pg_dump --version | cut -d' ' -f3)"

mkdir -p backups
DUMP=backups/${DB}_$(date +%F_%H-%M).sql.gz
# дамп собирается файлом, а не трубой в gzip: у трубы код выхода берётся от
# последней команды, и падение pg_dump в нём теряется
pg_dump > "$DUMP.plain"
gzip -f "$DUMP.plain" && mv "$DUMP.plain.gz" "$DUMP"
echo "дамп: $DUMP, строк SQL $(gunzip -c "$DUMP" | wc -l | tr -d ' ')"

PGOPTIONS='-c client_min_messages=warning' dropdb --if-exists "$FRESH"
createdb "$FRESH"
# ON_ERROR_STOP=1: восстановление обязано остановиться на первой же ошибке
if gunzip -c "$DUMP" | psql -q -o /dev/null -v ON_ERROR_STOP=1 -d "$FRESH"; then
  echo "восстановление в чистую базу $FRESH: без ошибок"
else
  echo "восстановление в чистую базу $FRESH: psql отказал"
  exit 1
fi

psql -tAF' ' -d "$DB"    -f check.sql > before.txt
psql -tAF' ' -d "$FRESH" -f check.sql > after.txt
cat after.txt
if diff -q before.txt after.txt > /dev/null; then
  echo "сверка: содержимое совпало"
else
  echo "сверка: РАСХОЖДЕНИЕ"
  diff before.txt after.txt
fi

dropdb "$FRESH"
rm -f before.txt after.txt
sh restore-check.sh

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

Слово «чистая» во втором шаге — не про аккуратность. Что psql после ошибки дочитывает файл до конца и выходит с нулём, вы уже видели на трёх вставках; здесь та же механика стоит дороже. Прогноз: тот же дамп поднимают в базу, где схема уже есть и строки тоже, — сколько удалённых строк вернётся на место?

запись с машины: macOS arm64, тот же временный кластер PostgreSQL 18.4 и та же база app_db, что у прогона ship-backup-restore · 28 сентября 2026
тот же дамп поверх базы, в которой уже есть схема и строки
# Восстановление дампа ПОВЕРХ базы, в которой уже есть схема и строки, —
# так делают, когда «надо срочно вернуть удалённое».
# Хост и порт libpq берёт из окружения; PGUSER и имя базы — как в compose.
set -eu
export PGUSER=${POSTGRES_USER:-app}
DB=${POSTGRES_DB:-app_db}
LIVE=${DB}_live

# Дамп «за прошлую ночь»
pg_dump -d "$DB" | gzip > before.sql.gz

# Сцена: та же база, из которой кто-то удалил черновики. Это и есть инцидент,
# после которого хочется вернуть строки из ночного дампа
PGOPTIONS='-c client_min_messages=warning' dropdb --if-exists "$LIVE"
createdb "$LIVE"
gunzip -c before.sql.gz | psql -q -o /dev/null -v ON_ERROR_STOP=1 -d "$LIVE"
psql -q -d "$LIVE" -c "DELETE FROM notes WHERE status = 'draft'"
echo "после инцидента: users $(psql -tAc 'SELECT count(*) FROM users' -d "$LIVE"), notes $(psql -tAc 'SELECT count(*) FROM notes' -d "$LIVE")"

# Восстановление так, как его запускают в спешке: psql без ON_ERROR_STOP
set +e
gunzip -c before.sql.gz | psql -q -o /dev/null -d "$LIVE" 2> errors.txt
CODE=$?
set -e
echo "восстановление поверх: код psql $CODE, строк с ERROR $(grep -c '^ERROR' errors.txt | tr -d ' ')"
echo "первая ошибка: $(head -1 errors.txt)"
echo "после восстановления: users $(psql -tAc 'SELECT count(*) FROM users' -d "$LIVE"), notes $(psql -tAc 'SELECT count(*) FROM notes' -d "$LIVE")"

# То же самое, но psql остановлен на первой ошибке
set +e
gunzip -c before.sql.gz | psql -q -o /dev/null -v ON_ERROR_STOP=1 -d "$LIVE" 2> /dev/null
echo "то же с ON_ERROR_STOP=1: код psql $?"
set -e

dropdb "$LIVE"
rm -f errors.txt before.sql.gz
sh restore-onto-live.sh

Ни одной: заметок было восемь, восемь и осталось. Семнадцать строк с ERROR в потоке ошибок — и ноль в коде выхода, то есть единственное, о чём этот ноль сообщает, — что файл дочитан. А вернуть удалённое дамп и не мог: COPY в нём — одна команда на всю таблицу, и она падает целиком на первом же конфликте ключа, не вставив ни строки. Тот же запуск с ON_ERROR_STOP=1 кончается кодом 3.

Восстановление поверх живой базы не годится ещё и потому, что дамп ничего не удаляет: строки, появившиеся после него, останутся, а удалённые не вернутся. Выйдет смесь двух состояний, которой не было никогда.

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

Текстовый дамп поднимается psql. Свой формат -Fc и каталог -Fd поднимает pg_restore: он умеет выборочно — только схему, только данные, одну таблицу — и в несколько потоков. Перепутать не получится: pg_restore, которому дали текстовый дамп, отвечает «input file appears to be a text format dump. Please use psql» и выходит кодом 1 (проверено на 18.4).

Код выхода psql тоже стоит знать заранее: 2 — не удалось соединиться, 3 — ошибка в сценарии при ON_ERROR_STOP. Ноль означает «файл дочитан», и только сверка отличает его от «база восстановлена».

Обновление версии на сервере — это три команды, и вся работа выката прячется в двух последних: первая только привозит код.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сборка неслучайно отдельная команда: up -d --build собирал бы всё, что разошлось, разом, а на двух ядрах VPS три параллельные сборки уже роняли прод — поэтому сценарий выката автора собирает по одному образу и только потом поднимает стек. Сколько займёт вторая команда, решает при этом не мощность сервера, а ключ шага: правка обработчика не должна сбивать установку зависимостей. Отдельной команды для схемы в этом списке нет намеренно: миграция — сервис стека, который приложение дожидается по условию успешного выхода, потому что применять миграцию стартом приложения нельзя — копий приложения бывает больше одной. Выкат без правок схемы от этого не дорожает: джоба выходит с нулём, не сделав ничего.

Перед всеми тремя стоит нулевая: снять дамп. Не потому, что миграция обязательно сломается, а потому, что шаг, который её ломает, чаще всего не отказывает, а тихо портит данные, — и без копии «до» это не лечится.

Дальше начинается то, из-за чего выкат вообще может стоить данных. Контейнер — вещь одноразовая, и up -d после пересборки образа заменяет его целиком.

ЧтоПереживает пересоздание контейнераПочему
Строки базы в именованном томедатом живёт отдельно от контейнера и подключается к новому
Файлы, записанные внутрь контейнеранетфайловая система контейнера удаляется вместе с ним
Журнал контейнеранетжурнал принадлежит контейнеру; новый начинает с пустого
Каталог данных при смене мажора образа базынеткаталог помечен мажором, который его создал

Последняя строка — самая дорогая. В каталоге данных лежит файл с номером мажора, и сервер другой версии этот каталог своим не считает. У образов с 18-й версии он вложен в том подкаталогом с номером мажора, так что образ нового мажора в этот каталог даже не заглядывает — зато находит его рядом со своим и вместо тихой пустой базы печатает объяснение и выходит с кодом 1. Итог один: одна правка тега образа с 18 на 19 превращает выкат в остановку базы, а лечится она не откатом тега, а переносом (pg_upgrade, которому нужны обе версии сразу) или дампом, снятым до обновления.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · каталог данных временного кластера PostgreSQL 18.4 на машине автора
18

Остаётся окно, в котором сайт мигает: прокси пересоздание пережил и продолжает работать, а приложения за ним несколько секунд нет, и читатель получает от прокси отказ. На одном VPS с одной копией приложения это окно не убрать — его можно только сделать коротким и знать, когда оно кончилось. Для второго и нужна ручка готовности вместе с пробой в описании стека: «контейнер запущен» и «сервис отвечает» — разные события, и выкат заканчивается на втором.

Старые образы копятся и занимают заметное место, но чистка — не действие момента аварии. При пересборке новый образ получает то же имя, и прежний остаётся без тега, то есть для команды чистки он мусор: после неё откатываться уже не на что.

«Откатимся» — самое частое предложение в первые минуты аварии, и почти всегда оно означает «вернуть прежний образ». С образом это и правда просто: он неизменяемый, лежит под своим тегом, и выкат прежнего тега — обычный выкат. Проблема в том, что образ — единственная часть стека, которая так умеет.

Схема назад не ходит. Миграция уже применена, и если она была расширяющей — добавила колонку, добавила таблицу, — прежний код на новой схеме работает: он про новое просто не знает. Если она что-то убрала или переименовала, прежний код на ней падает, и откат образа делает только хуже. Поэтому шаги схемы и разбиваются на выпуски: не ради красоты, а чтобы у отката оставалась хоть одна рабочая пара «код плюс схема».

Копия базы — тоже не откат, а возврат в прошлое: всё, что произошло после дампа, исчезает. Восстановление уместно там, где данные испорчены, и неуместно там, где просто выкатили плохой код: во втором случае дешевле выпуск вперёд.

А есть и то, чего восстановление не возвращает вовсе, потому что оно не в базе, — состояние клиентов. Прогноз: сервер синхронизации поднят из ночного дампа, в котором были только две первые операции. Телефон помнит курсор, взятый до аварии. Что он заберёт при следующем обращении?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Ничего. Строка-счётчик уехала в дамп вместе с базой, номера пошли заново, и новая правка получила третий номер — тот, который телефон уже прошёл. Курсор у телефона не меньше головы сервера, значит, для него «нового нет» — и отказа тоже нет, ответ законный. Дыра тянется от новой головы счётчика до прежнего курсора: всё, что получит номер не больше трёх, телефон не увидит, а дальше синхронизация пойдёт как ни в чём не бывало.

На живой базе дыра между откатившимся счётчиком и курсором телефона — не одна запись: сколько номеров человек сжёг после того, как дамп был снят, столько новых правок и пропадёт молча.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Отсюда правило: в аварии откат — это выпуск вперёд. Прежний тег образа годится как способ выиграть время, но починка почти всегда оказывается новым выпуском, а не возвратом.

Когда прод лежит, соблазн один: открыть журнал и читать его сверху вниз. Это худший способ потратить первые десять минут. Вопросов всего три, и они идут по порядку: что именно сломано, с какого момента, что менялось до этого момента. Первый отвечает состояние контейнеров, второй и третий — журнал.

Собирать картину по сквозному номеру запроса вы уже умеете: сводка по запросам даёт число отказов, причину и маршруты, а одна причина на двух маршрутах значит, что сломалась общая зависимость. Здесь нужен следующий шаг — момент начала. И на нём легко обмануться, потому что самая заметная ошибка редко бывает первой.

Прогноз: в журнале десять записей от двух контейнеров, и в файле они не по времени. Какая ошибка окажется самой частой, какая — первой и что стояло в журнале перед ней?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Самая частая ошибка — «база не ответила», пять раз из шести, и починка началась бы с неё. Первая по времени другая: свободные соединения в пуле кончились раньше, чем база стала отказывать в новых. Порядок здесь и есть подсказка: сначала уже открытые соединения перестали отвечать и заняли весь пул, и только потом попытки открыть новое начали получать отказ. Смотреть надо на первую строку — она ближе всего к событию, которого в журнале приложения может не быть вовсе.

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

Сортировка по строковому времени работает, только если время в журнале записано в UTC и с ведущими нулями. Смешанные часовые пояса от двух машин превращают «первую ошибку» в лотерею.

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

Строка «что изменили» — единственная, которая делает отчёт полезным. Если в ней написано «перезапустили», инцидент вернётся.

Первый вопрос — «что именно сломано» — отвечает список контейнеров. Отвечает он, впрочем, ровно на то, о чём спросили: по умолчанию список показывает работающие контейнеры — в том числе те, что крутятся в перезапуске, — а вышедшую одноразовую работу только с флагом -a. Ровно на этом и строится самая частая ошибка разбора.

Прогноз: пять сервисов, у одного код выхода 1, у другого 137 и шесть перезапусков. Кого модель назовёт первым подозреваемым в списке без -a и кого — в полном?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

В коротком списке виноват api: он не может стартовать, его перезапускают, он снова падает, а код 137 уводит разбор в память машины. В полном списке первым оказывается migrate: одноразовая работа вышла с кодом 1, то есть миграция не применилась и схема осталась прежней. Её журнал читают первым: строк там немного, и они отвечают, доехал ли выкат до конца. Память и шесть перезапусков api — вопрос второй, и пока не прочитан первый, чинить начинают не то.

Что видноЧто это значитЧто спросить дальше
exited и код 0 у одноразовой работыона отработала и вышла — так и задуманоничего; это нормальное состояние джобы
exited и код не 0работа отказала и выкат идти дальше не долженеё журнал целиком: строк там немного
restarting, перезапусков многопроцесс не доживает до готовностижурнал за последнюю попытку, а не за всё время
код 137процесс убит сигналом 9 — обычно кончилась памятьсколько памяти на машине и что там ещё собиралось
running, но unhealthyпроцесс жив, а проба не проходитчто именно спрашивает проба и чем отвечает сервис
running и healthy у всехсломано не то, что вы смотритепрокси и то, что снаружи: домен, сертификат, DNS

Дальше — журнал, и его берут не целиком. Окно задаётся флагом «с какого момента» (--since 20m или отметка времени), последние строки — флагом числа строк, а отметки времени включаются отдельно: контейнер печатает свои, а не время получения. Полезное упражнение — выписать окно инцидента из предыдущего раздела и взять журнал ровно по нему: всё остальное только мешает.

Код 137 — это 128 плюс 9. Так же читаются и остальные: 143 — это сигнал 15, то есть штатная остановка, а вовсе не авария.

Журнал принадлежит контейнеру, а не сервису: пересоздание уносит его целиком. Это значит, что после up -d с пересобранным образом строки предыдущей версии недоступны — а именно они чаще всего и нужны, когда авария началась до выката. Отсюда и правило про поток вывода: строки, которые нужны после пересоздания, обязан складывать наружу кто-то снаружи контейнера — сборщик логов, читающий этот поток.

У контейнера в цикле перезапусков обратная беда: строк много, и почти все они от прежних попыток. Помогает флаг числа строк — взять последние несколько десятков, посмотреть, чем кончается попытка, и только потом решать, нужна ли остальная история.

  • Том — не бэкап: он переживает пересоздание контейнера и не переживает ни команды, которая его стирает, ни DELETE без WHERE, ни смерти сервера. Бэкап базы — это дамп, снятый pg_dump внутри контейнера базы: клиент младшей версии со старшим сервером работать отказывается вовсе.
  • В дампе базы нет ролей и паролей кластера, файлов и секретов, зато есть журнал миграций — база из копии для раннера уже применённая. Поэтому «бэкап» — это всегда несколько разных копий, а не один файл.
  • Файл появился — ещё не дамп: труба отдаёт код последней команды, и упавший pg_dump оставляет 20-байтовый архив при нуле на выходе. Лечится pipefail в сценарии и монитором, который смотрит свежесть и размер.
  • Копию проверяет только восстановление, и только в чистую базу со сверкой содержимого по контрольной сумме. Поверх живой базы тот же дамп даёт семнадцать ошибок, ноль в коде выхода и ни одной вернувшейся строки: psql без ON_ERROR_STOP сообщает лишь о том, что дочитал файл.
  • Откатывается назад только образ. Схема назад не ходит, поэтому её меняют выпусками; восстановление базы отбрасывает всё, что было после дампа, а состояние клиентов не возвращает вовсе: счётчик синхронизации после восстановления сдвигают за любой выданный номер, иначе всё, что попадёт в дыру между новой головой и курсором телефона, тот не увидит — без ошибки и без отказа.
  • Разбор падения идёт по порядку: что сломано (список контейнеров, обязательно с вышедшими работами), с какого момента (первая ошибка по времени, а не самая частая) и что менялось до этого момента. Журнал берут окном по этому моменту, а не целиком, — тем более что пересоздание контейнера уносит журнал прежней версии.

1/4Почему pg_dump в боевом сценарии запускают внутри контейнера базы?

2/4Дамп подняли в базу, где схема и строки уже есть. psql вышел с нулём. Что это означает?

3/4Сервер синхронизации поднят из дампа, в котором не хватает последних операций. Что увидит телефон с прежним курсором?

4/4В журнале шесть ошибок: пять «база не ответила» и одна «нет свободного соединения», и она первая по времени. С чего начинать?

Ротация, которая удаляет самое свежее

Сценарий оставляет три самых свежих дампа, остальные отдаёт под удаление. Имена приходят от старого сценария: дата в них записана как день.месяц.год. Сейчас функция сравнивает имена как строки — и под удаление уходят как раз свежие файлы. Почините её так, чтобы удалялись два самых старых.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Строковое сравнение работает только при формате от крупного к мелкому. Значит, из имени надо достать дату и переставить её части:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь под удаление уходят 29.09.2026 и 30.09.2026. Вывод из этого не про регулярные выражения: дату в имени файла пишут %F — тогда порядок имён и порядок дат совпадают сами, и сортировать можно чем угодно. Сценарий автора идёт другим путём и сортирует по времени изменения файла, но у имён, приехавших из объектного хранилища, времени может и не быть.

Цена инцидента для отчёта

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Считать надо разные значения полей, а не строки:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Сверка, которая не заметила расхождения

Сверка после восстановления сравнивает только числа строк и только у таблиц, которые есть справа. На этих данных она печатает «содержимое совпало», хотя расхождений два: у одной таблицы не сходится контрольная сумма, а одной таблицы справа нет вовсе. Сделайте так, чтобы она назвала оба.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Обходить надо объединение имён, а сравнивать — и число строк, и сумму:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Печатает два расхождения: drizzle.__drizzle_migrations (справа нет) и public.notes (сумма другая). Первое — тот самый журнал миграций, который легко потерять, сняв дамп с фильтром по схеме; второе — случай, ради которого сверка и считает сумму, а не строки.