Как мы спасали Magento 2 с 1 млн товаров и 10 млн CMS страниц от 504 ошибок

Читайте на: Английском (English)
Magento 2 Optimisation

Технологический стек:

  • Magento 2.4.4
  • MariaDB
  • Redis
  • Varnish
  • OpenSearch

Размер проекта:

  • ~1,000,000 товаров
  • ~3,000 категорий
  • ~10,000,000 CMS страниц

Проблема:

  • 504 ошибки под нагрузкой поисковых роботов
  • Высокая нагрузка на MariaDB
  • Нестабильный FPC hit ratio

Год:

  • 2022

Let's go!

Сразу оговорюсь: события происходили в 2022 году. На тот момент я не планировал вести блог, поэтому полноценные метрики из Grafana уже не сохранились. Но часть заметок осталась в старых тикетах, поэтому основные цифры и решения получилось восстановить.

Магазины с каталогом порядка 1 000 000 товаров, особенно на Magento 2, уже сами по себе задача не простая. А если добавить к этому ~ 3 000 категорий и более 10 000 000 CMS-страниц, можно представить, что происходит во время пиковых нагрузок и активного налета краулеров.

На старте инфраструктура выглядела как по статьям: Magento 2, Redis, Varnish и OpenSearch. Все компонентные слои были настроены в соответствии с рекомендациями по оптимизации Adobe Commerce / Magento.

Но во время активного обхода сайта клиентами с user-agent'ами вроде Googlebot/2.1, DuckDuckBot/1.0, Mozilla/5.0 (bingbot), а также многочисленными менее известными пауками, ситуация переросла в пожар. К этому всему, пиковые нагрузки пользовательского трафика, особенно в акционные дни.

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

Для оценки объёма Full Page Cache я посчитал примерный теоретический максимум. На проекте медианный размер HTML-страниц составляла около 100 KB.

Считаем:

  • ~1,000,000 товаров
  • ~3,000 категорий
  • ~10,000,000 CMS-страниц

Всего: ~11,003,000 приблизительно страниц

11,003,000 × 100 KB = 1,049 GB ≈ 1 TB

Конечно, это теоретический максимум. На практике весь этот объём никогда одновременно не окажется в кеше: часть страниц устаревает имея TTL, часть вытесняется, часть просто не запрашивается.

Тем не менее именно от таких цифр я отталкивался при перепроектировании системы кэширования и выборе стратегии работы с FPC.

Redis

Memory crash

Возникает вопрос: сколько денег будет стоить хранение такого объема данных в Redis и вообще в RAM?

Даже если отбросить теоретический максимум в 1 TB данных для Full Page Cache (а мы его значительно режим и будем мониторить), остается очевидная проблема: Redis нужен не только для FPC. В нём также должны храниться сессии пользователей, системный кеш Magento, конфигурация, layout, block_html и другие данные.

Поэтому идея масштабировать инфраструктуру за счёт постоянного увеличения объема RAM выглядела не экономно, особенно с технической точки зрения.

Метрики Redis в Grafana лишь подтверждали это.

Очевидно, что выделять около 1 TB RAM (или хотя бы 100GB) только под Full Page Cache большинство бизнесов держащих Magento-магазины не могут быть готовы.

Поэтому я принял решение использовать Redis только для хранения системного кеша и пользовательских сессий, а для Full Page Cache я выбрал другой механизм хранения.

FPC мы не держим в Redis

В итоге, я разделил ответственность между различными хранилищами.

Для системного кеша Magento использовал Redis (Cm_Cache_Backend_Redis), а для page_cache - файловый backend (Magento\Framework\Cache\Backend\File).

Позже хранение Full Page Cache будет полностью делегировано Varnish, но на данном этапе важно исключить FPC из Redis и снизить требования к объемам оперативной памяти.

'cache' => [
    'frontend' => [
        'default' => [
            'backend' => 'Cm_Cache_Backend_Redis',
            'backend_options' => [
                'server' => '127.0.0.1',
                'port' => '6379',
                'database' => '0',
            ],
        ],
        'page_cache' => [
            'backend' => 'Magento\Framework\Cache\Backend\File',
        ],
    ],
],
'session' => [
    'save' => 'redis',
    'redis' => [
        'host' => 'redis.host',
        'port' => '6379',
        'timeout' => '2.5',
        'persistent_identifier' => '',
        'database' => '2',

        'compression_threshold' => '4096',
        'compression_library' => 'gzip',

        'log_level' => '1',

        'max_concurrency' => '10',

        'break_after_frontend' => '5',
        'break_after_adminhtml' => '30',

        'first_lifetime' => '600',
        'bot_first_lifetime' => '60',
        'bot_lifetime' => '7200',

        'disable_locking' => '0',

        'min_lifetime' => '60',
        'max_lifetime' => '2592000'
    ]
]

В итоге распределение данных выглядело примерно так:

  • ~около 100 ГБ - Full Page Cache в файловой системе
  • ~2–3 GB — системный кеш Magento (config, layout, block_html, collections, db_ddl, eav, и другие типы кеша) в Redis;
  • ~1–2 GB — пользовательские сессии в Redis.

Такой подход позволил использовать дорогую оперативную память только там, где она действительно приносит КПД.

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

Varnish

Varnish configuration

Следующий шаг - полностью делегировать Full Page Cache серверу Varnish

В Magento это настраивается через:

Stores » Configuration » Advanced » System » Full Page Cache

где необходимо выбрать:

Caching Application = Varnish Cache

После этого Magento перестаёт рассматриваться как основной участник процесса отдачи кэшированных страниц. Её задача сводится к генерации контента и управлению инвалидацией кеша, тогда как хранение и доставка FPC передаются Varnish.

Теперь Varnish становится ключевым игроком, через который проходит основной поток трафика.

Varnish: RAM или Memory Mapped File?

По умолчанию Varnish хранит объекты в оперативной памяти (malloc). Для большинства проектов это вполне разумный выбор, как и Redis. Однако в нашем случае уже ясно, что хранить сотни гигабайт Full Page Cache в RAM экономически нецелесообразно.

Достаточно взглянуть на предыдущие метрики Redis, чтобы понять, что путь постоянного наращивания объема памяти нам не подходит.

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

Storage Типичная задержка
Varnish (malloc) ~0.1–1 µs
Varnish (file) + объект находится в Linux Page Cache ~1–10 µs
Varnish (file) + чтение с NVMe ~50–200 µs
Magento File Cache ~100–1000+ µs
Генерация страницы через PHP 10–500+ ms

Даже в худшем случае чтение объекта из NVMe остаётся на несколько порядков быстрее, чем генерация страницы через PHP и последующие запросы к базе данных.

Поэтому вместо хранения Full Page Cache в RAM, я решил использовать файловое хранилище Varnish (file storage), работающее через механизм Memory Mapped Files.

Для запуска Varnish в таком режиме можно использовать такую конфигурацию:

Varnish configuration
ExecStart=/usr/sbin/varnishd \
    -a :80 \
    -f /etc/varnish/default.vcl \
    -s file,/var/lib/varnish/cache.bin,100G

Для локальных экспериментов в Docker это может выглядеть следующим образом.

Для образа varnish:6.6.1 это выглядело бы так:

CMD ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-s", "file,/var/lib/varnish/cache.bin,100G"]

Почему 100 GB?

Потому что теоретический объём в районе 1 TB никогда не будет находиться в кеше. Часть страниц устаревает по TTL, часть просто не запрашивается пользователями или поисковыми роботами.

При проектировании кеша имеет смысл ориентироваться не на теоретический максимум, а на реальный рабочий набор данных (working set), который действительно участвует в обслуживании клиентского трафика.

В результате получаем следующую схему:

Varnish
    ↓
Linux Page Cache (RAM)
    ↓
HDD / SSD / NVMe

В такой конфигурации, операционная система сама управляет тем, какие объекты должны находиться в памяти, а какие могут быть выгружены на диск.

Это позволяет использовать RAM максимально эффективно и не резервировать сотни гигабайт памяти исключительно под Full Page Cache.

Проверка

Varnish configuration

После настройки необходимо убедиться, что страницы обслуживает именно Varnish.

Проверяем заголовки ответа:

X-Varnish: XXXXXX XXXXX
X-Magento-Cache-Debug: HIT

Заголовок X-Varnish должен присутствовать (если он включён в VCL), а X-Magento-Cache-Debug должен возвращать значение HIT.

Для быстрой проверки производительности я использовал Apache Benchmark:

ab -n 100 https://xxx.xxx.xxx.xxx/
Varnish Apache Bench result

В моём случае после прогрева кэша, среднее время ответа составило около 3 мс на запрос.

Для сравнения я провёл подобный тест со встроенным Full Page Cache Magento.

Varnish Apache Bench result

На тех же 100 запросах среднее время ответа составило около 97 мс против 3 мс у Varnish.

Конечно, конкретные цифры зависят от инфраструктуры, конфигурации сервера и состояния кеша. Однако даже такой простой тест хорошо демонстрирует разницу между отдачей страницы напрямую из Varnish и обработкой запроса алгоритмами Magento.

Но главное здесь даже не разница между 97 мс и 3 мс, а то, что каждый запрос, обслуженный Varnish, не доходит до PHP-FPM и MariaDB. Именно это позволяет выдерживать большие объёмы crawler-трафика без появления 504 ошибок.

ESI (Edge Side Includes) и Full-Stack разработка

После настройки Varnish может показаться, что проблема решена. Но на практике, это не всегда так.

Дело в том, что связка Magento + Varnish активно использует ESI (Edge Side Includes) для динамических блоков. Если на странице присутствуют некэшируемые элементы, Varnish всё равно будет вынужден обращаться к PHP для их генерации.

Именно здесь начинается самая сложная часть работы.

Для достижения максимально эффективного Full Page Cache мне пришлось последовательно переводить блоки в режим:

cacheable="true"

Важно понимать, что в Magento наличие даже одного блока с атрибутом cacheable="false" может сделать всю страницу не кэшируемой.

Поэтому необходимо внимательно анализировать layout и добиваться максимально возможного покрытия блоками с cacheable="true".

Однако здесь возникает другая проблема.

Нельзя просто заменить cacheable="false" на cacheable="true" и считать задачу решенной.

Большинство таких блоков работают с персональными данными пользователя:

  • корзина
  • генератор ключей для защиты от CSRF
  • информация о клиенте
  • персональные цены
  • специальные предложения
  • данные авторизации
  • различные пользовательские состояния
  • checkout

Если такие данные случайно попадут в Full Page Cache, может оказаться больно - от отображения чужих данных до утечек чувствительной информации.

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

По сути, это означает перенос динамики на клиентскую сторону.

Например:

  • пользовательский блок можно загружать через AJAX/XHR, а точнее, данные клиента можно получать через стандартный механизм Magento_Customer/js/customer-data
  • form_key используемый для защиты от CSRF-атак, можно получать отдельным запросом через AJAX или Fetch API
  • элементы интерфейса, зависящие от состояния пользователя, можно собирать уже после загрузки страницы средствами JavaScript, рисовать с помощью Knockout.js или даже отрисовывать средствами React или Vue.js

В результате HTML страницы становится полностью статическим и могут храниться в Full Page Cache.

Насколько далеко стоит заходить в такой переработке - зависит от требований бизнеса, от того как бизнес считает ROI.

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

Отдельно хочу упомянуть интересную особенность Magento 2.4.4, артефакт на который наткнулся.

На момент работы над проектом атрибут cacheable="false" анализировался на уровне полного набора layout-файлов, а не итогового результата после их объединения. По крайней мере именно такое поведение наблюдалось в версии 2.4.4.

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

В качестве финальной проверки стоит контролировать значение заголовка: X-Magento-Cache-Debug

Ожидается, что прогретые страницы генерируют X-Magento-Cache-Debug: HIT как результат.

Значение MISS допустимо при первом обращении к странице после очистки кеша или истечения TTL..

Cloudflare, CloudFront и Browser Cache

После того как добился стабильного Full Page Cache на стороне Varnish, появился следующий уровень оптимизации.

Если страница стабильно отдаётся из FPC, имеет смысл позволить кешировать её ещё ближе к пользователю:

  • Cloudflare
  • Amazon CloudFront
  • корпоративные прокси
  • браузеры пользователей

Для этого необходимо корректно настроить заголовки Cache-Control и Pragma.

Тем, не менее здесь есть нюанс.

Стандартная конфигурация Varnish для Magento часто удаляет эти заголовки:

sub vcl_backend_response {
    unset beresp.http.Cache-Control;
    unset beresp.http.Pragma;
}

sub vcl_deliver {
    unset resp.http.Cache-Control;
    unset resp.http.Pragma;
}

Если ваша стратегия кэширования предполагает управление TTL непосредственно из Magento, эти строки стоит удалить.

В таком случае Magento будет самостоятельно формировать эти заголовки, а Cloudflare, CloudFront и браузеры пользователей смогут использовать их.

Фактически получается многоуровневая схема кэширования:

Browser Cache
    ↓
Cloudflare / CloudFront
    ↓
Varnish
    ↓
Magento

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

В результате

На проектах такого масштаба нужно оптимизировать не скорость PHP, а количество запросов, которые вообще доходят до PHP

В результате удалось существенно снизить нагрузку на всю инфраструктуру.

До внедренных изменений MariaDB регулярно упиралась в CPU и RAM, а во время активной индексации поисковыми роботами, рекламных кампаний и сезонных пиков нагрузки сайт периодически отвечал ошибкой 504 Gateway Timeout.

После перевода Full Page Cache на Varnish File Storage и устранения проблем с cacheable="false" ситуация изменилась.

Страницы начали стабильно отдавать:

X-Magento-Cache-Debug: HIT

Большое количество запросов к каталогам, карточкам товаров и CMS-страницам перестали доходить до PHP-FPM.

Основной поток трафика обслуживался непосредственно CloudFlare и Varnish, что значительно снизило нагрузку на PHP и MariaDB и практически устранило проблемы с недоступностью.

Перспективы

Разумеется, тема масштабирования Magento значительно глубже, чем может поместиться в одну статью.

Здесь мы рассмотрели лишь один из аспектов - организацию эффективного Full Page Cache и снижение нагрузки на инфраструктуру.

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

  • параметрические URL и их влияние на индексацию
  • ограничение crawler-трафика
  • настройку robots.txt
  • crawl-delay
  • борьбу с бесконечными URL-комбинациями
  • защиту инфраструктуры от бесполезного бот-трафика

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