Недавно мой проект «Шакализатор сайтов» победил в номинации «Креативное решение» первой премии «Сделано с ИИ» от Яндекс Практикума. Это хороший повод наконец ответить на вопрос, который интересовал меня не меньше самой премии: какого черта VPS с одним ядром и 1 ГБ памяти вообще пережил набег с Пикабу?
Я маркетолог, а не системный архитектор. Серверную часть и большую часть защитных механизмов писал Codex: я формулировал задачи, проверял результат и выкатывал изменения. Поэтому, когда пост попал в горячее, я в основном обновлял статистику и ждал, в какой момент сервер испустит последний вздох.
Но он не испустил.
Теперь у меня остались код на момент всплеска, история коммитов и сохраненные серверные срезы. Поэтому можно спокойно разобрать систему по слоям и понять, что именно ее спасло, что работало не так хорошо и насколько страшны 1,2 млн запросов, если убрать из числа драматическую музыку.
О том, как я собирал сам прокси и превращал современные сайты в Web 1.0, я уже рассказывал в предыдущей технической статье. Здесь речь именно о нагрузке.
Что вообще произошло
«Шакализатор» берет URL современного сайта, скачивает HTML, удаляет стили и скрипты, переписывает ссылки и изображения, добавляет Comic Sans, бегущие строки, GIF-баннеры и возвращает пользователю версию страницы из альтернативного 1999 года.
27 апреля я опубликовал пост на Пикабу. Он попал в горячее, и люди начали шакалить банки, маркетплейсы, GitHub, YouTube, сайты компаний, сам Пикабу и все остальное, что попадалось под руку.
Утром 28 апреля серверный срез за последние 24 часа выглядел так:
Метрика | Значение |
|---|---|
Все HTTP-запросы | 1 202 182 |
Просмотры страниц по серверной оценке | 90 805 |
Открытия | 57 772 |
Отправки формы | 48 686 |
Запросы к image-proxy | 240 161 |
Ответы | 695 766 |
Ответы | 380 809 |
Ошибки | 18 747 |
Записи Nginx со статусом | 69 454 |
На момент замера у VPS было:
1 vCPU;
870 МБ доступной физической памяти;
4 ГБ swap;
занято 818 МБ RAM;
использовано 619 МБ swap;
занято 94% диска;
свободно 1,3 ГБ.
retroweb и Nginx продолжали работать. Процесс приложения занимал около 441 МБ памяти, зафиксированный пик составлял 586 МБ.
На первый взгляд это выглядит как чудо. Но только пока мы смотрим на суточную сумму.

