天天看點

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

Redis 是一個高性能的 key-value 存儲系統,被廣泛用于微服務架構中。如果我們想要使用 Redis 叢集模式提供的進階特性,則需要對用戶端代碼進行改動,這帶來了應用更新和維護的一些困難。利用 Istio 和 Envoy ,我們可以在不修改用戶端代碼的前提下實作用戶端無感覺的 Redis Cluster 資料分片,并提供讀寫分離、流量鏡像等進階流量管理功能。

Redis Cluster

Redis 的一個常見用途是用作資料高速緩存。通過在應用伺服器和資料庫伺服器之間加入一個 Redis 緩存層,可以減少應用伺服器對資料庫的大量讀操作,避免資料庫伺服器在大壓力下響應緩慢甚至當機的風險,顯著加強整個系統的健壯性。Redis 作為資料緩存的原理如圖所示:

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

在一個小規模的系統中,上圖所示的單個 Redis 就可以很好地實作緩存層的功能。當系統中需要緩存的資料量較大時,一個 Redis 伺服器無法承擔所有應用伺服器的緩存需求;同時單個 Redis 執行個體失效時也會導緻大量讀請求被直接發送到後端的資料庫伺服器上,導緻資料庫伺服器瞬時壓力超标,影響系統的穩定性。我們可以采用 Redis Cluster 來對緩存資料進行分片,将不同的資料放到不同的 Redis 分片中,以提高 Redis 緩存層的容量能力。在每個 Redis 分片中,還可以采用多個 replica 節點對緩存的讀請求進行負載分擔,并實作 Redis 的高可用。采用了 Redis Cluster 的系統如下圖所示:

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

從圖中可以看到,在 Redis Cluster 模式下,用戶端需要根據叢集的分片規則将不同 key 的讀寫操作發送到叢集中不同的 Redis 節點上,是以用戶端需要了解 Redis Cluster 的拓撲結構,這導緻我們無法在不修改用戶端的情況下将一個使用 Redis 獨立節點模式的應用平滑遷移到 Redis Cluster 上。另外,由于用戶端需要了解 Redis Cluster 的内部拓撲,也将導緻用戶端代碼和 Redis Cluster 運維上的耦合,例如要實作讀寫分離或者流量鏡像的話,就需要修改每個用戶端的代碼并重新部署。

這種場景下,我們可以在應用伺服器和 Redis Cluster 之間放置一個 Envoy 代理伺服器,由 Envoy 來負責将應用發出的緩存讀寫請求路由到正确的 Redis 節點上。一個微服務系統中存在大量需要通路緩存伺服器的應用程序,為了避免單點故障和性能瓶頸,我們以 Sidecar 的形式為每個應用程序部署一個 Envoy 代理。同時,為了簡化對這些代理的管理工作,我們可以采用 Istio 作為控制面來統一對所有 Envoy 代理進行配置,如下圖所示:

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

在本文的後續部分,我們将介紹如何通過 Istio 和 Envoy 來管理 Redis Cluster,實作用戶端無感覺的資料分區,以及讀寫分離、流量鏡像等進階路由政策。

部署 Istio

Pilot 中已經支援了 Redis 協定,但功能較弱,隻能為 Redis 代理配置一個預設路由,而且不支援 Redis Cluster 模式,無法實作 Redis filter 的資料分片、讀寫分離、流量鏡像等進階流量管理功能。為了讓 Istio 可以将 Redis Cluster 相關的配置下發到 Envoy Sidecar 上,我們修改了 EnvoyFilter 配置相關代碼,以支援 EnvoyFilter 的 "REPLCAE" 操作。該修改的 PR Implement REPLACE operation for EnvoyFilter patch 已經送出到 Istio 社群,并合入到了主分支中,将在 Istio 後續的版本中釋出。

在撰寫本文的時候,最新的 Istio 釋出版本 1.7.3 中尚未合入該 PR。是以我建構了一個 Pilot 鏡像,以啟用 EnvoyFilter 的 "REPLACE" 操作。在安裝 Istio 時,我們需要在 istioctl 指令中指定采用該 Pilot 鏡像,如下面的指令行所示:

$ cd istio-1.7.3/bin
$ ./istioctl install --set components.pilot.hub=zhaohuabing --set components.pilot.tag=1.7.3-enable-ef-replace           
備注:如果你采用的 Istio 版本新于 1.7.3,并且已經合入了該 PR,則可以直接采用 Istio 版本中預設的 Pilot 鏡像。

部署 Redis Cluster

請從 https://github.com/zhaohuabing/istio-redis-culster 下載下傳下面示例中需要用到的相關代碼:

$ git clone https://github.com/zhaohuabing/istio-redis-culster.git
$ cd istio-redis-culster           

我們建立一個 "redis" namespace 來部署本例中的 Redis Cluster。

$ kubectl create ns redis
namespace/redis created           

部署 Redis 伺服器的 Statefulset 和 Configmap。

$ kubectl apply -f k8s/redis-cluster.yaml -n redis
configmap/redis-cluster created
statefulset.apps/redis-cluster created
service/redis-cluster created           

驗證 Redis 部署

确認 Redis 節點已經啟動并正常運作:

$ kubectl get pod -n redis
NAME              READY   STATUS    RESTARTS   AGE
redis-cluster-0   2/2     Running   0          4m25s
redis-cluster-1   2/2     Running   0          3m56s
redis-cluster-2   2/2     Running   0          3m28s
redis-cluster-3   2/2     Running   0          2m58s
redis-cluster-4   2/2     Running   0          2m27s
redis-cluster-5   2/2     Running   0          117s           

建立 Redis Cluster

在上面的步驟中,我們采用 Statefulset 部署了6個 Redis 節點,但目前這6個節點還是互相獨立的,并未形成一個叢集。下面我們采用 Redis 的

cluster create

指令将這些節點組成一個 Redis Cluster。

$ kubectl exec -it redis-cluster-0 -n redis -- redis-cli --cluster create --cluster-replicas 1 $(kubectl get pods -l app=redis-cluster -o jsonpath='{range.items[*]}{.status.podIP}:6379 ' -n redis)
Defaulting container name to redis.
Use 'kubectl describe pod/redis-cluster-0 -n redis' to see all of the containers in this pod.
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 172.16.0.72:6379 to 172.16.0.138:6379
Adding replica 172.16.0.201:6379 to 172.16.1.52:6379
Adding replica 172.16.0.139:6379 to 172.16.1.53:6379
M: 8fdc7aa28a6217b049a2265b87bff9723f202af0 172.16.0.138:6379
   slots:[0-5460] (5461 slots) master
M: 4dd6c1fecbbe4527e7d0de61b655e8b74b411e4c 172.16.1.52:6379
   slots:[5461-10922] (5462 slots) master
M: 0b86a0fbe76cdd4b48434b616b759936ca99d71c 172.16.1.53:6379
   slots:[10923-16383] (5461 slots) master
S: 94b139d247e9274b553c82fbbc6897bfd6d7f693 172.16.0.139:6379
   replicates 0b86a0fbe76cdd4b48434b616b759936ca99d71c
S: e293d25881c3cf6db86034cd9c26a1af29bc585a 172.16.0.72:6379
   replicates 8fdc7aa28a6217b049a2265b87bff9723f202af0
S: ab897de0eca1376558e006c5b0a49f5004252eb6 172.16.0.201:6379
   replicates 4dd6c1fecbbe4527e7d0de61b655e8b74b411e4c
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
.
>>> Performing Cluster Check (using node 172.16.0.138:6379)
M: 8fdc7aa28a6217b049a2265b87bff9723f202af0 172.16.0.138:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
M: 4dd6c1fecbbe4527e7d0de61b655e8b74b411e4c 172.16.1.52:6379
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
S: 94b139d247e9274b553c82fbbc6897bfd6d7f693 172.16.0.139:6379
   slots: (0 slots) slave
   replicates 0b86a0fbe76cdd4b48434b616b759936ca99d71c
M: 0b86a0fbe76cdd4b48434b616b759936ca99d71c 172.16.1.53:6379
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
S: ab897de0eca1376558e006c5b0a49f5004252eb6 172.16.0.201:6379
   slots: (0 slots) slave
   replicates 4dd6c1fecbbe4527e7d0de61b655e8b74b411e4c
S: e293d25881c3cf6db86034cd9c26a1af29bc585a 172.16.0.72:6379
   slots: (0 slots) slave
   replicates 8fdc7aa28a6217b049a2265b87bff9723f202af0
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.           

驗證 Redis Cluster

我們可以采用

cluster info

指令檢視 Redis Cluster 的配置資訊和 Cluster 中的成員節點,以驗證叢集是否建立成功。

$ kubectl exec -it redis-cluster-0 -c redis -n redis -- redis-cli cluster info 
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:206
cluster_stats_messages_pong_sent:210
cluster_stats_messages_sent:416
cluster_stats_messages_ping_received:205
cluster_stats_messages_pong_received:206
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:416           

部署測試用用戶端

我們部署一個用戶端,以用于發送測試指令:

$ kubectl apply -f k8s/redis-client.yaml -n redis
deployment.apps/redis-client created           

通過 Istio 下發 Redis Cluster 相關的 Envoy 配置

在下面的步驟中,我們将通過 Istio 向 Envoy Sidecar 下發 Redis Cluster 相關配置,以在無需改動用戶端的情況下啟用 Redis Cluster 的進階功能,包括資料分片、讀寫分離和流量鏡像。

建立 Envoy Redis Cluster

Envoy 提供了 "envoy.clusters.redis" 類型的 Envoy Cluster 來連接配接後端的 Redis Cluster,Envoy 會通過該 Cluster 擷取後端 Redis Cluster 的拓撲結構,包括有多少個分片(shard),每個分片負責哪些 slot,以及分片中包含哪些節點,以将來自用戶端的請求分發到正确的 Redis 節點上。

采用 EnvoyFilter 來建立所需的 Envoy Redis Cluster:

$ kubectl apply -f istio/envoyfilter-custom-redis-cluster.yaml
envoyfilter.networking.istio.io/custom-redis-cluster created           

建立 Envoy Redis Proxy

Istio 預設下發的 LDS 中配置的是 TCP proxy filter,我們需要将其替換為 Redis Proxy filter。

由于 1.7.3 中尚不支援 EnvoyFilter 的 "REPLACE" 操作,我們首先需要更新 EnvoyFilter 的 CRD 定義,然後才能建立該 EnvoyFilter:

$ kubectl apply -f istio/envoyfilter-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/envoyfilters.networking.istio.io configured           

采用 EnvoyFilter 來将 TCP proxy filter 替換為 Redis Proxy filter,以使 Envoy 可以代理來自用戶端的 Redis 操作請求:

$ sed -i .bak "s/\${REDIS_VIP}/`kubectl get svc redis-cluster -n redis -o=jsonpath='{.spec.clusterIP}'`/" istio/envoyfilter-redis-proxy.yaml
$ kubectl apply -f istio/envoyfilter-redis-proxy.yaml
envoyfilter.networking.istio.io/add-redis-proxy created           

驗證 Redis Cluster 功能

現在一切就緒,下面我們來驗證 Redis Cluster 的各項功能。

Redis 資料分片

我們通過 Istio 将 EnvoyFilter 中定義的配置下發到 Envoy 後,Envoy 就能夠自動發現後端 Redis Cluster 的拓撲結構,并根據用戶端請求中的 key 将請求自動分發到 Redis Cluster 中正确的節點上。

根據前面建立 Redis Cluster 步驟中的指令行輸出,我們可以看出該 Redis Cluster 的拓撲結構:Cluster 中有三個分片,每個分片中有一個 Master 節點,一個 Slave(Replica) 節點。用戶端通過和其部署在同一個 Pod 中的 Envoy Proxy 通路 Redis Cluster,如下圖所示:

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

Redis Cluster 中各個分片的 Master 和 Slave 節點位址:

Shard[0] Master[0]  redis-cluster-0 172.16.0.138:6379   replica  redis-cluster-4 172.16.0.72:6379  -> Slots 0 - 5460 
Shard[1] Master[1]  redis-cluster-1 172.16.1.52:6379    replica  redis-cluster-5 172.16.0.201:6379 -> Slots 5461 - 10922
Shard[2] Master[2]  redis-cluster-2 172.16.1.53:6379    replica  redis-cluster-3 172.16.0.139:6379 -> Slots 10923 - 16383           
備注:如果你在自己的 K8s cluster 中部署該示例,那麼 Redis Cluster 中各個節點的 IP 位址和拓撲結構可能稍有不同,但基本結構應該是類似的。

我們嘗試從用戶端向 Rdeis Cluster 發送一些不同 key 的

set

請求:

$ kubectl exec -it `kubectl get pod -l app=redis-client -n redis -o jsonpath="{.items[0].metadata.name}"` -c redis-client -n redis -- redis-cli -h redis-cluster
redis-cluster:6379> set a a
OK
redis-cluster:6379> set b b
OK
redis-cluster:6379> set c c
OK
redis-cluster:6379> set d d
OK
redis-cluster:6379> set e e
OK
redis-cluster:6379> set f f
OK
redis-cluster:6379> set g g
OK
redis-cluster:6379> set h h
OK           

從用戶端來看,所有的請求都成功了,我們可以使用

scan

指令在伺服器端檢視各個節點中的資料:

檢視分片 Shard[0] 中的資料,master 節點是 redis-cluster-0 slave 節點是 redis-cluster-4。

