流心知识

100 K8s Mistakes and How to Avoid Them - 上

前言

  1. 1. 一直从事 k8s 控制器相关的开发工作,不可避免的遇到一些新人常犯的错误以及从其他同事(感谢天元、晖树、一分、酒祝、明昼)那里学到经典的优化和调试技巧

  2. 2. 本文的题目是借鉴于 100 Go Mistakes and How to Avoid Them 这本书,本文的内容和这本书一样也比较零散,大家随便选择相应的章节阅读

  3. 3. 本文面向所有 K8S 领域相关的开发同学,但是不会深入分析代码和讲解原理,个人感觉堆叠大量代码,会影响阅读体验,对原理不太了解的新人可以跟着本文的一些参考文章继续深入学习,老司机可以按需阅读,评论区欢迎大家指出问题和讨论,我会根据大家的反馈修改文章

⚠️ 因为篇幅原因,文章分为上下两篇,上篇主要讲解 kubectl、控制器的内容,下篇主要讲解apiserver、稳定性相关的内容,下篇会带来更多生产的经验和意想不到的k8s玩法

目录

  • • #1 为什么资源删不掉-关于 Finalizers

  • • #2 patch的字段名带有 "/" 该如何操作 - "/" 转义为 ~1

  • • #3 kubectl 到底做了什么 - 使用 -v 10 查看详细信息

  • • #4 kubectl get all -n xxx

  • • #5 性能提升1-内置资源使用PB格式传输

  • • #6 性能优化2-根据场景决定是否要DeepCopy

  • • #7 性能优化3-加索引从O(n)到O(1)的提升

  • • #8 你正确使用Patch了吗?- Patch的时候忘记指定UID

  • • #9 Helm用了这么久,什么是Three Way Strategic Merge Patch

  • • #10 Node Agent 控制器要怎么设计

开胃小菜

#1 为什么资源删不掉-关于 Finalizers

刚接触到 k8s 的同学会经常遇到资源删不掉的问题,明明执行了 kubectl delete 命令,可是一会发现被删除的资源还保留在集群里,并且一直处于 Terminating 状态。

遇到这种问题是因为这个资源的 .metadata.finalizers 字段被填充了特定的标记,只有当这个标记被其他组件去除后,资源才会继续被回收。

关于 Finalizers

这里引出了 finalizers 的概念,简单的解释一下:finalizers 是K8S 垃圾回收中的一个删除拦截机制,为了控制器能够实现在资源真正删除前实现一些收尾的工作:释放关联(内外部)资源和一些控制器自己的业务逻辑。当控制器处理完自己相关的收尾动作,会把和自己相关的 finalizers 标记从被删除对象中移除,K8S 垃圾回收器观察到资源的 .metadata.finalizers 被置空,就会真正的把资源从 Etcd 中删除。

常见的 finalizers 标记有:

  1. 1. kubernetes.io/pvc-protection, PVC 资源的删除保护,只有当关联PVC的Pod被删除才会回收 PVC

  2. 2. kubernetes.io/pv-protection,PV 资源的删除保护,只有当关联PV的PVC被删除才会回收 PV

  3. 3. kubernetes 一般常见于 Terminating 的 Namespace 资源的finalizers字段,Namespace 一直处于Terminating状态,多半是因为 Namespace下的资源迟迟无法被回收级联到 Namespace 一直无法删除。

开发经验

编程范式

如果你编写的控制器需要对关心的资源实现删除前回调的能力,也就是给资源加上特定的 finalizers 标记,常见的编程范式如下:

  1. 1. 控制器监听到资源的变更事件(创建、更新)后进入到 Reconcile 逻辑

    1. 1. 首先先判断资源的 .metadata.finalizers 数组中有没有加入控制器特定的标记 F ,如果没有的话会更新资源的 .metadata.finalizers 字段把 F 标记添加进去,然后直接结束 Reconcile,等待下一次轮询。

    2. 2. 如果已经加入了特定的 finalizers 标记,则继续 Reconcile 执行维持状态的逻辑

  2. 2. 一段时间后,控制器监听到资源的删除事件

    1. 1. 先判断资源是否存在 finalizers 标记 F,如果已有标记 F 执行相应的资源删除收尾动作,清理动作执行结束,去掉 finalizers 中的标记 F

    2. 2. 如果资源不存在 finalizers 标记 F,则什么也不处理直接结束轮询

为什么加 Finalizers

大家想一想为什么要加上 Finalizers 才能完成资源回收动作

  1. 1. 控制器回收资源的信息一般存储在资源的Spec或者Status内,如果不加 Finalizers,当资源被删除的时候会直接从Etcd中移除,控制器接收到删除事件的时候,本地 Cache 中也已经没有资源的信息了,这时候根本无法执行回收动作

  2. 2. 如果在资源删除的时候,控制器处于异常重启状态,在控制器恢复正常的时候很有可能丢失掉了删除事件,导致资源泄露,一直无法回收相应的资源,以CCM为例如果丢失事件就会导致云产品没有被正常回收,一直在扣费,客户马上就会找上门来

适用场景

  1. 1. 想监听资源的状态,并且不想丢失删除事件,可以加上一个finalizer进行资源防护

  2. 2. k8s资源绑定的外部依赖需要和k8s资源一起回收

Mistakes

看了 Finalizers 的原理和使用方式那你一定不要犯下面这些错误。

看到资源被 Finalizers 删除拦截了,直接一把梭哈把 Finalizers 去掉

回看下上面的内容,你会发现直接去掉Finalizers会导致资源泄露等问题,所以你需要先 describe 一下看有没有相关的事件,根据事件判断如何解除一直删除不了的问题,遇到特殊的 Finalizers 标记,还是先多了解下是什么控制器添加的标记然后再做判断

轻易删除 Namespace

经常删除 Namespace 的朋友(此处在开玩笑,不要轻易删除ns),会遇到 Namespace 删除被卡住的情况,这时候也不要直接去掉 Finalizers,还是先 describe 一下看下相关的事件,通过事件提示的原因进行操作,我遇到的大多有下面2个原因:

  1. 1. 因为该Namespace下的资源也有Finalizers没有被正常去除,先把这些资源的删除卡住的原因解决

  2. 2. 该 Namespace 下有 APIService(注意是APIService不是Service,APIService的相关知识请自行查阅)绑定的服务出现异常,你要先恢复APIService对应的Pod或者选择直接清理掉 APIService,这个要需要你们自行判断。

参考文章

https://kubernetes.io/blog/2021/05/14/using-finalizers-to-control-deletion/

https://www.zeng.dev/post/2023-k8s-apiserver-aggregation-internals/

https://kubernetes.io/zh-cn/docs/concepts/extend-kubernetes/api-extension/apiserver-aggregation/

kubectl 篇

本节专门讲解使用 kubectl的一些小技巧和会遇到的错误,内容不多也有些牵强,大家见谅

#2 patch的字段名带有 "/" 该如何操作 - "/" 转义为 ~1

一些K8S资源的 annotations 和 labels 会有带有 / 的 key, 使用 json-patch来修改这个字段的时候,字段里带有的 / 和用来区分层级的 / 会混淆。

metadata:
    annotations:
        alibabacloud.com/skip-qutoa-check: "false"

从 RFC https://datatracker.ietf.org/doc/html/rfc6901#section-3 的定义中可以知道:

  • • ~ 转义为 ~0

  • • / 转义为 ~1

所以修改 alibabacloud.com/skip-qutoa-check 字段可以这样做:

kubectl patch cloneset/demo --type=json --patch [{"op":"replace","path":"/metadata/annotations/alibabacloud.com~1skip-qutoa-check","value":"true"},

#3 kubectl 到底做了什么 - 使用 -v 10 查看详细信息

故事背景

经常做PaaS的平台的朋友都知道(唉~),平台可能是用非Go的语言来搭建,想实现 WebShell 的功能的时候,没法直接使用 client-go 的 sdk 去实现,只能通过直连 k8s-apiserver 的API来实现。类似这样的场景很多很多,自然会有下面这些问题:

  1. 1. kubectl 实现的 exec 功能对应的 API 是什么?

  2. 2. 确定API之后,需要传递哪些参数,带上什么认证信息?

  3. 3. 我自己遇到的问题,上面2个问题都确定了之后,为什么我用kubectl exec可以生效,自己拼接的地址却不生效

-v 10

针对上述问题,你可以如下在kubectl加上-v 10参数,让kubectl打印出更多的调试日志

[root@vmaster ~]# kubectl exec -it  nginx -v 10 -- /bin/sh
Config loaded from file:  /root/.kube/config
...
curl -v -XGET  -H "Accept: application/json, */*" -H "User-Agent: kubectl/v1.27.3 (linux/arm64) kubernetes/25b4e43" 'https://10.211.55.13:6443/api/v1/namespaces/default/pods/nginx'
HTTP Trace: Dial to tcp:10.211.55.13:6443 succeed
GET https://10.211.55.13:6443/api/v1/namespaces/default/pods/nginx 200 OK in 7 milliseconds
HTTP Statistics: DNSLookup 0 ms Dial 0 ms TLSHandshake 4 ms ServerProcessing 2 ms Duration 7 ms
Response Headers:
Date: Mon, 25 Mar 2024 22:41:12 GMT
Audit-Id: 934ad852-d4c7-4e2f-9f1e-e4297cc1285d
Cache-Control: no-cache, private
Content-Type: application/json
X-Kubernetes-Pf-Flowschema-Uid: 96503db1-0b24-4e7a-9abf-295ad86964a0
X-Kubernetes-Pf-Prioritylevel-Uid: 8dd37474-e2e8-42e6-bb56-e4145a173e1e
...
Defaulting container name to nginx
curl -v -XPOST  -H "X-Stream-Protocol-Version: v4.channel.k8s.io" -H "X-Stream-Protocol-Version: v3.channel.k8s.io" -H "X-Stream-Protocol-Version: v2.channel.k8s.io" -H "X-Stream-Protocol-Version: channel.k8s.io" -H "User-Agent: kubectl/v1.27.3 (linux/arm64) kubernetes/25b4e43" 'https://10.211.55.13:6443/api/v1/namespaces/default/pods/nginx/exec?command=%2Fbin%2Fsh&container=nginx&stdin=true&stdout=true&tty=true'
...

从调试日志可以看到 kubectl 到底做了什么,每个请求的API和请求参数是什么,一目了然,遇到类似的问题,都可以尝试把调试日志打印出来。

#4 kubectl get all -n xxx

Mistake

直接删除 kubectl get all -n XX 下的资源

一个月黑风高的夜晚,小明在测试集群里安装一些开源的K8S组件,但是被自己玩烂了,想删除 Namespace 重新安装,小明也遇到了 Namespace 一直删不掉的问题,于是执行

kubectl get all -n test

想看看 Namespace下面到底有哪些资源影响了 Namespace 的删除,然后一把梭哈把列出下资源给删了。

到这里大家觉得似乎没有什么问题,但是注意 kubectl get all -n test 列出的资源并不都是 namespace scope的,cluster scope 的资源也会被列出来。这里 all 指的是 api-resource的分类,并没有说明列出的资源都是这个命名空间下的。

所以大家注意不要理所应当的认为列出的资源都是指定命名空间下的资源,有可能会有 cluster 级别的资源,别问我怎么知道的 😎 → 😭

参考文章

JSON-PATCH RFC6901 https://datatracker.ietf.org/doc/html/rfc6901#section-3

控制器篇

controller-runtime[1] 不必多说,几乎已经成为现在编写控制器必用的库,即使你不编写控制器,只是和K8S的APIServer交互也完全可以使用这个库提供的Client。这部分内容主要是围绕这个库展开,并附带一些控制器的编写技巧

#5 性能提升1-内置资源使用PB格式传输

OpneKruise 同样是基于 controller-runtime 开发的控制器,Kruise团队的开发者给 controller-runtime 提了很多性能优化的PR,下面这个PR是酒祝 controller-runtime 的 Client 提供一个增强能力,对所有K8S内置资源默认走PB的格式进行传输。

https://github.com/kubernetes-sigs/controller-runtime/pull/1149

Image

核心代码如下,如果资源是内置资源类型,rest.Config 对象内的 ContentType 就被设为 runtime.ContentTypeProtobuf,即使你不使用 controller-runtime 的Client,直接使用 client-go,也可以在构建clientset的时候给restConfig的传输格式指定为 runtime.ContentTypeProtobuf

    // TODO(FillZpp): In the long run, we want to check discovery or something to make sure that this is actually true.
    if cfg.ContentType == "" && !isUnstructured {
        protobufSchemeLock.RLock()
        if protobufScheme.Recognizes(gvk) {
            cfg.ContentType = runtime.ContentTypeProtobuf
        }
        protobufSchemeLock.RUnlock()
    }

如果你仔细看下这个PR的comment,还可以了解到:

Image

  1. 1. 只有内置的K8S对象支持PB格式的数据传输

  2. 2. CRD不支持PB传输,但是Aggregated API可以使用PB传输

参考文章

https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/#advanced-features-and-flexibility

https://github.com/kubernetes-sigs/controller-runtime/pull/1149

#6 性能优化2-根据场景决定是否要DeepCopy

又是酒祝大佬提的性能优化PR,大致背景是使用 controller-runtime 的Client进行list和get的时候,默认会对查到的资源进行DeepCopy,这样在大规模场景下控制器很容易出现OOM的问题

https://github.com/kubernetes-sigs/controller-runtime/pull/1274

Image

这个PR里把是否DeepCopy做成了一个可选项,开发者可以根据自己的业务场景决定查询资源的时候要不要进行DeepCopy。

// New initializes and returns a new Cache.
func New(config *rest.Config, opts Options) (Cache, error) {
    opts, err := defaultOpts(config, opts)
    if err != nil {
        return nil, err
    }
    selectorsByGVK, err := convertToSelectorsByGVK(opts.SelectorsByObject, opts.DefaultSelector, opts.Scheme)
    if err != nil {
        return nil, err
    }
    disableDeepCopyByGVK, err := convertToDisableDeepCopyByGVK(opts.UnsafeDisableDeepCopyByObject, opts.Scheme)
    if err != nil {
        return nil, err
    }
    transformByGVK, err := convertToTransformByKindAndGVK(opts.TransformByObject, opts.DefaultTransform, opts.Scheme)
    if err != nil {
        return nil, err
    }

    im := internal.NewInformersMap(config, opts.Scheme, opts.Mapper, *opts.Resync, opts.Namespace, selectorsByGVK, disableDeepCopyByGVK, transformByGVK)
    return &informerCache{InformersMap: im}, nil
}
// Cache knows how to load Kubernetes objects, fetch informers to request
// to receive events for Kubernetes objects (at a low-level),
// and add indices to fields on the objects stored in the cache.
type Cache interface {
    // Cache acts as a client to objects stored in the cache.
    client.Reader

    // Cache loads informers and adds field indices.
    Informers
}

controller-runtime的 Cache对象你可以理解为就是client-go的缓存,在构建这个缓存的时候,你可以在option里指定需要对哪些Object关闭DeepCopy。

应用场景

Image

#7 性能优化3-加索引从O(n)到O(1)的提升

这是一个编写控制器的同学一般都会用到的优化技巧,知道这个技巧之后会给你的控制器的性能带来质的提升。

一般我们在使用List方法去查询资源的时候,不使用索引的时候,Client会遍历缓存中所有的对象并一个个匹配你的Selector,匹配上的会聚合一下返回给你。但是在数据规模较大的场景下,这样的查询效率会非常耗时。

client-go是支持用户自定义索引器,可以把查询效率提升到O(1),具体的索引实现碍于篇幅这部分就不展开了,大家可以自行网上查找。

下面是使用 controller-runtime 的时候添加索引的代码示例:

// This example shows how to set up and consume a field selector over a pod's volumes' secretName field.
func ExampleFieldIndexer_secretName() {
    // someIndexer is a FieldIndexer over a Cache
    _ = someIndexer.IndexField(context.TODO(), &corev1.Pod{}, "spec.volumes.secret.secretName", func(o client.Object) []string {
        var res []string
        for _, vol := range o.(*corev1.Pod).Spec.Volumes {
            if vol.Secret == nil {
                continue
            }
            // just return the raw field value -- the indexer will take care of dealing with namespaces for us
            res = append(res, vol.Secret.SecretName)
        }
        return res
    })

    // elsewhere (e.g. in your reconciler)
    mySecretName := "someSecret" // derived from the reconcile.Request, for instance
    var podsWithSecrets corev1.PodList
    _ = c.List(context.Background(), &podsWithSecrets, client.MatchingFields{"spec.volumes.secret.secretName": mySecretName})
}

场景是,开发者想查出来使用指定secret作为volume的pod列表,这里给缓存加了一个名字为 spec.volumes.secret.secretName 的索引,回调函数会在每个Pod更新的时候更新索引: 即添加Pod和Secret的映射关系,这种关系的绑定在回调函数里实现。在使用List方法查询的时候,就可以指定索引和索引值(这里就是secret的名字),因为已经构建好了索引,所以查询效率也是非常高效。

试想一下如果没有索引,我们就需要获取到某个Ns下全量的Pod,然后逐个查看volumes有没有目标secret,效率大大降低。

索引的使用场景不局限于此,完全取决于你的想象力(哈哈,有点过了),大家可以看看自己之前的项目,哪些地方可以用索引来优化吧。

#8 你正确使用Patch了吗?- Patch的时候忘记指定UID

大家都知道使用Patch的时候,K8S会取消乐观锁的限定,你可以无视resourceversion,随意对资源进行修改,但是下面的问题你有注意到吗?

在做 Patch 的时候,最佳实践是把 original 的 UID 填在 patch 里。因为 API server 的 key-value store 是以“namespace + name”作为 key 的。在任意一个时间,这个组合都是唯一的。但是如果把时间这条轴加进来的话,比如你有一个 pod,删除后过了一会儿,又在同一个 namespace 下建了同名的 pod,但是把所有的 spec 都改掉了,那么 controller 旧的 patch 可能会被应用到这个新建的 pod 上,这样就会有 bug 了。如果在 patch 里加入 uid 的话,一旦发生刚才所说的情况,apiserver 会以为你是要修改 uid,这个是不允许的,所以这个 patch 就会 fail 掉,防止了 bug。

关于controller-runtime,你可以通过阅读也是我的一个朋友王岳的这篇文章继续深入了解:聊聊 controller-runtime 缓存那些事

参考文章

https://www.kubernetes.org.cn/1309.html

https://mp.weixin.qq.com/s/okp4EkDcMb_JgXb6BI8eEA

#9 Helm用了这么久,什么是Three Way Strategic Merge Patch

Helm每个人都不陌生,但是你知道Helm 的 Three Way Strategic Merge Patch机制吗?这部分内容,我会专门开一篇文章讲解,大家可以看下面Helm的官方解释了解一下

参考文章

https://helm.sh/docs/faq/changes_since_helm2/#improved-upgrade-strategy-3-way-strategic-merge-patches

#10 Node Agent 控制器要怎么设计

这里提一嘴如何设计一个Agent类型的控制器,这种类型的控制器会以 Daemonset的形态部署到K8S集群里,通常会有自定义的CRD。每个节点上的控制器都会去APIServer Watch资源的变化。这里需要注意一个前提:

  1. 1. K8S不支持指定CRD资源的Spec Field进行条件Watch,以kubelet为例,kubelet可以只Watch nodeName上指定本节点的Pod,这样APIServer给每个Node推送的数据就比较小。但是CRD不支持,所以他会Watch全量的数据,在大规模的集群下,这种请求规模会对APIServer造成很大的压力

  2. 2. 但是K8S支持指定CRD资源的Name和Label进行Watch,这就给我们带来了一个思路,就是在CRD的名字上保持和节点一一对应(参考Kruise的NodeImage)

如果实现上没法保证资源和Node一一对应,可以搞一个旁路的控制器对CRD资源进行二次抽象为Node粒度的资源

引用链接

[1] controller-runtime: https://github.com/kubernetes-sigs/controller-runtime