CA(cluster autoscaler)删除 Node逻辑排查

49,084 阅读6分钟

背景

CA 并不是只有开启缩容功能才会下线机器,使用过程中发现没有开启缩容开关也出现了 Node被删除导致服务不可用的情况,本文针对 CA中所有涉及到高危的删除操作做排查,避免掉坑

CA 自动扩缩容需要实现 CloudProvider 接口中的多个方法,其中接口中涉及删除操作的接口有两个:

  type NodeGroup interface {
    ......
    // 删除 Node 实例
    DeleteNodes([]*apiv1.Node) error
    // 缩小 ASG 当前实例数
    DecreaseTargetSize(delta int) error
  }

DeleteNodes

共有三处调用该函数的地方:

  • removeOldUnregisteredNodes:移除未注册的实例(没有 ProviderID 的实例)
  • deleteCreatedNodesWithErrors:删除创建失败的实例(AWS不会有此场景)
  • deleteNodeFromCloudProvider:缩容操作

其中,前两个是在 CA 启动后就会执行的逻辑,最后一个是需要开启缩容开关并评估Node可以缩容才会执行。

启动CA后可能会删除 Node 的逻辑

场景

CA 启动后有两种情况下会执行删除 Node 操作:

  • 未注册的 Node
  • 创建失败的 Node
未注册的 Node

未注册的 Node 表示:将 k8s 获取到的所有 Node 中包含 ProviderID 的节点,和 ASG纳管的实例 ID 做比较,不匹配的都是未注册的节点。包括两种情况

  • 在 ASG 中,但是没有 ProviderID 的 k8s Node(图中红色节点)
  • 在 ASG 中,但是通过 kubectl get node 获取不到的 Node(图中黄色节点)

暂时无法在Lark文档外展示此内容

func getNotRegisteredNodes() {
  // 遍历所有 Node,即:kubectl get node 拿到的所有 node
  for _, node := range allNodes {
    // 保存所有的 ProviderID 到集合中,就是 registred 的列表
    registered.Insert(node.Spec.ProviderID)
  }
  notRegistered := make([]UnregisteredNode, 0)
  // 遍历被 ASG 纳管的所有 AWS 实例
  for _, instances := range cloudProviderNodeInstances {
    for _, instance := range instances {
      // AWS 实例和 registered 不匹配的,都是未注册的 Node
      if !registered.Has(instance.Id) {
        notRegistered = append(notRegistered, UnregisteredNode{
          Node:              fakeNode(instance, cloudprovider.FakeNodeUnregistered),
          UnregisteredSince: time,
        })
      }
    }
  }
  return notRegistered
}
创建失败的 Node

判断依据:instance.Status != nil && instance.Status.State == cloudprovider.InstanceCreating && instance.Status.ErrorInfo != nil。其中,Status字段是可选值,AWS没有用到 Status(Status==nil),其他云厂商用到了这个值(比如 azure、华为cloud),因此我们的 CA 在使用中这种情况永远不会发生。

type Instance struct {
  // Id is instance id.
  Id string
  // Status represents status of node. (Optional)
  // 可选字段
  Status *InstanceStatus
}
​
for i, asgNode := range asgNodes {
    // AWS 在初始化时,只用到的 Id 字段,没有用到 Status 字段
    instances[i] = cloudprovider.Instance{Id: asgNode.ProviderID}
}

特点

  • 强制删除Node,没有 pod 驱逐的动作

复现

以上分析可知,即使关闭了缩容功能,CA启动时,也有有两种场景会触发Node被强制删除:

场景一:没有 ProviderID,这种场景上线过程中已经出现。

场景二:k8s显示没有,但是 ASG 显示还有的机器。下面会复现该场景

  • 执行 kubectl delete node xxx,删除一台机器,过一段时间会观察到 ASG的实例被删除
# 删除 node
kubectl delete no ip-10-120-101-241.ap-southeast-1.compute.internal
​
# 观察 CA,发现如下日志
I0817 13:08:38.124841       1 static_autoscaler.go:320] 1 unregistered nodes present
I0817 13:08:38.124862       1 static_autoscaler.go:592] Removing unregistered node aws:///ap-southeast-1a/i-06abd1b00011269e1
I0817 13:08:38.288023       1 auto_scaling_groups.go:277] Terminating EC2 instance: i-06abd1b00011269e1

如何避坑

  • 确保被CA自动发现的 ASG 纳管的所有实例都包含 ProviderID 信息
  • 确保被CA自动发现的 ASG 纳管的所有实例都是 k8s 的Node节点,不要将其他 EC2实例加进来

关键代码

func RunOnce() {
  ...
  // 移除未注册 Node 的逻辑
  if len(unregisteredNodes) > 0 {
    removeOldUnregisteredNodes()
  }
  ...
  // 删除创建失败 Node 的逻辑
  deleteCreatedNodesWithErrors()
}
​
func removeOldUnregisteredNodes() {
  // 内部会调用 DeleteNodes
  DeleteNodes()
}
​
func deleteCreatedNodesWithErrors() {
  // 内部会调用 DeleteNodes
  DeleteNodes()
}

开启缩容才可能会执行删除 Node 的逻辑

场景

开启缩容开关后,CA会经过一系列逻辑判断,确认 Node 符合缩容条件后,会执行Node删除操作。

这种情况下,所有调用都会统一走 deleteNodeFromCloudProvider 函数,该函数有两个地方被调用:

  • scheduleDeleteEmptyNodes:删除候选节点中的空节点
  • deleteNode:删除候选节点中的非空节点

空节点的判断逻辑:FastGetPodsToMove

  • 返回一个pod可以移动到任何节点的列表
// 找到非孤立的 pod
podsToRemove, _, _, err := FastGetPodsToMove(nodeInfo, true, true, *skipNodesHostPaths, nil, timestamp)
// 一个可以被移动的 pod 都没有,才认为是 emptyNode
if err == nil && len(podsToRemove) == 0 {
  result = append(result, node)
}

特点

  • 会先驱逐 DaemonSet pod
  • 再 drain node(drain过程中会驱逐非 daemonset 的pod)
  • 最后才删除实例

关键代码

func TryToScaleDown(...) {
  ...
  // 这里会进行一序列检查操作,确认哪些 Node 可以安全删除
  ...
  // 这种场景:有打污点、驱逐daemonset pod、删除 Node,但是没有 drain node
  // 没有 pod 在 Node 上运行,直接删除,不需要驱逐
  if len(emptyNodes) > 0 {
    scheduleDeleteEmptyNodes()
    return
  }
  // 这种场景:有打污点、驱逐daemonset pod、drain node、删除 Node
  sd.deleteNode(...)
}
​
func scheduleDeleteEmptyNodes() {
  // 打污点,禁止调度
  MarkToBeDeleted()
  // 驱逐 DaemonSet Pod
  evictDaemonSetPods()
  // 调用 deleteNodeFromCloudProvider, 内部会调用 DeleteNodes
  deleteNodeFromCloudProvider()
}
​
func deleteNode() {
  // 打污点,禁止调度
  MarkToBeDeleted()
  // 驱逐 DaemonSet Pod
  daemonset.PodsToEvict()
  // drain node
  drainNode()
  // 调用 deleteNodeFromCloudProvider, 内部会调用 DeleteNodes
  deleteNodeFromCloudProvider()
}
​
func deleteNodeFromCloudProvider() {
   // 内部会调用 DeleteNodes
  DeleteNodes()
}

DecreaseTargetSize

场景

共有 1 处调用该函数的地方:

  • fixNodeGroupSize:检查k8s中的 node数量和 cloud provider侧的数量是否相等

触发逻辑

nodes := len(ASG{kubectl get no|)

  1. 从 kubectl get no 中取出所有属于该 ASG 的node 数量 N
  1. N > asg.Max || N < asg.Min 的才认为是错误的 ASG(这种情况理论上应该不会发生)
  1. 从错误的 ASG 中取出 delta, delta = CurrentSize - ExpectedSize < 0 时触发(这个条件和上一个是互斥的,所以更不可能发生)
  • 当前值 CurrentSize:kubectl get nodes中所有属于这个 ASG 的Node数量(k8s侧拿到的数据)
  • 期望值 ExpectedSize:ASG 中配置的当前实例数(aws 侧拿到的数据)

暂时无法在Lark文档外展示此内容

复现

  • 执行 kubectl delete node xxx,删除一台机器,观察 ASG 是否会随机下掉一台机器?
  • 现象:删除 Node,优先触发 “未注册的 Node”的场景,定向的从 ASG中删除了节点。
  • 结论:这种场景是一种双重检查,理论上不会触发该场景? 还是有其他场景,需要进一步确认。
# 删除 node
kubectl delete no ip-10-120-101-241.ap-southeast-1.compute.internal
​
# 观察 CA,发现如下日志
I0817 13:08:38.124841       1 static_autoscaler.go:320] 1 unregistered nodes present
I0817 13:08:38.124862       1 static_autoscaler.go:592] Removing unregistered node aws:///ap-southeast-1a/i-06abd1b00011269e1
I0817 13:08:38.288023       1 auto_scaling_groups.go:277] Terminating EC2 instance: i-06abd1b00011269e1

