GX博客

分享个人 Full-Stack JavaScript 项目开发经验

在Kubernetes中部署高安全级别的Redis集群和集群代理

由 Redis 官方推出的 Redis Cluster Proxy 可以很方便地解决了部署在 Kubernetes 上的 Redis 集群节点 Pod IP 不固定而引起的问题。Redis 客户端连接到 Redis 代理就像连接到普通 Redis 主机一样方便,并不需要顾虑集群节点情况。 本文将会介绍 Redis 集群基本概念,以及如何在 Kubernetes 上搭建高安全级别的 Redis 集群和集群代理。


环境及版本说明:

  • Redis v6.0.6
  • Kubeadm v1.18.4 搭建的 Kubernetes 集群
  • Redis Cluster Proxy v999.999.999 (unstable),目前官方还未推出稳定版本。

内容列表:


Redis 集群基本概念

Redis 群集化的好处主要还是提高可用性,自动将你的数据集分割在多个节点之间,当一部分节点出现故障或无法与集群的其余部分通信时,集群可以继续运行。另外一个好处就是可以提供不停机的横向伸缩能力。


Redis 群集使用的 TCP 端口

每个 Redis 群集节点都需要打开两个 TCP 连接。

  1. 用于为客户端提供服务的常规 Redis TCP 端口,默认为 6379。
  2. 用于节点间通信端口,默认是数据端口加 10000 的端口,即对应为 16379。

Redis 集群数据分片

Redis 群集中有 16384 个哈希槽,每个键从概念上讲都是哈希槽的一部分。Redis 群集中的每个节点都负责哈希槽的子集。

因为将哈希槽从一个节点移动到另一个节点不需要停止操作,所以添加和删除节点或更改节点持有的哈希槽的百分比不需要任何停机时间。

另外,用户也可以通过使用 hash tag 来强制多个键成为同一个哈希槽的一部分。


Redis 集群主从模型

Redis 集群使用 master-slave 模型来实现高可用性。每个 Redis 的 master 节点后会添加至少一个 slave 节点用于主从复制。例如,我们设置 3 个主节点 A、B、C,它们会拥有各自的哈希槽子集:

  • A 节点:0-5500
  • B 节点:5501-11000
  • C 节点:11001-16383

它们被创建后,会添加对应的 slave 节点 A1、B1、C1,并且会开始复制对应主节点的哈希槽。当 B 节点故障时,B1 节点会在节点超时时间后成为新的 master 节点继续工作。

但是如果 B 和 B1 同时故障,则 Redis 集群无法继续工作,直到 B 节点重新加入集群后,集群才能恢复可用。


Redis 群集一致性保证

Redis 群集无法保证强一致性。原因有两个:

  1. 主从复制是异步的,即使使用同步复制,也不能完全避免复制过程中主节点故障而导致的数据丢失。
  2. 当发生网络分区时,主节点的写入数据可能会丢失。例如集群有节点 A、B、C、A1、B1、C1 和客户端 Z1,发生网络分区后,可能在分区的一侧有 A、C、A1、B1、C1,而在另一侧有 B 和 Z1。 在节点超时内,Z1 仍然能够写入 B。最后多数派的一侧选举出 B1 作为主节点,少数派的 B 则进入错误状态。但在这期间 Z1 写入的数据将会丢失。

Redis 集群配置参数

Redis 集群在配置文件 redis.conf 文件中引入了一些特定的配置参数。

  • cluster-enabled<yes/no>

    是否启用 Redis 集群支持。

  • cluster-config-file<filename>

    它不是用户可编辑的配置文件,而是 Redis 集群实例在启动时生成,节点每次发生更改时进行更新。默认情况下为 nodes.conf。

  • cluster-node-timeout<milliseconds>

    节点超时时间,在指定的时间内无法到达大多数主节点的每个节点都将停止接受查询。

  • cluster-slave-validity-factor<factor>

    如果设置为 0,则从节点将始终尝试对主节点进行故障转移。

    如果设置为正数,从节点会在该正数乘以 cluster-node-timeout 的时间内尝试对主节点进行故障转移。超过这时间,从节点就不会继续尝试对主节点进行故障转移。如果没有从节点可以进行故障转移,则集群变成不可用,直到主节点恢复正常。

  • cluster-migration-barrier<count>

    主节点保持连接的最小从节点数量。这样,最小值以外的从节点可以迁移到没有从节点覆盖的其它主节点。

  • cluster-require-full-coverage<yes/no>

    yes:如果某个节点未覆盖一定比例的密钥空间,集群将停止接受写入。(默认 yes,当有密钥空间缺失时,集群停止写入。)

    no:即使仅可以处理有关密钥子集的请求,群集仍将提供操作。

  • cluster-allow-reads-when-down<yes/no>

    当集群标记为失败,或者节点无法达到大多数主节点(大多数主节点故障了),或者密钥空间未全覆盖时,节点是否停止所有流量。


