Переезд наоборот: почему мы перенесли сайт с VPS на shared-хостинг из-за ТСПУ

Обычно миграция идёт в одну понятную сторону: сайт вырастает, ему становится тесно на shared-хостинге, и его переносят на VPS ради выделенных ресурсов и полного контроля. Этот кейс - ровно наоборот. Образовательный сервис (онлайн-курсы по психологии и психоанализу) переехал с VPS на обычный тарифный хостинг, и сделал это не из экономии, а потому что VPS оказался физически недоступен части посетителей.

Как обнаружили: сайт пропадает именно во время работы ТСПУ

Заказчик стал получать жалобы: сайт периодически не открывается, соединение просто виснет по таймауту - не 403, не 500, а тишина. Совпадений с нагрузкой на сервер не было: ресурсов VPS хватало с большим запасом, в мониторинге - никаких аномалий. Зато совпадение было с другим - с периодами активности ТСПУ (технических средств противодействия угрозам, которыми провайдеры в России по требованию регулятора замедляют или блокируют часть трафика).

Заказчик проверил гипотезу своими силами: попросил сотрудников в разных городах страны параллельно пробовать открыть сайт в моменты, когда о работе ТСПУ было известно из открытых источников. Картина подтвердилась - сайт на VPS переставал открываться именно в эти окна, у пользователей из разных регионов и разных провайдеров одновременно.

Почему решили сменить не провайдера, а тип хостинга

Первая, самая очевидная идея - сменить VPS-провайдера. Взял VPS с REG.RU - та же проблема повторилась. Это был важный сигнал: дело не в конкретном провайдере VPS, а в чём-то более общем, что касается инфраструктуры VPS как класса. Других хостеров для VPS дополнительно проверять не стал - решил действовать по уже нащупанной зацепке.

Дальше заказчик заметил: сайты, которые он же видел развёрнутыми на тарифах линейки VIP того же REG.RU (обычный shared/managed-хостинг, не VPS), у тех же самых «недоступных» пользователей в те же окна времени открывались нормально. Гипотеза, которая объясняет разницу лучше других: диапазоны адресов с крупного хостинг-провайдера на его основных тарифных линейках, скорее всего, попадают в какие-то списки исключений - блокировать их означало бы задеть заодно тысячи чужих сайтов на том же диапазоне, а VPS чаще получают адреса из пулов, которые так не защищены. Это предположение, не подтверждённая изнутри провайдера информация - но оно совпадает с тем, что наблюдалось на практике: конкретный тариф решил проблему, а смена VPS-провайдера - нет.

Решение о переезде принял сам заказчик, взвесив, что для его сайта важнее - гибкость VPS или стабильная доступность для посетителей по всей стране. Выбрал здесь же: если вам интересно попробовать тот же тариф REG.RU, вот моя реферальная ссылка (reg.ru/hosting) и промокод на скидку 9320-9EA8-FB93-49E1.

От первых жалоб пользователей до завершённого переезда прошёл примерно месяц - тестирование гипотезы, поиск подходящего тарифа и сама миграция.

Переезд позади - начинается настоящая работа

Сайт переехал на shared-хостинг REG.RU (панель ISPmanager, Apache с ModSecurity за SSL-терминирующим nginx). Сайт написан под PHP 5.6 и MySQL 5.7, переписывать код под более новые версии пока не планируется - поэтому сразу после переезда через техподдержку отдельно запросил подключение именно этих версий, хотя на хостинге по умолчанию доступны более новые 7.4-8.4. У проекта два домена на одном docroot: основной и второй, добавленный отдельно для приёма зарубежных платежей (карты не-российских банков, PayPal - на основном домене такие платежи не проходят). Первая просьба заказчика была простой - «проверь логи, нет ли критичных проблем после переезда». Простая просьба развернулась в три дня диагностики.

