Готовим высокодоступный memcached с mcrouter в Kubernetes

image-loader.svg

В одном из проектов мне пришлось столкнуться с классической ситуацией: нагрузка со стороны приложения на реляционную БД была чрезвычайно высока из-за большого RPS (requests per second). Однако реальный процент уникальных данных, извлекаемых приложением из БД, был относительно невелик. К тому же, медленный ответ БД порождал рост числа подключений к ней со стороны приложения — это еще больше увеличивало нагрузку и вызвало эффект снежного кома.

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

Проблема

После миграции в K8s проект выиграл в целом за счет легкости масштабирования и прозрачности работы выбранной схемы с кэшированием. Однако средняя отзывчивость приложения снизилась. Анализ производительности средствами New Relic показал, что после переезда заметно выросло время, которое приложение стало проводить в memcached.

Я стал изучать причину возросших задержек и понял, что они связаны исключительно с сетевой конфигурацией. Если раньше приложение и memcached находились на одном физическом узле, то в K8s-кластере Pod с приложением и Pod с memcached чаще всего оказывались на разных узлах. В таких случаях неизбежны сетевые задержки.

Решение

NB. Предложенная ниже методика проверена в production-кластере с 10 экземплярами memcached. На более масштабных инсталляциях решение не проверялось.

Очевидно, что memcached необходимо запускать в DaemonSet на тех же узлах, на которых работает приложение, а значит — потребуется настройка node affinity. Чтобы конфигурация была интересной, прикладываю приближенный к production листинг, в котором можно также увидеть probes и requests/limits:

apiVersion: apps/v1
kind: DaemonSet
metadata:
 name: mc
 labels:
   app: mc
spec:
 selector:
   matchLabels:
     app: mc
 template:
   metadata:
     labels:
       app: mc
   spec:
     affinity:
       nodeAffinity:
         requiredDuringSchedulingIgnoredDuringExecution:
           nodeSelectorTerms:
           - matchExpressions:
             - key: node-role.kubernetes.io/node
               operator: Exists
     containers:
     - name: memcached
       image: memcached:1.6.9
       command:
       - /bin/bash
       - -c
       - --
       - memcached --port=30213 --memory-limit=2048 -o modern v --conn-limit=4096 -u memcache
       ports:
       - name: mc-production
         containerPort: 30213
       livenessProbe:
         tcpSocket:
           port: mc-production
         initialDelaySeconds: 30
         timeoutSeconds: 5
       readinessProbe:
         tcpSocket:
           port: mc-production
         initialDelaySeconds: 5
         timeoutSeconds: 1
       resources:
         requests:
           cpu: 100m
           memory: 2560Mi
         limits:
           memory: 2560Mi
---
apiVersion: v1
kind: Service
metadata:
 name: mc
spec:
 selector:
   app: mc
 clusterIP: None
 publishNotReadyAddresses: true
 ports:
 - name: mc-production
   port: 30213

Но у приложения есть дополнительное требование к когерентности кэша. Данные во всех экземплярах кэша должны точно соответствовать данным в реляционной БД. В приложении есть механизм, который в обязательном порядке при обновлении в БД кэшируемых данных также обновляет их и в memcached. Следовательно, нам необходимо обеспечить механизм, который транслировал бы обновления кэша, произведенные экземпляром приложения на одном из узлов, на все остальные узлы. Для этого в качестве прослойки между приложением и memcached прекрасно подходит mcrouter — маршрутизатор для масштабирования memcached-инсталляций. Мы уже даже писали о нем статью.

Добавляем в кластер mcrouter

Чтобы ускорить чтение данных из кэша, mcrouter тоже нужно запустить как DaemonSet. Так mcrouter будет «знать», какой из экземпляров memcached — ближайший, т. е. запущен на его узле. Для этого mcrouter можно поместить sidecar-контейнером в Pod«ы с memcached. Тогда ближайший memcached для mcrouter«a будет находиться по адресу 127.0.0.1.

Но чтобы повысить отказоустойчивость, лучше выделить mcrouter в отдельный DaemonSet и вынести memcached и mcrouter в hostNetwork. При таком разделении любые проблемы с каким-либо экземпляром memcached не повлияют на доступность кэша для приложения. Перевыкат как для memcached, так и для mcrouter можно выполнять раздельно, что повышает отказоустойчивость всей системы при таких операциях.

Чтобы выделить memcached в hostNetwork, добавим в манифест: hostNetwork: true.

Также добавим переменную окружения с IP-адресом узла, на котором запущен Pod, в контейнер с memcached:

       env:
       - name: HOST_IP
         valueFrom:
           fieldRef:
             fieldPath: status.hostIP

И модифицируем команду запуска memcached, чтобы порт был открыт только на внутреннем IP кластера:

       command:
       - /bin/bash
       - -c
       - --
       - memcached --listen=$HOST_IP --port=30213 --memory-limit=2048 -o modern v --conn-limit=4096 -u memcache

