Використовуємо 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 поверне помилку і згенерує 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 вирішено, надійність зберігання кешу підвищена. Крім очевидних плюсів додатку це дало простір для маневру під час проведення робіт над платформою: коли всі компоненти мають резерв, життя адміністратора сильно спрощується. Так, цей метод має свої недоліки, може виглядати «милицею», але якщо він економить гроші, ховає проблему і не викликає нових — чому б і ні?

PS

Читайте також у нашому блозі:

Джерело: habr.com

Додати коментар або відгук