kubernetes controller源码解读之StatefulSet

1. StatefulSet应用场景说明

Deployment部署的无状态应用,应用的各个实例是相互独立的。但是在实际应用中存在如下需求:

  1. 应用的各个实例之间有一定依赖关系。如多个实例组成一个集群(比如ETCD集群),实例之间需要通过通信选出leader。这就要求各个实例的网络地址必须固定(不管是实例重启,还是热迁移到别的节点等实例的网络地址都必须保持不变),否则就无法组成稳定集群。
  2. 应用的实例和存储数据需要绑定。比如部署mysql集群(主备模式),两个实例通过 sharding来保存数据,所以访问这两个实例是分库之后不同的数据。而用户会要求访问两个实例返回数据是稳定的,也就是要求实例和存储数据必须要绑定(不管是实例是重启,或者迁移到其他节点),实例对应的 storage必须固定。
  3. 应用的各实例启动需要遵循一定的顺序。比如首先启动的实例必须为 Master实例,其他 Slave实例才能后续启动。

针对上面的需求,如果用户不通过 kubernetes来实现的话,就需要为每个实例提供 static ip和固定的 volume,并且要定制化来保证一定的启动顺序。这样对应用的部署会产生很大的约束。而在kubernetes中可以通过 Statefulset来满足上述的场景。

解决方案是通过 StatefulSet部署的Pod会有一个固定身份和身份相关的其他固定信息。不管Pod是原地重启还是迁移到其他节点,这些固定信息都会跟着他,同时启动顺序也有充分保证。从而对应用的部署约束大大降低。

除了上述功能外, Stateful|Set也具备方便的实例升级和实例扩缩容能力。这也是用户通过自身运维很难简单实现的。下面通过分析StatefulSet的源码,深入看一下各功能实现。

2. StatefulSet资源定义

具体分析源码前,我们需要先了解StatefulSet资源定义。单个 StatefulSet资源的定义结构( tatefulSet的yam定义需要遵守该结构)如下

type StatefulSet struct {
  metal.TypeMeta
  metal.ObjectMeta
  Spec StatefulSetspec
  Status StatefulSetStatus
}

kube-controller-manager组件中的StatefulSet Controller模块会根据 StatefulSetSpec的定义来驱动控制逻辑,并刷新StatefulSetStatus来反馈StatefulSet实时状态。 StatefulSetspec和 StatefulSetStatus的定义如下:

StatefulSetspec的定义如下:

type StatefulSetspec struct {
  Replicas *int32
  Selector *metav1.LabelSelector
  Template v1.PodTemplateSpec
  VolumeClaimTemplates []v1.PersistentVolumeClaim
  ServiceName string
  PodManagementPolicy PodManagementPolicyType
  UpdateStrategy StatefulSetUpdateStrategy
  RevisionHistoryLimit *int32
}

按用途可以把 StatefulSetSpec中的字段分成下面3类:

  1. Pod创建或者删除; Selector, Template, VolumeClaimTemplates, ServiceName,
    PodManagementPolicy
  2. Pod副本数控制: Replicas
  3. Pod升级和回滚: UpdateStrategy,RevisionHistoryLimit

Statefulset中的 StatefulSetStatus的定义如下:

type StatefulSetStatus struct {
  ObservedGeneration *int64
  Replicas int32
  ReadyReplicas int32
  CurrentReplicas int32
  UpdatedReplicas int32
  CurrentRevision string
  UpdateRevision string
  CollisionCount *int32
  Conditions []StatefulSetCondition
}

StatefulSet的yam1具体实例,可以参照:
https://raw.githubusercontent.com/kubernetes/website/master/content/en/docs/tutorials/stateful-application/web.yaml

注意: 部署StatefulSet时,需要先部署一个headless的service。其中service的Name用于填充StatefulSetSpec中的ServiceName字段。
了解完StatefulSet资源定义后,基于资源定义我们分析StatefulSet的具体实现,打开看看她是怎么满足各场景需求的。

3. StatefulSet Controller详细说明

刚开始有提到 Statefulset部署的Pod带有固定身份标识,那首先我们就先看一下Pod的身份标识:

3.1 Pod固定身份标识

主要给Pod绑定固定名称固定网络地址(DNS记录)固定存储信息这3个维度的信息。具体如下:

  • 名称维度

PodName由 Statefulse的Name+应用实例索引号来决定。索引号={0~ StatefulSetSpec.Replicas - 1}。StatefulSet Controller保证在每个索引号创建一个Pod,所以每个索引位置的Pod名称是固定的。PodName代码处理如下:

@kubernetes/pkg/controller/statefulset/stateful_set_utils.go
func getPodName(set *apps.StatefulSet, ordinal int) string {
    return fmt.Sprintf("%s-%d", set.Name, ordinal)  // ordinal为索引号
}
  • 网络地址维度:

根据kubernetes dns spc的约定,headless service对应pod的网络地址(完整域名)为: <hostname>.<subdomain>.<ns>.svc.cluster.local,而StatefulSet pod的 hostname和 subdomain生成代码如下:

@kubernetes/pkg/controller/statefulset/stateful_set_utils.go
func initIdentity(set *apps.StatefulSet, pod *v1.Pod) {
    updateIdentity(set, pod)
    // Set these immutable fields only on initial Pod creation, not updates.
    pod.Spec.Hostname = pod.Name  // hostname设置为 podName
    pod.Spec.Subdomain = set.Spec.ServiceName // subdomain设置为 Headless Service的名称
}

因为从名称维度可知,podName是固定的,所以pod的网络地址也是固定的。

  • 存储维度

Pod的各个 volume是通过PVC来管理的,所以只要 Volume对应的PVC能保持不变,那就可以保证存储不变。那么顺其自然一定会想到,只要PVC的名称也和Pod的索引位置绑定,那问题就解决了。代码中处理如下:

@kubernetes/pkg/controller/statefulset/stateful_set_utils.go
func getPersistentVolumeClaimName(set *apps.StatefulSet, claim *v1.PersistentVolumeClaim, ordinal int) string {
    // NOTE: This name format is used by the heuristics for zone spreading in ChooseZoneForVolume
    // ordinal为pod的索引号
    return fmt.Sprintf("%s-%s-%d", claim.Name, set.Name, ordinal)
}

所以综上说明,可以看到3个维度的固定,本质都是依赖Pod的索引位置来固定的。

理解完Pod的身份标识,接下来分析一下Pod的创建。

3.2 StatefulSet Pod创建

根据StatefulSetSpec.PodManagementPolicy的设置,Pod创建分为OrderedReadyParallel两种模式。

  • OrderedReady模式: 按索引号0 ~ replicas-1的顺序,前序Pod创建成功后,才会接下来创建下一个Pod。
  • Parallel模式:并发创建各个Pod。

代码中处理如下

monotonic := !allowsBurst(set) //获取创建模式, monotonic=true:顺序创建, false:并发创建

// 按0~replicas-1遍历每个索引号,保证前序Pod创建成功后再创建后续Pod
for i := range replicas {
    // 索引位置Pod存在但是为fai1ed状态,则重建该Pod,并更新StatefulSet.Status
    if isFailed(replicas[i]) {
        if err := ssc.podControl.DeleteStatefulPod(set, replicas[i]); err != nil {
            return &status, err
        }
        if getPodRevision(replicas[i]) == currentRevision.Name {
            status.CurrentReplicas--
        } else if getPodRevision(replicas[i]) == updateRevision.Name {
            status.UpdatedReplicas--
        }
        status.Replicas--
        replicas[i] = newVersionedStatefulSetPod(
            currentSet,
            updateSet,
            currentRevision.Name,
            updateRevision.Name,
            i)
    }
    // 索引位置的pod还没有创建,那么就创建它,并更新StatefulSet.Status
    if !isCreated(replicas[i]) {
        if err := ssc.podControl.CreateStatefulPod(set, replicas[i]); err != nil {
            return &status, err
        }
        status.Replicas++
        if getPodRevision(replicas[i]) == currentRevision.Name {
            status.CurrentReplicas++
        } else if getPodRevision(replicas[i]) == updateRevision.Name {
            status.UpdatedReplicas++
        }

        // 顺序创建模式时,后续索引位置Pod需要等该Pod运行正常后才能创建
        if monotonic {
            return &status, nil
        }
        // pod created, no more work possible for this round
        continue
    }
    // 索引位置pod为删除状态且为顺序创建模式,那就等该pod删除后再创建后续Pod。
    if isTerminating(replicas[i]) && monotonic {
        return &status, nil
    }

    // 索引位置pod还未ready且为顺序创建模式,那就等该pod状态ready后再创建后续Pod。
    if !isRunningAndReady(replicas[i]) && monotonic {
        return &status, nil
    }
    // 检查pod的身份标识是否变化,没有变化说明Pod正常可以继续创建后续Pod
    if identityMatches(set, replicas[i]) && storageMatches(set, replicas[i]) {
        continue
    }
    // 否则Pod身份变化就刷新该Pod
    replica := replicas[i].DeepCopy()
    if err := ssc.podControl.UpdateStatefulPod(updateSet, replica); err != nil {
        return &status, err
    }
}