Аналогично опишем DaemonSet mcrouter«a, Pod«ы которого также должны запускаться в hostNetwork:

apiVersion: apps/v1
kind: DaemonSet
metadata:
 name: mcrouter
 labels:
   app: mcrouter
spec:
 selector:
   matchLabels:
     app: mcrouter
 template:
   metadata:
     labels:
       app: mcrouter
   spec:
     affinity:
       nodeAffinity:
         requiredDuringSchedulingIgnoredDuringExecution:
           nodeSelectorTerms:
           - matchExpressions:
             - key: node-role.kubernetes.io/node
               operator: Exists
     hostNetwork: true
     imagePullSecrets:
     - name: "registrysecret"
     containers:
     - name: mcrouter
       image: {{ .Values.werf.image.mcrouter }}
       command:
       - /bin/bash
       - -c
       - --
       - mcrouter --listen-addresses=$HOST_IP --port=31213 --config-file=/mnt/config/config.json --stats-root=/mnt/config/
       volumeMounts:
       - name: config
         mountPath: /mnt/config
       ports:
       - name: mcr-production
         containerPort: 30213
       livenessProbe:
         tcpSocket:
           port: mcr-production
         initialDelaySeconds: 30
         timeoutSeconds: 5
       readinessProbe:
         tcpSocket:
           port: mcr-production
         initialDelaySeconds: 5
         timeoutSeconds: 1
       resources:
         requests:
           cpu: 300m
           memory: 100Mi
         limits:
           memory: 100Mi
       env:
       - name: HOST_IP
         valueFrom:
           fieldRef:
             fieldPath: status.hostIP
     volumes:
     - configMap:
         name: mcrouter
       name: mcrouter
     - name: config
       emptyDir: {}

Поскольку mcrouter тоже запущен в hostNetwork, для него также сделано ограничение, чтобы порт открывался только на внутреннем IP узлов.

Вариант сборки mcrouter, как мы это делаем с помощью werf (не составит труда переписать на обычный Dockerfile, если такая необходимость есть):

image: mcrouter
from: ubuntu:18.04
mount:
- from: tmp_dir
 to: /var/lib/apt/lists
ansible:
beforeInstall:
- name: Install prerequisites
  apt:
    name:
    - apt-transport-https
    - apt-utils
    - dnsutils
    - gnupg
    - tzdata
    - locales
    update_cache: yes
- name: Add mcrouter APT key
  apt_key:
    url: https://facebook.github.io/mcrouter/debrepo/bionic/PUBLIC.KEY
- name: Add mcrouter Repo
  apt_repository:
    repo: deb https://facebook.github.io/mcrouter/debrepo/bionic bionic 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

И самое интересное — это конфигурационный файл mcrouter. Он должен генерироваться на лету, при запуске Pod«а на конкретном узле, чтобы подставить адрес «своего» узла как приоритетный для чтения. Для этого необходим init-контейнер в Pod«е с mcrouter«ом, который генерирует конфигурационный файл и подкладывает его в общий volume в emptyDir:

     initContainers:
     - name: init
       image: {{ .Values.werf.image.mcrouter }}
       command:
       - /bin/bash
       - -c
       - /mnt/config/config_generator.sh /mnt/config/config.json
       volumeMounts:
       - name: mcrouter
         mountPath: /mnt/config/config_generator.sh
         subPath: config_generator.sh
       - name: config
         mountPath: /mnt/config
       env:
       - name: HOST_IP
         valueFrom:
           fieldRef:
             fieldPath: status.hostIP

Вот так может выглядеть сам генератор конфигурационного файла, который выполняется в init-контейнере:

apiVersion: v1
kind: ConfigMap
metadata:
 name: mcrouter
data:
 config_generator.sh: |
   #!/bin/bash
   set -e
   set -o pipefail
 
   config_path=$1;
   if [ -z "${config_path}" ]; then echo "config_path isn't specified"; exit 1; fi
 
   function join_by { local d=$1; shift; local f=$1; shift; printf %s "$f" "${@/#/$d}"; }
 
   mapfile -t ips < <( host mc.production.svc.cluster.local 10.222.0.10 | grep mc.production.svc.cluster.local | awk '{ print $4; }' | sort | grep -v $HOST_IP )
 
   delimiter=':30213","'
 
   servers='"'$(join_by $delimiter $HOST_IP "${ips[@]}")':30213"'
 
   cat <<< '{
     "pools": {
       "A": {
         "servers": [
           '$servers'
         ]
       }
     },
     "route": {
       "type": "OperationSelectorRoute",
       "operation_policies": {
         "add": "AllSyncRoute|Pool|A",
         "delete": "AllSyncRoute|Pool|A",
         "get": "FailoverRoute|Pool|A",
         "set": "AllSyncRoute|Pool|A"
       }
     }
   }
   ' > $config_path

Скрипт обращается к внутреннему DNS в K8s-кластере, получает все IP-адреса для Pod«ов memcached и формирует список адресов. Первый в списке — IP-адрес того узла, на котором запущен наш экземпляр mcrouter.