集群安全策略

1、禁用 CONFIG 命令。在 redis.conf 中配置:

rename-command CONFIG ""

2、设置身份认证密码,这个密码最好足够长,以方式暴力破解。在 redis.conf 中配置:

requirepass your_long_enough_password
masterauth your_long_enough_password

3、使用非 Root 的 uid=999(redis) gid=999(redis) groups=999(redis) 用户运行容器。对应 Kubernetes 配置如下:

containers:
  securityContext:
    runAsNonRoot: true
    runAsUser: 999

4、Kubernetes 中使用 NetworkPolicy 限制其它 Pod 对 Redis Pod 的访问。(前提是你的集群网络插件支持使用 NetworkPolicy。)

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: redis-cluster-network-policy
spec:
  podSelector:
    matchLabels:
      app: redis-cluster
  ingress:
    - from:
        - podSelector:
            matchLabels:
              run: redis-client
          namespaceSelector: {}
      ports:
        - port: 6379
        - port: 16379

创建 Redis 集群

按预期工作的最小群集要求至少包含三个主节点(并且考虑"脑裂"情况,节点数目应该始终使用奇数)。下面是一个简洁的配置例子:

cluster-enabled yes
cluster-require-full-coverage no
cluster-node-timeout 5000
cluster-config-file /data/nodes.conf
cluster-migration-barrier 1
cluster-allow-reads-when-down no
requirepass your_long_enough_password
masterauth your_long_enough_password
appendonly yes
rename-command CONFIG ""

上面配置中,除了必要的常规集群配置外,还配置了:

  • 设置访问密码
  • 设置主从复制密码(同访问密码)
  • 将数据异步写入到文件
  • 禁止使用 CONFIG 命令

下面是 Kubernetes 完整的集群配置例子:

---

apiVersion: v1
kind: Namespace
metadata:
  name: redis-cluster

---

apiVersion: v1
kind: Service
metadata:
  name: redis-cluster
  namespace: redis-cluster
spec:
  type: ClusterIP
  ports:
    - port: 6379
      targetPort: 6379
      name: client
    - port: 16379
      targetPort: 16379
      name: gossip
  selector:
    app: redis-cluster

---

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: redis-cluster-network-policy
spec:
  podSelector:
    matchLabels:
      app: redis-cluster
  ingress:
    - from:
        - podSelector:
            matchLabels:
              run: redis-client
          namespaceSelector: {}
      ports:
        - port: 6379
        - port: 16379

---

apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-cluster
  namespace: redis-cluster
data:
  # update-node.sh 用于启动时,更新 /data/nodes.conf 中自身的 Pod IP
  update-node.sh: |
    #!/bin/sh
    REDIS_NODES="/data/nodes.conf"
    sed -i -e "/myself/ s/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/${POD_IP}/" ${REDIS_NODES}
    exec "$@"
  redis.conf: |
    cluster-enabled yes
    cluster-require-full-coverage no
    cluster-node-timeout 5000
    cluster-config-file /data/nodes.conf
    cluster-migration-barrier 1
    cluster-allow-reads-when-down no
    requirepass your_long_enough_password
    masterauth your_long_enough_password
    appendonly yes
    always-show-logo yes
    rename-command CONFIG ""

---

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-cluster
  namespace: redis-cluster
spec:
  serviceName: redis-cluster
  replicas: 6
  selector:
    matchLabels:
      app: redis-cluster
  template:
    metadata:
      labels:
        app: redis-cluster
        run: redis-client
    spec:
      imagePullSecrets:
        - name: aliyun-docker-registry
      volumes:
        - name: conf
          configMap:
            name: redis-cluster
            defaultMode: 0755
        - name: hugepage
          hostPath:
            path: /sys/kernel/mm/transparent_hugepage/enabled
      initContainers:
        - name: tcp-backlog
          image: busybox
          imagePullPolicy: IfNotPresent
          command: ["sh", "-c", "echo 511 > /proc/sys/net/core/somaxconn"]
          securityContext:
            privileged: true
        - name: transparent-hugepage
          image: busybox
          imagePullPolicy: IfNotPresent
          command: ["sh", "-c", "echo never > /sys/kernel/mm/transparent_hugepage/enabled"]
          securityContext:
            privileged: true
          volumeMounts:
            - name: hugepage
              mountPath: /sys/kernel/mm/transparent_hugepage/enabled
      containers:
        - name: redis
          image: registry.cn-shenzhen.aliyuncs.com/leeguangxing/redis:6.0.6
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 6379
              name: client
            - containerPort: 16379
              name: gossip
          command: ["/conf/update-node.sh", "redis-server", "/conf/redis.conf"]
          env:
            - name: POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
          volumeMounts:
            - name: conf
              mountPath: /conf
              readOnly: false
            - name: data
              mountPath: /data
              readOnly: false
          securityContext:
            runAsNonRoot: true
            runAsUser: 999
          resources:
            limits:
              cpu: 1000m
              memory: 1Gi
  volumeClaimTemplates:
    - metadata:
        name: data
        labels:
          app: redis-cluster
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: nfs-dynamic
        resources:
          requests:
            storage: 1Gi

