Как вывезти живой docker-проект с умирающего диска: стрим pg_dump, pull образов, якорный exclude
Короткий ответ: на умирающем диске 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 -Fcна источнике, вывод поssh -Tв файл на быстром диске цели →pg_restore -j 4разворачивает 9,6 ГБ и 115 таблиц с расширениемrumна месте. - Решение: образы тянуть из registry, кред брать с источника —
/root/.docker/config.jsonкопируется однимscp, иdocker compose pullвытягивает все 5 образов (точные прод-версии). - Решение: для теста поднимать только web-контур через
COMPOSE_FILE=docker/docker-compose.yml— очереди и шедулер лежат, никакие письма и платежи никуда не уходят. - Грабли: дамп в дохлый диск не падает, а зависает — размер стоит, процесс жив. Мониторить надо дельту размера файла, а не
pgrep, который совпадает сам с собой. - Грабли:
rsync --exclude=log/рекурсивен — режетlog/на всех уровнях. Ошибку выдаёт приложение, а не rsync. - Грабли: внешние сервисы фильтруют по IP. С нового узла ClickHouse встречает
Connection refused— allowlist нового IP собирать заранее, а не по ходу дела.
Почему pg_dump на умирающем диске виснет, а не падает
Дамп в мёртвый диск не отдаёт ошибку — он перестаёт двигаться, оставаясь живым процессом. Первое, что я попробовал — очевидное: pg_dump -Fc внутри контейнера в bind-каталог backup/ на самом источнике. Файл дорос до 1601044831 байта и встал. Две проверки размера с интервалом в 6 секунд вернули одно и то же число, процесс — S+ в ps. Диск сдох именно на записи, и из userspace это выглядит ровно так: pg_dump ждёт очередной write(), а тот не возвращается.
Отдельно врал сторожок. Я мониторил жизнь дампа через pgrep -f pg_dump — и получал ложный «STILL_RUNNING», потому что командная строка самого pgrep содержит подстроку pg_dump и матчит саму себя. Проверки, которые не обманывают:
ps -C pg_dump— точное совпадение по имени процесса, а не по подстроке в командной строке.- Дельта размера выходного файла между двумя
stat -c %s— еслиdelta=0за 6 с при живом процессе, дамп встал.
Мораль такая: 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 дампа испортится:
ssh -T— без выделения pseudo-tty. Иначе\nв бинарном потоке превращается в\r\n(мапинг ONLCR терминала), иpg_restoreпотом рапортует про corrupt custom archive.docker exec— без-tпо той же причине. Толькоdocker exec app-postgres pg_dump ..., без-it.
С этими двумя флагами дамп 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.4 | buster | Err:5 http://deb.debian.org/debian buster Release 404 Not Found |
postgres:11 | stretch | E: 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-тест:
- API →
200 - сайт →
302на логин - админка → рендерит форму входа
Одна внешняя интеграция всё-таки вылезла. Backend страниц, которые ходят в аналитический ClickHouse, отдавал 503, и в логе:
/dev/tcp/clickhouse.example:31124: Connection refused
Не проблема сети, а allowlist: ClickHouse на удалённом узле фильтрует по IP, и новый узел в белом списке ещё не был. Это общая закономерность: любой сервис на белом списке (БД, mail, аналитика, платёжка) — часть чек-листа миграции, а не сюрприз в момент cutover.
Пошагово
- Понять сначала характер отказа диска. «Чтение живо, запись висит» переворачивает все дальнейшие решения — не пробовать дальше писать на источник.
- Скопировать
/root/.docker/config.jsonс источника на цель однимscp. Убедиться, чтоdocker compose pullвытягивает все образы. - Стрим дампа:
ssh -T source 'docker exec db pg_dump -Fc -U app appdb' > /srv/dumps/appdb.dumpна целевом узле. Сторожок — дельта размера, неpgrep. pg_restore --no-owner -j 4 -d appdb /srv/dumps/appdb.dump. Проверить\dx— расширения на месте.- rsync кода и медиа с якорным exclude’ом:
--exclude=/log/, а не--exclude=log/. Второй прогон покажет, что пропущено. - Поднять web-контур через
COMPOSE_FILE=docker/docker-compose.yml docker compose up -d— очереди и шедулер лежат. - Smoke-тест: API, публичная морда, админка. Собрать список внешних сервисов, у которых новый IP не в allowlist.
- Cutover — позже, в окно тишины: свежий дамп поверх, DNS, IP в белые списки, mail-репутация.
Итог
Умирающее железо меняет правила: источник — только на чтение, хочешь ты этого или нет. Из этого вытекает всё остальное — стримить дамп, а не писать локально; тянуть образы из registry, а не пересобирать EOL-базы; якорить exclude’ы до того, как они срежут vendor/. Копия проекта поднялась за несколько часов, и большая часть потерянного времени — драки с проблемами (архивы Debian, «битый vendor»), которые уже были решены ограничениями задачи.
Что осталось за скобками этой сессии: доступ нового IP к ClickHouse (получить allowlist), финальный cutover со свежим дампом и переключением DNS, репутация mail-IP на новом хосте. Cutover — отдельная история и, кажется, отдельная статья.
Первоисточники, к которым я обращался по дороге:
<< Previous Post
|
Next Post >>