使用 mcrouter 构建高可用 memcached
在近一个项目中,我发现自己面临一个经典问题:由于高 RPS(每秒请求数)速率,关系数据库上的应用程序负载非常重。但是,从数据库中检索到的数据的实际百分比相对较低。此外,缓慢的数据库响应迫使应用程序建立新的连接,进一步增加负载并造成滚雪球效应。
这个问题的解决方案很明显:数据缓存。我使用 memcached 作为缓存系统。数据检索请求首当其冲。但是,当我尝试将应用程序迁移到 Kubernetes 时,出现了一些问题。
问题
由于所选缓存方案易于扩展和透明,该应用程序受益于迁移到 K8s。但是,应用程序的平均响应时间增加了。使用 New Relic 平台进行的性能分析显示,迁移后 memcached 事务时间显着增加。
在调查了延迟增加的原因后,我意识到它们完全是由网络延迟引起的。问题是,在迁移之前,应用程序和 memcached 运行在同一个物理节点上,而在 K8s 集群中,应用程序和 memcached Pod 通常运行在不同的节点上。在这种情况下,网络延迟是不可避免的。
解决方案
免责声明:以下技术已在具有 10 个 memcached 实例的生产集群中进行了测试。请注意,我没有在任何更大的部署中尝试过。
显然,memcached 必须作为 DaemonSet 在运行应用程序的相同节点上运行。这意味着您必须配置节点亲和性[1]。这是一个类似于生产的清单,带有探测和请求/限制:
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
就我而言,应用程序还需要缓存一致性[2]。换句话说,所有缓存实例中的数据必须与数据库中的数据相同。该应用程序具有用于更新 memcached 数据以及更新数据库中缓存数据的机制。因此,我们需要一种机制,将一个节点上的应用程序实例所做的缓存更新传播到所有其他节点。为此,我们将使用mcrouter[3],一个用于扩展 memcached 部署的 memcached 协议路由器。
将 mcrouter 添加到集群
Mcrouter 也应该作为 DaemonSet 运行,以加快读取缓存数据的速度。因此,我们可以保证 mcrouter 连接到近的 memcached 实例(即,在同一个节点上运行)。基本方法是在 memcached Pod 中将 mcrouter 作为 sidecar 容器运行。在这种情况下,mcrouter 可以连接到近的 memcached 实例 127.0.0.1。
但是,为了提高容错性,好将 mcrouter 放在单独的 DaemonSet 中,同时启用hostNetwork
memcached 和 mcrouter。此设置可确保任何 memcached 实例的任何问题都不会影响应用程序的缓存可用性。同时,您可以独立重新部署 memcached 和 mcrouter,从而提高整个系统在此类操作过程中的容错能力。
让我们添加hostNetwork: true
到清单中以使 memcached 能够使用hostNetwork
.
我们还向 memcached 容器添加一个环境变量,其中包含运行 Pod 的主机的 IP 地址:
env:
- name: HOST_IP
valueFrom:
fieldRef:
fieldPath: status.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
):
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 地址。
下面是使用werf[4]构建 mcrouter 镜像的配置文件,您可以轻松地将其转换为常规 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/London"
- name: Ensure a locale exists
locale_gen:
name: en_US.UTF-8
state: present
install:
- name: Install 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 容器中运行的配置生成器脚本的示例:
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 发送请求,获取 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 的配置:
- 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 容器中运行的脚本调用了 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 会更新配置,因为它每秒会重新读取一次参数[5]。
现在您所要做的就是通过包含 memcached 地址的环境变量将 Node 的 IP 地址传递给应用程序 Pod,同时指定 mcrouter 端口而不是 memcached 端口:
env:
- name: MEMCACHED_HOST
valueFrom:
fieldRef:
fieldPath: status.hostIP
- name: MEMCACHED_PORT
value: 31213
总结一下
终目标已经实现:应用程序现在运行得更快。New Relic 数据显示,处理用户请求的 memcached 事务时间已从 70-80 毫秒降至约 20 毫秒。
优化前:
优化后:
该解决方案已投入生产约六个月;在那段时间没有发现任何坑。
文章中提到的文件(Helm 图表和werf.yaml
)可以在examples 存储库[6]中找到。
参考资料
节点亲和性: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
[2]缓存一致性: https://en.wikipedia.org/wiki/Cache_coherence
[3]mcrouter: https://github.com/facebook/mcrouter
[4]werf: https://werf.io/
[5]参数: https://github.com/facebook/mcrouter/blob/168a35060ac504c4169896ca38f90354250c0bfb/mcrouter/mcrouter_options_list.h#L539
[6]examples 存储库: https://github.com/flant/examples/tree/master/2021/09-memcached-mcrouter
相关文章