Сегодня хотим рассказать о том, как строили систему, к которой сейчас обращается более 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
Тем не менее, это не мешает расширению успешно работать в продакшене.


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:




Заключение


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

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


  1. ruFog
    24.01.2016 20:37
    +1

    Спасибо за статью. Как мониторите производительность страниц/запросов/API? Как деплоите?


    1. transpond
      24.01.2016 20:40

      «Как мониторите производительность страниц/запросов/API?»
      Старые добрые Pinba + Munin.

      «Как деплоите?»
      Magallanes + самописные скрипты.


      1. PerlPower
        24.01.2016 21:07

        А если не секрет то за что отвечает магеллан кроме создания симлинков?


        1. transpond
          24.01.2016 21:37

          Как пример:

          Deploying to XXX:YYYY
          		Running Deploy via Rsync (with Releases) [built-in] ... OK
          		Running Symfony v2 - Cache Clear [built-in] ... OK
          Deployment to XXX:YYYY completed: 2/2 tasks done.
          Starting the Releasing
          		Releasing on host XXX:YYYY ... OK
          Finished the Releasing
          Starting Post-Release tasks for XXX:YYYY:
          		Running Reload nginx ... OK
          		Running Reload hhvm ... OK
          Finished Post-Release tasks for XXX:YYYY: 2/2 tasks done.
          


  1. gtbear
    24.01.2016 22:27
    +2

    >> скрипт-автоподниматель, который проверяет по крону,

    это у вас че такой production? ни тебе zabbix agent, ни monit ни на худой конец systemd?


    1. RPG18
      25.01.2016 00:19

      Да хоть старый добрый daemontools, чем это лучше или хуже?


  1. enleur
    25.01.2016 10:32

    Новый ext-mongodb драйвер из коробки должен работать с HHVM, так что можно отказаться от полифила


  1. TimTowdy
    25.01.2016 10:36
    +4

    Неужели этот набор костылей — повод для гордости?

    Поэтому для каждого компонента системы подгружаем только необходимый набор бандов (например, на фронтенде не нужны бандлы админки, а в API не нужны бандлы админки и фронтенда и т.д.).
    Хитрость в том…

    Это не хитрость, это здравый смысл.

    Ранее с данной БД не работали, подкупила фраза «CouchDB предназначен именно для веба».

    Иными словами, базу данных мы выбрали случайным образом. Потом оказалось что она под наши требования не подходит, нафигачили костылей, чтоб меньше тормозило.
    Чем же вам монга не угодила? Вы разве не слышали, «MongoDB is Web Scale» (сарказм)

    Практически все руководства о создании индексов для MongoDB утверждают, что, если в запросе используются операции выбора диапазона ($in, $gt или $lt), то для такого запроса индекс не будет использоваться ни при каких обстоятельствах

    Чуть за чушь? B-Tree индексы, которым сто лет в обед, прекрасно работают с range запросами. Меньше читайте «руководства», больше думайте головой.

    Опытным путём для нашей системы был подобран параметр...

    Если б вы параметр подобрали не опытным путем, а исходя из каких-то метрик, это было бы интересно почитать. А у вас вот так: «методом тыка подставляли разные значения пока не попустило». Что вы через месяц будете делать, когда добавите новые фичи и характер нагрузки поменяется? Опять методом тыка конфиги править?

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


    1. transpond
      25.01.2016 10:45
      -4

      >> и отдавать напрямую клиентам с помощью JavaScript без авторизации
      Разве MongoDB позволяет такое из коробки? Если да, будем рады почитать/посмотреть.


      1. TimTowdy
        25.01.2016 11:02
        +4

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

        скрипт-автоподниматель, который проверяет по крону

        facepalm.jpg

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


  1. Makaveli
    25.01.2016 12:28
    +2

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


    Тем не менее, получив документ по его _id методом GET любой желающий может его поменять через PUT? В том числе, и удалить методом DELETE? Или вы как-то это перекрыли? Я бы всё-таки прослойку какую-то сделал, чтобы наружу отдавать только то, что нужно, а не полный доступ к БД


    1. transpond
      25.01.2016 12:43
      +1

      У анонимных пользователей доступ только на чтение. На запись имеет доступ только специальный пользователь и админ.

      _users:

      {
         "_id": "org.couchdb.user:writer",
         "name": "writer",
         "password": "password",
         "roles": [
             "read",
             "write"
         ],
         "type": "user"
      }
      

      В БД:
      {
         "_id": "_design/_auth",
         "language": "javascript",
         "validate_doc_update": "function(newDoc, oldDoc, userCtx) {   if (userCtx.roles.indexOf('write') !== -1 || userCtx.name == 'admin') {     return;   } else {     throw({forbidden: 'No permission'});   } }"
      }