因为每个集群节点需要保存自己集群配置文件,以及持久化数据文件,所以使用 StatefulSet 和 volumeClaim 部署。可以注意到,StatefulSet 的 template 中,使用了两个特权 initContainers,这是为了解决 Redis 给出的 TCP backlog 过低和没有禁用节点主机 THP 的警告。

根据上面配置初始化启动 Redis 实例后,会看到类似下面日志:

No cluster configuration found, I'm 25026b92dceb9b13ab6fc13994f1114e1588a117

由于 nodes.conf 文件不存在引起的,因此每个节点都会为其分配一个新的节点 ID,这个 ID 在整个生命周期中都不会变化。


使用 redis-cli 创建集群

kubectl exec -it redis-cluster-0 -n redis-cluster -- redis-cli -a 'your_long_enough_password' --cluster create --cluster-replicas 1 \
$(kubectl get pods -l app=redis-cluster -n redis-cluster -o jsonpath='{range.items[*]}{.status.podIP}:6379 ')

上面命令会在 StatefulSet 节点 0 中执行创建集群命令,Redis 集群节点 Pod IP 由 kubectl 命令动态获取。创建前会提示检查和确认,最后回复 yes 确认创建。


验证部署集群

查询集群信息:

kubectl exec -it redis-cluster-0 -n redis-cluster -- redis-cli -a 'your_long_enough_password' cluster info

在命令行中输入密码存在风险,因为可能被系统所记录,存在隐患,所以执行此命令时候会有警告。官方建议使用环境变量方式设置密码,但是个人认为这种方式也有隐患,期待官方可以推出像 MySQL 一样的密码输入方式。

查询每个节点角色信息:

for x in $(seq 0 5); do echo "redis-cluster-$x"; kubectl exec redis-cluster-$x -n redis-cluster -- redis-cli -a 'your_long_enough_password' role; echo; done

亦可在节点容器内查看集群配置文件:

cat /data/nodes.conf

测试 Redis 集群

我们可以在 kubernetes 集群内部,使用 redis-cli 客户端访问 Redis 集群(但是所在 Pod 要在符合 NetworkPolicy):

redis-cli -a 'your_long_enough_password' -c -h redis-cluster.redis-cluster.svc.cluster.local

注意到使用参数 -c 启动客户端集群模式,和使用了完整了 DNS 名称。

当你删除其中一个主节点后,若超过 cluster-node-timeout 时间新节点 Pod 才创建完毕,它将会变成从节点,它原来的从节点会提升为主节点。可以使用 redis-cli 连接后,使用下面命令查看节点情况:

cluster nodes

到这里为止,我们已经部署好了三个主节点和三个从节点的 Redis 集群了。接下来,我们校验部署 Redis 集群带来。


部署 redis-cluster-proxy

由于 redis-cluster-proxy 还没有 stable 版本,所以官方也没有推出相关镜像。我们要构建自己的 redis-cluster-proxy 镜像,以下是 Docker Hub 社区开发者提供的一份 Dockerfile:

FROM alpine:3.11 AS build

RUN apk add --no-cache gcc musl-dev linux-headers openssl-dev make git

RUN addgroup -S app && adduser -S -G app app
RUN chown -R app:app /usr/local

WORKDIR /tmp
USER app
RUN git clone https://github.com/kornrunner/redis-cluster-proxy
RUN cd redis-cluster-proxy && make install

FROM alpine:3.11 AS runtime

RUN apk add --no-cache libstdc++
RUN apk add --no-cache strace
RUN apk add --no-cache python3
RUN apk add --no-cache redis

