Привет, меня зовут Александр Ададуров. Я — руководитель проектов ФГБУ «Центр информационно-технического обеспечения». В этой статье я опишу опыт настройки сайта с образовательным контентом под нагрузку в пиках до 15 000 запросов в секунду или до нескольких миллионов пользователей в день. 

Образовательный контент сайта представлял собой иллюстрированные HTML-страницы, видеоуроки и различные интерактивные задания, преимущественно на JavaScript, которые проверяли правильность выполнения заданий запросами к бэкенду. Сайт жил спокойной жизнью и вяло развивался до введения локдаунов в связи с распространением COVID-19. Первые месяцы карантина существенно изменили код приложения, его архитектуру и даже серверную инфраструктуру, на которой оно располагалось. 

Первоначальная архитектура

Команда разработки состояла в разные периоды из 3–5 человек, проект писали несколько лет, в течение которых менялись взгляды на архитектуру и концепцию в целом. Отдельные части переписывали, менялась команда. В итоге к началу пандемии код проекта был достаточно рыхлый и не всегда выверенный в плане оптимальности. Когда нагрузка выросла, в коде были классы, методы и даже бандлы, назначение которых команда не вполне понимала.

Cайт был написан на PHP-фреймворке Symfony 3, без четкого разделения на фронт и бэк. Веб-интерфейсы рендерили с помощью шаблонизатора Twig, для интерактива использовали преимущественно JQuery. В качестве СУБД была PostgreSQL 9.6, а часть данных по инициативе разработчиков кешировалась в NoSQL СУБД Redis. На сайте был API для загрузки и многоэтапной обработки нового контента, для этого была выстроена система очередей на двух брокерах RabbitMQ.

Проект располагался на 16 физических серверах, фронтенды и бэкенды — по 24 ядра и 128 ОЗУ каждый, ноды СУБД имели 56 ядер и 512 ГБ ОЗУ. В каждом сервере было по четыре 10-гигабитных сетевых интерфейса, которые давали агрегированный канал шириной 40 Гбит. На нодах стояли жесткие диски по 2 ТБ с установленной ОС, а на бэкенд-нодах дополнительно располагался код PHP/Symfony. Разделяемые ресурсы, такие как изображения, видео и загружаемые файлы, которые требовались на всех нодах, хранились в СХД и монтировались к каждой ноде в виде сетевых шар NFS.

Первоначальная архитектура приложения
Первоначальная архитектура приложения

В первоначальной архитектуре уже были заложены некоторые идеи для работы в условиях высоких нагрузок. 

Например, проект был разделен на два сегмента по типу обработки контента и состоял из «видеосервиса» и «движка».

Видеосервис находился на отдельном поддомене video. Все видеоматериалы загружались в видеосервис, обрабатывались отдельно и встраивались в контент через <iframe>. Каждый видеоролик разделялся на тысячи чанков различного качества для разных каналов связи. JS-видеоплеер определял скорость соединения по скорости загрузки первого чанка и выбирал видео соответствующего качества для показа.

Движок был классической системой управления HTML-контентом: авторизация, избранное, история действий, каталог.

На входе стояли Nginx-балансировщики (фронтенды) по два на каждый сегмент, входящие запросы между балансировщиками распределялись DNS-сервером по методу Round Robin. Между бэкендами запросы распределялись по алгоритму Least Connections, когда очередной запрос передается бэкенду с наименьшим количеством соединений:

upstream backend {     least_conn;     server 192.168.1.100:80 weight=10 max_fails=10 fail_timeout=2s; ...     server 192.168.1.104:80 weight=10 max_fails=10 fail_timeout=2s; }

Для длительных ресурсоемких операций, таких как загрузка, распаковка, обработка нового контента, подготовка видеороликов и нарезка чанков для видеосервиса, использовались очереди RabbitMQ и дополнительное ПО операционной системы: ffmpeg, zip, wkhtmltopdf.

К серверной был подведен 20-гигабитный интернет-канал с возможностью расширения до 40 Гбит. Мы, как выражаются сетевики, «сидели на девятке» (ММТС-9).

Рост нагрузки

С переводом всех на удаленку в апреле 2020 года нагрузка на портал резко возросла. Большую роль при обнаружении проблем и поиске решений сыграли различные средства мониторинга и визуализации операций: Zabbix, Symfony Profiler, Cockpit, DBeaver, Nginx Amplify.

Обсуждение рабочего момента с использованием DBeaver (функции мониторинга БД)
Обсуждение рабочего момента с использованием DBeaver (функции мониторинга БД)