而对于Pod删除,用户直接删除StatefulSet的Pod是无法凑效的,因为 StatefulSet马上就会重建。如果要删除Pod,必须通过调整 StatefulSet的 Spec.Replicas来达到删除目的。即为Pod扩缩容处理。

3.3Pod扩缩容
  • 扩容处理: Replicas增大的情况,则直接是Pod创建的逻辑(参考3.2章节)。因为 StatefulSet会在每一个索引位置创建一个Pod,所以扩容就是多创建几个后续Pod。
  • 缩容处理: 因为需要减少Pod,为了不和Pod创建过程冲突,缩容是从最大索引号开始删除Pod。

代码中处理如下(下面代码中顺序还是并发删除说明省略)

if ord := getOrdinal(pods[i]); 0 <= ord && ord < replicaCount {
    replicas[ord] = pods[i]
} else if ord >= replicaCount {
    //pod索引号大于最新的Spec.Replicas,说明Replicas减小了,这些Pod需要被缩容掉
    condemned = append(condemned, pods[i])
}

// 从最大索引号开始缩容
for target := len(condemned) - 1; target >= 0; target-- {
    // 如果该Pod正在被删除,则等待被删除完成即可
    if isTerminating(condemned[target]) {
        if monotonic {
            return &status, nil
        }
        continue
    }

    // 如果被删除pod的前序pod中有不健康的,那么需要等待前序pod恢复为正常状态后才能继续缩容
    if !isRunningAndReady(condemned[target]) && monotonic && condemned[target] != firstUnhealthyPod {
        return &status, nil
    }

    // 到这里可以执行pod缩容删除了
    if err := ssc.podControl.DeleteStatefulPod(set, condemned[target]); err != nil {
        return &status, err
    }
    // 更新StatefulSet.Status
    if getPodRevision(condemned[target]) == currentRevision.Name {
        status.CurrentReplicas--
    } else if getPodRevision(condemned[target]) == updateRevision.Name {
        status.UpdatedReplicas--
    }
    if monotonic {
        return &status, nil
    }
}

当分析完Pod的创建和扩缩容后,接下来需要看看pod的升级。

3.4 Pod的升级
  1. Pod升级动作主要指更新Spec.Template中的内容(一般主要更新镜像),从而触发新旧Pod的替换。
  2. Pod升级策略由Spec.Update.Strategy字段指定,目前支持OnDeleteRollingUpdate两种模式。
  3. Spec.UpdateStrategy.Type=OnDelete: Spec.Template更新后,需要用户手动删除旧Pod,然后StatefulSet Controller会利用新的Spec.Template创建新Pod(新Pod创建细节可以参照3.2章节)。代码中处理如下:
if set.Spec.UpdateStrategy.Type == apps.OnDeleteStatefulSetStrategyType {
    return &status, nil
}

当升级策略为OnDelete时,执行直接返回,等待用户手动删除pod。

  1. Spec.UpdateStrategy.Type=RollingUpdate: Spec.Template更新后,StatefulSet Controller会从最大索引号开始逐个升级Pod。即先删除pod,然后等到删除的pod被创建好后再进行下一个索引号的Pod升级。
  2. RollingUpdate模式的Pod升级,可以只升级部分Pod。新旧Pod分水岭的索引号由Spec.UpdateStrategy.RollingUpdate.Partition指定。其中[0 ~ partition-1]索引号的Pod为旧版本,而[partition ~ replicas-1]索引号的Pod为新版本。当然如果 partition > Spec.Replicas,则不会升级任何Pod。

RollingUpdate模式的代码如下(下面主要为删除旧Pod,而新Pd创建请参照3.2章节)

updateMin := 0
if set.Spec.UpdateStrategy.RollingUpdate != nil {
    updateMin = int(*set.Spec.UpdateStrategy.RollingUpdate.Partition)
}

// 从最大索引号开始执行Pod升级处理(此处为旧Pod删除)
for target := len(replicas) - 1; target >= updateMin; target-- {
    // 如果pod为旧版本并且不在被删除状态,则执行Pod删除
    if getPodRevision(replicas[target]) != updateRevision.Name && !isTerminating(replicas[target]) {
        err := ssc.podControl.DeleteStatefulPod(set, replicas[target])
        status.CurrentReplicas--
        return &status, err // 直接退出,等待被删除Pod被创建
    }

    // 等待到被删除pod创建且ready,才进行下一个pod的升级。否则就退出for循环
    if !isHealthy(replicas[target]) {
        return &status, nil
    }
}

