Използваме mcrouter за хоризонтално мащабиране на memcached

Използваме 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 ще върне грешка и ще генерира уведомление. AllMajorityRoute не е толкова строг и позволява извеждането до половината възли от експлоатация без по-гореописаните проблеми.

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

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

Сборка и стартиране на 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: Инсталиране на предварителни зависимости
   apt:
     name: [ 'apt-transport-https', 'tzdata', 'locales' ]
     update_cache: yes
 - name: Добавяне на APT ключа за mcrouter
   apt_key:
     url: https://facebook.github.io/mcrouter/debrepo/xenial/PUBLIC.KEY
 - name: Добавяне на хранилище на mcrouter
   apt_repository:
     repo: deb https://facebook.github.io/mcrouter/debrepo/xenial xenial contrib
     filename: mcrouter
     update_cache: yes
 - name: Настройване на времевата зона
   timezone:
     name: "Europe/Moscow"
 - name: Уверете се, че локалът съществува
   locale_gen:
     name: en_US.UTF-8
     state: present
 install:
 - name: Инсталиране на 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|poold" $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.

Прочетете също в нашия блог:

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster