- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

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

Symfony

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

Как известно, каждый подгруженный бандл 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 [3]. Базу размещаем в том же датацентре, что и основные сервера — благо, Compose позволяет размещать [4] БД в DigitalOcean.

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

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

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

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

И вуаля:

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

CouchDB

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

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

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

Форсаж под нагрузкой на 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 [7], который в нашем случае работает в разы бодрее и стабильнее используемой ранее связки PHP-FPM + APC. Благо Symfony 2.3 на 100% совместима [8] с HHVM. Устанавливается [9] HHVM на Debian 8 без каких-либо сложностей.

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

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 [13] и другие подобные сервисы, чтобы отслеживать сервера — потенциальные рассыльщики спама, им-то и выдавалось предупреждение. Техподдержка CloudFlare долгое время не могла предложить вразумительного решения. В итоге, помогло простое переключение Security Level на Essentially Off в панели CloudFlare:

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

Заключение

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

Автор: transpond

Источник [14]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/php-2/110071

Ссылки в тексте:

[1] DigitalOcean: https://m.do.co/c/d094555af590

[2] kernel.terminate: http://symfony.com/doc/current/components/http_kernel/introduction.html#the-kernel-terminate-event

[3] Compose.io: https://compose.io/

[4] позволяет размещать: https://www.compose.io/digitalocean/

[5] здесь: http://blog.mongolab.com/2012/06/cardinal-ins/

[6] уплотнения: https://wiki.apache.org/couchdb/Compaction

[7] HHVM: http://hhvm.com/

[8] на 100% совместима: http://symfony.com/blog/symfony-2-3-achieves-100-hhvm-compatibility

[9] Устанавливается: https://docs.hhvm.com/hhvm/installation/linux#debian-8-jessie

[10] Mongofill for HHVM: https://github.com/mongofill/mongofill-hhvm

[11] небольшого: https://github.com/mongofill/mongofill-hhvm/issues/45

[12] бага: https://github.com/mongofill/mongofill-hhvm/pull/47

[13] Project Honey Pot: https://www.projecthoneypot.org

[14] Источник: https://habrahabr.ru/post/275661/