Переезд между хостингами почти никогда не бывает мгновенным: часть посетителей ещё несколько часов приходит на старый IP, пока обновляются кэши DNS. Эта статья — про то, как свести окно недоступности записи к нескольким минутам и не потерять за это время заказы, сессии и письма. Все команды даны под связку Ubuntu 24.04 LTS, nginx 1.24, PHP 8.3 и MySQL 8.0; отличия MariaDB 10.11 отмечены отдельно.

Что значит «без простоя»

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

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

Что переезжает, кроме файлов и базы

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

Что переносимЧемЧто теряется молча
Файлы сайтаrsync -aHAX --delete, на приёмнике под rootвладельцы файлов, если на приёмнике нет прав на chown
База MySQLmysqldump --routines --triggers --eventsпроцедуры, триггеры, события, кодировка и collation
Сессии, кэш, очередикопия dump.rdb из Redisвсе пользователи разлогинены, задания очереди исчезли
Сертификаты Let's Encryptrsync /etc/letsencrypt/ от rootсимлинки live/ на archive/ и таймер автопродления
Конфиги nginxrsync /etc/nginx/ от rootпуть к сокету PHP-FPM от старой версии PHP — это 502
Cron и systemd-таймерытолько вручнуюзадания из /etc/cron.d и crontab других пользователей
Ключи DKIMкопия каталога ключей вашего MTAподпись писем; TXT-запись селектора при этом менять не нужно
SPFправка DNS заранееисходящая почта с нового IP уходит в спам

Сессии, кэш и очереди

На типовом сайте сессии и кэш чаще лежат в Redis или Memcached, а не в файлах и не в MySQL. Ни rsync каталога сайта, ни mysqldump их не переносят, а сайт после переключения выглядит рабочим — просто все авторизованные пользователи разлогинены, корзины пусты, а необработанные задания из очереди исчезли вместе со старым сервером. Сначала посмотрите, что вообще используется:

grep -E 'SESSION_DRIVER|CACHE_STORE|QUEUE_CONNECTION' .env

Если это Redis, его переносят отдельно — после остановки воркеров, чтобы в снимок не попала половина обработанного задания:

# на старом сервере: сохраняем снимок и убеждаемся, что он свежий
redis-cli BGSAVE
sleep 5
redis-cli LASTSAVE

sudo rsync -a /var/lib/redis/dump.rdb root@203.0.113.10:/var/lib/redis/dump.rdb

# на новом сервере: вернуть владельца и перезапустить, иначе снимок не загрузится
sudo chown redis:redis /var/lib/redis/dump.rdb
sudo systemctl restart redis-server

Cron и systemd-таймеры

Периодические задания на Ubuntu живут как минимум в пяти местах, и rsync каталога сайта не копирует ни одно из них. Проверять надо все:

# пользовательские crontab всех пользователей, а не только текущего и www-data
sudo ls -1 /var/spool/cron/crontabs/
for u in $(sudo ls -1 /var/spool/cron/crontabs/); do
  echo "=== $u ==="
  sudo crontab -u "$u" -l
done

# системные задания — их легко забыть; сюда же пакеты кладут свои, включая certbot
sudo cat /etc/crontab
sudo ls -l /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/

systemctl list-timers --all

Собираем новый сервер как зеркало старого

Сертификаты и конфиги nginx

Каталог /etc/letsencrypt копируется отдельной командой и обязательно от root. Причин три. Подкаталоги archive и keys имеют режим 0700 root:root — непривилегированный пользователь получит «Permission denied» по каждому файлу и код возврата 23, причём live/ при этом создастся и ошибку легко пролистать. Файлы в live/example.com/ — это относительные символические ссылки на ../../archive/example.com/: копировать только live/ или с ключом -L нельзя, иначе получите битые ссылки либо файлы без истории, и certbot renew потом сломается.

# от root на старом сервере; -a сохраняет симлинки live/ на archive/, -L использовать НЕЛЬЗЯ
sudo rsync -aHAX --numeric-ids /etc/letsencrypt/ root@203.0.113.10:/etc/letsencrypt/
sudo rsync -aHAX --numeric-ids /etc/nginx/     root@203.0.113.10:/etc/nginx/

Пакет certbot на новом сервере нужно установить отдельно: копирование /etc/letsencrypt не создаёт certbot.timer, и без него автопродление не заработает. Заодно откройте /etc/letsencrypt/renewal/example.com.conf — там записан способ проверки со старого сервера (authenticator = nginx или webroot_path), и на новом он может не соответствовать реальности.

