#!/usr/bin/env bash # Перенос баз `forgejo` и `glitchtip` из общего кластера продукта # (`gendesign-postgres-1`) в лёгкий инфраструктурный (`gendesign-infra-postgres`) # — раннбук шага 3 переезда Beget → Selectel (#3061). # # КОНТЕКСТ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare # (188.124.37.140), окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает # целиком вместе с томом postgres_data (~15 ГБ), а базы forgejo (265 МБ, # git.gendsgn.ru + Actions CI, из которого идёт сам деплой) и glitchtip (729 МБ, # errors.gendsgn.ru) обязаны ОСТАТЬСЯ на Beget. Их новый дом — сервис # infra-postgres из docker-compose.prod.yml (профиль `infra`), роли и пустые # базы в нём заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh. # Полный порядок переезда (шаги 1..4) — в шапке сервиса infra-postgres в # docker-compose.prod.yml; этот скрипт закрывает ТОЛЬКО шаг 3. # # ЗАПУСКАЕТСЯ РУКАМИ на Beget, до окна переезда. Не cron, не CI: он гасит # git-хост и трекер ошибок, такое не делают по расписанию. # # bash /opt/gendesign/ops/split-infra-postgres.sh # СУХОЙ ПРОГОН # bash /opt/gendesign/ops/split-infra-postgres.sh --apply # реальный перенос # # ПО УМОЛЧАНИЮ — СУХОЙ ПРОГОН, и это не формальность: без --apply скрипт не # создаёт каталогов, не пишет файлов, не останавливает контейнеров и ничего не # заливает. Он только ЧИТАЕТ (docker ps / docker inspect / SELECT'ы) и печатает, # что сделал бы. Прогнать сухой прогон заранее — способ выяснить про забытый # пароль в .env или несовпадение версий днём, а не ночью на заливке дампа. # # ЧЕГО СКРИПТ НЕ ДЕЛАЕТ СОЗНАТЕЛЬНО: # * не переключает приложения на новый кластер (шаг 4 — правка app.ini и .env, # печатается в конце); # * не удаляет исходные базы — откат обязан оставаться бесплатным; # * не удаляет дампы — печатает их пути. # # ИДЕМПОТЕНТНОСТЬ. Повторный прогон видит уже перенесённую базу (в приёмнике # есть таблицы) и НЕ трогает её: молча лить дамп поверх живых данных, в которые # после cutover уже пишет приложение, — худшее, что здесь можно сделать. # Перезалить осознанно: --force-restore (pg_restore --clean --if-exists). Он # отказывается работать, если cutover уже сделан (app.ini / .env указывают на # infra-postgres), и требует ввести имена баз руками: после cutover такая # заливка затирает данные приложений СТАРЫМ дампом, а сверка фазы 5 сравнивает # приёмник с источником и подтверждает это как успех. # # СТИЛЬ. log()/notify() — общая библиотека ops/lib-backup.sh, та же, что у # ops/backup.sh и ops/backup-forgejo.sh. notify() здесь НЕ используется: скрипт # интерактивный, у экрана сидит человек, телеграм-алерт ему ничего не добавит. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./lib-backup.sh source "$SCRIPT_DIR/lib-backup.sh" # --- конфигурация (переопределяется окружением) -------------------------- SRC_CONTAINER="${SRC_CONTAINER:-gendesign-postgres-1}" DST_CONTAINER="${DST_CONTAINER:-gendesign-infra-postgres}" # Роль-владелец у обеих баз совпадает с именем базы (так их завёл initdb-скрипт), # поэтому отдельной таблицы соответствий не нужно — везде "$db" в обеих ролях. DATABASES="${DATABASES:-forgejo glitchtip}" DUMP_DIR="${DUMP_DIR:-/opt/gendesign/backups/migration-3061}" FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}" # Где на самом деле лежит app.ini. Раньше здесь подразумевалось # "${FORGEJO_DIR}/app.ini", и такого файла на хосте НЕТ: FORGEJO_DIR — каталог # со стеком (docker-compose.yml, раннеры), а конфиг живёт внутри тома данных, # который монтируется в контейнер как /data. Проверено на боевом хосте: # /home/gendesign/forgejo/data/forgejo -> /data # app.ini = /home/gendesign/forgejo/data/forgejo/gitea/conf/app.ini # Тихо это ломало страж assert_not_cut_over: он проверяет файл через # `[[ -r "$ini" ]]`, несуществующий путь читается как "признака нет", и # половина защиты от повторного --force-restore после cutover просто не # срабатывала — молча, без единого сообщения. FORGEJO_APP_INI="${FORGEJO_APP_INI:-}" if [[ -z "$FORGEJO_APP_INI" ]]; then for candidate in "${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini" "${FORGEJO_DIR}/data/gitea/conf/app.ini" "${FORGEJO_DIR}/app.ini" do [[ -r "$candidate" ]] && { FORGEJO_APP_INI="$candidate"; break; } done fi # Писатели, которых надо погасить на время дампа. Порядок важен только тем, что # forgejo идёт первым: это git и CI, его простой заметнее всего. FORGEJO_CONTAINER="${FORGEJO_CONTAINER:-forgejo}" GLITCHTIP_CONTAINERS="${GLITCHTIP_CONTAINERS:-glitchtip-web glitchtip-worker}" # Соответствие «база → её писатели». Нужно затем, что ненайденный писатель # блокирует дамп ИМЕННО своей базы: снимать дамп с базы, в которую продолжают # писать, нельзя (см. проверку MISSING_WRITERS ниже). declare -A DB_WRITERS=( [forgejo]="$FORGEJO_CONTAINER" [glitchtip]="$GLITCHTIP_CONTAINERS" ) # Файл окружения продукта — читается только на предмет «cutover уже сделан?». GENDESIGN_ENV="${GENDESIGN_ENV:-/opt/gendesign/.env}" # Запас свободного места сверх суммарного размера баз. Дамп -Fc сжат и заведомо # меньше базы, так что требование «влезет ещё одна полная копия» консервативно. DISK_MARGIN_PCT="${DISK_MARGIN_PCT:-30}" APPLY=0 FORCE_RESTORE=0 ALLOW_MISSING_WRITER=0 # --- разбор аргументов ---------------------------------------------------- usage() { cat <<'USAGE' Использование: bash ops/split-infra-postgres.sh [--apply] [--force-restore] [--dump-dir DIR] (без флагов) сухой прогон: только чтение и печать плана, ноль побочных эффектов --apply реально останавливает писателей, снимает дампы и восстанавливает --force-restore перезалить базу, в которой на приёмнике УЖЕ есть таблицы (pg_restore --clean --if-exists). Только вместе с --apply. Спросит подтверждение и откажется работать после cutover. --allow-missing-writer снять дамп базы, писатель которой не найден среди запущенных контейнеров. По умолчанию это ОШИБКА: чаще всего контейнер просто называется иначе и продолжает писать. --dump-dir DIR куда класть дампы (по умолчанию /opt/gendesign/backups/migration-3061) USAGE } while (( $# > 0 )); do case "$1" in --apply) APPLY=1 ;; --force-restore) FORCE_RESTORE=1 ;; --allow-missing-writer) ALLOW_MISSING_WRITER=1 ;; --dump-dir) shift; [[ $# -gt 0 ]] || { usage >&2; exit 2; }; DUMP_DIR="$1" ;; -h|--help) usage; exit 0 ;; *) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 2 ;; esac shift done die() { log "ОШИБКА: $*" >&2; exit 1; } phase() { log "──────── $* ────────"; } # Единая пометка строк, которые в сухом прогоне ТОЛЬКО печатаются. plan() { log " [сухой прогон] сделал бы: $*"; } if (( FORCE_RESTORE == 1 && APPLY == 0 )); then die "--force-restore без --apply бессмысленен: в сухом прогоне заливать нечего." fi # --- стражи --force-restore ---------------------------------------------- # pg_restore --clean --if-exists дропает объекты и льёт их заново из дампа. Если # приложения УЖЕ переключены на приёмник, это уничтожает всё, что они записали # после cutover — а сверка фазы 5 такую заливку ПОДТВЕРДИТ как успех: она # сравнивает источник с приёмником, и записей, сделанных после cutover, в # источнике никогда не было. Поэтому единственная защита — не дать запуститься. assert_not_cut_over() { local ini="$FORGEJO_APP_INI" hit="" # Не нашли app.ini — это НЕ "признака нет". Раньше здесь молча пропускалась # половина стража, и --force-restore после cutover залил бы устаревший дамп # поверх живых данных, а сверка фазы 5 подтвердила бы это как успех. # Лучше остановиться и заставить указать путь явно. if [[ -z "$ini" || ! -r "$ini" ]]; then die "не найден app.ini Forgejo — проверить, сделан ли уже cutover, нечем. Искал: ${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini, ${FORGEJO_DIR}/data/gitea/conf/app.ini, ${FORGEJO_DIR}/app.ini. Укажи путь через FORGEJO_APP_INI=... Пропускать эту проверку нельзя: именно она не даёт --force-restore залить устаревший дамп поверх данных, записанных после cutover." fi if grep -Eq '^[[:space:]]*HOST[[:space:]]*=[[:space:]]*infra-postgres' "$ini"; then hit+="${ini} (HOST = infra-postgres); " fi if [[ -r "$GENDESIGN_ENV" ]] && grep -Eq '^[[:space:]]*GLITCHTIP_DB_HOST[[:space:]]*=[[:space:]]*infra-postgres' "$GENDESIGN_ENV"; then hit+="${GENDESIGN_ENV} (GLITCHTIP_DB_HOST=infra-postgres); " fi [[ -z "$hit" ]] || die \ "cutover уже выполнен — ${hit}приложения пишут в ${DST_CONTAINER}. --force-restore залил бы поверх их данных УСТАРЕВШИЙ дамп из ${SRC_CONTAINER}, и сверка фазы 5 подтвердила бы это как успех. Если перезалить всё же необходимо: верните HOST/GLITCHTIP_DB_HOST на postgres, погасите писателей, снимите ОТДЕЛЬНЫЙ бэкап приёмника — и только тогда повторяйте." } # Второй рубеж: осознанность. Флаг легко скопировать из раннбука не думая, # а ввод имён баз руками — нет. confirm_force_restore() { # confirm_force_restore <базы через пробел> local dbs answer="" dbs="$(tr -s ' ' <<< "$1" | sed 's/^ *//; s/ *$//')" log "--force-restore ПЕРЕЗАЛЬЁТ в ${DST_CONTAINER} базы: ${dbs}" log "Их нынешнее содержимое в приёмнике будет удалено, отката у этого шага нет." printf 'Введите имена баз ровно как выше для подтверждения: ' >&2 read -r answer || true [[ "$(tr -s ' ' <<< "$answer" | sed 's/^ *//; s/ *$//')" == "$dbs" ]] \ || die "подтверждение не совпало — ничего не тронуто." } # --- вспомогательные запросы --------------------------------------------- # Ходим ровно тем же доступом, которым потом пойдут pg_dump/pg_restore: psql под # прикладной ролью через `docker exec` (внутри контейнера это unix-сокет, на нём # в официальном образе trust — пароль не нужен). Так проверка не расходится с # делом: до чего не достучалась она, там и дамп не снимется. q() { # q <контейнер> <база/роль> docker exec "$1" psql -U "$2" -d "$2" -tAc "$3" 2>/dev/null } # Построчные счётчики по ФАКТИЧЕСКИМ таблицам: имена берутся из системного # каталога, а не из зашитого списка — выдуманное имя таблицы превратило бы # сверку в самообман. count(*) считается честно (не reltuples из pg_class: там # оценка планировщика, после свежего восстановления она вообще нулевая до # ANALYZE), через query_to_xml — так один запрос возвращает счётчики по всем # таблицам сразу, без генерации SQL на стороне bash. # relkind='r' — обычные таблицы. Партиционированные родители ('p') намеренно # исключены из СЧЁТЧИКОВ (их count(*) продублировал бы строки партиций), но # учитываются в числе таблиц ниже. В forgejo/glitchtip партиций нет — это # страховка на будущее, а не описание сегодняшнего дня. SNAP_SQL=" SELECT n.nspname || '.' || c.relname || E'\t' || (xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', n.nspname, c.relname), false, true, '')))[1]::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1;" TABLES_SQL=" SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema');" # ========================================================================== # ФАЗА 1. Preflight — всё, что можно проверить, не тронув ничего # ========================================================================== phase "ФАЗА 1: preflight" if (( APPLY == 1 )); then log "РЕЖИМ: --apply. Писатели будут остановлены, дампы сняты и восстановлены." else log "РЕЖИМ: сухой прогон (по умолчанию). Ни одного побочного эффекта: ни mkdir," log " ни docker stop, ни записи в файл. Для реального переноса — --apply." fi log "Источник: ${SRC_CONTAINER} → приёмник: ${DST_CONTAINER}; базы: ${DATABASES}" command -v docker >/dev/null 2>&1 || die "docker не найден в PATH." # Источник и приёмник обязаны быть РАЗНЫМИ контейнерами. При совпадении весь # preflight проходит (контейнер запущен, healthy, роли и базы на месте), а # pg_restore --clean --if-exists бьёт по единственной живой копии баз. [[ "$SRC_CONTAINER" != "$DST_CONTAINER" ]] || die \ "источник и приёмник совпадают (${SRC_CONTAINER}). Проверьте SRC_CONTAINER / DST_CONTAINER: перенос базы в саму себя с --force-restore разрушил бы единственную копию." (( FORCE_RESTORE == 0 )) || assert_not_cut_over running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" for c in "$SRC_CONTAINER" "$DST_CONTAINER"; do grep -qxF -- "$c" <<< "$running" \ || die "контейнер ${c} не запущен. Запущены: $(tr -s '[:space:]' ' ' <<< "$running")" log " ${c}: запущен" done # Приёмник обязан быть healthy — его healthcheck намеренно требует наличия ОБЕИХ # баз (разбор «отравленного тома» — в шапке сервиса infra-postgres в # docker-compose.prod.yml). Красный healthcheck здесь = initdb-скрипт не # отработал, и лечится это пересозданием тома, а не заливкой дампа. dst_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DST_CONTAINER" 2>/dev/null || echo unknown)" [[ "$dst_health" == "healthy" ]] || die \ "приёмник ${DST_CONTAINER} в состоянии '${dst_health}', а не healthy. Его healthcheck требует наличия обеих баз — красный статус значит, что initdb-скрипт не отработал (чаще всего пуст FORGEJO_DB_PASS или GLITCHTIP_DB_PASS в /opt/gendesign/.env). Лечение: заполнить пароли, ПЕРЕСОЗДАТЬ том gendesign_infra_postgres_data и поднять кластер заново (команда — в шапке docker-compose.prod.yml). Дамп в такой кластер лить нельзя." log " ${DST_CONTAINER}: healthy" # Мажорная версия. postgres:16-alpine против postgis/postgis:16-3.4 — обе 16, # формат дампа совместим один-в-один. Расхождение мажора означало бы, что # кто-то поменял образ, и pg_restore из старшей в младшую просто не пройдёт. src_ver="$(docker exec "$SRC_CONTAINER" psql -U postgres -tAc 'SHOW server_version_num' 2>/dev/null | tr -dc '0-9' || true)" [[ -n "$src_ver" ]] || src_ver="$(q "$SRC_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)" dst_ver="$(q "$DST_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)" [[ -n "$src_ver" && -n "$dst_ver" ]] || die "не удалось прочитать server_version_num (src='${src_ver}', dst='${dst_ver}')." src_major=$(( src_ver / 10000 )); dst_major=$(( dst_ver / 10000 )) (( src_major == dst_major )) || die \ "мажорные версии PostgreSQL не совпадают: источник ${src_major}, приёмник ${dst_major}. Перенос дампом между мажорами этим скриптом не поддержан — сначала выровняйте образы." log " версии совпадают: PostgreSQL ${src_major} в обоих кластерах" # Роли и базы в приёмнике. Успешный коннект ролью $db в базу $db разом # доказывает и существование роли, и существование базы, и право входа — # то есть ровно те три вещи, на которых упало бы восстановление. total_bytes=0 declare -A ALREADY=() SRC_TABLES=() for db in $DATABASES; do src_t="$(q "$SRC_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)" [[ -n "$src_t" ]] || die "в источнике ${SRC_CONTAINER} база ${db} под ролью ${db} недоступна." (( src_t > 0 )) || die "в источнике ${SRC_CONTAINER} база ${db} ПУСТА (0 таблиц) — переносить нечего, проверьте имя контейнера." SRC_TABLES["$db"]="$src_t" dst_t="$(q "$DST_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)" [[ -n "$dst_t" ]] || die \ "в приёмнике ${DST_CONTAINER} нет роли ${db} и/или базы ${db}. Их заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh, и только на ПУСТОМ томе. Лечение — пересоздать том gendesign_infra_postgres_data и поднять кластер заново с заполненными паролями." ALREADY["$db"]="$dst_t" size="$(q "$SRC_CONTAINER" "$db" "SELECT pg_database_size('${db}')" | tr -dc '0-9' || echo 0)" total_bytes=$(( total_bytes + size )) if (( dst_t > 0 )); then log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); в приёмнике УЖЕ ${dst_t} таблиц" else log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); приёмник пуст — заготовка на месте" fi done # Место на диске. Каталога дампов может ещё не быть (в сухом прогоне мы его и не # создаём) — меряем ближайшего существующего родителя, файловая система у них # одна и та же. probe_dir="$DUMP_DIR" while [[ ! -d "$probe_dir" && "$probe_dir" != "/" ]]; do probe_dir="$(dirname "$probe_dir")"; done free_bytes=$(( $(df -Pk "$probe_dir" | awk 'NR==2{print $4}') * 1024 )) need_bytes=$(( total_bytes + total_bytes * DISK_MARGIN_PCT / 100 )) log " место в ${probe_dir}: свободно $(( free_bytes / 1024 / 1024 )) МБ, нужно ≥ $(( need_bytes / 1024 / 1024 )) МБ (сумма баз + ${DISK_MARGIN_PCT}%)" (( free_bytes >= need_bytes )) || die "недостаточно места под дампы в ${probe_dir}." # Писатели. Ненайденный контейнер трактуется как ОПАСНОСТЬ, а не как удобство: # «уже погашен» и «называется иначе и пишет прямо сейчас» с этой стороны # выглядят одинаково, а последствия у них противоположные. WRITERS="$FORGEJO_CONTAINER $GLITCHTIP_CONTAINERS" declare -A MISSING_WRITERS=() for c in $WRITERS; do if grep -qxF -- "$c" <<< "$running"; then log " писатель ${c}: запущен, будет остановлен" continue fi log " писатель ${c}: НЕ найден среди запущенных" for db in $DATABASES; do if [[ " ${DB_WRITERS[$db]:-} " == *" $c "* ]]; then MISSING_WRITERS["$db"]+="${c} " fi done done # Все базы уже на месте → дампить и заливать нечего, остаётся сверка. pending="" # `|| true` обязателен: под `set -e` ложное (( )) в конце тела цикла уронило бы # весь скрипт ровно в счастливом случае «всё уже перенесено». for db in $DATABASES; do (( ${ALREADY[$db]} == 0 || FORCE_RESTORE == 1 )) && pending+="${db} " || true done if [[ -z "$pending" ]]; then log "ВСЕ базы уже перенесены (в приёмнике есть таблицы). Останов писателей и" log "дамп пропускаю — повторный прогон ничего не затирает. Перехожу к сверке." log "Осознанно перезалить: --apply --force-restore." else log "К переносу: ${pending}" # Дамп базы, писатель которой не погашен, теряет всё записанное после # снимка, и сверка фазы 5 этого НЕ покажет: слева в ней стоит замороженный # SNAP_BEFORE, снятый в тот же момент, что и дамп, справа — приёмник; при # любом объёме дозаписи они совпадут. Единственный шанс поймать — здесь. for db in $pending; do miss="${MISSING_WRITERS[$db]:-}" [[ -n "$miss" ]] || continue if (( ALLOW_MISSING_WRITER == 1 )); then log " WARN: писатели базы ${db} не найдены (${miss}) — продолжаю по --allow-missing-writer. Ответственность за то, что в ${db} никто не пишет, на вас." else die "писатели базы ${db} не найдены среди запущенных: ${miss}. Скорее всего контейнер называется иначе (посмотрите docker ps и переопределите FORGEJO_CONTAINER / GLITCHTIP_CONTAINERS) — тогда он продолжает писать, и всё записанное после дампа потеряется на cutover молча. Если писатель действительно погашен заранее — повторите с --allow-missing-writer." fi done # Подтверждение перезаливки — только для баз, которые реально будут дропнуты. if (( FORCE_RESTORE == 1 )); then clean_dbs="" for db in $pending; do (( ${ALREADY[$db]} > 0 )) && clean_dbs+="${db} " || true done [[ -z "$clean_dbs" ]] || confirm_force_restore "$clean_dbs" fi fi ts="$(date -u +'%Y%m%d_%H%M%S')" RUN_DIR="${DUMP_DIR}/${ts}" # ========================================================================== # ФАЗА 2. Останов писателей # ========================================================================== if [[ -n "$pending" ]]; then phase "ФАЗА 2: останов писателей" # Гасим ИМЕННО контейнеры, а не `docker compose stop`: Forgejo живёт своим # стеком в /home/gendesign/forgejo (в этом репозитории его compose-файла # нет), GlitchTip — в стеке продукта, и угадывать имена сервисов в двух # разных компоузах незачем — имя контейнера здесь однозначно и стабильно # (container_name зафиксирован в docker-compose.prod.yml). # Зачем вообще гасить: дамп с живого писателя даёт консистентный снимок # (pg_dump работает в одной транзакции), но всё, что приложение запишет # ПОСЛЕ снимка, до нового кластера не доедет и потеряется на cutover. for c in $WRITERS; do if ! grep -qxF -- "$c" <<< "$running"; then log " ${c}: не запущен — пропускаю" continue fi if (( APPLY == 1 )); then log " останавливаю ${c}" docker stop "$c" >/dev/null || die "не смог остановить ${c}." still="$(docker ps --format '{{.Names}}' | grep -cxF -- "$c" || true)" (( still == 0 )) || die "${c} всё ещё в docker ps после stop." log " ${c}: остановлен" else plan "docker stop ${c}" fi done fi # ========================================================================== # ФАЗА 3. Снимок «до» и дампы # ========================================================================== declare -A SNAP_BEFORE=() DUMP_FILE=() if [[ -n "$pending" ]]; then phase "ФАЗА 3: снимок «до» и дампы" if (( APPLY == 1 )); then # umask 077: в дампе forgejo — хеши паролей, access-токены и секреты # вебхуков, в glitchtip — DSN и тела событий. Дампы намеренно НЕ # удаляются и попадают в общий бэкап каталога backups, значит лежать они # обязаны нечитаемыми для прочих пользователей хоста. umask 077 mkdir -p "$RUN_DIR" chmod 700 "$RUN_DIR" # каталог мог существовать с прошлого прогона log "Каталог дампов: ${RUN_DIR}" else plan "mkdir -p ${RUN_DIR}" fi for db in $pending; do # Снимок снимается ПОСЛЕ останова писателей — иначе счётчики поехали бы # между «до» и «после» по совершенно законной причине, и сверка перестала # бы что-либо доказывать. SNAP_BEFORE["$db"]="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" [[ -n "${SNAP_BEFORE[$db]}" ]] || die "не смог снять счётчики по базе ${db} в источнике." log " ${db}: снимок «до» — таблиц $(grep -c . <<< "${SNAP_BEFORE[$db]}"), строк всего $(awk -F'\t' '{s+=$2} END{print s+0}' <<< "${SNAP_BEFORE[$db]}")" f="${RUN_DIR}/${db}_${ts}.dump" DUMP_FILE["$db"]="$f" if (( APPLY == 1 )); then log " снимаю дамп ${db} → ${f}" # -Fc: собственный формат, сжат, восстанавливается pg_restore с # --no-owner. --no-owner/--no-privileges уже на дампе: в новом кластере # ни ролей-хозяев чужих объектов, ни исходных ACL воспроизводить не # нужно — владельцем станет роль, которой мы восстанавливаем. docker exec "$SRC_CONTAINER" pg_dump -U "$db" -d "$db" -Fc --no-owner --no-privileges > "$f" \ || die "pg_dump базы ${db} упал; файл ${f} оставлен для разбора." chmod 600 "$f" [[ -s "$f" ]] || die "дамп ${db} нулевого размера → ${f}." # Дамп -Fc бинарный, gzip-проверка verify_dump_integrity() из # lib-backup.sh к нему неприменима. Эквивалент по смыслу — прочитать # оглавление: битый/обрезанный архив на этом и споткнётся, а базы # такое чтение не касается вовсе. docker exec -i "$DST_CONTAINER" pg_restore -l < "$f" > /dev/null \ || die "дамп ${db} не читается pg_restore -l (обрезан?) → ${f}." log " ${db}: дамп OK, $(du -h "$f" | cut -f1) ($(wc -c < "$f") байт)" # Контрольный пересъём. Проверка писателей выше ловит «контейнер # назван иначе», но не ловит писателя вне docker (ручной psql, # забытый воркер на хосте, второй экземпляр). Если источник изменился # между SNAP_BEFORE и этой строкой — дамп уже неполон, а сверка фазы # 5 сравнивает приёмник именно с SNAP_BEFORE и промолчит. snap_after_dump="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" if [[ "$snap_after_dump" != "${SNAP_BEFORE[$db]}" ]]; then diff <(printf '%s\n' "${SNAP_BEFORE[$db]}") <(printf '%s\n' "$snap_after_dump") | sed -n '1,20p' >&2 || true die "источник ${db} изменился во время дампа — в него кто-то ещё пишет (см. расхождение выше). Найдите и погасите писателя, затем повторите; дамп ${f} использовать нельзя." fi else plan "pg_dump -U ${db} -d ${db} -Fc из ${SRC_CONTAINER} → ${f}" fi done fi # ========================================================================== # ФАЗА 4. Восстановление ЦЕЛЕВОЙ РОЛЬЮ # ========================================================================== if [[ -n "$pending" ]]; then phase "ФАЗА 4: восстановление" # ПОЧЕМУ РОЛЬЮ, А НЕ СУПЕРЮЗЕРОМ — разбор в шапке # ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh: дамп снят с # --no-owner, то есть владельцем восстановленных объектов становится роль, # ОТ ИМЕНИ КОТОРОЙ идёт восстановление. Суперюзер забрал бы себе все # таблицы, и приложение получило бы "permission denied for table" на первой # же записи — уже после cutover, когда откатываться поздно. for db in $pending; do f="${DUMP_FILE[$db]}" restore_args=( -U "$db" -d "$db" --no-owner --no-privileges ) if (( ${ALREADY[$db]} > 0 )); then (( FORCE_RESTORE == 1 )) || die "внутренняя ошибка: ${db} не должна была попасть в очередь." log " ${db}: в приёмнике уже ${ALREADY[$db]} таблиц, --force-restore → добавляю --clean --if-exists" restore_args+=( --clean --if-exists ) fi if (( APPLY == 0 )); then plan "pg_restore ${restore_args[*]} < ${f} (в ${DST_CONTAINER}, ролью ${db}, НЕ суперюзером)" continue fi rlog="${RUN_DIR}/${db}_restore.log" rc=0 docker exec -i "$DST_CONTAINER" pg_restore "${restore_args[@]}" < "$f" > "$rlog" 2>&1 || rc=$? errors=$(grep -c '^pg_restore: error' "$rlog" 2>/dev/null || true); errors=${errors:-0} warns=$(grep -c '^pg_restore: warning' "$rlog" 2>/dev/null || true); warns=${warns:-0} log " ${db}: pg_restore rc=${rc}, ошибок ${errors}, предупреждений ${warns}, лог ${rlog}" if (( errors > 0 || rc != 0 )); then sed -n '1,20p' "$rlog" >&2 log "ПОДСКАЗКА: 'permission denied to create extension' — недоверенное расширение владелец базы поставить не может. Лечится разово: создать его суперюзером кластера и повторить прогон с --force-restore." >&2 die "восстановление ${db} прошло с ошибками — см. ${rlog}. Исходная база НЕ тронута, откат бесплатный." fi # ANALYZE не для красоты: после восстановления статистики нет вообще, и # первый же запрос приложения пошёл бы по плану, построенному вслепую. docker exec "$DST_CONTAINER" psql -U "$db" -d "$db" -qc 'ANALYZE' >/dev/null 2>&1 \ || log " WARN: ANALYZE по ${db} не прошёл — не блокирует, но прогоните вручную" done fi # ========================================================================== # ФАЗА 5. Сверка до/после # ========================================================================== phase "ФАЗА 5: сверка" mismatch=0 for db in $DATABASES; do # В сухом прогоне сверять нечего у тех баз, которые ещё предстоит перенести: # приёмник пуст по определению, расхождение было бы шумом, а не находкой. if (( APPLY == 0 )) && [[ " $pending " == *" $db "* ]]; then log " ${db}: сухой прогон — дамп не заливался, сверять нечего." log " ${db}: в источнике таблиц ${SRC_TABLES[$db]}" continue fi before="${SNAP_BEFORE[$db]:-}" # Для базы, которую этот прогон не переносил (уже была на месте), «до» берём # из источника прямо сейчас: старый кластер мы не трогали, счётчики в нём — # это и есть эталон на момент переноса, пока приложение не переключено. # ⚠️ После cutover эталоном они быть перестают: пишут уже в новый кластер, # и расхождение здесь будет означать не потерю, а нормальную работу. [[ -n "$before" ]] || before="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" after="$(q "$DST_CONTAINER" "$db" "$SNAP_SQL" || true)" b_t=$(grep -c . <<< "$before" || true); a_t=$(grep -c . <<< "$after" || true) b_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$before") a_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$after") log " ${db}: таблиц ${b_t} → ${a_t}; строк ${b_r} → ${a_r}" if [[ "$before" == "$after" ]]; then log " ${db}: СОВПАДАЕТ построчно по всем таблицам" else mismatch=1 log " ${db}: РАСХОЖДЕНИЕ (слева источник, справа приёмник):" >&2 diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | sed -n '1,40p' >&2 || true fi done (( mismatch == 0 )) || die "сверка не сошлась — НЕ переключайте приложения. Исходные базы целы, откат = просто запустить контейнеры обратно." log "Сверка возражений не имеет." # ========================================================================== # ФАЗА 6. Что дальше — РУКАМИ # ========================================================================== phase "ФАЗА 6: ручные шаги (скрипт их НЕ делает)" cat <