$ kubectl exec redis-cluster-0 -c redis -n redis -- redis-cli --scan
b
f
$ kubectl exec redis-cluster-4 -c redis -n redis -- redis-cli --scan
f
b           

檢視分片 Shard[1] 中的資料,master 節點是 redis-cluster-1 slave 節點是 redis-cluster-5。

$ kubectl exec redis-cluster-1 -c redis -n redis -- redis-cli --scan
c
g
$ kubectl exec redis-cluster-5 -c redis -n redis -- redis-cli --scan
g
c           

檢視分片 Shard[2] 中的資料,master 節點是 redis-cluster-2 slave 節點是 redis-cluster-3。

$ kubectl exec redis-cluster-2 -c redis -n redis -- redis-cli --scan
a
e
d
h
$ kubectl exec redis-cluster-3 -c redis -n redis -- redis-cli --scan
h
e
d
a           

從上面的驗證結果中可以看到,用戶端設定的資料被分發到了 Redis Cluster 中的三個分片中。該資料分發過程是由 Envoy Redis Proxy 自動實作的,用戶端并不感覺後端的 Redis Cluster,對用戶端而言,和該 Redis Cluster 的互動與和一個單一 Redis 節點的互動是相同的。

采用該方法,我們可以在應用業務規模逐漸擴張,單一 Redis 節點壓力過大時,将系統中的 Redis 從單節點無縫遷移到叢集模式。在叢集模式下,不同 key 的資料被緩存在不同的資料分片中,我們可以增加分片中 Replica 節點的數量來對一個分片進行擴容,也可以增加分片個數來對整個叢集進行擴充,以應對由于業務不斷擴充而增加的資料壓力。由于 Envoy 可以感覺 Redis Cluster 叢集拓撲,資料的分發由 Envoy 完成,整個遷移和擴容過程無需用戶端,不會影響到線上業務的正常運作。

Redis 讀寫分離

在一個 Redis 分片中,通常有一個 Master 節點,一到多個 Slave(Replica)節點,Master 節點負責寫操作,并将資料變化同步到 Slave 節點。當來自應用的讀操作壓力較大時,我們可以在分片中增加更多的 Replica,以對讀操作進行負載分擔。Envoy Redis Rroxy 支援設定不同的讀政策:

  • MASTER: 隻從 Master 節點讀取資料,當用戶端要求資料強一緻性時需要采用該模式。該模式對 Master 壓力較大,在同一個分片内無法采用多個節點對讀操作進行負載分擔。
  • PREFER_MASTER: 優先從 Master 節點讀取資料,當 Master 節點不可用時,從 Replica 節點讀取。
  • REPLICA: 隻從 Replica 節點讀取資料,由于 Master 到 Replica 的資料複制過程是異步執行的,采用該方式有可能讀取到過期的資料,是以适用于用戶端對資料一緻性要求不高的場景。該模式下可以采用多個 Replica 節點來分擔來自用戶端的讀負載。
  • PREFER_REPLICA: 優先從 Replica 節點讀取資料,當 Replica 節點不可用時,從 Master 節點讀取。
  • ANY: 從任意節點讀取資料。

在前面下發的 EnvoyFilter 中,我們将 Envoy Redis Proxy 的讀政策設定為了 "REPLICA", 是以用戶端的讀操作應該隻會被發送到 Replica 節點。讓我們使用下面的指令來驗證讀寫分離的政策:

通過用戶端發起一系列 key 為 "b" 的

get

set

操作:

$ kubectl exec -it `kubectl get pod -l app=redis-client -n redis -o jsonpath="{.items[0].metadata.name}"` -c redis-client -n redis -- redis-cli -h redis-cluster

redis-cluster:6379> get b
"b"
redis-cluster:6379> get b
"b"
redis-cluster:6379> get b
"b"
redis-cluster:6379> set b bb
OK
redis-cluster:6379> get b
"bb"
redis-cluster:6379>            

在前面的 Redis Cluster 拓撲中,我們已經得知 key "b" 屬于 Shard[0] 這個分片。我們可以通過指令

redis-cli monitor

檢視該分片中 Master 和 Replica 節點中收到的指令。

Master 節點:

$ kubectl exec redis-cluster-0 -c redis -n redis -- redis-cli monitor           

Slave 節點:

$ kubectl exec redis-cluster-4 -c redis -n redis -- redis-cli monitor           

從下圖中可以看到,所有

get

請求都被 Envoy 發送到了 Replica 節點上。

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

Redis 流量鏡像