В отдельные моменты Zabbix и другие средства мониторинга показывали суммарную нагрузку до 15 000 запросов в секунду. Во многом это было следствием рекламных кампаний, проводимых коллегами. Каждая рекламная кампания приносила очередной всплеск. Мы быстро выяснили, что сайт не справляется с такими нагрузками: на экране пользователи наблюдают ошибку 502 Bad Gateway либо сайт вообще не отвечает, как при DDoS-атаке. Нужно было срочно что-то предпринимать.

Ниже привожу показания метрик за тот период: общее количество запросов и посещаемость в неделю.

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

По системам мониторинга было видно, что проблема не где-то в одном месте, перегружено всё: и фронты, и бэкенды, и СУБД. Требовалось комплексное решение, поэтому оптимизацией занимались параллельно в нескольких направлениях при непрерывном взаимодействии всех коллег. Опишу по порядку, что было сделано.

Балансировка нагрузки

Узким местом на фронтах-балансировщиках, как показали графики, оказались логи Nginx. Чтобы оптимизировать дисковые операции, мы включили буферизацию логов Nginx (параметры buffer и flush в настройках access_log блока HTTP файла nginx.conf). Nginx в нашей конфигурации сбрасывал логи запросов на локальный диск через определенные промежутки времени, и на графиках эти моменты были резкими всплесками. Получался гребенчатый график, и иногда очередной такой всплеск «уходил в полку», то есть балансировщик зависал.

Чтобы устранить проблему, в качестве экстренной меры мы перенесли логи на виртуальные RAM-диски, которые сделали средствами ОС.

mount -t tmpfs -o size=25G tmpfs /mnt/ramdisk

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

Redis и кеширование

Следующей задачей было снизить нагрузку на бэкенды. Решили это кешированием всего, что возможно закешировать. В первоначальной архитектуре в Redis главным образом хранились сессионные ключи, также некоторые разработчики хранили там часть симфонического кеша. Это никак не регламентировалось, отдельные разработчики делали это по собственной инициативе.

Поскольку самая большая нагрузка приходилась на главную страницу, кеширование начали с нее, чтобы эта страница на 100% отдавалась из кеша Redis. После этого она начала открываться мгновенно. Далее мы пересмотрели весь код наиболее часто используемых функций, скорректировали его на предмет кеширования и хранения кеша в Redis. Это тоже дало свои результаты, скорость выросла, а нагрузка на бэкенды упала.

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

/server/redis/redis-cli --cluster create
192.168.1.70:7000
192.168.1.70:7001
192.168.1.70:7002
192.168.1.70:7003
192.168.1.70:7004
192.168.1.70:7005
192.168.1.70:7006
192.168.1.70:7007
192.168.1.70:7008
192.168.1.70:7009
--cluster-replicas 1
--cluster-yes

Использовали в данном случае также возможность Symfony напрямую работать с Redis-кластерами.

Но как бы ни радовало увеличение скорости с появлением Redis-кластера и перенесением в него симфонического кеша, эта реализация все равно давала потери. Сравнительные тесты показывали, что связь с кластером, который находится где-то в сети, пусть даже и в той же серверной, работает все равно медленнее, чем получать данные напрямую из локальной памяти ОЗУ. 

Кроме того, через несколько дней пришли коллеги-сетевики и показали графики, где было видно, что сеть перегружена запросами к Redis-кластеру. Тогда было решено разделить весь кеш на два уровня:

  • первый, куда бы входили данные, актуальные только для каждого отдельно взятого бэкенда;

  • второй, который был нужен всем и куда выделялись пользовательские сессии и другие данные, возникшие после авторизации пользователей.

СУБД

Единственная нода, выделенная для СУБД-движка, хотя и была мощной (56 ядер и 512 ГБ ОЗУ), также не справлялась с количеством запросов. Мы разделили оптимизацию БД на две части: работа с кодом и организация СУБД-кластера.

Работа с кодом. Средствами Postgres и главным образом встроенным профайлером Symfony выявили избыточные и неоправданно сложные запросы к БД, скорректировали код.

СУБД-кластер. Выяснилось, что БД также подвисает от лавинообразного роста числа соединений. Чтобы управлять пулом соединений, на входе поставили PgBouncer в режиме пула сеансов (pool_mode = session). 

PgBouncer — это приложение из экосистемы PostgreSQL, которое управляет пулом соединений с базой данных, причем для клиента это происходит прозрачно, как будто соединение происходит с самим PostgreSQL-сервером. PgBouncer принимает подключения, передает их СУБД-серверу или ставит в очередь, когда все соединения в пуле (default_pool_size) заняты. При освобождении соединений из пула очередь обрабатывается.

Также к СУБД добавили четыре сервера и создали СУБД-кластер из пяти нод. Запросы по нодам распределялись с помощью PGPool — еще одного полезного приложения для PostgreSQL. PgPool был настроен на балансировку нагрузки, причем так, чтобы запросы на запись (INSERT, UPDATE, DELETE) направлялись только на master-ноду. 

