Нагрузка на процессор 100% на WordPress: не атака, а краулеры

Наш сервер WordPress снова и снова упирался в лимит: нагрузка на процессор 100%. Причиной оказалась не атака, а наш собственный сайт. Краулеры заходили в основном на одну страницу, список статей блога. Сайт при каждом визите частично пересобирал её, и каждая пересборка отправляла в WordPress новые запросы.
Всё началось с письма от хостинга. Там видели около 2 400 запросов в день с нескольких IP-адресов американских облаков, с user agent node. Хостинг решил, что их шлёт какой-то скрипт или цикл опроса. Однажды вечером процессор 40 минут держался на 100%.
Ниже рассказываем, как мы нашли причину и что поменяли. В конце есть чек-лист для вашего headless WordPress.
Как устроен сайт: кто обращается к WordPress
Наш сайт headless. WordPress только хранит контент. Страницы, которые видят посетители, собирает приложение на Next.js на другом хостинге.
- Сайт получает контент через WPGraphQL на трёх языках (EN, FR, RU).
- Посетители и боты никогда не обращаются к WordPress напрямую. Страницы им отдаёт сервер, и большинство из них берёт из кэша.
Последний пункт важен. Когда сервер пересобирает страницу, он снова запрашивает данные у WordPress. Значит, каждый просмотр страницы, которой нет в кэше, создаёт работу для WordPress.
Два кэша перед базой данных. Публикация очищает оба.
Список статей пересобирался при каждом визите, потому что пагинация работала через параметры URL. Кэш был с самого начала, но Next.js кэшировал только сами страницы, а не их варианты с параметрами. Поэтому каждый заход краулера на эту страницу нагружал WordPress.
Что показали логи
Первой подсказкой стал user agent node. Его отправляет Next.js, когда получает данные на сервере. Американские облачные IP совпадали с регионом по умолчанию нашей хостинг-платформы. Значит, запросы слало наше же приложение. Оставался вопрос: почему так часто?
Ответ дали логи запросов хостинг-платформы. Мы выгрузили 23 часа логов через её консольную утилиту. Потом посчитали страницы, которые платформа пересобирала, вместо того чтобы отдать из кэша.
| Страница | Пересборок за 23 ч | Кто заходил чаще всего |
|---|---|---|
/blog (все 3 языка) | 1 344 | ClaudeBot (1 073), затем MJ12bot, PetalBot и другие |
/works | около 90 | разные |
| Всё остальное | почти всегда из кэша | — |
Логи ошибок и история деплоев дали ещё две находки:
- Сканеры запрашивали пути вроде
/.envи/ads.txt. Главная страница принимала.envза код языка и отправляла с ним запрос в WordPress. В ответ получался 404. - Сборки совпали с самым тяжёлым вечером. Хостинг-платформа запустила шесть полных сборок за 40 минут. Каждая сборка запрашивала все страницы на трёх языках.
Ещё одну подсказку дала история хостинга за 30 дней. В два самых тяжёлых дня мы почти ничего не деплоили. Сборки давали короткие пики, а постоянную нагрузку создавали краулеры.
Почему нагрузка на процессор доходила до 100%
Одного конкретного бага не было. Сложились четыре вполне обычных решения.
- Одна страница никогда не кэшировалась. Список статей читает из URL
?page=и?tag=. Из-за этого Next.js рендерит страницу заново на каждый запрос. Краулер, который перебирает все сочетания страниц и тегов, пересобирает её много раз. А каждая пересборка ещё и пересчитывала все статьи, по шесть за раз, только чтобы показать номера страниц. - Данные устаревали каждый час. Все запросы к WordPress мы кэшировали на час. Между двумя визитами краулера кэш часто успевал истечь, и пересборка снова шла в WordPress.
- Каждый релиз собирал сайт трижды. Хостинг-платформа собирала полную копию на каждом этапе нашего процесса проверки.
- WordPress не кэшировал запросы. Плагин WPGraphQL Smart Cache у нас стоял. Но его объектный кэш (object cache), та часть, что работает с нашими POST-запросами, был выключен. Поэтому каждый запрос целиком проходил через PHP и базу данных.
Пункты 1 и 4 появились не вчера: они существовали больше года. Изменился трафик. Краулеры нейросетей теперь приходят гораздо чаще, а наш robots.txt их пускал.
Что мы поменяли
Мы внесли пять небольших изменений. Ни одно не потребовало новой инфраструктуры.
| Изменение | Где | Что даёт |
|---|---|---|
Запрет ?page= и ?tag= в robots.txt, Crawl-delay: 10 для ботов | Next.js | Краулеры не заходят на некэшируемые варианты URL. Поисковики по-прежнему находят статьи через sitemap.xml |
| Кэш данных WordPress на 24 часа вместо 1 часа | Next.js | Каждая страница берёт свежие данные из WordPress не чаще раза в день |
| 404 для неизвестных кодов языка до любого запроса | Next.js | URL сканеров вроде /.env не доходят до WordPress |
| Собирать только продакшен, по одной сборке за раз | Настройки хостинга | Одна сборка на релиз, а не три одновременно |
| Включить объектный кэш Smart Cache на 24 часа | Настройки WordPress | Повторные запросы, включая большинство запросов при сборке, не идут в базу данных |
Первое изменение сработало за одну ночь. Пересборки списка статей от ClaudeBot упали примерно с 84 за два часа до нуля.
Два кэша перед базой данных. Публикация очищает оба.
Запрос доходит до базы данных, только если промахнулись оба кэша. Публикация очищает оба кэша, поэтому новый контент появляется через несколько секунд.
Результаты: что стало с нагрузкой на сервер
Через пять дней WordPress получал 176 GraphQL-запросов в день. Это больше чем на 90% меньше, чем раньше, и сервер ни разу не упёрся в лимит CPU.
| Показатель | До | 4 ноября | 5 ноября | 6 ноября | 7 ноября | 8 ноября |
|---|---|---|---|---|---|---|
| GraphQL-запросы к WordPress | 2 400–2 900 | — | 1 196 | 334 | 335 | 176 |
| Превышения лимита CPU | в большинство дней | несколько | бывали | несколько | несколько | ни одного |
| Продакшен-сборки | 3 на релиз | 2 | 5 | 0 | 1 | 0 |
| Пересборки списка статей | около 1 300 | 268 | 43 | 81 | 59 | 42 |
| Визиты ClaudeBot на список статей | 1 073 | 4 | — | 0 | 0 | 0 |
Каждый столбец — это 24 часа примерно с 09:00 по парижскому времени. Прочерк значит, что данных за этот день у нас нет. 6 ноября больше половины запросов создала наша собственная разработка.
Оставшиеся превышения шли короткими всплесками. Каждая продакшен-сборка запрашивает весь сайт сразу. В дни, когда мы пишем код, наша разработка добавляет запросов. Последним исправлением стала настройка WordPress: объектный кэш Smart Cache. Он хранит ответы достаточно долго, чтобы покрыть время между двумя релизами. 8 ноября релиза не было, так что результат покажет следующая сборка.
Следующий шаг: список статей (ещё не доделали)
Эта часть ещё в работе. Список статей по-прежнему ссылается на свои же варианты: в HTML страницы /blog 11 ссылок вроде ?page=2 и ?tag=seo. Краулеры переходят по каждой, и одна страница превращается в десятки URL. Sitemap от этого не спасает, потому что краулеры сначала идут по ссылкам. Правило в robots.txt останавливает вежливых краулеров, но причину не устраняет.
Мы публикуем около двух статей в неделю, то есть за год на каждом языке наберётся около 100 статей. Решение должно выдерживать такой рост. Сравниваем три варианта:
| Вариант | Как работает | Плюсы | Минусы |
|---|---|---|---|
| Все статьи на одной странице | На странице списка все статьи. Номера страниц и теги фильтруются в браузере | Просто. Одна статическая страница. Нет вариантов URL | При 100 и более статьях страница становится тяжёлой |
| Страницы в пути URL | /blog/page/2, /blog/tag/seo. Каждая страница статическая и содержит только свои статьи | Выдерживает сотни статей. Настоящие URL, которые поисковики могут индексировать | Больше маршрутов. Для старых ссылок ?page= нужны редиректы |
| Частичный рендеринг (Next.js 16) | Статическая оболочка страницы. Блок со списком кэшируется для каждой страницы и тега | Небольшое изменение в коде страницы | Нужно крупное обновление Next.js. Краулеры по-прежнему ходят по каждой ссылке ?page= |
Пока ни один вариант нам полностью не подходит: каждый решает часть проблемы и добавляет новые издержки. Так что ищем дальше и допишем сюда результат.
Три слоя кэша, от визита до базы данных. База работает только после публикации.
Что бы мы ни выбрали, цель та же: обычный визит останавливается на кэше страниц, а база данных работает только после публикации. Публикация очищает все три кэша, поэтому новые статьи по-прежнему появляются через несколько секунд.
Чек-лист: что проверить, если headless WordPress грузит сервер
Хостинг жалуется на большой GraphQL-трафик к WordPress? Проверьте эти пункты, прежде чем переходить на тариф подороже.
- Выясните, кто шлёт запросы. User agent
nodeозначает ваш собственный фронтенд. Имя бота значит, что бот обращается к WordPress напрямую. - Найдите страницы, которые никогда не кэшируются. В логах хостинга ищите маршруты, где всегда стоит
MISS. Обычно это страницы, которые читают параметры из URL. - Посмотрите, кто заходит на эти страницы. Если в основном краулеры, закройте варианты URL в
robots.txt. Саму страницу целиком не закрывайте. - Храните кэш дольше. Кэшируйте данные WordPress на сутки. Обновляйте их по вебхуку при публикации. Не запрашивайте свежие данные каждый час.
- Посчитайте сборки. Каждая превью-сборка запрашивает весь сайт. Если превью вам не нужны, собирайте только продакшен.
- Включите объектный кэш. В WPGraphQL Smart Cache отметьте Use Object Cache. Поменяйте срок хранения с 10 минут по умолчанию на 24 часа или больше. Этот кэш работает с POST-запросами и сам очищается при публикации.
- Отсекайте мусорные URL сразу. До запроса к WordPress отдавайте 404 на любой URL, которого нет среди настоящих маршрутов.
- Контролируйте URL фильтров и страниц. Дайте страницам и фильтрам настоящие кэшируемые URL или уберите их из ссылок. Иначе одна страница списка превращается для краулеров в десятки URL.