Сокет PHP-FPM

Это самая частая причина 502 сразу после переезда. Старый сервер почти наверняка был на PHP 8.1 или 8.2, и в скопированном конфиге стоит fastcgi_pass unix:/run/php/php8.1-fpm.sock;. На Ubuntu 24.04 с PHP 8.3 такого сокета нет: сокет называется /run/php/php8.3-fpm.sock, юнит — php8.3-fpm.service, пул лежит в /etc/php/8.3/fpm/pool.d/www.conf, логи — в /var/log/php8.3-fpm.log.

Типичная ошибка: убедиться, что nginx -t проходит, и считать конфиг рабочим. Путь к сокету на этапе теста конфигурации не проверяется — nginx -t скажет «syntax is ok», а сайт будет отдавать 502 на каждый PHP-запрос.

grep -rn 'fastcgi_pass' /etc/nginx/
ls -l /run/php/
systemctl status php8.3-fpm --no-pager

sudo nginx -t && sudo systemctl reload nginx

Привычку nginx -t && systemctl reload nginx стоит закрепить сразу: дальше в статье будет несколько правок конфига, и каждая вступает в силу только после перезагрузки nginx.

Расширения и настройки PHP

Сравнивать наборы расширений нужно у того SAPI, под которым работает сайт. В PATH лежит CLI, а сайт крутится под FPM, и наборы у них различаются штатно: в /etc/php/8.3/cli/conf.d и /etc/php/8.3/fpm/conf.d разные симлинки, а opcache в CLI по умолчанию выключен. Сравнив CLI, вы получите чистый diff и уверенность, что всё в порядке, — а сайт упадёт на отсутствующем в FPM imagick или intl.

# сравниваем именно FPM SAPI, а не CLI: наборы расширений у них разные
ssh deploy@198.51.100.20 'php-fpm8.2 -m' | sort > /tmp/old-modules.txt   # версия старого сервера
php-fpm8.3 -m | sort > /tmp/new-modules.txt
diff /tmp/old-modules.txt /tmp/new-modules.txt

Вторая по частоте причина «на старом же работало» — разошедшиеся значения ini. Проверьте их на обоих серверах, а потом отдельно посмотрите, что переопределяет пул FPM:

for h in 198.51.100.20 203.0.113.10; do
  echo "=== $h ==="
  ssh deploy@$h 'php -i | grep -E "^(memory_limit|upload_max_filesize|post_max_size|max_execution_time|date.timezone) "'
done

grep -E 'php_admin_value|php_value' /etc/php/8.3/fpm/pool.d/www.conf

За неделю: почта, SPF и ключи DKIM

Проверять почту после переезда поздно: с момента смены записи A и до правки SPF вся исходящая почта улетает в спам или отбивается с 550 SPF check failed. У SPF-записи есть собственный TTL, который тоже надо снижать заранее.

Добавьте новый IP в SPF, не удаляя старый, и оставьте оба на время переходного периода: v=spf1 ip4:198.51.100.20 ip4:203.0.113.10 ~all. Убрать старый IP можно через неделю после переезда. DKIM от IP не зависит вообще: это пара «приватный ключ в конфиге MTA плюс публичный ключ в TXT-записи селектора». Перенесите приватные ключи со старого сервера (/etc/opendkim/keys/ или каталог ключей вашего MTA) — TXT-запись при этом менять не нужно. После переключения проверьте обе записи:

dig example.com TXT +short | grep spf1
dig mail._domainkey.example.com TXT +short

За сутки: TTL и полный список записей

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

Типичная ошибка: снизить TTL за час до переключения. Если раньше стояло 86400, часть резолверов узнает о новом значении только через сутки, а до тех пор будет держать старый IP ровно столько, сколько записано в уже отданном ответе.

Смотреть надо все записи, которые придётся переключать, а не только A. Если у зоны есть AAAA — а на современных хостингах IPv6 включён по умолчанию, — клиенты с IPv6 продолжат ходить на старый сервер сколько угодно долго. То же касается www: это может быть CNAME, и правка A у апекса на него не подействует.

dig example.com     A    +noall +answer
dig example.com     AAAA +noall +answer
dig www.example.com ANY  +noall +answer   # A / AAAA / CNAME
dig example.com     TXT  +short | grep spf1

Запрос ANY надёжнее задавать авторитативному серверу зоны: часть публичных резолверов отвечает на него по RFC 8482 и полного списка записей не покажет.

Проверять, разошёлся ли новый TTL, нужно с явно указанным резолвером. Без @-адреса dig идёт в /etc/resolv.conf, где на Ubuntu 24.04 стоит stub systemd-resolved 127.0.0.53, а за ним — несколько апстримов провайдера с независимыми кэшами: два запроса подряд могут уйти к разным апстримам, и убывающего счётчика вы просто не увидите.

# резолвер фиксируем явно, иначе два запроса могут уйти к разным апстримам
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
  echo -n "$r: "; dig @$r example.com A +noall +answer
done
sleep 30
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
  echo -n "$r: "; dig @$r example.com A +noall +answer
done

Держите в голове, что TTL 300 — это ориентир для большинства, а не гарантия для всех. Часть публичных и провайдерских резолверов принудительно поднимает мелкие TTL до своего минимума (нередко 30–60 секунд, у некоторых корпоративных — до часа), а браузеры держат собственный кэш DNS поверх системного. Планируйте, что единичные клиенты придут на старый IP ещё несколько часов; именно поэтому старый сервер в конце превращается в прокси, а не гасится.

Первая синхронизация файлов

Файлы копируют заранее и целиком, чтобы в момент переключения остался только короткий догоняющий проход. Набор исключений задайте один раз переменной и используйте во всех проходах: в rsync --exclude не только пропускает файлы при передаче, но и защищает их на приёмнике от --delete. Если убрать исключения в поздних проходах, вы зальёте на новый сервер многогигабайтный кэш и логи, а --delete снесёт то, что новый сервер успел нагенерировать за время тестов.

# один набор исключений на все проходы
RSYNC_OPTS=(-aHAX --numeric-ids --delete
  --exclude 'storage/framework/cache/'
  --exclude 'var/cache/'
  --exclude '*.log')

# слэш в конце источника обязателен: копируем содержимое каталога, а не сам каталог
time rsync "${RSYNC_OPTS[@]}" --info=progress2 \
     --rsync-path='sudo rsync' \
     /var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/

Ключ --numeric-ids сохраняет числовые UID и GID, чтобы права не разъехались при другом порядке создания пользователей. Но сохранение владельца требует прав на chown на приёмнике: непривилегированный deploy просто не сможет назначить чужой UID, rsync это молча проигнорирует, и все файлы окажутся deploy:deploy — www-data не сможет писать в storage/, var/cache/ и public/uploads/. Отсюда --rsync-path='sudo rsync'; для этого на новом сервере нужна строка в sudoers:

deploy ALL=(root) NOPASSWD: /usr/bin/rsync

Если давать такие права не хочется, честнее не переносить владельцев вовсе: добавьте --no-owner --no-group, уберите --numeric-ids и один раз выставьте владельца на новом сервере.

sudo chown -R www-data:www-data /var/www/example.com

Проверка нового сервера до переключения

Чтобы точно знать, на какой сервер вы попали, повесьте на новом сервере отладочный заголовок. Важно, где именно: add_header наследуется в location только при условии, что в этом location нет ни одного своего add_header. В любом реальном конфиге PHP-сайта свои заголовки есть, и серверный X-Origin-Host там будет отброшен — вы не найдёте его в ответе и решите, что попали на старый сервер, хотя попали на новый.

