company_banner

Используем mcrouter для горизонтального масштабирования memcached



    Разработка высоконагруженных проектов на любом языке требует особого подхода и применения специальных инструментов, но когда речь заходит о приложениях на PHP, ситуация может обостриться настолько, что приходится разрабатывать, к примеру, собственный сервер приложений. В данной заметке речь пойдет про знакомую всем боль с распределенным хранением сессий и кэшировании данных в memcached и о том, как мы решали эти проблемы в одном «подопечном» проекте.

    Виновник торжества — приложение на PHP, базирующееся на фреймворке symfony 2.3, обновлять который в планы бизнеса совсем не входит. Помимо вполне стандартного хранения сессий в этом проекте вовсю использовалась политика «кэширования всего» в memcached: ответов на запросы к БД и API-серверам, различных флагов, блокировок для синхронизации выполнения кода и многого другого. В такой ситуации поломка memcached становится фатальной для работы приложения. Вдобавок, потеря кэша ведет к серьезным последствиям: СУБД начинает трещать по швам, API-сервисы — банить запросы и т.д. Стабилизация ситуации может занять десятки минут, а в это время сервис будет жутко тормозить или вовсе станет недоступным.

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

    Что не так с самим memcached?


    Вообще, расширение memcached для PHP «из коробки» поддерживает распределенное хранение данных и сессий. Механизм консистентного хеширования ключей позволяет равномерно размещать данные на многих серверах, однозначно адресуя каждый конкретный ключ определенному серверу из группы, а встроенные средства failover'а обеспечивают высокую доступность сервиса кэширования (но, к сожалению, не данных).

    С хранением сессий дела обстоят немного лучше: можно настроить memcached.sess_number_of_replicas, в результате чего данные будут сохраняться сразу на несколько серверов, а в случае отказа одного экземпляра memcached данные будут отдаваться с других. Однако, если сервер вернется в строй без данных (как обычно бывает после рестарта), часть ключей будет перераспределена в его пользу. Фактически это будет означать потерю данных сессии, так как нет возможности «сходить» в другую реплику в случае промаха.

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

    В идеале требовалась репликация записи в memcached и обход реплик в случае промаха или ошибки. Реализовать эту стратегию нам помог mcrouter.

    mcrouter


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

    • реплицировать запись;
    • делать fallback на другие сервера группы в случае возникновения ошибки.

    За дело!

    Конфигурация mcrouter


    Перейду сразу к конфигу:

    {
     "pools": {
       "pool00": {
         "servers": [
           "mc-0.mc:11211",
           "mc-1.mc:11211",
           "mc-2.mc:11211"
       },
       "pool01": {
         "servers": [
           "mc-1.mc:11211",
           "mc-2.mc:11211",
           "mc-0.mc:11211"
       },
       "pool02": {
         "servers": [
           "mc-2.mc:11211",
           "mc-0.mc:11211",
           "mc-1.mc:11211"
     },
     "route": {
       "type": "OperationSelectorRoute",
       "default_policy": "AllMajorityRoute|Pool|pool00",
       "operation_policies": {
         "get": {
           "type": "RandomRoute",
           "children": [
             "MissFailoverRoute|Pool|pool02",
             "MissFailoverRoute|Pool|pool00",
             "MissFailoverRoute|Pool|pool01"
           ]
         }
       }
     }
    }

    Почему три пула? Почему повторяются серверы? Давайте разберемся, как это работает.

    • В данной конфигурации mcrouter выбирает путь, по которому будет отправлен запрос исходя из команды запроса. Об этом ему говорит тип OperationSelectorRoute.
    • GET-запросы попадают в обработчик RandomRoute, который случайным образом выбирает пул или маршрут среди объектов массива children. Каждый элемент этого массива в свою очередь является обработчиком MissFailoverRoute, который пройдется по каждому серверу в пуле, пока не получит ответ с данными, что и будет возвращен клиенту.
    • Если бы мы использовали исключительно MissFailoverRoute с пулом из трех серверов, то все запросы приходили бы сперва на первый экземпляр memcached, а остальные получали бы запросы по остаточному принципу, когда данные отсутствуют. Такой подход привел бы к чрезмерной нагрузке первого в списке сервера, поэтому и было решено сгенерировать три пула с адресами в разной последовательности и выбирать их случайным образом.
    • Все остальные запросы (а это запись) обрабатываются с помощью AllMajorityRoute. Данный обработчик отправляет запросы на все серверы пула и ждет ответов, как минимум, от N/2 + 1 из них. От использования AllSyncRoute для операций записи пришлось отказаться, так как данный метод требует положительного ответа от всех серверов группы — в противном случае он возвратит SERVER_ERROR. Хоть при этом mcrouter и сложит данные в доступные кэши, но вызывающая функция PHP возвратит ошибку и сгенерирует notice. AllMajorityRoute не столь строг и позволяет выводить до половины узлов из эксплуатации без вышеописанных проблем.

    Основной минус этой схемы в том, что если данных в кэше действительно нет, то на каждый запрос от клиента фактически будет выполнено N запросов к memcached — ко всем серверам в пуле. Можно сократить количество серверов в пулах, например, до двух: жертвуя надежностью хранения, мы получим большую скорость и меньшую нагрузку от запросов к отсутствующим ключам.

    NB: Полезными ссылками для изучения mcrouter могут также оказаться документация в wiki и issues проекта (в том числе и закрытые), представляющие целый кладезь различных конфигураций.

    Сборка и запуск mcrouter


    Приложение (и сам memcached) у нас работает в Kubernetes — соответственно, там же место и mcrouter. Для сборки контейнера мы используем werf, конфиг для которого будет выглядеть следующим образом:

    NB: Листинги, приведённые в статье, опубликованы в репозитории flant/mcrouter.

    configVersion: 1
    project: mcrouter
    deploy:
     namespace: '[[ env ]]'
     helmRelease: '[[ project ]]-[[ env ]]'
    ---
    image: mcrouter
    from: ubuntu:16.04
    mount:
    - from: tmp_dir
     to: /var/lib/apt/lists
    - from: build_dir
     to: /var/cache/apt
    ansible:
     beforeInstall:
     - name: Install prerequisites
       apt:
         name: [ 'apt-transport-https', 'tzdata', 'locales' ]
         update_cache: yes
     - name: Add mcrouter APT key
       apt_key:
         url: https://facebook.github.io/mcrouter/debrepo/xenial/PUBLIC.KEY
     - name: Add mcrouter Repo
       apt_repository:
         repo: deb https://facebook.github.io/mcrouter/debrepo/xenial xenial contrib
         filename: mcrouter
         update_cache: yes
     - name: Set timezone
       timezone:
         name: "Europe/Moscow"
     - name: Ensure a locale exists
       locale_gen:
         name: en_US.UTF-8
         state: present
     install:
     - name: Install mcrouter
       apt:
         name: [ 'mcrouter' ]

    (werf.yaml)

    … и набрасываем Helm-чарт. Из интересного — здесь только генератор конфига от количества реплик (если у кого-то есть более лаконичный и элегантный вариант — делитесь в комментариях):

    {{- $count := (pluck .Values.global.env .Values.memcached.replicas | first | default .Values.memcached.replicas._default | int) -}}
    {{- $pools := dict -}}
    {{- $servers := list -}}
    {{- /* Заполняем  массив двумя копиями серверов: "0 1 2 0 1 2" */ -}}
    {{- range until 2 -}}
     {{- range $i, $_ := until $count -}}
       {{- $servers = append $servers (printf "mc-%d.mc:11211" $i) -}}
     {{- end -}}
    {{- end -}}
    {{- /* Смещаясь по массиву, получаем N срезов: "[0 1 2] [1 2 0] [2 0 1]" */ -}}
    {{- range $i, $_ := until $count -}}
     {{- $pool := dict "servers" (slice $servers $i (add $i $count)) -}}
     {{- $_ := set $pools (printf "MissFailoverRoute|Pool|pool%02d" $i) $pool -}}
    {{- end -}}
    ---
    apiVersion: v1
    kind: ConfigMap
    metadata:
     name: mcrouter
    data:
     config.json: |
       {
         "pools": {{- $pools | toJson | replace "MissFailoverRoute|Pool|" "" -}},
         "route": {
           "type": "OperationSelectorRoute",
           "default_policy": "AllMajorityRoute|Pool|pool00",
           "operation_policies": {
             "get": {
               "type": "RandomRoute",
               "children": {{- keys $pools | toJson }}
             }
           }
         }
       }

    (10-mcrouter.yaml)

    Выкатываем в тестовое окружение и проверяем:

    # php -a
    Interactive mode enabled
    
    php > # Проверяем запись и чтение
    php > $m = new Memcached();
    php > $m->addServer('mcrouter', 11211);
    php > var_dump($m->set('test', 'value'));
    bool(true)
    php > var_dump($m->get('test'));
    string(5) "value"
    php > # Работает! Тестируем работу сессий:
    php > ini_set('session.save_handler', 'memcached');
    php > ini_set('session.save_path', 'mcrouter:11211');
    php > var_dump(session_start());
    PHP Warning:  Uncaught Error: Failed to create session ID: memcached (path: mcrouter:11211) in php shell code:1
    Stack trace:
    #0 php shell code(1): session_start()
    #1 {main}
      thrown in php shell code on line 1
    php > # Не заводится… Попробуем задать session_id:
    php > session_id("zzz");
    php > var_dump(session_start());
    PHP Warning:  session_start(): Cannot send session cookie - headers already sent by (output started at php shell code:1) in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
    PHP Warning:  session_start(): Unable to clear session lock record in php shell code on line 1
    PHP Warning:  session_start(): Failed to read session data: memcached (path: mcrouter:11211) in php shell code on line 1
    bool(false)
    php >

    Поиск по тексту ошибки результата не дал, однако по запросу «mcrouter php» в первых рядах значилась старейшая незакрытая проблема проекта — отсутствие поддержки бинарного протокола memcached.

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

    Дело в шляпе: осталось лишь переключиться на ASCII-протокол и всё заработает…. Однако в данном случае привычка искать ответы в документации на php.net сыграла злую шутку. Правильного ответа вы там не найдете… если, конечно, не долистаете до конца, где в секции «User contributed notes» будет верный и незаслуженно заминусованный ответ.

    Да, правильное название опции — memcached.sess_binary_protocol. Её необходимо отключить, после чего сессии начнут работать. Осталось лишь положить контейнер с mcrouter в pod с PHP!

    Заключение


    Таким образом, одними лишь инфраструктурными изменениями нам удалось решить поставленную задачу: вопрос с отказоустойчивостью memcached решен, надежность хранения кэша повышена. Помимо очевидных плюсов для приложения это дало пространство для маневра при проведении работ над платформой: когда все компоненты имеют резерв, жизнь администратора сильно упрощается. Да, этот метод имеет и свои недостатки, может выглядеть «костылем», но если он экономит деньги, хоронит проблему и не вызывает новых — почему бы и нет?

    P.S.


    Читайте также в нашем блоге:

    Флант
    DevOps-as-a-Service, Kubernetes, обслуживание 24×7

    Комментарии 6

      +1

      Memcache обычно используют как самое быстрое ключ-значение сетевое хранилище.
      На сколько выросли задержки и разброс времени ответа при использовании mcrouter?
      Что происходит с приложением во время падения mcrouter?
      Какова задержка восстановления?

        +1
        Развернутого и полномасштабного нагрузочного тестирования мы не проводили. В тестах на стенде, приближенном к боевым условиям, различий между mcrouter+memcached и plain memcached выявлено не было. Количество запросов при этом практически на 2 порядка превышало то, что выдает приложение в своей повседневной работе. Для сравнительного тестирования производительности нужно использовать другой подход и инструменты. Возможно опишу это в своей следующей статье.

        У нас было два варианта использования mcrouter:
        • StatefulSet из нескольких реплик. В таком случае запросы к mcrouter-ам равномерно балансируются средствами kubernetes, а в случае падения реплик они будут автоматически исключены из балансировки. Конечно, это происходит не мгновенно и часть запросов может пропасть.
        • sidecar-контейнер в pod с PHP — даст zero latency при обращении к mcrouter, общий localhost ведь. Но в данном случае нужна корректная liveness проба у основного приложения, чтобы в случае падения контейнера mcrouter трафик с pod-a снимался.
        +1
        отчего не tarantool c memcached wrapper и репликацией? imho, самое простое и доступное решение.
          +1
          Спасибо, мы не обратили внимания, но обязательно посмотрим на tarantool. Но пока, на первый взгляд последнему коммиту в репозитории github.com/tarantool/memcached скоро стукнет год, а внизу README красуется предупреждение: «This rock is in early beta». Хотелось придерживаться поверенных решений.
            +2
            у меня уже полтора года живёт, но к «несчастью» кластер тарантула ни разу не упал, поэтому теста в бою не было, а ручное переключение не считается. + там мониторинг через экспортёр, удобно.
        • НЛО прилетело и опубликовало эту надпись здесь

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

          Самое читаемое