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