20 有状态应用的默认特性落地分析

一直以来跑在 Kubernetes 的应用都是无状态的应用,所有数据都是不落盘的,应用死掉之后,应用状态也不复存在,比如 Nginx 作为反向代理的场景。

如果你的应用涉及业务逻辑,一般都会涉及把数据在本地放一份。如果应用实例死掉了可以再拉起一个新应用实例继续服务当前的连接请求。

那么有状态应用在 Kubernetes 场景下又有哪些特性需要我们记住呢?请随着笔者的章节一步一步了解它。

StatefulSet 对象

当我们使用 Deployment 对象部署应用容器实例的时候,一定会注意到 Pod 实例后缀总是带有随机字符串,这是无状态应用区分实例的一种策略。

现实应用中,对于分布式系统的编排,随机的字符串标识是无法应用的。

它要求在启动 Pod 之前,就能明确标记应用实例,这个场景下 StatefulSet 对象应景而生。

如下 Pod 例子中显示顺序索引如下:

kubectl get pods -l app=nginx
NAME      READY     STATUS    RESTARTS   AGE
web-0     1/1       Running   0          1m
web-1     1/1       Running   0          1m

当你在终端中把所有 Pod 删掉后,StatefulSet 会自动重启它们:

kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

kubectl get pod -w -l app=nginx
NAME      READY     STATUS              RESTARTS   AGE
web-0     0/1       ContainerCreating   0          0s
NAME      READY     STATUS    RESTARTS   AGE
web-0     1/1       Running   0          2s
web-1     0/1       Pending   0         0s
web-1     0/1       Pending   0         0s
web-1     0/1       ContainerCreating   0         0s
web-1     1/1       Running   0         34s

使用 kubectl exec 和 kubectl run 查看 Pod 的主机名和集群内部的 DNS 项如下:

查看 Pod 的主机名和集群内部的 DNS 项如下:

for i in 0 1; do kubectl exec web-$i -- sh -c 'hostname'; done
web-0
web-1

kubectl run -i --tty --image busybox:1.28 dns-test --restart=Never --rm /bin/sh
nslookup web-0.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-0.nginx
Address 1: 10.244.1.7

nslookup web-1.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-1.nginx
Address 1: 10.244.2.8

Pod 的序号、主机名、SRV 条目和记录名称都没有改变,但和 Pod 相关联的 IP 地址却发生了变更。这个现象说明即使是有状态的容器实例,它的 IP 也是变化的。因为传统遗留的系统很多,很多遗留系统在迁到云原生平台的时候期望能固定 Pod IP,虽然从场景上来讲合理。但毕竟这是遗留系统的设计,它已经不是云原生架构推荐的设计方式了,所以默认 Kubernetes 是没有这个特性的。如果想支持这个特性,就需要在 CNI 上做好扩展才行。开源网络方案 Calico 就提供这种特性,请参考:

# 配置 ipam
cat /etc/cni/net.d/10-calico.conflist

# 配置 ipam, 这个 cni plugin 将解析指定的注解来配置 IP
        "ipam": {
              "type": "calico-ipam"
          },

# 在 Pod 对象中加上注解
annotations:
      "cni.projectcalico.org/ipAddrs": "[\"192.168.0.1\"]"

以下是腾讯云提供的注解例子来支持固定 IP 特性:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  annotations:
    tke.cloud.tencent.com/enable-static-ip: "true"
  labels:
    k8s-app: busybox
  name: busybox
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      k8s-app: busybox
      qcloud-app: busybox
  serviceName: ""
  template:
    metadata:
      annotations:
        tke.cloud.tencent.com/vpc-ip-claim-delete-policy: Never
      creationTimestamp: null
      labels:
        k8s-app: busybox
        qcloud-app: busybox
    spec:
      containers:
      - args:
        - "10000000000"
        command:
        - sleep
        image: busybox
        imagePullPolicy: Always
        name: busybox
        resources:
          limits:
            tke.cloud.tencent.com/eni-ip: "1"
          requests:
            tke.cloud.tencent.com/eni-ip: "1"

有状态存储

StatefulSet 大部分情况下还会挂盘启动。因为 Kubernetes 从 1.13 版本开始已经全面拥抱 CSI 接口标准,默认流程主要是先创建 StorageClass, 然后使用 PersistentVolumeClaim 对象动态申请存储资源。底层 PersistentVolume 对象会驱动 StorageClass 调用指定的存储驱动来创建存储设备。因为每个存储驱动的设计复杂度也不太一样,建议读者可以先从 NFS 存储开始不断积累经验。

很多读者误以为有了 StatefulSet 加上 PersistentVolume 之后,可以应对所有有状态应用的部署情况。我的实践经验分享是很多情况下,你需要针对每种应用的部署方式配置合适的特性才能真正保证有状态应用的运行。因为这种复杂度,所以业界才推出了 Operator 框架来为复杂的应用提供一键部署的管理控制器。你留心分解这些控制器后发现,它们无非是对 Pod 特性的拼装组合。所以不要被表明的例子所迷惑,对于有状态应用的部署,你需要详细了解架构布局的方式,然后在结合 Kubernetes 提供的特性来支持。

默认情况下,Kubernetes 可以把 StatefulSet 的 Pods 部署在相同节点上,如果有两个服务并存于相同的节点上并且该节点发生故障时,你的服务就会受到影响。所以当你期望服务可以尽可能减少停服时间,就应该配置 podAntiAffinity。

