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