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

в 16:53, , рубрики: couchdb, HHVM, mongodb, nosql, php, symfony, symfony2, Varnish, Веб-разработка, высокая производительность

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

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

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

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

Symfony

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

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

Класс /app/BaseAppKernel.php

<?php

use SymfonyComponentHttpKernelKernel;
use SymfonyComponentConfigLoaderLoaderInterface;

class BaseAppKernel extends Kernel
{
    protected $bundle_list = array();

    public function registerBundles()
    {
        // Минимально необходимый набор бандлов
        $this->bundle_list = array(
            new SymfonyBundleFrameworkBundleFrameworkBundle(),
            new SymfonyBundleSecurityBundleSecurityBundle(),
            new SymfonyBundleTwigBundleTwigBundle(),
            new SymfonyBundleMonologBundleMonologBundle(),
            new SymfonyBundleAsseticBundleAsseticBundle(),
            new DoctrineBundleDoctrineBundleDoctrineBundle(),
            new SensioBundleFrameworkExtraBundleSensioFrameworkExtraBundle(),
            new DoctrineBundleMongoDBBundleDoctrineMongoDBBundle()
        );

        // Здесь когда нужно, подгружаем все бандлы системы
        if ($this->needLoadAllBundles()) {
            // Admin
            $this->addBundle(new SonataBlockBundleSonataBlockBundle());
            $this->addBundle(new SonataCacheBundleSonataCacheBundle());
            $this->addBundle(new SonatajQueryBundleSonatajQueryBundle());
            $this->addBundle(new SonataAdminBundleSonataAdminBundle());
            $this->addBundle(new KnpBundleMenuBundleKnpMenuBundle());
            $this->addBundle(new SonataDoctrineMongoDBAdminBundleSonataDoctrineMongoDBAdminBundle());

            // Frontend
            $this->addBundle(new LikebtnFrontendBundleLikebtnFrontendBundle());
			
            // API
            $this->addBundle(new LikebtnApiBundleLikebtnApiBundle());
        }

        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 LikebtnApiBundleLikebtnApiBundle());
        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. И наконец, поля, которые участвуют в выборе диапазона.

И вуаля:

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

CouchDB

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

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

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

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

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

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

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) в кэше:

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

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

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

Чтобы изменения в настройках вступили в силу, 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:

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

Заключение

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

Автор: transpond

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js