# Enabling load balancing for read queries load_balance_mode = on # Enabling master-slave mode with streaming replication master_slave_mode = on master_slave_sub_mode = 'stream' 

Были также ограничены входящие запросы на самих PostgreSQL-нодах (max_connections).

Схема СУБД-кластера
Схема СУБД-кластера

Увеличение количества бэкендов и фронтендов

Описанные выше меры позволили оптимизировать работу сайта и эффективно использовать существующие серверные мощности: ядра и память были загружены. Всё бы хорошо, но через месяц такой напряженной работы начали выходить из строя различные хардовые детали серверов, где-то память, где-то сетевой интерфейс или диск. Стало ясно, что оптимальным будет использование серверных мощностей не на 70–80% их возможностей, а примерно на 40–50%.

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

Масштабирование серверной инфраструктуры
Масштабирование серверной инфраструктуры

В заключение

История оптимизации на этом не закончилась. Следующим узким местом системы стал 20-гигабитный канал, который периодически заполнялся на 100%. Наиболее приемлемым решением во всех отношениях представлялась доработка сайта на использование CDN, но это уже другая история.

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

  • использовать запросы с параметрами, чтобы не обрабатывать весь массив объектов ради одного свойства;

  • минимизировать использование циклов с запросами к БД;

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

  • где это возможно, использовать очереди для асинхронной обработки ресурсоемких и долгих операций, например отправки email, загрузки файла;

  • кешировать всё, что можно;

  • переносить части функционала с бэка в браузер пользователя;

  • выделять статический контент в отдельный сегмент с возможностью подключения к CDN.

вАЙТИ — DIY-медиа для ИТ-специалистов. Делитесь личными историями про решение самых разных ИТ-задач и получайте вознаграждение.

Комментарии (7)


  1. rinace
    13.08.2024 04:15

    Версия PostgreSQL = 9.6 это не опечатка ?

    Чем обусловлено использование столь старой версии СУБД ?


    1. adadurov
      13.08.2024 04:15

      На момент проектирования системы в 2016 году Postgresql 9.6 только вышел (29 сентября 2016) и не был старым.


      1. rinace
        13.08.2024 04:15

        Прошло 8 лет. Версия СУБД - не менялась ?


        1. adadurov
          13.08.2024 04:15

          На тот момент (2020) прошло  3,5 года, и версия, да, была 9.6. Мысли про обновление СУБД были, но без доработок кода в папке vendor приложение не работало, поэтому данный вопрос тогда отложили.

          Вообще когда доработки архитектуры заканчиваются и нужно всё оформить, возникает много вопросов не касающихся напрямую новых возможностей ПО и тогда не дата релиза определяет решение перехода на новые версии. У нас такие решение принимала целая коллегия. При рассмотрении смотрят, например, если приложение аттестовано, как повлияет на аттестацию новая версия ПО, является ли оно рекомендованным и протестированным на этой версии ОС, есть ли какие-то рекомендации ФСТЭК, есть ли одобрение на эти действия вышестоящих инстанций и т.п.


  1. mikker
    13.08.2024 04:15

    Подскажите, по структуре кластера СУДБ, у вас организована синхронная репликация или асинхронная? Интересно, позволяет ли приложение неконсистентное чтение данных которые ещё не успели примениться на slavы, но закомичены на мастере?


    1. adadurov
      13.08.2024 04:15

      Реплекация была дефолтная, т.е. асинхронная, но с одной стороны она была практически мгновенной, а с другой PgBouncer и PgPool с настройками, которые я указывал в PgBouncer pool_mode = session и в PGPool master_slave_mode = on master_slave_sub_mode = 'stream' позволяли избегать проблем.

      Само приложение неконсистентное чтение данных не позволяет и не отслеживает. Т.е. при рассинхронизации нод, с точки зрения приложения возможно, что пользователь сохраняет что-то в ЛК, например сменил имя, нажимает F5 и видит старые данные. Это ещё самый безобидный пример, в большинестве случаев это будет ошибка 500, или в dev режиме - красный экран ошибки Symfony и что-нибудь про SQL.

      Но PgBouncer держал сессию и все запросы как правило пролетали внутри этой сессии, и PGPool, тоже, как я понял, в рамках сессии все запросы старается направлять на одну какую-то конкретную ноду.


  1. isumix
    13.08.2024 04:15

    Возможно если выделить фронт от бэка, перейти полностью на js, скомпилировать его и отдавать с cdn статические ассеты, отдав на откуп всю клиентскую логику броузеру пользователей, то прирост мог бы быть еще более ощутимым.