1. StatefulSet应用场景说明
Deployment部署的无状态应用,应用的各个实例是相互独立的。但是在实际应用中存在如下需求:
- 应用的各个实例之间有一定依赖关系。如多个实例组成一个集群(比如ETCD集群),实例之间需要通过通信选出leader。这就要求各个实例的网络地址必须固定(不管是实例重启,还是热迁移到别的节点等实例的网络地址都必须保持不变),否则就无法组成稳定集群。
- 应用的实例和存储数据需要绑定。比如部署mysql集群(主备模式),两个实例通过 sharding来保存数据,所以访问这两个实例是分库之后不同的数据。而用户会要求访问两个实例返回数据是稳定的,也就是要求实例和存储数据必须要绑定(不管是实例是重启,或者迁移到其他节点),实例对应的 storage必须固定。
- 应用的各实例启动需要遵循一定的顺序。比如首先启动的实例必须为 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类:
- Pod创建或者删除; Selector, Template, VolumeClaimTemplates, ServiceName,
PodManagementPolicy
- Pod副本数控制: Replicas
- 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时,需要先部署一个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创建分为OrderedReady
和 Parallel
两种模式。
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的升级
- Pod升级动作主要指更新
Spec.Template
中的内容(一般主要更新镜像),从而触发新旧Pod的替换。 - Pod升级策略由
Spec.Update.Strategy
字段指定,目前支持OnDelete
和RollingUpdate
两种模式。 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。
Spec.UpdateStrategy.Type=RollingUpdate
:Spec.Template
更新后,StatefulSet Controller会从最大索引号开始逐个升级Pod。即先删除pod,然后等到删除的pod被创建好后再进行下一个索引号的Pod升级。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的控制流程。具体如下:
- 获取 StatefulSet: 由key从
set.Lister
(本地缓存)中获取到需要处理的 StatefulSet实例 - 获取 Statefulset所有Pod: 由
StatefulSet.Spec.Selector
从 pod.Lister(本地缓存)中过滤所有符合条件的Pod(且podName和 set.Name匹配) - 获取当前版本和升级版本的 Controller Revision: 如果升级版本的 Controller Revision不存在,就新创建一个。(StatefulSet创建时,当前版本和升级版本相同。当前升级完成后,他们也相同)
- 从
0 ~ Spec.Replicas-1
逐个索引,创建StatefulSet Pod(3.2章节) - 所有pod创建完成后,进入扩缩容逻辑处理(如果需要扩缩容操作的话)(3.3章节)
- 扩缩容操作完成后,进入Pod升级逻辑(如果需要Pod升级操作的话)(3.4章节)
- 更新 StatefulSet的 Status(3.5章节)
5. StatefulSet的几点思考
- StatefulSet使用时有几点要求,1:需要创建 headless service用于pod的DNS A记录创建。2:最好采用分布式存储做数据存储。如果采用本地存储的话,就需要保证Pod重启必须调度到同一台机器,就需要用户再设置亲和性等参数。
- StatefulSet的Pod升级实现方式和 Daemonset类似,都利用 Controller revision对版本进行管理。因为Controllerrevision中保存了 Pod.Spec信息,所以用户来可以利用 Controller Revison来做Pod回滚。
- 当 Statefulset部署Pod失败时,用户同样也可以采用 kubectl工具进行问题定位。具体命令可以看[参考链接1]
- 因为 StatefulSet部署Pod可以严格按照索引号[0~replicas-1]的顺序启动,所以对启动顺序有要求的应用(比如说主备模式部署)可以充分利用这点。同时在Pod(容器)中,也可以通过获取 hostname信息(带有索引号),从而知道自己启动顺次,方便做各自独立的配置。比如说0索引号和其他索引号的应用配置文件不同等。
- StatefulSet的Pod因为顺次启动,一个Pod启动并且ready后,才能启动后续的Pod。所以当Pod出现错误,或者健康检查fail时, kubelet必须可以重启pod,否则就会影响后续Pod的启动。因此
spec.Template.Spec.RestartPolicy
一定要设置要为Always
(这个和 Deamonset是一样的)。
- .从第5点延伸出来,如果容器中是从脚本启动的业务进程,脚本应该要保证: 如果子进程(业务进程)退出后,脚本也能自行退出,从而引起容器退出。否则在无健康检查的情况下,业务不可用时kubelet也无法重启该容器。
- 从章节4可以看出, StatefulSet的Pod处理优先级为: Pod创建 > Pod扩缩容 > Pod升级,即必须
spec.Replicas
指定的Pod数创建完成后,才会执行缩容处理(删除多余的Pod)。最后才会轮到Pod升级。也就是说如果某个索引位置Pod没有创建成功会阻塞Pod缩容和升级。
6.参考链接
- StatefulSets
- StatefulSet源码(v1.11.0)
- Kubernetes DNS-Based Service Discovery