BitPage

Как вывезти живой docker-проект с умирающего диска: стрим pg_dump, pull образов, якорный exclude

 · 7 мин чтения

Короткий ответ: на умирающем диске pg_dump в локальный файл виснет молча — надо стримить дамп через ssh -T на здоровый диск целевой машины, чтобы источник только читал. Образы — тянуть из registry, скопировав /root/.docker/config.json с самого источника; чинить сборку из EOL-баз (buster/stretch, PGDG для PG11) — тупик: apt отдаёт 404. И rsync --exclude=log/ без ведущего слэша вырезает vendor/yiisoft/yii2/log/Logger.php, а приложение отдаёт 500 «failed to open stream».

Сервер под проектом — PHP-Yii2 + PostgreSQL 12 ГБ + Redis + 8 очередей, 14 сервисов в docker-compose — жил на OpenVZ-хосте с деградировавшим RAID. Диск сдох на записи, а трафик переключать было нельзя: нужно было поднять прод-подобную копию на новом Proxmox-узле (LXC + Docker), чтобы владелец пощупал вживую, но без исходящих писем и платежей. Пара часов работы, четыре тупика по дороге — все четыре из-за того, что на живом железе действуют одни правила, а на умирающем — другие.

Коротко (TL;DR)

Почему pg_dump на умирающем диске виснет, а не падает

Дамп в мёртвый диск не отдаёт ошибку — он перестаёт двигаться, оставаясь живым процессом. Первое, что я попробовал — очевидное: pg_dump -Fc внутри контейнера в bind-каталог backup/ на самом источнике. Файл дорос до 1601044831 байта и встал. Две проверки размера с интервалом в 6 секунд вернули одно и то же число, процесс — S+ в ps. Диск сдох именно на записи, и из userspace это выглядит ровно так: pg_dump ждёт очередной write(), а тот не возвращается.

Отдельно врал сторожок. Я мониторил жизнь дампа через pgrep -f pg_dump — и получал ложный «STILL_RUNNING», потому что командная строка самого pgrep содержит подстроку pg_dump и матчит саму себя. Проверки, которые не обманывают:

Мораль такая: pgrep -f вообще не годится сторожем над командами с общеупотребимой подстрокой в имени — это ложный друг, который матчит наблюдателя.

Стрим дампа: любую запись — с источника долой

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

ssh -T target-host \
  "cat > /srv/dumps/appdb.dump" \
  < <(docker exec app-postgres pg_dump -Fc -U app appdb)

Или наоборот — с цели pull’ом:

ssh -T old-host \
  'docker exec app-postgres pg_dump -Fc -U app appdb' \
  > /srv/dumps/appdb.dump

Дамп с живой базы — норма для PostgreSQL, документация 11-й версии говорит это прямо: «pg_dump does not block other users accessing the database (readers or writers)» (pg_dump 11 reference). Слоты снапшота держит сам pg_dump, приложение продолжает работать.

Две детали в транспорте, без которых стрим бинарного -Fc дампа испортится:

С этими двумя флагами дамп 1,6 ГБ прошёл, EXIT=0, а на цели:

pg_restore --no-owner -j 4 -d appdb /srv/dumps/appdb.dump

Развернулся в 9,6 ГБ, 115 таблиц, расширение rum подхватилось. Проверил \dx — на месте.

Почему EOL-образы нельзя пересобрать, но можно вытянуть

Готовый образ в registry — это точная прод-версия, а сборка Dockerfile через несколько лет упирается в исчезнувшие apt-репозитории. Первый docker compose pull на новом узле лёг:

Error response from daemon: error from registry: access forbidden

Приватный GitLab-registry, новая машина против него не авторизована. Я пошёл не в ту сторону — решил, что раз pull не идёт, соберу из docker/ (Dockerfile’ы те же, что в проде). Собирать пришлось из EOL-баз, и apt по очереди вернул мне это:

База образаDebian внутриЧто говорит apt в 2026
nginx:1.17.4busterErr:5 http://deb.debian.org/debian buster Release 404 Not Found
postgres:11stretchE: Failed to fetch http://apt.postgresql.org/pub/repos/apt/dists/stretch-pgdg/11/binary-amd64/Packages 404 Not Found

Buster ещё как-то перенаправляется на archive.debian.org. А stretch-based postgres:11 дальше не идёт: PGDG для EOL-мажора вычистили, официальных пакетов не существует. Я потратил на этот проулок полчаса, пока не сообразил, что решаю не ту задачу.

Ответ всё это время лежал на самом источнике: он же откуда-то эти образы pull’ит. Значит, у него есть рабочие креды registry, и лежат они там, куда их складывает docker login — в /root/.docker/config.json. Копия одного файла:

scp old-host:/root/.docker/config.json /root/.docker/config.json

И docker compose pull вытянул все 5 образов — те самые байты, что крутятся в проде. Никакой сборки.

Как rsync-exclude без слэша съел framework

--exclude=log/ без ведущего слэша матчит любой каталог log/ на любой глубине, а не только корневой. Я добавил это, чтобы не тащить 2,2 ГБ логов приложения через сеть. Rsync отработал чисто, приложение поднялось — и отдал HTTP 500:

include(/app/vendor/yiisoft/yii2/log/Logger.php):
failed to open stream: No such file or directory

Ошибка ведёт куда угодно, только не к rsync. «Битый vendor» — первая гипотеза, за ней я и погнался: перекачал vendor, проверил композер-lock, сравнил размеры. И на третьей минуте понял, что exclude’ы у меня рекурсивные — и они срезали vendor/yiisoft/yii2/log/ заодно с логами приложения.

Правило в мане rsync проговаривается прямым текстом: «if the pattern starts with a / then it is anchored to a particular spot in the hierarchy of files, otherwise it is matched against the end of the pathname» (rsync(1)). Ключевое слово — «otherwise».

Фикс — один символ:

rsync -a --exclude=/log/ code/ target:/app/

Ведущий слэш якорит паттерн к корню передачи. Второй прогон rsync без изменений прислал только те log-каталоги, что были ошибочно вырезаны — секунды инкрементального трафика.

Тест на боевых интеграциях без сайд-эффектов

Именно очереди и шедулер порождают исходящие эффекты — письма, платежи, запросы в CRM. Web-морда поверх копии БД безопасна. Приложение общалось с боевым платёжным шлюзом, CRM, SMTP и ClickHouse, и тестовая копия должна была подняться, никого не разбудив. Docker-compose был уже разбит на два файла — docker-compose.yml (nginx, php-fpm, api) и docker-compose.queue.yml (8 очередей + шедулер) — так что весь предохранитель свёлся к одной переменной:

COMPOSE_FILE=docker/docker-compose.yml docker compose up -d

Поднялся только web-контур. Smoke-тест:

Одна внешняя интеграция всё-таки вылезла. Backend страниц, которые ходят в аналитический ClickHouse, отдавал 503, и в логе:

/dev/tcp/clickhouse.example:31124: Connection refused

Не проблема сети, а allowlist: ClickHouse на удалённом узле фильтрует по IP, и новый узел в белом списке ещё не был. Это общая закономерность: любой сервис на белом списке (БД, mail, аналитика, платёжка) — часть чек-листа миграции, а не сюрприз в момент cutover.

Пошагово

  1. Понять сначала характер отказа диска. «Чтение живо, запись висит» переворачивает все дальнейшие решения — не пробовать дальше писать на источник.
  2. Скопировать /root/.docker/config.json с источника на цель одним scp. Убедиться, что docker compose pull вытягивает все образы.
  3. Стрим дампа: ssh -T source 'docker exec db pg_dump -Fc -U app appdb' > /srv/dumps/appdb.dump на целевом узле. Сторожок — дельта размера, не pgrep.
  4. pg_restore --no-owner -j 4 -d appdb /srv/dumps/appdb.dump. Проверить \dx — расширения на месте.
  5. rsync кода и медиа с якорным exclude’ом: --exclude=/log/, а не --exclude=log/. Второй прогон покажет, что пропущено.
  6. Поднять web-контур через COMPOSE_FILE=docker/docker-compose.yml docker compose up -d — очереди и шедулер лежат.
  7. Smoke-тест: API, публичная морда, админка. Собрать список внешних сервисов, у которых новый IP не в allowlist.
  8. Cutover — позже, в окно тишины: свежий дамп поверх, DNS, IP в белые списки, mail-репутация.

Итог

Умирающее железо меняет правила: источник — только на чтение, хочешь ты этого или нет. Из этого вытекает всё остальное — стримить дамп, а не писать локально; тянуть образы из registry, а не пересобирать EOL-базы; якорить exclude’ы до того, как они срежут vendor/. Копия проекта поднялась за несколько часов, и большая часть потерянного времени — драки с проблемами (архивы Debian, «битый vendor»), которые уже были решены ограничениями задачи.

Что осталось за скобками этой сессии: доступ нового IP к ClickHouse (получить allowlist), финальный cutover со свежим дампом и переключением DNS, репутация mail-IP на новом хосте. Cutover — отдельная история и, кажется, отдельная статья.

Первоисточники, к которым я обращался по дороге:

#Docker #PostgreSQL #rsync #миграция #Proxmox

<< Previous Post

|

Next Post >>