Обратите внимание! Для того, чтобы при обращении к DNS были получены адреса Pod«ов, в приведенном выше манифесте Service для memcached указана спецификация clusterIP: None.

Результат работы скрипта:

cat /mnt/config/config.json
{
  "pools": {
    "A": {
      "servers": [
"192.168.100.33:30213","192.168.100.14:30213","192.168.100.15:30213","192.168.100.16:30213","192.168.100.21:30213","192.168.100.22:30213","192.168.100.23:30213","192.168.100.34:30213"
      ]
    }
  },
  "route": {
    "type": "OperationSelectorRoute",
    "operation_policies": {
      "add": "AllSyncRoute|Pool|A",
      "delete": "AllSyncRoute|Pool|A",
      "get": "FailoverRoute|Pool|A",
      "set": "AllSyncRoute|Pool|A"
    }
  }
}

Так мы обеспечиваем синхронизацию записи изменений на все экземпляры memcached и приоритетное чтение со «своего» узла.

NB. Если строгого требования к когерентности кэша нет, то для большей скорости работы и меньшей чувствительности к нестабильности кластера в целом рекомендуется вместо AllSyncRoute использовать дескриптор маршрута AllMajorityRoute или даже AllFastestRoute.

Поправка на ветер

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

  • Появятся новые экземпляры memcached и mcrouter.

  • Новые экземпляры mcrouter будут писать в старые экземпляры memcached. А старые экземпляры mcrouter о новых экземплярах memcached не узнают.

А в случае уменьшения числа узлов — при условии использовании в mcrouter политики AllSyncRoute — кэш на узлах фактически перейдет в режим read-only.

Вариант решения: в Pod«е с mcrouter«ом сделать sidecar-контейнер с cron«ом, по которому бы делалась проверка списка узлов и применялись изменения.

Конфигурация sidecar«а:

     - name: cron
       image: {{ .Values.werf.image.cron }}
       command:
       - /usr/local/bin/dumb-init
       - /bin/sh
       - -c
       - /usr/local/bin/supercronic -json /app/crontab
       volumeMounts:
       - name: mcrouter
         mountPath: /mnt/config/config_generator.sh
         subPath: config_generator.sh
       - name: mcrouter
         mountPath: /mnt/config/check_nodes.sh
         subPath: check_nodes.sh
       - name: mcrouter
         mountPath: /app/crontab
         subPath: crontab
       - name: config
         mountPath: /mnt/config
       resources:
         limits:
           memory: 64Mi
         requests:
           memory: 64Mi
           cpu: 5m
       env:
       - name: HOST_IP
         valueFrom:
           fieldRef:
             fieldPath: status.hostIP

Скрипты, работающие в этом cron«е, вызывают тот же самый config_generator.sh, который используется в init-контейнере:

 crontab: |
   # Check nodes in cluster
   * * * * * * *   /mnt/config/check_nodes.sh /mnt/config/config.json
 
 check_nodes.sh: |
   #!/usr/bin/env bash
   set -e
 
   config_path=$1;
   if [ -z "${config_path}" ]; then echo "config_path isn't specified"; exit 1; fi
 
   check_path="${config_path}.check"
 
   checksum1=$(md5sum $config_path | awk '{print $1;}')
 
   /mnt/config/config_generator.sh $check_path
 
   checksum2=$(md5sum $check_path | awk '{print $1;}')
 
   if [[ $checksum1 == $checksum2 ]]; then
       echo "No changes for nodes."
       exit 0;
   else
       echo "Node list was changed."
       mv $check_path $config_path
       echo "mcrouter is reconfigured."
   fi

Раз в секунду вызывается скрипт, который генерирует конфигурационный файл для mcrouter. При изменении контрольной суммы конфигурационного файла обновленный файл подкладывается mcrouter«у через общий между контейнерами каталог в emptyDir. Дополнительно заставлять mcrouter обновлять конфигурацию не требуется, т. к. он сам перечитывает свой конфигурационный файл раз в секунду.

Теперь осталось только в Pod«е с приложением указать IP-адрес самого узла — в переменной окружения, в которой указывается адрес memcached. А в качестве порта memcached указать порт mcrouter«a:

       env:
       - name: MEMCACHED_HOST
         valueFrom:
           fieldRef:
             fieldPath: status.hostIP
       - name: MEMCACHED_PORT
         value: 31213

Результат

В итоге цель проекта была достигнута: удалось заметно ускорить работу приложения. По данным New Relic, время взаимодействия приложения с memcached в процессе обработки пользовательского запроса сократилось с 70–80 мс до ~20 мс.

Состояние до оптимизации:

image-loader.svg

После оптимизации:

image-loader.svg

Решение применяется в production примерно полгода, и за это время подводных камней не всплыло.

Итоговые листинги, упомянутые в статье (Helm-чарты и werf.yaml), доступны в репозитории flant/examples.

P.S.

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

© Habrahabr.ru