Слой нулевой: миллион запросов — это не миллион одновременно
Первая полезная операция в любом разговоре о большой суточной цифре — разделить ее на 86 400 секунд.
1 202 182 / 86 400 = 13,9 запроса в секунду в среднем
Четырнадцать запросов в секунду — это уже нагрузка, но далеко не тот highload, который требует стойки серверов, Kubernetes и инженера с огнетушителем.
Кроме того, далеко не каждый из этих запросов запускал полную шакализацию сайта.
/shakal: 57 772 / 86 400 = 0,67 запроса в секунду image-proxy: 240 161 / 86 400 = 2,78 запроса в секунду /api/shakalize: 48 686 / 86 400 = 0,56 запроса в секунду
Это средние значения, а не пики: внутри суток могли быть минуты значительно горячее. Старые логи с посекундным распределением я не сохранил, поэтому придумывать максимальный RPS не буду. Но уже видно главное: большая часть миллиона состояла не из миллиона тяжелых запусков обработки HTML.
Один пользователь открывал страницу, загружал стили, скрипты, GIF-баннеры и десятки изображений. Каждый такой ресурс становился отдельной строкой в access.log. Красивое число быстро росло, хотя количество действительно тяжелых операций было на порядки скромнее.
Еще 380 809 запросов, почти треть всего трафика, завершились статусом 304 Not Modified. Это не означает, что Nginx тайно стал CDN. Но это означает, что клиент уже имел копию ресурса и серверу не требовалось снова отправлять его тело целиком.
Первый ответ оказался очень прозаическим: сервер выдержал миллион запросов, потому что миллион запросов и миллион полноценных вычислений — совершенно разные вещи.
Слой первый: Nginx стоял у двери
Снаружи запросы принимал Nginx, а само Next.js-приложение слушало локальный 127.0.0.1:3000.
Схема была простой:
Пользователь | v Nginx :80/:443 | v Next.js / Node.js :3000 | +--> скачать HTML чужого сайта +--> обработать DOM через Cheerio +--> скачать и испортить картинки через sharp
В конфигурации не было балансировщика, нескольких реплик или отдельного Nginx-кэша. По сути Nginx завершал внешнее соединение и проксировал запрос в один процесс Node.js.
Это не магическая броня, но полезное разделение обязанностей. Node не торчал напрямую наружу, TLS и внешние соединения обслуживались привычным фронтом, а приложение занималось своей работой.
Важно не приписывать Nginx лишнего: основная причина выживания находилась дальше, внутри приложения.
Слой второй: сервис не запускал настоящий браузер
Самое дорогое, что можно было сделать в таком проекте, — поднимать Chromium для каждого запроса, ждать выполнения JavaScript и рендерить страницу как настоящий пользователь.
«Шакализатор» этого не делал.
Он скачивал исходный HTML обычным fetch, загружал его в Cheerio и последовательно менял DOM. Cheerio работает примерно как серверный инструмент для работы с HTML: он умеет искать и менять элементы, но не рисует страницу, не исполняет клиентский JavaScript и не создает полноценное окружение браузера.
Из-за этого сложные React-приложения иногда пережевывались хуже, зато стоимость одной операции была гораздо ниже. Сервер не пытался эмулировать десятки браузеров на одном ядре — он работал со строкой HTML и деревом элементов.
Это хороший пример компромисса, который оказался полезен под нагрузкой: результат не всегда идеален, но система делает ограниченную и предсказуемую работу.
Дополнительно у входящих данных были жесткие границы:
HTML_FETCH_TIMEOUT_MS = 10_000 IMAGE_FETCH_TIMEOUT_MS = 12_000 MAX_HTML_BYTES = 2_500_000 MAX_IMAGE_BYTES = 6_000_000
Если чужой сайт отвечал слишком долго или пытался прислать слишком большой файл, запрос завершался ошибкой. Для конкретного пользователя это неприятно, зато один зависший upstream не мог бесконечно удерживать ресурсы приложения.
Слой третий: одинаковая работа не выполнялась снова и снова
У сервиса с самого первого MVP был небольшой кэш в памяти процесса:
готовые страницы хранились 5 минут, максимум 120 записей;
готовые изображения — 30 минут, максимум 72 записи и 64 МБ;
ответы сайтов с
403и429временно запоминались, чтобы не продолжать добивать заблокировавший нас ресурс.
Это был не Redis и не распределенный кэш. Обычные Map внутри Node.js. После перезапуска процесса все исчезало.
Но для вирусного трафика такой примитивный кэш попал точно в характер нагрузки. Люди массово шакалили одни и те же популярные сайты: Пикабу, Mail.ru, Яндекс, VK, Ozon, Google. Только pikabu.ru за пиковые сутки набрал 7 663 открытия.
Первый запрос скачивал и обрабатывал страницу, следующие в течение TTL могли получить уже готовый HTML.
Еще важнее был кэш незавершенной работы, или in-flight deduplication. Если десять запросов одновременно просили один и тот же ресурс, приложение не запускало десять одинаковых скачиваний и преобразований. Оно сохраняло текущий Promise, а остальные запросы ждали его результат.
Упрощенно логика выглядела так:
const cached = getCached(key); if (cached) return cached; const inflight = tasksInProgress.get(key); if (inflight) return inflight; const task = createValue(); tasksInProgress.set(key, task); return task;
Это защищало от эффекта «стада»: когда ссылка разлетается по горячему посту и множество людей одновременно открывает одну и ту же цель.
Кэш был маленьким и простым, но для короткой волны повторяющихся запросов этого оказалось достаточно.
Слой четвертый: тяжелые операции стояли в очереди
Главная опасность находилась не в переписывании HTML, а в работе с чужими сайтами и изображениями.
Если позволить каждому входящему запросу одновременно скачивать HTML и запускать sharp, одно ядро и 1 ГБ RAM закончатся очень быстро. Поэтому в приложении стояли простые ограничители конкурентности:
HTML_FETCH_CONCURRENCY = 8 IMAGE_PROCESSING_CONCURRENCY = 2
Одновременно сервис скачивал не больше восьми HTML-документов и обрабатывал не больше двух изображений. Остальные задачи ждали в очереди.
Сам sharp был настроен еще осторожнее:
sharp.concurrency(1); sharp.cache(false);
То есть библиотека не пыталась занять все доступные потоки и не держала собственный внутренний кэш поверх нашего ограниченного кэша изображений.
Это решение не увеличивает пропускную способность. Наоборот, оно намеренно делает отдельные запросы медленнее. Но зато нагрузка не превращается в неконтролируемый параллельный пожар.
Если объяснять совсем бытовым языком: в маленькую кухню не пустили сразу сто поваров. Внутри работали двое, остальные стояли в очереди и иногда уходили, не дождавшись заказа.
Именно поэтому наличие ошибок в логах не противоречит тому, что сервер выжил.
Слой пятый: медленная загрузка оказалась дешевой для процессора
В «Шакализаторе» картинки специально грузятся медленно и построчно, чтобы напоминать интернет через модем. Для этого готовый JPEG отдавался маленькими кусками с задержкой через setTimeout.
На первый взгляд добровольно замедлять ответы во время наплыва — безумие. Отчасти так и есть: соединения жили дольше, пользователи чаще закрывали вкладку, а в Nginx появлялись 499.
Но ожидание таймера почти не занимает процессор. Пока Node.js ждет следующую отправку куска, event loop может заниматься другими соединениями. Это не бесплатная операция — каждый открытый запрос требует памяти и служебных структур, — но она значительно дешевле постоянного вычисления.
Сама картинка перед отдачей проходила через довольно жестокую обработку:
уменьшалась примерно до 18% исходного размера;
растягивалась обратно ближайшим соседом;
превращалась в JPEG с качеством
7;отдавалась с кэширующими заголовками на стороне клиента.
В результате изображение становилось визуально ужасным, зато обычно весило заметно меньше оригинала. Здесь эстетическая деградация неожиданно совпала с экономией трафика.
Слой шестой: у «1 ГБ RAM» был четырехгигабайтный подвал
Формулировка «сервер с 1 ГБ памяти» технически верна, но неполна. У системы было 4 ГБ swap.
Во время пикового замера физическая память выглядела почти исчерпанной:
RAM total: 870 MB RAM used: 818 MB RAM available: 51 MB swap used: 619 MB / 4095 MB
Swap намного медленнее оперативной памяти, потому что находится на диске. Но медленно продолжать работу для такого проекта лучше, чем мгновенно получить OOM и лишиться процесса.
Судя по замеру, система действительно воспользовалась этим запасным этажом. Без swap история могла закончиться значительно раньше.
Приложение также работало под systemd с настройкой:
Restart=always RestartSec=5
Это не делает систему отказоустойчивой: при падении пользователи все равно получают ошибки, а кэш в памяти исчезает. Но если процесс завершается, systemd поднимает его снова через пять секунд. Даже неидеальный автоподъем лучше, чем ручной запуск после сообщения «у вас сайт умер».
По сохранившемуся срезу я могу уверенно сказать, что сервис был активен на момент проверки. Утверждать, что процесс ни разу не перезапускался внутри всей волны, старые журналы уже не позволяют.
Он выдержал, но не без потерь
Самая нечестная версия этой статьи звучала бы так: «сервер принял 1,2 млн запросов и даже не вспотел».
Он вспотел.
Из 1 202 182 записей около 89,5% закончились статусами 200 или 304. Это хороший результат для самодельного прокси, но далеко не 100%.
Nginx записал 69 454 статуса 499. Это означает, что клиент закрыл соединение раньше, чем сервер успел закончить ответ. Причины могли быть разными: пользователь устал ждать имитацию модема, закрыл iframe, перешел к другой цели или браузер отменил загрузку ненужной картинки.
Еще было 18 159 ответов 502. Снапшот не сохранил распределение этих ошибок по маршрутам, но устройство приложения и наблюдения во время всплеска указывают прежде всего на image-proxy и внешние сайты: картинка могла не скачаться, upstream мог ответить ошибкой, не уложиться в таймаут или вернуть не изображение.
То есть приложение использовало своеобразную естественную graceful degradation:
часть картинок не загрузилась;
часть пользователей не дождалась ответа;
некоторые сайты включили антибот и вернули
403или429;отдельные запросы получили ошибку;
но главная страница, форма и значительная часть шакализаций продолжали работать.
Для банковского API это было бы неприемлемо. Для сервиса, который намеренно имитирует сломанный интернет 1999 года, битая картинка иногда даже выглядела как фича.
Но считать ошибки успехом все равно не стоит. Они просто показывают, каким способом система переживала превышение своих возможностей: не падала целиком, а теряла отдельные ответы.
Настоящей угрозой оказался диск
Пока я ждал проблем с CPU и RAM, корневой раздел заполнился до 94%. Свободно оставалось около 1,3 ГБ.
Каждый из 1,2 млн запросов становился строкой в access.log. Добавлялись error.log, журнал systemd и старые релизы приложения. Кэши страниц и картинок жили в памяти и были ограничены, а вот логи продолжали расти на диске.
Именно это было ближе всего к настоящей аварии. Заполненный диск может сломать логирование, деплой и работу других процессов, даже если CPU еще способен обслуживать запросы.
Срез был снят 28 апреля в 07:59 по Москве. Уже в 08:18 появился коммит с автоматическим обслуживанием диска:
ротация слишком больших логов Nginx;
сжатие старых логов;
очистка journal;
удаление старых релизов;
очистка пакетного кэша.
На следующий день диск вернулся к 87%, свободное место выросло до 2,5 ГБ, а нагрузка уже схлынула до 190 тысяч запросов за сутки.
Это важное разделение причин и последствий: автоматическая чистка не спасла сервер во время основной волны — ее добавили после пикового замера. Но она закрыла риск, который волна обнаружила.
Так почему он все-таки выжил
Если убрать Comic Sans и собрать ответ в одном месте, получится не магия и не секретная архитектура :
Суточная цифра была большой, но средняя интенсивность — умеренной. Около 14 запросов в секунду, причем тяжелых операций значительно меньше.
Трафик состоял из разных по стоимости запросов. Статика,
304и отправка готового ресурса намного дешевле скачивания и преобразования нового сайта.Cheerio не запускал браузер. Сервис работал с HTML, а не поднимал Chromium для каждого пользователя.
Кэш попадал в повторяющийся вирусный трафик. Популярные сайты запрашивали снова и снова.
Одинаковые одновременные задачи объединялись. Один upstream-запрос мог обслужить сразу нескольких ожидающих пользователей.
Тяжелая работа была ограничена очередями. Восемь HTML-fetch, две обработки изображений, один поток
sharp.Таймауты и лимиты не позволяли внешним ресурсам зависать бесконечно.
Swap дал системе запас по памяти. Медленный, но достаточный, чтобы не получить немедленный OOM.
Сервис мог ошибаться частями. Неудачная картинка не обязательно убивала весь процесс.
За состоянием следили во время волны. Когда опасным стал диск, проблему заметили до полного заполнения.
И есть еще один менее технический фактор: всплеск был коротким. Уже на следующий день количество запросов упало с 1,2 млн до 190 тысяч. Серверу не пришлось жить в таком режиме неделями.
Что я сделал бы иначе
Задним числом список довольно очевидный:
заранее подключил бы нормальный мониторинг CPU, памяти, latency и RPS по минутам;
сохранил бы сырые логи пикового периода отдельно от рабочей ротации;
разделил бы метрики пользователей, просмотров, внутренних запросов и ботов;
поставил бы явные ограничения на длину очередей, а не только на конкурентность;
добавил бы rate limit на тяжелые маршруты;
вынес бы обработку изображений в отдельный worker или хотя бы отдельный процесс;
настроил бы более аккуратную стратегию кэширования и наблюдаемость cache hit rate;
включил бы обслуживание диска до публикации, а не через 19 минут после тревожного замера.
Но есть опасность переписать историю так, будто для мемного проекта перед публикацией требовалась полноценная инфраструктурная платформа. Если бы я сначала месяц строил идеальный мониторинг, очередь задач и распределенный кэш, пост на Пикабу мог бы вообще никогда не появиться.
Для первого запуска простая архитектура с разумными ограничителями оказалась лучше идеальной архитектуры, которая осталась в планах.
Причем здесь премия
Через несколько месяцев после этого всплеска «Шакализатор» победил в номинации «Креативное решение» первой премии Яндекс Практикума «Сделано с ИИ». Я получил 500 тысяч рублей и грант на 200 тысяч рублей для Yandex AI Studio.
Проект изначально не создавался ни как конкурсная работа, ни как стартап. Это была шутка, которую я как маркетолог смог довести до работающего сервиса с помощью ИИ-инструментов и агентной разработки.
Наверное, именно поэтому мне и хотелось написать не победный пост, а этот разбор. Табличка с премии отвечает на вопрос, чем закончилась история. Но мне было интереснее понять, почему она вообще не закончилась сообщением 502 Bad Gateway еще вечером 27 апреля.
Ответ оказался не таким эффектным, как «я случайно изобрел highload».
Сервер слабее тостера выжил потому, что большая цифра состояла в основном из маленьких запросов, тяжелую работу заранее зажали ограничителями, повторяющуюся — закэшировали, лишнюю — оборвали по таймауту, а нехватку памяти временно прикрыли swap.
То есть он не был бессмертным. Он просто очень грамотно деградировал.
Для «Шакализатора» более подходящего финала придумать сложно.
Репа по прежнему тут
P. S. Я продолжаю собирать странные штуки на стыке маркетинга, кода и ИИ и разбирать, почему они вообще работают. Всё это складываю в Telegram-канал