RUN addgroup -S app && adduser -S -G app app
COPY --chown=app:app --from=build /usr/local/bin/redis-cluster-proxy /usr/local/bin/redis-cluster-proxy
RUN chmod +x /usr/local/bin/redis-cluster-proxy
RUN ldd /usr/local/bin/redis-cluster-proxy

RUN mkdir -p /usr/local/etc/redis-cluster-proxy
RUN mkdir -p /usr/local/run/redis-cluster-proxy
RUN chown -R app:app /usr/local
VOLUME /usr/local/etc/redis-cluster-proxy
VOLUME /usr/local/run/redis-cluster-proxy

# Now run in usermode
USER app
WORKDIR /home/app

ENTRYPOINT ["/usr/local/bin/redis-cluster-proxy"]
EXPOSE 7777
CMD ["redis-cluster-proxy"]

它使用多阶段构建是 Docker 17.06 版本提出的,若果 Docker 版本过低,请参考官方安装升级说明先将 Docker 升级到 17.06 或以上版本。

部署 redis-cluster-proxy 相对简单,只需要将配置以 ConfigMap 方式挂载到 Pod 中,并重写启动参数即可。下面是使用 Deployment 部署的例子:

---

apiVersion: v1
kind: Service
metadata:
  name: redis-cluster-proxy
spec:
  ports:
    - port: 7777
      targetPort: 7777
  selector:
    app: redis-cluster-proxy

---

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: redis-cluster-proxy-network-policy
spec:
  podSelector:
    matchLabels:
      app: redis-cluster-proxy
  ingress:
    - from:
        - podSelector:
            matchLabels:
              proxy: redis-proxy-client
          namespaceSelector: {}
      ports:
        - port: 7777

---

apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-proxy-conf
data:
  proxy.conf: |+
    cluster redis-cluster.redis-cluster.svc.cluster.local:6379
    auth 'your_long_enough_password'
    bind 0.0.0.0
    enable-cross-slot yes

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-cluster-proxy
  labels:
    app: redis-cluster-proxy
spec:
  replicas: 1
  minReadySeconds: 30
  revisionHistoryLimit: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  progressDeadlineSeconds: 600
  selector:
    matchLabels:
      app: redis-cluster-proxy
  template:
    metadata:
      labels:
        app: redis-cluster-proxy
        run: redis-client
    spec:
      imagePullSecrets:
        - name: aliyun-docker-registry
      initContainers:
        - name: tcp-backlog
          image: busybox
          imagePullPolicy: IfNotPresent
          command: ["sh", "-c", "echo 511 > /proc/sys/net/core/somaxconn"]
          securityContext:
            privileged: true
      volumes:
        - name: conf
          configMap:
            name: redis-proxy-conf
      containers:
        - name: redis-cluster-proxy
          image: registry.cn-shenzhen.aliyuncs.com/leeguangxing/redis-cluster-proxy:6.0.6
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 7777
              protocol: TCP
          volumeMounts:
            - name: conf
              mountPath: /data
          command: ["redis-cluster-proxy"]
          args:
            - -c
            - /data/proxy.conf
          resources:
            limits:
              cpu: 1000m
              memory: 1Gi

上面配置中,enable-cross-slot 设置为 yes,以允许跨哈希插槽查询。这些查询会破坏很多 Redis 命令的原子性设计,并且跨插槽查询并不支持所有命令。配置中还指定了集群入口的 DNS 名称和端口。

通过 Kubernetes 部署 Redis 集群可以降低部署复杂度,并且提供了更好的高可用性等优点,配合 redis-cluster-proxy 更可降低客户端连接 Redis 集群的复杂性。虽然官方仍未推出稳定版本 redis-cluster-proxy,但经验证目前版本功能已经相对稳定,相信应该不久将来就推出稳定版本。


一篇来自 rancher.com 的 Redis 进群部署文章:
https://rancher.com/blog/2019/deploying-redis-cluster
官方 Redis 集群教程:
https://redis.io/topics/cluster-tutorial
官方 Redis 集群规范:
https://redis.io/topics/cluster-spec
官方 Redis 安全说明:
https://redis.io/topics/security
官方 Redis 配置:
http://download.redis.io/redis-stable/redis.conf
官方 redis-cluster-proxy 配置:
https://github.com/RedisLabs/redis-cluster-proxy/blob/unstable/proxy.conf
社区 redis-cluster-proxy Dockerfile:
https://hub.docker.com/r/kornrunner/redis-cluster-proxy/dockerfile
Docker CentOS 环境安装和升级说明:
https://docs.docker.com/engine/install/centos/

版权声明:

本文为博主原创文章,若需转载,须注明出处,添加原文链接。

https://leeguangxing.cn/blog_post_87.html