Envoy Redis Proxy 支援流量鏡像,即将用戶端發送的請求同時發送到一個鏡像 Redis 伺服器/叢集上。流量鏡像是一個非常有用的功能,我們可以使用流量鏡像将生産環境中的線上資料導入到測試環境中,以使用線上資料對應用進行盡可能真實的模拟測試,同時又不會影響到線上使用者的正常使用。

我們建立一個單節點的 Redis 節點,用做鏡像伺服器:

$ kubectl apply -f k8s/redis-mirror.yaml -n redis 
deployment.apps/redis-mirror created
service/redis-mirror created           

采用 EnvoFilter 來啟用鏡像政策:

$ sed -i .bak "s/\${REDIS_VIP}/`kubectl get svc redis-cluster -n redis -o=jsonpath='{.spec.clusterIP}'`/" istio/envoyfilter-redis-proxy-with-mirror.yaml
$ kubectl apply -f istio/envoyfilter-redis-proxy-with-mirror.yaml
envoyfilter.networking.istio.io/add-redis-proxy configured           

get

set

$ kubectl exec -it `kubectl get pod -l app=redis-client -n redis -o jsonpath="{.items[0].metadata.name}"` -c redis-client -n redis -- redis-cli -h redis-cluster
redis-cluster:6379> get b
"b"
redis-cluster:6379> get b
"b"
redis-cluster:6379> get b
"b"
redis-cluster:6379> set b bb
OK
redis-cluster:6379> get b
"bb"
redis-cluster:6379> set b bbb
OK
redis-cluster:6379> get b
"bbb"
redis-cluster:6379> get b
"bbb"           

可以通過指令

redis-cli monitor

分别檢視 Master、Replica 和鏡像節點中收到的指令。

Master 節點:

$ kubectl exec redis-cluster-0 -c redis -n redis -- redis-cli monitor           
$ kubectl exec redis-cluster-4 -c redis -n redis -- redis-cli monitor           

鏡像 節點:

$ kubectl exec -it `kubectl get pod -l app=redis-mirror -n redis -o jsonpath="{.items[0].metadata.name}"` -c redis-mirror -n redis -- redis-cli monitor           

set

請求都被 Envoy 發送到了一份鏡像節點上。

在 Istio 中實作 Redis 叢集的資料分片、讀寫分離和流量鏡像

實作原理

在上面的步驟中,我們在 Istio 中建立了兩個 EnvoyFilter 配置對象。這兩個 EnvoyFilter 修改了 Envoy 代理的配置,主要包括兩部分内容:Redis Proxy Network Filter 配置和 Redis Cluster 配置。

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: add-redis-proxy
  namespace: istio-system
spec:
  configPatches:
  - applyTo: NETWORK_FILTER
    match:
      listener:
        name: ${REDIS_VIP}_6379             # Replace REDIS_VIP with the cluster IP of "redis-cluster service
        filterChain:
          filter:
            name: "envoy.filters.network.tcp_proxy"
    patch:
      operation: REPLACE
      value:
        name: envoy.redis_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.config.filter.network.redis_proxy.v2.RedisProxy
          stat_prefix: redis_stats
          prefix_routes:
            catch_all_route:
              request_mirror_policy:            # Send requests to the mirror cluster
              - cluster: outbound|6379||redis-mirror.redis.svc.cluster.local
                exclude_read_commands: True     # Mirror write commands only:
              cluster: custom-redis-cluster
          settings:
            op_timeout: 5s
            enable_redirection: true
            enable_command_stats: true
            read_policy: REPLICA               # Send read requests to replica           
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: custom-redis-cluster
  namespace: istio-system
spec:
  configPatches:
  - applyTo: CLUSTER
    patch:
      operation: INSERT_FIRST
      value:
        name: "custom-redis-cluster"
        connect_timeout: 0.5s
        lb_policy: CLUSTER_PROVIDED
        load_assignment:
          cluster_name: custom-redis-cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-0.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-1.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-2.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-3.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-4.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
            - endpoint:
                address:
                  socket_address:
                    address: redis-cluster-5.redis-cluster.redis.svc.cluster.local
                    port_value: 6379
        cluster_type:
          name: envoy.clusters.redis
          typed_config:
            "@type": type.googleapis.com/google.protobuf.Struct
            value:
              cluster_refresh_rate: 5s
              cluster_refresh_timeout: 3s
              redirect_refresh_interval: 5s
              redirect_refresh_threshold: 5           

小結

參考文檔

  • https://rancher.com/blog/2019/deploying-redis-cluster
  • https://medium.com/@fr33m0nk/migrating-to-redis-cluster-using-envoy-93a87ae79dc3
  • Implement REPLACE operation for EnvoyFilter patch

繼續閱讀