Часть находок первого дня была рутинной: на одном из поддоменов PHP 5.6 подключить забыли - висела версия по умолчанию, с которой часть легаси-кода сайта просто не работала; публично доступная директория с логами банковских операций (уже прикрытая правилом 403, но до этого её явно пытались открыть напрямую); полторы сотни запросов за 21 минуту к давно удалённому JS-файлу аналитики. Всё это поправил быстро. Дальше начались более интересные истории.

Крон и веб - будто два разных сервера

Пользователь прислал предупреждение из скрипта-монитора: PHP жаловался, что не может надёжно определить часовой пояс. Странно - в php.ini сайта часовой пояс был прописан явно. Проверка показала: при запуске скрипта через браузер всё было в порядке, а крон дёргал его через алиас, который в интерактивной оболочке вообще не резолвился - чисто крон-специфичная подстановка панели управления, со своим окружением, где настройка часового пояса не наследовалась.

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

*/1 * * * * cd ~/www/site.example && php56 ./monitor.php >> logs/monitor_cron_debug.log 2>&1

и увидел прямым текстом command not found. Заменил алиас на абсолютный путь к бинарнику PHP - один скрипт починился:

*/1 * * * * cd ~/www/site.example && /opt/php/5.6/bin/php-cgi ./monitor.php >> logs/monitor_cron_debug.log 2>&1

Но когда по аналогии на абсолютный путь переключил сразу все полтора десятка строк в crontab, без одного важного флага (-f для php-cgi) - /opt/php/5.6/bin/php-cgi -q ./mail_queue_worker.php - вылезла новая проблема: без него php-cgi воспринимает путь к скрипту не как файл на исполнение, а как CGI-параметр в специфичном формате командной строки. В логе валидации приложения появились строки вида:

[2026-08-30 13:52:01] .php - Unspecified param name: _/mail_queue_worker_php

Путь к скрипту превращался в мусорную строку, и собственная валидация приложения начала сыпать такими ошибками каждую минуту по каждому крон-скрипту. Вернул -f:

/opt/php/5.6/bin/php-cgi -q -f ./mail_queue_worker.php

Ошибки прекратились.

Отдельный урок здесь: крон и веб на одном и том же сайте могут молча использовать два разных php.ini. Без явного указания, какой конфиг подключать, крон-вызов может подхватить системный конфиг вместо конфига сайта - с другим sendmail_path, без нужного логирования, без часового пояса. Это всплывёт ещё раз чуть ниже, уже с более серьёзными последствиями.

Когда чинишь одно - ломает другое

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

Причина оказалась многослойной. На страницах была битая ссылка-заглушка, встречавшаяся в доброй половине шаблонов сайта - она резолвилась в запрос с пустым именем файла, а правило блокировки пустого User-Agent отвечало на него ошибкой, для которой была включена красивая страница ошибок. Сама эта страница делала внутренний подзапрос - с теми же заголовками, то есть с тем же пустым User-Agent - и снова получала ту же ошибку. Замкнутый круг. Но и это было не всё: даже после попытки залатать петлю через дополнительные условия она повторилась - потому что настоящая блокировка стояла ещё на уровень выше, в WAF (веб-файрволе), который проверяет заголовки запроса ещё до того, как до них доходит очередь с файла правил сайта. Внутренний подзапрос страницы ошибок нёс те же заголовки - и WAF резал его точно так же, как и оригинальный запрос. Это уровень, который правкой файла правил сайта в принципе не лечится - нужна отдельная настройка на стороне хостинга. В итоге включил только один код ошибки из пяти - тот, что не задевает подозрительные для WAF паттерны, остальные оставил выключенными до отдельного разговора с поддержкой.

Охота за «невалидной» DKIM-подписью

Проверка через открытый сервис оценки писем стабильно показывала: DKIM-подпись не проходит, ключ не находится. Первое подозрение - устаревшая DNS-запись от прежнего провайдера почты, где ключ подписи назывался иначе, чем на новом сервере. Добавил правильную запись через инструменты панели - ошибка осталась, просто с более точной формулировкой: ключ не найден в DNS.

