问题

接到测试同事反馈的一个小问题:

  1. 创建一个statefulset,container resource req明显过大,超过任何节点的allocatable值,此时pod会卡在pending状态
  2. edit上述statefuleset,将resource req改为合理的显然可调度的值
  3. 此时预期pending pod会被干掉,新的resource req的pod被创建,从而statefulset状态实现正常(这种预期本质上来自于常见的deployment逻辑)
  4. 然而3里的预期并未实现,实际情况是无事发生:
    1. pending pod不会被干掉
    2. 新pod不会创建出来
    3. 但是statefulset中的resource是修改成功了的

所以总结问题如下:statefulset控制的pod,状态流转到pending后,后续行为是什么?和无状态的deployment是否一致?

代码

根据之前看kubelet和kube-scheduler代码的经验,首先大致推测一下controller-manager在代码结构中的位置。通常核心逻辑还是位于pkg包下,按照字母排序从a往下遍历,很快便可以看到controller包。打开可以看到很多以object名称命名的目录。看到这里大概就应该知道找对了。果然在其中可以找到statefulset目录,应该就是statefulset控制器的代码了。

进入stateful_set.go,可以看到熟悉的基于client-go实现的controller模式:基于informer,为statefulset的新增、修改、删除分别注册事件处理函数,将发生变化的statefulset入队(生产者模型);同时启动worker协程,不断从队列中取出statefulset,进行逻辑处理。

我们只需要关注worker端,最初的处理入口:

// pkg/controller/statefulset/stateful_set.go:407
func (ssc *StatefulSetController) sync(key string) error {
...
return ssc.syncStatefulSet(set, pods)
}

沿着函数的调用,来到第一处对本文问题提供了有效信息的地方:一处注释。我们看到注释中有一句:当有任何pod处在unhealthy状态时,不会有新pod被创建。从这句注释,大概就可以推测了,本文描述的问题,应该是statefulset设计上的本意,并非bug

// UpdateStatefulSet executes the core logic loop for a stateful set, applying the predictable and
// consistent monotonic update strategy by default - scale up proceeds in ordinal order, no new pod
// is created while any pod is unhealthy, and pods are terminated in descending order. The burst
// strategy allows these constraints to be relaxed - pods will be created and deleted eagerly and
// in no particular order. Clients using the burst strategy should be careful to ensure they
// understand the consistency implications of having unpredictable numbers of pods available.
func (ssc *defaultStatefulSetControl) UpdateStatefulSet(set *apps.StatefulSet, pods []*v1.Pod) error {

    // list all revisions and sort them
    revisions, err := ssc.ListRevisions(set)
    if err != nil {
        return err
    }
    history.SortControllerRevisions(revisions)

    currentRevision, updateRevision, err := ssc.performUpdate(set, pods, revisions)
    if err != nil {
        return utilerrors.NewAggregate([]error{err, ssc.truncateHistory(set, pods, revisions, currentRevision, updateRevision)})
    }

    // maintain the set's revision history limit
    return ssc.truncateHistory(set, pods, revisions, currentRevision, updateRevision)
}

进一步,我们看最最核心的逻辑所处的函数:

func (ssc *defaultStatefulSetControl) updateStatefulSet(
    set *apps.StatefulSet,
    currentRevision *apps.ControllerRevision,
    updateRevision *apps.ControllerRevision,
    collisionCount int32,
  pods []*v1.Pod) (*apps.StatefulSetStatus, error) {
...
}

由于该函数长达300+行,不便一一展开。只记录和本文问题相关的关键点:

  1. 逻辑中会对statefulset下属的pod做状态判断,根据不同的状态,采取不同的行为。比如isFailedisCreatedisTerminatingisRunningAndReady等,处在pending状态的pod并不属于这些状态中的任何一个,因此在这些逻辑都不会操作pending状态下的pod。这就解释了为什么更新了statefulset后,不会有新的pod被创建出来
  2. 那么,为什么pending状态的pod没有被干掉?如果代码逻辑把这种情况考虑了,干掉这个pod,就可以修改后的pod创建出来了。但是查看代码发现,控制器删除pod的目标主要是超过副本数限制的pod,并没有考虑pending状态的pod。
  3. 综上,就会出现卡在pod无法调度,一直pending的状态,即使修改过后的statefulset是完全可调度的(只是用户知道,控制器当然不知道,因为调度信息在scheduler那边,所以逻辑设计成了现在的样子)。

最新版本

上述的代码来源于kubernetes 1.21版本。出于好奇,不知道最新的版本是否对pending状态的pod有所考虑?所以我们切到release-1.28分支,即当前最新的版本,查看是否有多加一些逻辑。发现确实增加了和pending pod相关的逻辑。

func (ssc *defaultStatefulSetControl) processReplica(
    ctx context.Context,
    set *apps.StatefulSet,
    currentRevision *apps.ControllerRevision,
    updateRevision *apps.ControllerRevision,
    currentSet *apps.StatefulSet,
    updateSet *apps.StatefulSet,
    monotonic bool,
    replicas []*v1.Pod,
  i int) (bool, error) {
  ...
  // If the Pod is in pending state then trigger PVC creation to create missing PVCs
    if isPending(replicas[i]) {
        logger.V(4).Info(
            "StatefulSet is triggering PVC creation for pending Pod",
            "statefulSet", klog.KObj(set), "pod", klog.KObj(replicas[i]))
        if err := ssc.podControl.createMissingPersistentVolumeClaims(ctx, set, replicas[i]); err != nil {
            return true, err
        }
    }
  ...
}

然而新增的逻辑考虑的是pod由于pvc没有创建而导致的pending。

workaround

业务层发现编辑了statefulset的resource后,删除一次其控制的所有pod(这种情况下其实只会有1个),使新statefulset从全新状态开始逐个创建pod,避免之前pending pod导致的阻塞。

进一步思考

一方面,从k8s官方设计的角度,尝试理解当前的逻辑:由于是分布式系统,controller-manager显然不能对资源调度情况有所干预,因此目前的实现里基本不会干预pending状态,因为pending状态的流转,主要是调度器负责的。

但是另一方面,遇到本文描述的这种情况,又确实不太符合k8s reconcile的设计理念:即对比spec和status的差别,自动化地实现二者的最终一致。

再进一步

上面对代码的主体逻辑有了了解后,是时候抠一下细节了。在代码中,有一些开关变量值得注意。比如这一行:monotonic := !allowsBurst(set),我们查看这个allowsBurst函数:发现statefulset有个字段叫.spec.podManagementPolicy,取值有两种:OrderedReadyParallel,显然我们常见的statefulset默认行为是基于OrderedReady策略的。

func allowsBurst(set *apps.StatefulSet) bool {
    return set.Spec.PodManagementPolicy == apps.ParallelPodManagement
}

在代码中,当monotonic(意味单调)为true时,代码中的很多逻辑一旦判断pod状态不正常,就会fast return,pod的删除逻辑就不会被触发到。因此才有了上面的分析。

经过测试,把statefulset的spec.podManagementPolicy改为Parallel后,编辑statefulset的resource后,即可触发pod的重建,不论旧pod状态是否正常。

注意,这个方法虽然可以解决本文开头描述的问题,但是却会破坏k8s对statefulset pod的严格生命周期顺序管理,需要权衡利弊后决策。

参考

详解 Kubernetes StatefulSet 实现原理 - 面向信仰编程 (draveness.me)

文章目录