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