Один пропущенный GET-параметр положил новостной сайт на час - и как я после этого научил его выдерживать вирусные всплески втрое больше

Веду сервер одного новостного портала (дальше в тексте - newsportal.example; у сайта ещё пара доменов-алиасов и кириллический домен, все указывают на один и тот же физический сервер). Стек классический для СМИ на 1С-Битрикс: CentOS 7, 24 ядра, 46 ГБ RAM, nginx перед Apache + mod_php, Percona (MySQL-совместимая), Redis и Memcached, поверх которого работает собственный composite-кэш Bitrix. У СМИ характерная нагрузка: спокойный фон большую часть времени и резкие вирусные всплески, когда какая-то новость выстреливает в Яндекс.Дзене или в топе поиска.

Архитектура: два независимых уровня кэша

Держит сайт под такими всплесками не один «волшебный» кэш, а два разных уровня, каждый решает свою задачу:

Читатель → nginx (edge-кэш всего HTTP-ответа) → Apache/PHP → Bitrix composite-кэш (Memcached) → MySQL

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

Уровень 1 - edge-кэш nginx. Кэширует весь HTTP-ответ бэкенда целиком через директиву proxy_cache_path и отдаёт его прямо из nginx, вообще не трогая Apache/PHP/Memcached:

proxy_cache_path /var/cache/nginx/cache_one levels=1:2 keys_zone=cache_one:4096m inactive=48h;

server {
    listen 443 ssl;
    server_name www.newsportal.example;
    ...

    location / {
        limit_req zone=bx_backend burst=60 nodelay;

        proxy_ignore_headers Cache-Control Expires Set-Cookie;

        # Не кешировать для авторизованных пользователей
        set $skip_cache 0;
        if ($http_cookie ~* "BITRIX_SM_ADMIN|BITRIX_SM_LOGIN|ADMIN_SECTION") {
            set $skip_cache 1;
        }

        # Не кешировать запросы с параметрами управления/админки
        if ($args ~* "clear_cache|ncc|bitrix_include_areas|mode=admin") {
            set $skip_cache 1;
        }

        proxy_pass http://127.0.0.1:8888;

        proxy_hide_header Cache-Control;
        proxy_hide_header Pragma;
        add_header Cache-Control "public, max-age=3600" always;
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_cache cache_one;
        proxy_cache_key "$host$request_uri";
        proxy_cache_valid 200 301 302 15m;
        proxy_cache_valid 404 60m;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_lock on;
        proxy_cache_background_update on;
        proxy_no_cache $skip_cache;
        proxy_cache_bypass $skip_cache;
    }
}

Три детали, которые стоит объяснить отдельно:

  • proxy_cache_use_stale ... updating - пока в фоне обновляется просроченная запись, читатели продолжают получать старую, но валидную версию страницы, а не ждут рендера.
  • proxy_cache_lock on - защита от «cache stampede»: если сотни читателей одновременно попадают на ещё не закэшированную страницу, до бэкенда дойдёт только один запрос, остальные подождут готовый результат.
  • proxy_no_cache $skip_cache вместе с proxy_cache_bypass $skip_cache - обе директивы обязательны разом: первая запрещает записывать ответ в кэш, вторая - читать из него. Без одной из двух легко получить утечку: например, только с bypass без no_cache ответ для админа спокойно ляжет в общий кэш и уйдёт следующему анонимному посетителю с тем же URL.

Реальный замер на проде - разница между первым и повторными запросами одной и той же страницы:

Запрос Время ответа
Первый (MISS) 1.840 с
Повторный (HIT) 0.178 с
Повторный (HIT) 0.275 с

Ускорение в 7-10 раз, без единого обращения к Apache/PHP/БД.

Уровень 2 - composite-кэш Bitrix через Memcached. Если запрос всё же не попал в edge-кэш (первый визит, кэш только что истёк - TTL 15 минут, короче времени жизни статьи в топе), Apache и PHP всё равно не делают полный рендер с нуля. Bitrix хранит готовый HTML собранной страницы в Memcached и отдаёт его за миллисекунды вместо секунд на сборку компонентов и запросы к MySQL.

// /home/bitrix/www/bitrix/.settings.php
'cache' => [
    'value' => [
        'type' => 'memcache',
        'memcache' => [
            'host' => '127.0.0.1',
            'port' => '11211',
        ],
    ],
],

Ключ кэша строится из URL страницы, из которого предварительно вычищается список «неважных» GET-параметров - той самой аналитики (utm_*, yclid, gclid и подобных), которая не должна порождать отдельную версию страницы в кэше:

// /home/bitrix/www/bitrix/html_pages/.config.php
"IGNORED_PARAMETERS" => "utm_source; utm_medium; utm_campaign; utm_content;
  fb_action_ids; utm_term; yclid; ysclid; gclid; _openstat; from;
  referrer1; r1; referrer2; r2; referrer3; r3; ",

Именно неполнота этого списка стала причиной единственного серьёзного инцидента на этом сервере.

Инцидент: как composite-кэш не заметил параметр ysclid

Вышла вирусная статья про ночную атаку в одном из городов. Трафик пошёл в основном из Яндекса - поиск и Дзен, и ссылки несли параметр ysclid (аналог gclid/yclid, но именно для Яндекс.Поиска и Дзена: на каждый переход Яндекс подставляет новое уникальное значение).

Список IGNORED_PARAMETERS на тот момент уже содержал utm_*, yclid, gclid, _openstat - но не ysclid. Composite-кэш строит ключ на основе полного URL вместе с query-строкой. Поэтому каждый переход с уникальным ysclid считался новой, никогда не виденной страницей - и уходил мимо не только edge-кэша nginx, но и мимо composite-кэша тоже, в полный рендер через PHP и запросы к MySQL.

По access-логам, минута в минуту:

Время Запросов/мин Комментарий
03:00-03:26 1600-2600 обычный фон
03:27 4224 начало срыва
03:29-03:35 6700-9239 пик разгона
04:02-04:19 4600-8700, пик 8651 основная фаза, ~144 запроса/сек
04:24-04:33 спад к 1500-2700 восстановление

Инцидент длился около часа. На одну статью пришло 150 101 запрос, из них 3347 - с уникальными значениями ysclid, то есть минимум 3347 отдельных, некэшированных, полных рендеров одной и той же страницы. За 10 минут пика на сайт зашло 2393 уникальных IP, из них 808 - именно на эту статью.

Load average поднялся до 35.78 при 24 ядрах - очередь запросов на CPU кратно больше числа ядер. Apache-пул был занят на 180 из 180 воркеров, новые запросы вставали в очередь. p50 времени ответа держался на уровне 0.007 с (то, что успевало отдаться из кэша), но p95 - 2.6 с, p99 - 3.2 с, а максимум для одного запроса - 151.9 секунды. Ошибок 502/503/504 почти не было - nginx не рвал соединения, Apache просто держал запросы в очереди. Читатель физически ждал страницу по 30-150 секунд вместо мгновенной отдачи.

Две неверные гипотезы, прежде чем нашлась настоящая

Первая гипотеза была логичной, но неверной: раз ключ кэша строится из URL с параметрами, можно вычистить лишние параметры прямо в nginx, до сравнения с файлом кэша.

Первая попытка - if-директивы с regex, вычисляющие «очищенный» query-string во временную переменную, затем проверка if (-f $composite_file). Технически заработало через раз: классическая проблема «if is evil» в nginx - несколько последовательных if, использующих общие capture-группы ($1$9), дают недетерминированный результат при повторных запусках.

Вторая попытка - map-директива вместо if, детерминированная альтернатива, переменная стабильно вычислялась правильно (подтвердил отладочным заголовком). Но кэш всё равно не подхватывался.

Провал был не в логике, а в том, что чинил не тот слой: у этой инсталляции composite-кэш физически хранится не в файлах (директория html_pages/ пуста), а в Memcached. Сравнение с несуществующим файлом кэша в nginx в принципе не могло сработать, независимо от того, насколько правильно вычищена query-строка - настоящий ключ кэша строит и проверяет PHP на стороне бэкенда, а не nginx.

Фикс - одна строка в конфиге приложения

// /home/bitrix/www/bitrix/html_pages/.config.php
"IGNORED_PARAMETERS" => "utm_source; utm_medium; utm_campaign; utm_content;
  fb_action_ids; utm_term; yclid; ysclid; gclid; _openstat; from;
  referrer1; r1; referrer2; r2; referrer3; r3; ",

Добавил один параметр - ysclid - в уже существующий список (и продублировал в параллельном PHP-массиве, который Bitrix использует наравне со строковым представлением). opcache.validate_timestamps=On, поэтому даже перезапуск PHP не потребовался - изменение подхватилось на лету.

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

Сопутствующие меры

Заодно, пока разбирался с логами, поправил ещё несколько вещей, которые сами по себе не про кэш, но снижают нагрузку на бэкенд и защищают от паразитного трафика.

Rate limiting на уровне nginx - ограничение на IP, защищает от агрессивных ботов и сканеров, не мешая обычным читателям:

limit_req_zone $binary_remote_addr zone=bx_backend:10m rate=15r/s;
limit_req_status 429;
# в location /
limit_req zone=bx_backend burst=60 nodelay;

Отсечение паразитного трафика по IP/Host - сайт раньше отдавался и по голому IP, и по любому произвольному Host-заголовку (боты сканировали поддомены вроде smtp.newsportal.example, pop.newsportal.example, каждый такой запрос всё равно доходил до nginx/Apache):

