使用 mcrouter 构建高可用 memcached

2022-04-13 00:00:00 节点 缓存 运行 应用程序 实例

在近一个项目中,我发现自己面临一个经典问题:由于高 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]中找到。

参考资料

[1]

节点亲和性: 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

相关文章