使用 mcrouter 构建高可用 memcached

在其中一个项目中,我发现自己面临一个经典问题:由于高 RPS(每秒请求数)速率,关系数据库上的应用程序负载非常重。但是,从数据库中检索到的唯一数据的实际百分比相对较低。此外,缓慢的数据库响应迫使应用程序建立新的连接,进一步增加负载并造成滚雪球效应。

这个问题的解决方案很明显:数据缓存。我使用 memcached 作为缓存系统。数据检索请求首当其冲。但是,当我尝试将应用程序迁移到 Kubernetes 时,出现了一些问题。

问题

由于所选缓存方案易于扩展和透明,该应用程序受益于迁移到 K8s。但是,应用程序的平均响应时间增加了。使用 New Relic 平台进行的性能分析显示,迁移后 memcached 事务时间显着增加。

在调查了延迟增加的原因后,我意识到它们完全是由网络延迟引起的。问题是,在迁移之前,应用程序和 memcached 运行在同一个物理节点上,而在 K8s 集群中,应用程序和 memcached Pod 通常运行在不同的节点上。在这种情况下,网络延迟是不可避免的。

解决方案

免责声明:以下技术已在具有 10 个 memcached 实例的生产集群中进行了测试。请注意,我没有在任何更大的部署中尝试过。

显然,memcached 必须作为 DaemonSet 在运行应用程序的相同节点上运行。这意味着您必须配置​​节点亲和性​​。这是一个类似于生产的清单,带有探测和请求/限制:

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
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 数据以及更新数据库中缓存数据的机制。因此,我们需要一种机制,将一个节点上的应用程序实例所做的缓存更新传播到所有其他节点。为此,我们将使用​​mcrouter​​,一个用于扩展 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
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​​构建 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 会更新配置,因为它每秒会重新读取一次​​参数​​。

现在您所要做的就是通过包含 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 毫秒。

优化前:

使用 mcrouter 在 kubernetes 中构建高可用 memcached_kubernetes

优化后:

使用 mcrouter 在 kubernetes 中构建高可用 memcached_kubernetes_02

该解决方案已投入生产约六个月;在那段时间没有发现任何坑。

文章中提到的文件(Helm 图表和​​werf.yaml​​​)可以在​​examples 存储库​​中找到。

参考

https://blog.flant.com/highly-available-memcached-with-mcrouter-in-kubernetes/

关注

微信公众号【我的小碗汤】,扫左侧码关注,了解更多咨询,更有免费资源供您学习