Вместо того чтобы гадать дальше, проверил руками, криптографически: сохранил реальное письмо целиком, написал скрипт, который по спецификации DKIM (c=relaxed/relaxed) пересчитывает хеш тела письма и собирает подписываемую строку, достал публичный ключ из DNS-записи в формате PEM и сверил подпись напрямую:

openssl dgst -sha256 -verify pubkey.pem -signature signature.bin signed_data.txt
# Verified OK

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

Крон слал письма не от того домена

Проверка реально доставленных писем от системных задач показала:

envelope-from <account123@vip300.hosting.reg.ru>
DKIM-Signature ... d=vip300.hosting.reg.ru

Письмо шло не с домена сайта, а буквально с адреса самого хостинг-аккаунта - и подпись DKIM была за тем же посторонним доменом. Тот же диагностический принцип, что и с часовым поясом: запустил крон-команду вручную, ровно в том виде, в каком её видел планировщик:

cd ~/www/site.example && /opt/php/5.6/bin/php-cgi -q -f ./_inicheck.php
# Loaded ini: /opt/php/5.6/etc/php.ini
# sendmail_path: /usr/sbin/sendmail -t -i     (без -f!)
# mail.log:                                    (пусто!)

И увидел явно, какой именно конфиг PHP подключается - системный, а не конфиг сайта, без нужного параметра отправителя и без логирования почты. Голый вызов интерпретатора без явного указания, откуда брать конфиг, наследовал не тот контекст, который используется при обычных запросах через веб-сервер.

Фикс был в том, чтобы явно указывать нужный конфиг при каждом крон-вызове:

cd ~/www/site.example && PHPRC='/var/www/account123/data/php-bin/site.example' /opt/php/5.6/bin/php-cgi -q -f ./mail_queue_worker.php

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

Главная находка: DMARC ругался, хотя SPF и DKIM были зелёными

После того как крон и явную отправку писем привели в порядок, всплыла ещё одна, отдельная и более тонкая проблема - на этот раз в боевой логике самого сайта, не в кроне. Разработчик ранее поменял видимый адрес отправителя в письмах с основного домена на второй (для визуального ребрендинга под новый домен, который использовался для зарубежных платежей). После этого часть писем стала получать отказ DMARC.

Разбор заголовков показал классическую картину «разъехавшейся» идентичности: и SPF, и DKIM честно проходили проверку - но оба за основным доменом. А в видимом поле «От кого» стоял уже второй домен. DMARC проверяет не сам факт наличия подписи, а совпадение домена подписи и конверта письма с доменом в видимом поле отправителя. Письма всё ещё доставлялись, потому что политика DMARC для второго домена стояла в режиме «только наблюдение» - но при более строгой политике такие письма начали бы просто отклоняться или слетать в спам.

Поиск по всему проекту сразу показал, где формируются письма:

grep -rln 'mail(' --include='*.php' .
# → только lib/mailer.php

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

// lib/mailer.php
$xFrom = str_replace("site.example", "site.example.foreign", $xFrom);
...
$xParams .= str_replace("site.example", "site.example.foreign", $ToNone); // Reply-To
...
$IsSent = mail($xTo, $xSubj, $xText, $xParams); // конверт не трогали

при этом конверт письма (техническая часть, которая как раз и подписывается DKIM) оставался завязан на то, что прописано в конфиге PHP сайта:

sendmail_path = "/usr/sbin/sendmail -t -i -f info@site.example"

Отсюда и разъезд: адрес поменяли только в одном месте, а конверт остался привязан к конфигу, который никто параллельно не трогал.

Первая идея была прямолинейной - поправить нужный параметр прямо в конфиге:

cp php.ini{,.bak-20260831}
sed -i 's|-f info@site.example|-f info@site.example.foreign|' php.ini
PHPRC='...' /opt/php/5.6/bin/php-cgi -q -i | grep sendmail_path
# → подтверждено: -f info@site.example.foreign

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