比如获取 zk Stateful Set 中的 Pods 的节点:



for i in 0 1 2; do kubectl get pod zk-$i --template {{.spec.nodeName}}; echo ""; done

kubernetes-minion-group-cxpk
kubernetes-minion-group-a5aq
kubernetes-minion-group-2g2d


zk StatefulSe 中所有的 Pods 都被部署在不同的节点。

这是因为 zk StatefulSet 中的 Pods 指定了 PodAntiAffinity:

affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: "app"
                    operator: In
                    values:
                    - zk
              topologyKey: "kubernetes.io/hostname"

请灵活运用这个技巧来应对高容错的应用场景。

有状态更新策略

默认 StatefulSet 的更新策略是使用 spec.updateStrategy 字段进行配置。spec.updateStrategy.type 字段接受 OnDelete 或 RollingUpdate 作为值。默认 OnDelete 可防止控制器自动更新其 Pod。您必须手动删除 Pod,以使控制器创建新 Pod 来反映您的更改。另外一种策略是 RollingUpdate 实现 StatefulSet 中的 Pod 的自动滚动更新。RollingUpdate 使控制器删除并重新创建其每个 Pod,并且一次只能处理一个 Pod。在更新的 Pod 运行并就绪之后,控制器才会更新其上一个 Pod。StatefulSet 控制器以反向顺序更新所有 Pod,同时遵循 StatefulSet 保证规则。

显然 RollingUpdate 默认更新策略需要很长时间才能更新完毕。如果需要更灵活的特性,可以借助开源的扩展插件来增强 StatefulSet 的特性,如采用 OpenKruise 调度器。

其中我想介绍的一个特性就是原地升级的策略:In-Place Pod Update Strategy。

apiVersion: apps.kruise.io/v1alpha1
kind: StatefulSet
spec:
  # ...
  podManagementPolicy: Parallel
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      podUpdatePolicy: InPlaceIfPossible
      inPlaceUpdateStrategy:
        gracePeriodSeconds: 10

StatefulSet 增加了 podUpdatePolicy 来允许用户指定重建升级还是原地升级。

  • ReCreate:控制器会删除旧 Pod 和它的 PVC,然后用新版本重新创建出来。

  • InPlaceIfPossible:控制器会优先尝试原地升级 Pod,如果不行再采用重建升级。目前,只有修改 spec.template.metadata.* 和 spec.template.spec.containers[x].image 这些字段才可以走原地升级。

  • InPlaceOnly:控制器只允许采用原地升级。因此,用户只能修改上一条中的限制字段,如果尝试修改其他字段会被 Kruise 拒绝。

更重要的是,使用 InPlaceIfPossible 或 InPlaceOnly 策略,必须要增加一个 InPlaceUpdateReady readinessGate,用来在原地升级的时候控制器将 Pod 设置为 NotReady。

一个完整的案例参考:

apiVersion: apps.kruise.io/v1alpha1
kind: StatefulSet
metadata:
  name: sample
spec:
  replicas: 3
  serviceName: fake-service
  selector:
    matchLabels:
      app: sample
  template:
    metadata:
      labels:
        app: sample
    spec:
      readinessGates:
         # A new condition that ensures the pod remains at NotReady state while the in-place update is happening
      - conditionType: InPlaceUpdateReady
      containers:
      - name: main
        image: nginx:alpine
  podManagementPolicy: Parallel # allow parallel updates, works together with maxUnavailable
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      # Do in-place update if possible, currently only image update is supported for in-place update
      podUpdatePolicy: InPlaceIfPossible
      # Allow parallel updates with max number of unavailable instances equals to 2
      maxUnavailable: 2

OpenKruise 调度器还提供了很多其它对象的扩展,如果你有兴趣可以作为扩展去关注,这里不在赘述。

总结

有状态应用一般都是多个不通类型的镜像组合而成的,不可能像 Nginx 一样只要构建一个镜像,然后使用 Deployment 对象就水平扩展了。在早期部署有状态应用的过程中,大家只看到了用 YAML 部署容器的便利性,并没有有效地认清楚 Kubernetes 的不足。虽然针对应用部署出来了 Helm 管理工具,但是仍然是针对单个应用的部署会简单很多,多个应用的部署例子基本上都是玩具类型的示范,不能当成生产可用的范例。从真实的运维场景出发,目前比较合适的生产范例,仍然需要采用 Operator 来自建自己的部署框架。当然,开源可参考的 Operator 也开始多了起来,这在一定程度上可以起到示范的作用。

从有状态应用的特性出发,我们首先关心的标识唯一性,Kubernetes 是通过 StatefulSet 保证的。从应用健壮性来讲,一定要采用 PodAntiAffinity。更新策略默认是手工删除,滚动更新是串行一个一个更新,时间会很长。为了提高效率,可以采用开源扩展的调度器来增强业务可操作下,笔者认为原地更新的策略是当前最实用的一个策略。

参考文章

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Kubernetes%20%e5%ae%9e%e8%b7%b5%e5%85%a5%e9%97%a8%e6%8c%87%e5%8d%97/20%20%e6%9c%89%e7%8a%b6%e6%80%81%e5%ba%94%e7%94%a8%e7%9a%84%e9%bb%98%e8%ae%a4%e7%89%b9%e6%80%a7%e8%90%bd%e5%9c%b0%e5%88%86%e6%9e%90.md