最后再统一说明一下StatefulSet.Status的各个字段,方便用户理解 StatefulSet的Pod创建和升级进度。

3.5 StatefulSet.Status的各字段说明:
  • Replicas: 所有属于该 StatefulSet的Pod数量
  • ReadyReplicas: 所有属于该 Statefulset的Pod且状态为ready的数量
  • CurrentReplicas:所有属于该 StatefulSet当前版本的Pod数量(升级完成时会等于UpdatedReplicas)
  • UpdatedReplicas:所有属于该 StatefulSet升级版本的Pod数量
  • CurrentRevision: Statefulset当前版本的 set.Name+hash
  • UpdateRevision: Stateful|Set升级版本的 set.Name+hash
4. Statefulset的控制流程

经过上面代码级别的细节说明,下面大致梳理一下 StatefulSet Controller的控制流程。具体如下:

  1. 获取 StatefulSet: 由key从set.Lister(本地缓存)中获取到需要处理的 StatefulSet实例
  2. 获取 Statefulset所有Pod: 由StatefulSet.Spec.Selector从 pod.Lister(本地缓存)中过滤所有符合条件的Pod(且podName和 set.Name匹配)
  3. 获取当前版本和升级版本的 Controller Revision: 如果升级版本的 Controller Revision不存在,就新创建一个。(StatefulSet创建时,当前版本和升级版本相同。当前升级完成后,他们也相同)
  4. 0 ~ Spec.Replicas-1逐个索引,创建StatefulSet Pod(3.2章节)
  5. 所有pod创建完成后,进入扩缩容逻辑处理(如果需要扩缩容操作的话)(3.3章节)
  6. 扩缩容操作完成后,进入Pod升级逻辑(如果需要Pod升级操作的话)(3.4章节)
  7. 更新 StatefulSet的 Status(3.5章节)
5. StatefulSet的几点思考
  1. StatefulSet使用时有几点要求,1:需要创建 headless service用于pod的DNS A记录创建。2:最好采用分布式存储做数据存储。如果采用本地存储的话,就需要保证Pod重启必须调度到同一台机器,就需要用户再设置亲和性等参数。
  2. StatefulSet的Pod升级实现方式和 Daemonset类似,都利用 Controller revision对版本进行管理。因为Controllerrevision中保存了 Pod.Spec信息,所以用户来可以利用 Controller Revison来做Pod回滚。
  3. 当 Statefulset部署Pod失败时,用户同样也可以采用 kubectl工具进行问题定位。具体命令可以看[参考链接1]
  4. 因为 StatefulSet部署Pod可以严格按照索引号[0~replicas-1]的顺序启动,所以对启动顺序有要求的应用(比如说主备模式部署)可以充分利用这点。同时在Pod(容器)中,也可以通过获取 hostname信息(带有索引号),从而知道自己启动顺次,方便做各自独立的配置。比如说0索引号和其他索引号的应用配置文件不同等。
  5. StatefulSet的Pod因为顺次启动,一个Pod启动并且ready后,才能启动后续的Pod。所以当Pod出现错误,或者健康检查fail时, kubelet必须可以重启pod,否则就会影响后续Pod的启动。因此
    spec.Template.Spec.RestartPolicy一定要设置要为Always(这个和 Deamonset是一样的)。
  6. .从第5点延伸出来,如果容器中是从脚本启动的业务进程,脚本应该要保证: 如果子进程(业务进程)退出后,脚本也能自行退出,从而引起容器退出。否则在无健康检查的情况下,业务不可用时kubelet也无法重启该容器。
  7. 从章节4可以看出, StatefulSet的Pod处理优先级为: Pod创建 > Pod扩缩容 > Pod升级,即必须 spec.Replicas指定的Pod数创建完成后,才会执行缩容处理(删除多余的Pod)。最后才会轮到Pod升级。也就是说如果某个索引位置Pod没有创建成功会阻塞Pod缩容和升级。
6.参考链接
  1. StatefulSets
  2. StatefulSet源码(v1.11.0)
  3. Kubernetes DNS-Based Service Discovery
上一篇:《设计模式沉思录》目录—导读


下一篇:kubernetes controller源码解读之DaemonSet