location ~ \.php$ {
    add_header X-Origin-Host "new" always;   # обязателен здесь: наследование от server отменяется

    include      snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

Дальше открываем новый сервер под настоящим доменом, не трогая DNS. Для командной строки удобнее --resolve: он не требует правки системного файла и не оставляет следов.

curl -sI --resolve example.com:443:203.0.113.10 https://example.com/ | grep -i x-origin-host

Для проверки в браузере правится /etc/hosts, и вписывать туда нужно оба адреса: если у домена есть AAAA, getaddrinfo предпочтёт IPv6 из DNS одинокой строке с IPv4 из hosts, и вы будете тестировать старый сервер.

203.0.113.10   example.com www.example.com
2001:db8::10   example.com www.example.com
# macOS: сбросить локальный кэш DNS после правки hosts
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder

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

  • вебхуки платёжного провайдера — сквозной сценарий оплаты так не проверяется в принципе, проверяется только редирект на платёжную форму;

  • OAuth-колбэки и любые ответы внешних сервисов;

  • входящая почта — она идёт по MX, и /etc/hosts на неё не влияет вообще;

  • исходящая почта уйдёт с нового IP, которого может ещё не быть в SPF, — тест даст ложноотрицательный результат.

Эти сценарии проверяют либо на тестовом домене, указывающем на новый IP, либо в песочнице провайдера, либо сразу после переключения.

Режим только чтения

Флаг режима только чтения должен лежать вне каталога, который синхронизируется rsync с ключом --delete. Иначе догоняющий проход принесёт флаг со старого сервера на новый, и новый сервер стартует в режиме только чтения. Хуже обратный случай: если после переключения кто-то ещё раз прогонит rsync со старого сервера, флаг вернётся на боевой сервер и запись отключится без единой строчки в логе.

<?php
// public/index.php, до подключения фреймворка
// путь вне /var/www — иначе флаг приедет на новый сервер вместе с rsync
$readOnly = is_file('/etc/example.com/readonly.flag');

if ($readOnly && ! in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD', 'OPTIONS'], true)) {
    http_response_code(503);
    header('Retry-After: 600');
    exit('Сайт временно доступен только для чтения. Попробуйте через 10 минут.');
}

Если менять путь не хочется, минимум — добавить --exclude 'storage/readonly.flag' во все проходы rsync и написать об этом в инструкции для дежурного.

Тот же режим можно включить и на уровне nginx. Директива if допустима только в контексте server и location: в контексте http nginx откажется стартовать с ошибкой "if" directive is not allowed here. И обязательно оставьте проход для вебхуков — многие платёжные провайдеры при 503 делают считанное число ретраев, а некоторые не делают ни одного, так что подтверждения оплат за всё окно вы просто потеряете.

server {
    # ...
    add_header Retry-After 600 always;

    # OPTIONS не отсекаем, иначе сломается CORS-preflight на фронтенде
    if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
        return 503;
    }

    # вебхуки должны продолжать приниматься — location приоритетнее if в server-контексте
    location = /webhooks/payment {
        try_files $uri /index.php$is_args$args;
    }
}

Перенос базы данных

Сначала снимите с боевой базы её кодировку и сортировку — они понадобятся при создании базы на новом сервере:

SELECT @@character_set_database, @@collation_database;

Дампить лучше отдельным пользователем. На MySQL 8.0.21 и новее mysqldump --single-transaction берёт LOCK INSTANCE FOR BACKUP, и для этого нужна привилегия BACKUP_ADMIN: пользователь с одним SELECT получит Access denied ровно в момент переключения — худшее место для такого сюрприза.

GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, PROCESS, RELOAD ON *.* TO 'backup'@'localhost';
GRANT BACKUP_ADMIN ON *.* TO 'backup'@'localhost';   -- MySQL 8.0, для MariaDB не нужно

Ключ --single-transaction снимает согласованный срез InnoDB внутри одной транзакции, не блокируя INSERT и UPDATE. Но «не блокирует запись» верно только для DML: дамп удерживает метаданные-локи на каждой таблице, поэтому любой ALTER TABLE, запущенный во время дампа, встанет в очередь, а за ним встанут все последующие запросы к этой таблице. Проще говоря, деплой миграции параллельно с дампом кладёт сайт наглухо.

mysqldump --single-transaction --quick --routines --triggers --events \
          --set-gtid-purged=OFF --default-character-set=utf8mb4 \
          -u backup -p shop_db > /root/shop_db.sql

Разберём ключи, которые чаще всего забывают. Без --routines --triggers --events хранимые процедуры и события никуда не уедут, и вы этого не заметите, пока не должно будет сработать первое событие. Без --default-character-set=utf8mb4 на старых сборках дефолтом окажется latin1 и в дампе будут «кракозябры». А --set-gtid-purged=OFF нужен потому, что на MySQL 8 с включёнными GTID дамп по умолчанию содержит SET @@GLOBAL.GTID_PURGED, и импорт падает с @@GLOBAL.GTID_PURGED can only be set when @@GLOBAL.GTID_EXECUTED is empty.

При импорте помните: mysqldump db_name без ключа --databases не выводит ни CREATE DATABASE, ни USE. На чистом сервере импорт упадёт с ERROR 1049 (42000): Unknown database 'shop_db'. Создайте базу сами и обязательно с той же сортировкой, что была на старом сервере: дефолт MySQL 8.0 на Ubuntu 24.04 — utf8mb4_0900_ai_ci, а на старом сервере почти наверняка было utf8mb4_unicode_ci, и расхождение вылезет как Illegal mix of collations на первом же JOIN со сравнением строк.

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS shop_db \
      CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql --default-character-set=utf8mb4 -u root -p shop_db < /root/shop_db.sql