if ($request_uri !~ ^/(munin|nagios)) {
    return 444;
}

(444 - фирменный код nginx «оборвать соединение молча», без единого байта ответа)

fail2ban с кастомным фильтром под Bitrix-эксплойты - во время разбора логов инцидента заодно нашёл живые попытки LFI/RCE-сканов (content_dir=/etc/passwd, .env, wp-login.php, проба PHP pearcmd RCE). Один такой запрос сразу банит IP на неделю.

Консолидация SSL - было 8 отдельных сертификатов Let’s Encrypt на 8 доменов-алиасов одного сайта, объединил в один multi-SAN сертификат - заодно нашёл и закрыл скрытый риск: потерянный deploy-hook для перезагрузки nginx после автообновления сертификата.

MySQL max_connections поднял с 450 до 800 - Bitrix использует постоянные соединения, поэтому число открытых соединений к MySQL растёт вместе с размером пула Apache-воркеров, а не с реальной интенсивностью запросов к БД. При обычной работе лимит 450 был занят на 80% - поднял с большим запасом.

«После» - та же ситуация, месяц спустя, кратно больше нагрузки

Три задокументированных всплеска, по возрастанию масштаба:

Дата Пиковый трафик Load avg (пик) Apache занято Ошибки 5xx p95 время ответа
Инцидент (до фикса) ~144 запр/сек (8651/мин) 35.78 180/180 - до 152 сек
Через месяц, всплеск 1 153 545 запр/час (~43/сек) 3.8 ~8/150 0 ~2.0 с
Через месяц, всплеск 2 302 499 запр/час (~84/сек) 3.3 ~10/150 0 1.48 с

Самый крупный зафиксированный всплеск дал больше чем в 2 раза больше запросов в секунду, чем инцидент, который тогда полностью положил сервер. И прошёл при load average в 10 раз ниже, с Apache-пулом, занятым на 7% от лимита, без единой ошибки, с временем ответа быстрее, чем во время того коллапса. За этот день суммарно прошло 2.16 миллиона запросов без единого 5xx.

Оценка запаса прочности

Через формулу Литтла (одновременно активных читателей = скорость прихода новых читателей × среднее время на странице) прикинул реальную нагрузку одного из дней. Пиковая минута дала около 2 новых читателей в секунду. Средняя длительность визита в тот час по Яндекс.Метрике - 113 секунд. Итого одновременно на сайте - примерно 230 читателей, и это заняло меньше 20% CPU-бюджета.

Экстраполяция по CPU-запасу даёт безопасный потолок порядка 1000-1200 одновременных читателей одной вирусной статьи, прежде чем CPU станет узким местом - ещё в 5 раз больше самого крупного зафиксированного пика.

Итог

Два уровня кэша, каждый решает свою простую и дешёвую задачу. Nginx снимает нагрузку с Apache/PHP целиком для повторных запросов одной и той же страницы, Bitrix composite-кэш через Memcached снимает нагрузку с MySQL для первого запроса за интервал жизни nginx-кэша. Оба уважают одно и то же правило игры - кэш только для анонимов, честный рендер для залогиненных - реализованное независимо в двух разных местах, но согласованно по смыслу.

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

Выводы

  1. Диагностика важнее самого фикса. Две почти правильные, но не туда направленные попытки (nginx if, потом map) - хороший урок: чинить нужно там, где реально живёт логика, а не там, где кажется логичнее.
  2. Список «игнорируемых» параметров кэша - это список, который никогда не бывает полным навсегда. Рекламные площадки время от времени добавляют свои параметры трекинга (Яндекс однажды добавил ysclid к уже привычным yclid/gclid), и каждый такой новый параметр, не попавший в список, - потенциальная дыра в кэше именно в момент максимальной нагрузки.
  3. Экономический эффект несопоставим с усилием. Одна строка правки против полного отказа сервиса ровно в момент, когда трафик и его ценность максимальны.
  4. Сопутствующие меры - это defense in depth, а не решение основной проблемы. Rate-limit, блокировка по Host, fail2ban и увеличенный лимит MySQL снижают паразитную нагрузку и расширяют запас прочности, но именно связка «edge-кэш + composite-кэш» убирает первопричину подавляющей части нагрузки от вирусного трафика.
  5. Реальные повторные всплески - лучший нагрузочный тест, который не нужно организовывать искусственно. Тот же класс события месяц спустя, кратно больший трафик, кардинально другой результат - живое сравнение «было/стало» без единого синтетического теста.

Разбирались с похожим «невидимым» параметром, который портил кэш именно тогда, когда трафик был максимальным? Пишите, обсудим.

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