使用 mcrouter 构建高可用 memcached
在其中一个项目中,我发现自己面临一个经典问题:由于高 RPS(每秒请求数)速率,关系数据库上的应用程序负载非常重。但是,从数据库中检索到的唯一数据的实际百分比相对较低。此外,缓慢的数据库响应迫使应用程序建立新的连接,进一步增加负载并造成滚雪球效应。
这个问题的解决方案很明显:数据缓存。我使用 memcached 作为缓存系统。数据检索请求首当其冲。但是,当我尝试将应用程序迁移到 Kubernetes 时,出现了一些问题。
问题
由于所选缓存方案易于扩展和透明,该应用程序受益于迁移到 K8s。但是,应用程序的平均响应时间增加了。使用 New Relic 平台进行的性能分析显示,迁移后 memcached 事务时间显着增加。
在调查了延迟增加的原因后,我意识到它们完全是由网络延迟引起的。问题是,在迁移之前,应用程序和 memcached 运行在同一个物理节点上,而在 K8s 集群中,应用程序和 memcached Pod 通常运行在不同的节点上。在这种情况下,网络延迟是不可避免的。
解决方案
免责声明:以下技术已在具有 10 个 memcached 实例的生产集群中进行了测试。请注意,我没有在任何更大的部署中尝试过。
显然,memcached 必须作为 DaemonSet 在运行应用程序的相同节点上运行。这意味着您必须配置节点亲和性。这是一个类似于生产的清单,带有探测和请求/限制:
apiVersionapps/v1
kindDaemonSet
metadata
namemc
labels
appmc
spec
selector
matchLabels
appmc
template
metadata
labels
appmc
spec
affinity
nodeAffinity
requiredDuringSchedulingIgnoredDuringExecution
nodeSelectorTerms
matchExpressions
key/node
operatorExists
containers
namememcached
imagememcached1.6.9
command
/bin/bash
-c
--
memcached --port=30213 --memory-limit=2048 -o modern v --conn-limit=4096 -u memcache
ports
namemc-production
containerPort30213
livenessProbe
tcpSocket
portmc-production
initialDelaySeconds30
timeoutSeconds5
readinessProbe
tcpSocket
portmc-production
initialDelaySeconds5
timeoutSeconds1
resources
requests
cpu100m
memory2560Mi
limits
memory2560Mi
---
apiVersionv1
kindService
metadata
namemc
spec
selector
appmc
clusterIPNone
publishNotReadyAddressestrue
ports
namemc-production
port30213
就我而言,应用程序还需要缓存一致性。换句话说,所有缓存实例中的数据必须与数据库中的数据相同。该应用程序具有用于更新 memcached 数据以及更新数据库中缓存数据的机制。因此,我们需要一种机制,将一个节点上的应用程序实例所做的缓存更新传播到所有其他节点。为此,我们将使用mcrouter,一个用于扩展 memcached 部署的 memcached 协议路由器。
将 mcrouter 添加到集群
Mcrouter 也应该作为 DaemonSet 运行,以加快读取缓存数据的速度。因此,我们可以保证 mcrouter 连接到最近的 memcached 实例(即,在同一个节点上运行)。基本方法是在 memcached Pod 中将 mcrouter 作为 sidecar 容器运行。在这种情况下,mcrouter 可以连接到最近的 memcached 实例 127.0.0.1。
但是,为了提高容错性,最好将 mcrouter 放在单独的 DaemonSet 中,同时启用hostNetworkmemcached 和 mcrouter。此设置可确保任何 memcached 实例的任何问题都不会影响应用程序的缓存可用性。同时,您可以独立重新部署memcached和mcrouter,从而提高整个系统在此类操作过程中的容错能力。
让我们添加hostNetwork: true到清单中以使 memcached 能够使用hostNetwork.
我们还向 memcached 容器添加一个环境变量,其中包含运行 Pod 的主机的 IP 地址:
env
nameHOST_IP
valueFrom
fieldRef
fieldPathstatus.hostIP
同时修改memcached启动命令,使端口只监听到内部clusterIP::
command
/bin/bash
-c
--
memcached --listen=$HOST_IP --port=30213 --memory-limit=2048 -o modern v --conn-limit=4096 -u memcache
现在对 mcrouter 的 DaemonSet 做同样的事情(它的 Pod 也必须使用hostNetwork):
apiVersionapps/v1
kindDaemonSet
metadata
namemcrouter
labels
appmcrouter
spec
selector
matchLabels
appmcrouter
template
metadata
labels
appmcrouter
spec
affinity
nodeAffinity
requiredDuringSchedulingIgnoredDuringExecution
nodeSelectorTerms
matchExpressions
key/node
operatorExists
hostNetworktrue
imagePullSecrets
name"registrysecret"
containers
namemcrouter
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
nameconfig
mountPath/mnt/config
ports
namemcr-production
containerPort30213
livenessProbe
tcpSocket
portmcr-production
initialDelaySeconds30
timeoutSeconds5
readinessProbe
tcpSocket
portmcr-production
initialDelaySeconds5
timeoutSeconds1
resources
requests
cpu300m
memory100Mi
limits
memory100Mi
env
nameHOST_IP
valueFrom
fieldRef
fieldPathstatus.hostIP
volumes
configMap
namemcrouter
namemcrouter
nameconfig
emptyDir
由于 mcrouter 使用hostNetwork,我们也将其限制为监听节点的内部 IP 地址。
下面是使用werf构建 mcrouter 镜像的配置文件,您可以轻松地将其转换为常规 Dockerfile:
imagemcrouter
fromubuntu18.04
mount
fromtmp_dir
to/var/lib/apt/lists
ansible
beforeInstall
nameInstall prerequisites
apt
name
apt-transport-https
apt-utils
dnsutils
gnupg
tzdata
locales
update_cacheyes
nameAdd mcrouter APT key
apt_key
urlhttps//facebook.github.io/mcrouter/debrepo/bionic/PUBLIC.KEY
nameAdd mcrouter Repo
apt_repository
repodeb https//facebook.github.io/mcrouter/debrepo/bionic bionic contrib
filenamemcrouter
update_cacheyes
nameSet timezone
timezone
name"Europe/London"
nameEnsure a locale exists
locale_gen
nameen_US.UTF-8
statepresent
install
nameInstall mcrouter
apt
name
mcrouter
现在让我们继续 mcrouter 配置。我们必须在特定节点上调度 Pod 后即时生成它,以将该节点的地址设置为主节点。为此,您需要在 mcrouter Pod 中运行一个 init 容器。它将生成配置文件并将其保存到共享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 容器中运行的配置生成器脚本的示例:
apiVersionv1
kindConfigMap
metadata
namemcrouter
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 发送请求,获取 memcached Pod 的所有 IP 地址,并生成它们的列表。列表中的第一个是运行此特定 mcrouter 实例的节点的 IP 地址。
请注意,您必须在上面的 memcached 服务清单中进行设置clusterIP: None,才能在请求 DNS 记录时获取 Pod 地址。
下面是脚本生成的文件示例:
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 实例。
注意。如果没有严格的缓存一致性要求,我们建议使用AllMajorityRoute甚至AllFastestRoute路由句柄来代替,AllSyncRoute以获得更快的性能和对集群不稳定性的一般敏感性。
适应集群不断变化的性质
但是,确实存在一个麻烦:集群不是静态的,集群中工作节点的数量可能会发生变化。如果集群节点的数量增加,则无法保持缓存一致性,因为:
- 会有新的 memcached/mcrouter 实例;
- 新的 mcrouter 实例将写入旧的memcached 实例;同时,旧的 mcrouter 实例将不知道有新的 memcached 实例可用。
同时,如果节点数量减少(前提是启用了 AllSyncRoute 策略),节点缓存本质上会变成只读的。
可能的解决方法是在 mcrouter Pod 中运行带有 cron 的 sidecar 容器,该容器将验证节点列表并应用更改。
下面是这样一个sidecar的配置:
namecron
image .Values.werf.image.cron
command
/usr/local/bin/dumb-init
/bin/sh
-c
/usr/local/bin/supercronic -json /app/crontab
volumeMounts
namemcrouter
mountPath/mnt/config/config_generator.sh
subPathconfig_generator.sh
namemcrouter
mountPath/mnt/config/check_nodes.sh
subPathcheck_nodes.sh
namemcrouter
mountPath/app/crontab
subPathcrontab
nameconfig
mountPath/mnt/config
resources
limits
memory64Mi
requests
memory64Mi
cpu5m
env
nameHOST_IP
valueFrom
fieldRef
fieldPathstatus.hostIP
在这个cron容器中运行的脚本调用了init容器中使用的config_generator.sh脚本:
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 生成配置文件。当配置文件的校验和发生变化时,更新的文件会保存到emptyDir共享目录中,以便 mcrouter 可以使用它。您不必担心 mcrouter 会更新配置,因为它每秒会重新读取一次参数。
现在您所要做的就是通过包含 memcached 地址的环境变量将 Node 的 IP 地址传递给应用程序 Pod,同时指定 mcrouter 端口而不是 memcached 端口:
env
nameMEMCACHED_HOST
valueFrom
fieldRef
fieldPathstatus.hostIP
nameMEMCACHED_PORT
value31213
总结一下
最终目标已经实现:应用程序现在运行得更快。New Relic 数据显示,处理用户请求的 memcached 事务时间已从 70-80 毫秒降至约 20 毫秒。
优化前:

优化后:

该解决方案已投入生产约六个月;在那段时间没有发现任何坑。
文章中提到的文件(Helm 图表和werf.yaml)可以在examples 存储库中找到。
参考
https://blog.flant.com/highly-available-memcached-with-mcrouter-in-kubernetes/
关注
微信公众号【我的小碗汤】,扫左侧码关注,了解更多咨询,更有免费资源供您学习
