Промежуточный файл можно не создавать, но тогда следите за кодом возврата: по умолчанию код пайплайна — это код последней команды, то есть ssh, и ошибка mysqldump в середине потока даст обрезанный, но «успешно» импортированный дамп. Учётные данные на принимающей стороне тоже нужны явно, иначе mysql запустится от deploy и в лучшем случае получит Access denied, а в худшем — под сокет-аутентификацией попадёт в чужую базу.

set -o pipefail
mysqldump --single-transaction --quick --routines --triggers --events \
          --set-gtid-purged=OFF --default-character-set=utf8mb4 \
          -u backup -p shop_db \
  | ssh deploy@203.0.113.10 \
        'mysql --defaults-file=/home/deploy/.my.cnf --default-character-set=utf8mb4 shop_db'
echo "exit=$?"   # без pipefail здесь всегда был бы 0

Файл /home/deploy/.my.cnf должен содержать секцию [client] с логином и паролем и иметь права 600.

Чек-лист переключения

  1. Объявить окно и включить режим только чтения на старом сервере.

  2. Остановить воркеры очередей и cron на старом сервере, дождаться завершения текущих заданий.

  3. Снять финальный дамп базы, перенести и импортировать его на новом сервере; там же обновить снимок Redis.

  4. Прогнать догоняющий rsync — сухой прогон, затем боевой.

  5. Проверить новый сервер через --resolve: заголовок X-Origin-Host, вход в админку, чтение страниц.

  6. Изменить записи A и AAAA — для апекса и для www, если это не CNAME. Если AAAA на новом сервере нет, её удаляют заранее, за старый TTL до переключения.

  7. Снять флаг только чтения на новом сервере, включить там cron и воркеры.

  8. Переключить старый сервер в режим прозрачного прокси на новый IP.

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

# сухой прогон: ничего не меняем, только смотрим
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes --rsync-path='sudo rsync' \
      /var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/ | head -50

# сам догоняющий проход
time rsync "${RSYNC_OPTS[@]}" --rsync-path='sudo rsync' \
      /var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/

Старый сервер как прокси

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

Единственная корректная роль старого сервера после переключения — прозрачный прокси на новый IP. Тогда поздний трафик обслуживается правильными данными, а запись можно включить сразу после импорта.

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass https://203.0.113.10;
        proxy_ssl_server_name on;              # иначе SNI не уйдёт и новый сервер отдаст дефолтный vhost
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Без proxy_ssl_server_name on; прокси молча не заработает так, как вы ожидаете: nginx не отправит SNI, новый сервер ответит сертификатом дефолтного сервера, и вы получите 502. Форма listen 443 ssl http2; здесь правильная: отдельная директива http2 появилась только в nginx 1.25.1, а в Ubuntu 24.04 приезжает nginx 1.24.

Сколько на самом деле длится окно

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

Пример расчёта для базы на 2 ГБ: финальный дамп и импорт — около 5 минут, догоняющий rsync — около 40 секунд, TTL — 300 секунд. Для посетителя, чей резолвер уже подхватил новый IP, запись недоступна примерно 6 минут; для того, у кого в кэше ещё старый ответ, — до 11 минут. Прокси на старом сервере сокращает второе число до первого. Это именно пример расчёта, а не результат замера: свои цифры вы получите на пробном прогоне, и импорт с построением индексов InnoDB обычно занимает больше, чем сам дамп.

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

После переключения

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

Дальше следим за трафиком на старом сервере. Наблюдение идёт неделю-две, а logrotate крутит логи nginx ежедневно, и здесь есть подвох: tail -f держит открытым файловый дескриптор, а не имя. После ротации он продолжит следить за переименованным access.log.1, куда nginx больше не пишет, вывод замрёт, и вы сделаете вывод «трафика нет», имея живых посетителей.

# -F, а не -f: переоткрывает файл по имени после ротации logrotate
sudo tail -F /var/log/nginx/access.log

# надёжнее — посчитать запросы за сутки по всем ротациям, включая сжатые
sudo zcat -f /var/log/nginx/access.log* | awk '{print $4}' | cut -d: -f1 | sort | uniq -c

Когда счётчик по суткам стабильно держится около нуля, старый сервер можно гасить. К этому моменту стоит вернуть прежний TTL, убрать старый IP из SPF и убедиться, что резервные копии снимаются уже с нового сервера.