Миграция WordPress между серверами без простоя: переезд на новый VPS по шагам

Эта статья разворачивает в полноценный план один абзац из Бэкапов — сценарий «увести сайт на новый хост без простоя». Там он был списком из пяти шагов; здесь — разбор того же перехода по косточкам, с командами и ловушками, которые обычно и выдают «незапланированный» даунтайм посреди миграции. «Без простоя» (ноль downtime) здесь означает: переключая сайт с одной машины на другую, вы не сажаете его в «белый экран» — пользователи либо остаются на старой копии, либо уходят на новую, и обе отвечают одинаково, потому что собраны из одного слепка данных.


Часть I. Подготовка: TTL, дедлайн и «окно»

Миграция, у которой нет заранее сниженного TTL, превращается в лотерею. Разберём, почему — и что ещё подготовить до всякой команды переноса.

Что такое TTL и зачем его снижать

TTL (time-to-live, «сколько жить») у DNS-записи говорит всем, кто её читает (резолверы провайдеров, браузеры, кэши), как долго держать в памяти ответ без переспроса. Пока ваш домен указывал на старый хост с TTL, скажем, 3600 секунд (1 час), после переключения записи половина мира будет ещё час стучаться на старый IP, а половина — на новый. Это не авария, но это значит, что:

  • вы не сможете чисто «проверить переключение» — часть трафика придёт на старый хост ещё с час;
  • откат (вернули старую запись) тоже будет «вполсилы» до конца TTL.

За сутки до переезда выставляете TTL на A/AAAA-записях сайта в значение вроде 300 секунд (или минимальное, которое даёт панель провайдера). После успешного переезда и пары спокойных дней возвращаете TTL к нормальному (не держим вечно маленький — это лишние запросы к DNS). Снижать TTL надо заранее: у записей, которые читали с длинным TTL, старые адреса уже сидят в кэшах с тем большим TTL — пока он не истечёт, ваш новый короткий TTL на пришедшие запросы не влияет.

Окно и «план действий»

Перед началом фиксируете три вещи (одной строкой в тикете/заметке, как в Runbook «Авария»):

  1. Точка, с которой снимаем слепок (дата/время дампа и rsync). Позже, при финальном переносе, вы будете сравнивать «что изменилось после слепка».
  2. Когда переключаете DNS — договоритесь о времени с минимальной посещаемостью (для личного сайта — ночь; это снижает число обращений к обеим машинам в переходный момент).
  3. Что будет «сдано» как успех: главная открывается, /wp-admin логинится, один пост можно создать, один файл можно загрузить в медиа-библиотеку. Это же и ваш тест приёма.

Новый хост ещё пустой — это хорошо

Новая машина не должна быть «готовым WordPress», на который вы сверху накатываете данные. Сначала вы собираете окружение-двойник старого: та же версия PHP (или заранее решённая новая, см. Часть VI), MariaDB, nginx. Прогон настройки этой базы — у Анатомии VPS и Nginx. Дальше — что уже было у старого хоста, должно повториться у нового (иначе это тихая часть «на проде легло»): почта через SMTP/mail(), права/владелец файлов, PHP-лимиты (upload_max_filesize, memory_limit — из PHP-FPM).


Часть II. Слепок и перенос на новый хост

Здесь ровно то же, что и при бэкапе, но вместо «положить в архив» — «положить на новую машину и поднять там». Один снимок = файлы + база.

Файлы: rsync с сохранением атрибутов

Bash
# bash — тянем дерево сайта со СТАРОГО хоста на новый (выполнять НА НОВОМ хосте)
# -a = рекурсивно + сохранить права/владева/время/симлинки (критично для wp-content)
rsync -avh --progress \
  -e "ssh -i ~/.ssh/migrate_key" \
  deploy@old.example.test:/var/www/example.test/ \
  /var/www/example.test/

Не используйте scp -r: он не несёт -a-свойств, и вы получите сайт с поломанными правами (иногда «500» из-за того, что владелец root, а PHP-FPM живёт под www-data). Именно владелец и права — одна из самых частых «неожиданностей на проде»: после rsync -a владелец уехал цифрой, а на новом хосте uid другого пользователя. Исправьте владельца после копирования:

Bash
# bash — привести владельца дерева к тому, под кем живёт PHP-FPM
sudo chown -R www-data:www-data /var/www/example.test

(Какой именно пользователь у пула — видно из www.conf/юнита: см. PHP-FPM, где разбирается user/group пула.)

База: дамп и импорт

Bash
# bash — на СТАРОМ хосте: согласованный снимок без блокировки живой базы (из Бэкапов, Часть II)
mysqldump --single-transaction --quick --routines --triggers --events \
  --set-gtid-purged=OFF -u wpuser -p wpdatabase > /tmp/wp-migrate.sql

# bash — перенос дампа на новый хост
scp -i ~/.ssh/migrate_key /tmp/wp-migrate.sql deploy@new.example.test:/tmp/
Bash
# bash — на НОВОМ хосте: создать пустую БД + пользователя и импортировать слепок
sudo mysql -e "CREATE DATABASE wpdatabase CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# пользователя и права заведите тем же скриптом grant, что описан в [MariaDB] про «только под один сайт»
sudo mysql wpdatabase < /tmp/wp-migrate.sql

Важная деталь: импортируйте дамп в пустую базу, а не «поверх» уже накатанного туда WordPress. Иначе вы получите конфликт таблиц и «частично переехавший» контент.

wp-config.php и подключение

Файл wp-config.php приехал в дереве с rsync. Он несёт старые креды БД и старые уникальные ключи-соли (AUTH_KEY, SECURE_AUTH_KEY, …). Два решения:

  • Креды БД (DB_NAME/DB_USER/DB_PASSWORD) — подставьте те, что создали на новом хосте, иначе сайт будет стучаться в несуществующую базу.
  • Ключи-соли — можно оставить как были (проще), но это же ваши ключи шифрования куки-сессий. Если подозревали утечку на старом хосте, сгенерируйте новые (https://api.wordpress.org/secret-key/1.1/salt/) — учтите, что это разлогинит всех текущих сессий. Обсуждение солей — в Шифрование данных.

Проверка PHP-версии

Bash
# bash — что за PHP реально работает на новом хосте
php -v
ls /etc/php/            # какая версия поставлена; пул должен совпасть с ней

Если на новом хосте другая мажорная версия PHP, чем на старом, это не ошибка миграции, а потенциальный баг плагина/темы под новую версию — тестите это на этапе подъёма по IP (Часть IV), а не после переключения DNS.


Часть III. Замена адреса внутри базы (главная причина «переехал — не работает»)

WordPress хранит абсолютные URL внутри базы: option siteurl/home, а также сериализованные массивы (виджеты, настройки темы, мета-поля meta_value с a:2:{...}) и ссылки внутри текстов постов. Когда сайт переезжает со https://old.example.test на https://example.test, все эти вхождения надо заменить. Ключевое слово — заменить так, чтобы сериализованные данные остались валидными.

Почему «sed по дампу» — плохая идея

Сериализованный PHP хранит длину строки в тексте: s:19:"https://old.examp…". Если вы тупо замените подстроку другой по длине строкой, а метку длины s:19: не пересчитаете, PHP не сможет прочитать поле — виджет/настройка тихо отвалится, а дамп при импорте не выдаст ошибки. Вы получите «сайт переехал, а часть настроек/виджетов пустая», и debugging-сессия будет долгой.

Правильно: wp search-replace

WP-CLI (официальная утилита командной строки WordPress) меняет URL с учётом сериализации — он разбирает сериализованные поля, заменяет внутри них и пересобирает с корректной длиной.

Bash
# bash — безопасная замена URL по всей базе (выполнять НА НОВОМ хосте, в корне сайта)
wp search-replace 'https://old.example.test' 'https://example.test' --all-tables --skip-columns=guid
  • --all-tables — пройтись не только по постам (wp_posts) и опциям (wp_options), но и по мета-таблицам, где тоже сидят абсолютные ссылки.
  • --skip-columns=guid — колонку guid (global unique identifier, «уникальный идентификатор» поста) не трогаем: это не «адрес картинки», а неизменяемый идентификатор, и его замена ломает RSS-ленту у подписчиков, которые его кэшировали.

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

Bash
# bash — предпросмотр без записи
wp search-replace 'https://old.example.test' 'https://example.test' --dry-run --report-changed-only

Если WP-CLI на новом хосте ещё не поставлен — это одна команда (wp-cli.org), но он должен запускаться с версией PHP вашего сайта. Если CLI не ставится или вы предпочитаете не ставить его на прод, есть аккуратная альтернатива — плагин-заменщик (Better Search Replace / Search & Replace), который делает то же: сериализацию понимает. Прямой sed/replace в SQL — только для колонок, которые вы уверены что не сериализованы.

Отдельно: HTTPS после переезда

Если при жизни на старом хосте редирект на HTTPS делал nginx, на новом его надо поставить заново: сертификат Let’s Encrypt выпускается на новой машине (certbot), и конфиг отдаёт его (см. Nginx про TLS/certbot). Иначе сразу после переключения DNS получите либо «не доверенный сертификат» для пользователей, либо бесконечный редирект между http и https — когда WordPress считает себя HTTPS-сайтом, а nginx ещё не отдаёт HTTPS.


Часть IV. Подъём и проверка по IP — до переключения DNS

До того как трогать домен, новый хост обязан сам отдавать сайт, проверенный напрямую, без участия DNS. Только так «без простоя» перестаёт быть обещанием и становится фактом.

Открываем новый хост по IP

На новом хосте сайт уже лежит. На время проверки можно открыть его напрямую:

  • у nginx-server блока — временное совпадение по IP или default_server, чтобы запрос по IP попал в нужную location (конфигурация location/server_name — из Nginx);
  • или локально у себя на машине в /etc/hosts приписать боевой домен к новому IP:
# /etc/hosts на вашей тестовой машине (не на сервере!) — временная подмена DNS локально
NEW_SERVER_IP   example.test

После этого браузер с вашей машины идёт на новый хост по боевому домену — и проверяет ровно то, что увидят пользователи: сериализованные URL поменялись, виджеты на месте, стили грузятся. (Если подменяете в /etc/hosts — отключите в браузере кэш DNS/secure-DNS и используйте приватное окно, иначе домен возьмётся из кэша.)

Тесты, которые доказывают, что переезд удался

Тот же «план действий» из Части I — прогоняем на новом хосте:

Bash
# bash — базовые проверки с машины-клиента
curl -sI https://example.test/ | head -1              # HTTP/2 200
curl -s  https://example.test/ | grep -c 'example.test'  # в HTML уже новый домен
curl -sI https://example.test/wp-login.php | head -1  # админка отвечает
  • Откройте главную, внутренний пост, страницу без комментария; зайдите в /wp-admin под своим пользователем.
  • Загрузите картинку в медиа-библиотеку: этот шаг проверяет сразу и права на wp-content/uploads, и что PHP-FPM может писать (частый «на проде не грузится», если chown забыли).
  • Зайдите в Настройки → Общие: поля WordPress address (URL) и Site address (URL) должны показывать новый адрес.
  • Если есть форма (поиск, комментарий, контакт) — проверьте одну отправку: это косвенно проверяет, что nonce/сессии живут на новом хосте.

Пока эти тесты зелёные на новом хосте по IP, трогать DNS нельзя. Пока хоть один красный — исправляем на новом хосте, старый не трогаем вообще (и пользователи продолжают спокойно работать на старом — это и есть «без простоя»).

Финальная дельта перед свитчем

Между «сняли слепок» (Часть II) и «переключаем DNS» (Часть V) на старом хосте могли написать новые посты/комментарии и загрузить новые картинки. Прямо перед переключением снимаем дельту — инкрементальный rsync файлов (дособерёт новые uploads) и свежий дамп базы (включит новые записи):

Bash
# bash — финальный добор файлов (только что изменилось после слепка)
rsync -avh --delete -e "ssh -i ~/.ssh/migrate_key" \
  deploy@old.example.test:/var/www/example.test/wp-content/uploads/ \
  /var/www/example.test/wp-content/uploads/

# bash — финальный дамп базы со старого + импорт на новый (команды из Части II)
# (после импорта повторите wp search-replace URL на свежем дампе!)

Здесь --delete уместен: за время подготовки uploads мог поменяться, и дельта синхронизирует новое состояние. Но помните предупреждение из Бэкапов: если на старом что-то случайно удалили, --delete сотрёт это и здесь — перед финальным добором убедитесь, что дамп и rsync совпадают по времени слепка и что вы не тащите «дыру».


Часть V. Переключение и быстрый откат

Свитч

DNS переведён на новый IP (после снижения TTL в Части I). Дальше — наблюдаем, а не чиним вслепую:

Bash
# bash — с машины-клиента убедиться, что резолвер уже отдаёт новый IP и сайт отвечает
dig +short example.test A          # должен показать NEW_SERVER_IP
curl -sI https://example.test/ | head -1

Пока старый TTL (для тех, кто его закэшировал раньше) не истёк, часть трафика ещё идёт на старый хост. Старый хост в это время остаётся живым и на записи — там работают формы и комменты, поэтому и делался финальный добор дельты в Части IV. Через несколько часов (по старому TTL) весь трафик уходит на новый; старый хост пока оставляем включённым.

Быстрый откат = «DNS обратно»

Если на новом хосте проявился сюрприз (плагин не работает на новой PHP, кривой .htaccess, не поднялся HTTPS), самый дешёвый откат — не трогая файлы и базу, вернуть A-запись на старый IP. Короткий TTL из Части I гарантирует, что откат сработает быстро, а не через час. Старый хост за время тестов не удалялся и не перезаписывался — значит он и есть рабочая копия «как было». Только после того, как откатились и разобрались с новым хостом по IP, делаете вторую попытку свитча.

Файловый/БД-откат (заменить состояние нового хоста на слепок) — это путь Бэкапов, Часть VI сценарий A. Но он дороже по времени, чем «DNS обратно», и обычно не нужен: держите старый хост живым и не удаляйте его раньше, чем новая машина прожила несколько дней без сюрпризов.

Когда гасить старый хост

Не в день переезда. Через несколько дней: убедитесь, что весь трафик уже на новом (по access-логам нового хоста — он отдаёт 100% запросов), что на старом больше нет живой почты/кронов, которые вы не перенесли, что вы не боитесь «а вдруг надо вернуться». И только после этого снимайте старый хост, предварительно сделав финальную копию всего дерева и базы — не выбрасывайте слепок, с которого начали: он и есть ваш резерв Бэкапов.


Инструменты под задачу

Если вы мигрируете с Apache на Nginx (типично для переезда «shared-хостинг/старый VPS на Apache → свой VPS на Nginx»): физически переносятся файлы и база, а виртуальный хост придётся переписать с синтаксиса Apache (VirtualHost, .htaccess, RewriteRule) на nginx-блоки (server/location). Это тот редкий случай, где «чуть поправить» не работает — ключи и логика разные. Держать в голове обе системы, чтобы не перепутать Directory и location, — как раз рутина для инструмента, который переводит между ними. В codestack.space есть AI Nginx ⇄ Apache Converter: он переносит конфиг в другую систему, не подставляя валидные ключи одной в другую.

Конвертер Nginx ⇄ .htaccess

Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

Он снимает механику переноса конфига; понимание «какие заголовки и редиректы вы хотите получить после переезда» (из Части III про HTTPS) остаётся за вами.


Часть VI. Что «забыли» при переезде — и почему оно всплывает после свитча

Каждый пункт ниже на вид мелочь и почти всегда молчит, пока вы не переключили DNS. Пройдитесь по ним до Части V.

  • SSL не продлевается на новом хосте. certbot ставит systemd-таймер/auto-renew на той машине, где он был запущен. Переехали на новый хост, но авто-продление осталось на старом — сертификат на новом через 90 дней не обновится, и вы об этом узнаете, когда он истечёт. Проверьте systemctl list-timers | grep -i certbot на новом хосте (Nginx про автопродление).
  • Системный cron сайта не уехал. У WordPress два «крона»: встроенный wp-cron (живёт с сайтом и уехал вместе с файлами) и, если вы разводили тяжёлые задачи на настоящий cron (бэкап-скрипт, wp cron event run), то crontab/systemd-timer на новом хосте надо завести заново — они в /etc/cron* и у пользователя, не в дереве сайта (см. Бэкапы, Часть IV).
  • Права и владелец не совпали. После rsync с другого uid/гид, как описано в Части II, PHP-FPM не может писать. Симптомы: «не загружается картинка», «не обновляется плагин», «не создаётся debug.log». Лечится chown -R, но не стоит доверять, что «на старом же было так» — на новом uid может быть другим.
  • Разные PHP-лимиты и php.ini. upload_max_filesize, post_max_size, memory_limit, max_execution_time на новом хосте по умолчанию другие → «форма загрузки падает на файле, который на старом проходил». Сверяем и правим пул/ini по PHP-FPM.
  • upload_max_filesize в PHP ≠ лимиту nginx/apache. Даже подняв PHP-лимит, большой загрузочный файл может резать nginx (client_max_body_size) — при переезде на Nginx это отдельный параметр, и после переезда с Apache его «по-старому» нигде нет.
  • Разные версии PHP/MySQL. Тема или плагин молча завёлся на PHP 8.1 и упал на 8.3; или ONLY_FULL_GROUP_BY в новой MariaDB сломал кастомный запрос плагина. Отсюда и тест на новом хосте по IP в Части IV — это про это.
  • Почта (mail()/SMTP) не настроена. На новом хосте нет настроенного sendmail/SMTP-релей, и письма WordPress (сброс пароля, уведомления) не уходят. Отдельная тема — настраивается заново, а не переезжает.
  • Смена IP в списки/ограничения. Если на старом хосте были whitelist по IP (панели, внешние API, fail2ban-исключения, «только для доверенных сетей»), они надо обновить под новый IP (Firewall/SSH).
  • robots.txt, .well-known/acme-challenge/, спец-пути. Проверьте, что они на месте на новом хосте: acme-challenge нужен для выпуска/продления сертификата; если редирект/location его перехватывает — сертификат не перевыпустится.
  • Не выставлены WP_HOME/WP_SITEURL при «жёстком» адресе. Обычно адреса берутся из базы и их правит wp search-replace. Но если в wp-config.php у вас на старом хосте стояли жёсткие константы WP_HOME/WP_SITEURL (частый способ задать адрес), они приехали в wp-config.php со старым доменом и перекроют то, что в базе — их надо править там же.
  • После переезда влезла старая кэш-плагиновая копия. Если на старом хосте был страничный кэш (файлы wp-content/.../cache), он приехал как есть и может отдавать старый HTML со ссылками на старый домен. Перед проверкой по IP очистите кэш.

Чек-лист граблей (кратко)

  • TTL DNS не снижен заранее → переключение и откат «висят» по старому кэшу.
  • Переключили DNS до проверки по IP → сюрприз увидят пользователи, а не вы.
  • Меняли URL через `sed`/ручной SQL → битые сериализованные виджеты/настройки без явной ошибки.
  • Импорт дампа «поверх» готового WordPress → конфликт и «полупереезд».
  • Забыли финальный добор дельты → новые записи/uploads «потерялись» между слепком и свитчем.
  • `chown`/права не сверили → загрузки и обновления молча падают.
  • `client_max_body_size` (nginx) и PHP-лимиты разошлись → «большой файл не грузится».
  • Не перевели cron/timer и авто-продление сертификата на новый хост → узнали об этом через 90 дней.
  • Жёсткие `WP_HOME`/`WP_SITEURL` в `wp-config.php` со старым доменом → не дают смене адреса из базы подействовать.
  • Старый хост погасили в день переезда → лишили себя дешёвого отката «DNS обратно».
  • Старая копия страничного кэша приехала → отдаёт старый домен в HTML.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.