for i in 1 2 3 4 5 6; do sleep 20; grep sendmail_path php.ini; stat -c '%y' php.ini; done

Правка держится ровно один цикл (~20 секунд), а затем панель управления сама перегенерирует конфиг из своих внутренних настроек. Ручная правка этого конкретного файла через SSH на таком хостинге бессмысленна в принципе. В интерфейсе панели поле, отвечающее за этот параметр, было явно помечено как заблокированное, с пояснением «значение определяется настройками сайта» - панель знает, что это производная настройка, но не показывает, откуда именно она берётся.

Раз конфиг неисправляем, а код исправляем - решение перенёс в код: у функции mail() в PHP есть пятый параметр (additional_parameters), который как раз и переопределяет техническую часть письма (конверт), и он имеет приоритет над настройками конфига:

// было:
$IsSent = mail($xTo, $xSubj, $xText, $xParams);
// стало:
$IsSent = mail($xTo, $xSubj, $xText, $xParams, "-f info@site.example.foreign");

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

cat > _dkimtest_tmp.php << 'EOF'
<?php
include_once("bootstrap.php");
include_once("models.php");
include $KernelName; // lib/mailer.php
$Input["m-to"] = "test@example.com";
$Input["m-subj"] = "test";
$Input["m-text"] = "test";
Send();
EOF
PHPRC='...' /opt/php/5.6/bin/php-cgi -q -f ./_dkimtest_tmp.php
rm -f _dkimtest_tmp.php

Получил заголовки с полным совпадением:

dkim=pass header.i=@site.example.foreign
spf=pass smtp.mailfrom=info@site.example.foreign
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=site.example.foreign

DKIM, SPF и DMARC все за одним и тем же доменом. Через несколько минут пришло и обычное боевое письмо из настоящей очереди (не тестовое) - с идентичными, уже корректными заголовками.

Итог

Сайт остаётся доступным для посетителей вне зависимости от того, работает ли в моменте ТСПУ - переезд с VPS на конкретный тариф хостинга снял проблему полностью. Три дня последующего аудита закрыли отдельный список более мелких находок (неподключённый на поддомене PHP 5.6, открытая директория с логами, забытый скрипт аналитики, боты, тянущие ресурсы сервера) и две связанные, но разные проблемы с почтой: крон, слегка отправлявший письма от чужого имени, и DMARC, ломавшийся из-за рассинхрона между видимым адресом и технической частью письма.

Выводы

  1. Недоступность сайта не всегда о самом сайте. Ни нагрузка, ни код здесь были ни при чём - искать нужно было на уровне сети и провайдера.
  2. Смена провайдера не то же самое, что смена типа хостинга. VPS у другого провайдера дал тот же результат, что и первый - решающим оказался именно класс хостинга, а не бренд.
  3. Крон и веб - разные окружения PHP, даже на одном и том же сайте. Без явного указания конфига крон может незаметно подхватить не тот php.ini - с другим отправителем почты, другим логированием, другим часовым поясом.
  4. На управляемом хостинге конфигурационный файл - не всегда то, чем кажется. Панель управления может периодически перегенерировать его из своих настроек; правка через SSH исчезнет молча и быстро.
  5. DMARC = совпадение доменов, а не факт наличия подписи. Меняете видимый адрес отправителя - обязательно проверьте, что техническая часть письма и подпись переезжают вместе с ним.
  6. Самый надёжный уровень для правок такого рода - код, а не конфиг, если конфиг управляется панелью и не поддаётся ручным правкам.
  7. Отсутствие ошибок в логе - не значит, что всё работает. Здесь одновременно было несколько независимых причин, по которым письмо реально уходило, но не оставляло следа в логе.

Если у вас похожая история - сайт "почему-то" недоступен части посетителей, или после смены отправителя писем начались проблемы с доставляемостью - пишите, разберёмся.

Оставьте комментарий