相关 issue

关键代码

func RunOnce() {
  fixNodeGroupSize()
}
​
// 如果 ASG 的当前值和期望值不匹配,调整 ASG 的当前值为期望值
func fixNodeGroupSize(context *context.AutoscalingContext, clusterStateRegistry *clusterstate.ClusterStateRegistry, currentTime time.Time) (bool, error) {
   fixed := false
   // 遍历所有的 ASG
   for _, nodeGroup := range context.CloudProvider.NodeGroups() {
      // 获取大小不符合预期的 ASG
      // 哪些 ASG 算是不符合预期的?
      incorrectSize := clusterStateRegistry.GetIncorrectNodeGroupSize(nodeGroup.Id())
      // 没有这样的 ASG 就跳过
      if incorrectSize == nil {
         continue
      }
      // 如果超过 MaxNodeProvisionTime 的时间,才进行纠正
      // MaxNodeProvisionTime 是机器就绪超时时间,默认是 15min,现货生产调整为 5min
      if incorrectSize.FirstObserved.Add(context.MaxNodeProvisionTime).Before(currentTime) {
         delta := incorrectSize.CurrentSize - incorrectSize.ExpectedSize
         if delta < 0 {
            // 调整 ASG 当前值,缩小 ASG 的大小
            if err := nodeGroup.DecreaseTargetSize(delta); err != nil {
               return fixed, fmt.Errorf("failed to decrease %s: %v", nodeGroup.Id(), err)
            }
            fixed = true
         }
      }
   }
   return fixed, nil
}
​
// 判断非预期的 ASG
// 注册的实例数(readiness.Registered)大于 asg 最大值或者小于 asg 最小值
func (csr *ClusterStateRegistry) updateIncorrectNodeGroupSizes(currentTime time.Time) {
   
   for _, nodeGroup := range csr.cloudProvider.NodeGroups() {
      // 拿到 acceptableRange,对应 ASG的 min、max、desireCapacity
      acceptableRange, found := csr.acceptableRanges[nodeGroup.Id()]
      // 拿到 readiness
      // Registered: 所有注册的实例,包括: (ready/unready/deleted/etc).
      readiness, found := csr.perNodeGroupReadiness[nodeGroup.Id()]
     
      // Registered > MaxNodes,或者 < MinNodes
      if readiness.Registered > acceptableRange.MaxNodes ||
         readiness.Registered < acceptableRange.MinNodes {
         
         // 这里都是不符合预期的 ASG
         // 会根据 delta = CurrentSize - ExpectedSize做判断
         incorrect := IncorrectNodeGroupSize{
            // 取的是 readiness.Registered
            CurrentSize:   readiness.Registered,
            // 取的是 acceptableRange.CurrentTarget
            ExpectedSize:  acceptableRange.CurrentTarget,
            FirstObserved: currentTime,
         }
      }
   }
}
​
// readiness.Registered 是如何生成的
// 通过 kubectl get no得到的数量就是 Registered
func (csr *ClusterStateRegistry) updateReadinessStats(currentTime time.Time) {
​
   update := func(current Readiness, node *apiv1.Node, ready bool) Readiness {
      // 这里 Registered 递增
      current.Registered++
      return current
   }
​
   for _, node := range csr.nodes {
      // 遍历所有的 Node,只要是 Node 都会被统计到
      perNodeGroup[nodeGroup.Id()] = update(perNodeGroup[nodeGroup.Id()], node, ready)
   }
}
​
// node 的获取(csr.nodes)
func (a *StaticAutoscaler) obtainNodeLists(cp cloudprovider.CloudProvider) ([]*apiv1.Node, []*apiv1.Node, errors.AutoscalerError) {
   // 获取所有的 Node,类似 kubectl get no
   allNodes, err := a.AllNodeLister().List()
   // 这里只是过滤了 readyNode,allNodes是原样返回的
   allNodes, readyNodes = a.processors.CustomResourcesProcessor.FilterOutNodesWithUnreadyResources(a.AutoscalingContext, allNodes, readyNodes)
allNodes, readyNodes = taints.FilterOutNodesWithIgnoredTaints(a.ignoredTaints, allNodes, readyNodes)
}