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

от автора

В одном из проектов мне пришлось столкнуться с классической ситуацией: нагрузка со стороны приложения на реляционную БД была чрезвычайно высока из-за большого 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 мс.

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

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

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

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

P.S.

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


ссылка на оригинал статьи https://habr.com/ru/articles/575656/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *