Форсаж под нагрузкой на Symfony + HHVM + MongoDB + CouchDB + Varnish

от автора

Сегодня хотим рассказать о том, как строили систему, к которой сейчас происходит более 1 млн. уникальных обращений в день (без учёта запросов к API), о тонкостях архитектуры, а также о тех граблях и подводных камнях, с которыми пришлось столкнуться. Поехали…

Исходные данные

Система работает на Symfony 2.3 и крутится на дроплетах DigitalOcean, работают бодро, никаких замечаний.

Symfony

У Symfony есть замечательное событие kernel.terminate. Здесь в фоне после того, как клиент получил ответ от сервера, выполняется вся тяжёлая работа (запись в файлы, сохранение данных в кэш, запись в БД).

Как известно, каждый подгруженный бандл Symfony так или иначе увеличивает потребление памяти. Поэтому для каждого компонента системы подгружаем только необходимый набор бандов (например, на фронтенде не нужны бандлы админки, а в API не нужны бандлы админки и фронтенда и т.д.). Перечень подгружаемых бандлов в примере сокращён для простоты, в реальности их, конечно, больше:

Класс /app/BaseAppKernel.php

<?php  use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Config\Loader\LoaderInterface;  class BaseAppKernel extends Kernel {     protected $bundle_list = array();      public function registerBundles()     {         // Минимально необходимый набор бандлов         $this->bundle_list = array(             new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),             new Symfony\Bundle\SecurityBundle\SecurityBundle(),             new Symfony\Bundle\TwigBundle\TwigBundle(),             new Symfony\Bundle\MonologBundle\MonologBundle(),             new Symfony\Bundle\AsseticBundle\AsseticBundle(),             new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),             new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),             new Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle()         );          // Здесь когда нужно, подгружаем все бандлы системы         if ($this->needLoadAllBundles()) {             // Admin             $this->addBundle(new Sonata\BlockBundle\SonataBlockBundle());             $this->addBundle(new Sonata\CacheBundle\SonataCacheBundle());             $this->addBundle(new Sonata\jQueryBundle\SonatajQueryBundle());             $this->addBundle(new Sonata\AdminBundle\SonataAdminBundle());             $this->addBundle(new Knp\Bundle\MenuBundle\KnpMenuBundle());             $this->addBundle(new Sonata\DoctrineMongoDBAdminBundle\SonataDoctrineMongoDBAdminBundle());              // Frontend             $this->addBundle(new Likebtn\FrontendBundle\LikebtnFrontendBundle()); 			             // API             $this->addBundle(new Likebtn\ApiBundle\LikebtnApiBundle());         }          return $this->bundle_list;     }      /**      * Проверка, нужно ли подгружать все бандлы.      * Если скрипт запущен в dev- или text-окружении или выполняется очистка кэша prod-окружения,      * подгружаем все бандлы системы      */     public function needLoadAllBundles()     {         if (in_array($this->getEnvironment(), array('dev', 'test')) ||             $_SERVER['SCRIPT_NAME'] == 'app/console' ||             strstr($_SERVER['SCRIPT_NAME'], 'phpunit')         ) {             return true;         } else {             return false;         }     }      /**      * Добавление бандла к списку подгружаемых      */     public function addBundle($bundle)     {         if (in_array($bundle, $this->bundle_list)) {             return false;         }         $this->bundle_list[] = $bundle;     }      public function registerContainerConfiguration(LoaderInterface $loader)     {         $loader->load(__DIR__.'/config/config_'.$this->getEnvironment().'.yml');     } } 

Класс /app/AppKernel.api.php

<?php  require_once __DIR__.'/BaseAppKernel.php';  class AppKernel extends BaseAppKernel {     public function registerBundles()     {         parent::registerBundles();         $this->addBundle(new Likebtn\ApiBundle\LikebtnApiBundle());         return $this->bundle_list;     } } 

Фрагмент /web/app.php

// Все компоненты системы располагаются на своих поддоменах // Если какой-то компонент располагается в поддиректории,  // просто нужно проверять путь в $_SERVER['REQUEST_URI'] if (strstr($_SERVER['HTTP_HOST'], 'admin.')) {     // Админка     require_once __DIR__.'/../app/AppKernel.admin.php'; } elseif (strstr($_SERVER['HTTP_HOST'], 'api.')) {     // API     require_once __DIR__.'/../app/AppKernel.api.php'; } else {     // Фронтенд     require_once __DIR__.'/../app/AppKernel.php'; } $kernel = new AppKernel('prod', false); 

Хитрость в том, что подгружать все бандлы нужно только в dev-окружении и в момент, когда выполняется очистка кэша на prod-окружении.

MongoDB

В качестве основной БД используется MongoDB на Compose.io. Базу размещаем в том же датацентре, что и основные сервера — благо, Compose позволяет размещать БД в DigitalOcean.

В определённый момент были сложности с медленными запросами, из-за которых общее быстродействие системы начинало снижаться. Решён вопрос был с помощью грамотно составленных индексов. Практически все руководства о создании индексов для MongoDB утверждают, что, если в запросе используются операции выбора диапазона ($in, $gt или $lt), то для такого запроса индекс не будет использоваться ни при каких обстоятельствах, например:

{"_id":{"$gt":ObjectId('52d89f120000000000000000')},"ip":"140.101.78.244"} 

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

  1. Сначала в индекс включаются поля, по которым выбираются конкретные значения.
  2. Затем поля, по которым идёт сортировка.
  3. И наконец, поля, которые участвуют в выборе диапазона.

И вуаля:

CouchDB

Данные статистического характера решено было хранить в CouchDB и отдавать напрямую клиентам с помощью JavaScript, лишний раз не дёргая сервера. Ранее с данной БД не работали, подкупила фраза «CouchDB предназначен именно для веба».

Когда уже всё было настроено и пришло время нагрузочного тестирования, выяснилось, что с нашим потоком запросов на запись, CouchDB просто захлёбывалась. Практически все руководства по CouchDB прямо не рекомендуют использовать её для часто обновляемых данных, но мы, конечно же, не поверили и понадеялись на авось. Оперативно было сделано аккумулирование данных в Memcached и переброска их в CouchDB через небольшие промежутки времени.

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

Futon — веб-админка CouchDB, доступна по адресу /_utils/ всем, в том числе анонимным пользователям. Единственный способ запретить всем желающим смотреть базу, который смогли найти — просто удалить следующие записи конфигурации CouchDB в секции [httpd_db_handlers] (админ при этом тоже теряет возможность просматривать списки документов):

_all_docs ={couch_mrview_http, handle_all_docs_req} _changes ={couch_httpd_db, handle_changes_req} 

В общем, расслабиться CouchDB не давала.

HHVM

Бэкенды, подготавливающие основной контент, крутятся на HHVM, который в нашем случае работает в разы бодрее и стабильнее используемой ранее связки PHP-FPM + APC. Благо Symfony 2.3 на 100% совместима с HHVM. Устанавливается HHVM на Debian 8 без каких-либо сложностей.

Чтобы HHVM мог взаимодействовать с базой MongoDB, используется расширение Mongofill for HHVM, реализованное наполовину на C++, наполовину на PHP. Из-за небольшого бага, в случае ошибок при выполнении запросов к БД вываливается:

Fatal error: Class undefined: MongoCursorException

Тем не менее, это не мешает расширению успешно работать в продакшене.

На случай, если на каком-нибудь бэкенде HHVM вдруг упадёт (такое было один раз, но на работу системы не повлияло, т.к. Varnish просто не направлял запросы на данный бэкенд), подготовлен вот такой скрипт-автоподниматель, который проверяет по крону, запущен ли сервис hhvm, и, если нужно, поднимает и отправляет уведомление админу:

Монитор HHVM

#!/bin/bash DATE_TIME=`date +%Y-%m-%d_%H:%M:%S` STATUS=`ps aux | grep "/usr/bin/hhvm" | wc -l` if [ $STATUS == "1" ]    then    MESSAGE="${DATE_TIME} HHVM is down. Starting...";    HOSTNAME=`hostname`    echo $MESSAGE;    service hhvm start    echo -e $MESSAGE | mail -s "[${HOSTNAME}] Started hhvm service" "admin@email.com"; else    echo "${DATE_TIME} HHVM is running"; fi 

Varnish

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

varnishd[23437]: Child (23438) not responding to CLI, killing it. varnishd[23437]: Child (23438) died signal=3 varnishd[23437]: Child cleanup complete varnishd[23437]: child (3786) Started varnishd[23437]: Child (3786) said Child starts 

Это приводило к очистке кэша и резкому росту нагрузки на систему в целом. Причин такого поведения, как выяснилось, превеликое множество, как и советов и рецептов по лечению. Сначала грешили на параметр -p cli_timeout=30s в /etc/default/varnish, но дело оказалось не в нём. В общем, после довольно длительных экспериментов и перебора параметров, было установлено, что происходило это в те моменты, когда Varnish начинал активно удалять из кэша элементы, чтобы поместить новые. Опытным путём для нашей системы был подобран параметр beresp.ttl в default.vcl, отвечающий за время хранения элемента в кэше, и ситуация нормализовалась:

sub vcl_fetch {     /* Set how long Varnish will keep it*/     set beresp.ttl = 7d; } 

Параметр beresp.ttl нужно было установить таким, чтобы старые элементы удалялись (expired objects) из кэша раньше, чем новым элементам начинало не хватать места (nuked objects) в кэше:

Процент кэш-попаданий при этом держится стабильно в районе 91%:

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

varnishadm -T 0.0.0.0:6087 -S /etc/varnish/secret vcl.load config01 /etc/varnish/default.vcl vcl.use config01 quit 

config01 — название новой конфигурации, можно задавать произвольно, например: newconfig, reload и т.д.

CloudFlare

CloudFlare прикрывает всё это дело и кэширует статику, а заодно и предоставляет SSL-сертификаты.

У некоторых клиентов были проблемы с доступом к нашему API — они получали запрос на ввод капчи «Challenge Passage». Как выяснилось, CloudFlare использует Project Honey Pot и другие подобные сервисы, чтобы отслеживать сервера — потенциальные рассыльщики спама, им-то и выдавалось предупреждение. Техподдержка CloudFlare долгое время не могла предложить вразумительного решения. В итоге, помогло простое переключение Security Level на Essentially Off в панели CloudFlare:

Заключение

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

ссылка на оригинал статьи https://habrahabr.ru/post/275661/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *