前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《一起读 kubernetes 源码》原来 k8s 也有 GC

《一起读 kubernetes 源码》原来 k8s 也有 GC

作者头像
LinkinStar
发布2024-03-14 09:40:07
1790
发布2024-03-14 09:40:07
举报
文章被收录于专栏:LinkinStar's Blog

📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!

前言

作为 k8s 的使用者而非维护者来说,对于 k8s 的 GC 其实是很难接触到的(几乎是无感的)。这也就是为什么标题写的惊讶 “原来 k8s 也有 GC”。GC 这个概念在很多语言中都有的,比如 Java 和 Golang,它就是帮助我们来回收垃圾的。在编程语言中,GC 主要是回收那些垃圾对象;那么相对于的 k8s 中,GC 需要回收哪些资源呢?今天的内容不复杂,源码里面都是那种很符合直觉的实现。

心路历程

其实,我一开始最好奇的就是镜像,由于 docker 镜像的大小我们是可想而知的。就算是我们常常使用的本地电脑,磁盘都有可能被占用很多,更别提是服务器这种动不动就更新镜像的情况了。

码前提问

  1. K8S 的 GC 回收哪些资源?
  2. K8S 的 GC 什么时候运行?
  3. K8S 的 GC 是谁运行的?

源码分析

今天的入口还是比较好找的,因为很明确的命名 GarbageCollection 找到它,肯定就是了。首先,我们依旧先来看接口

代码语言:javascript
复制
// pkg/kubelet/kubelet.go:231
// Bootstrap is a bootstrapping interface for kubelet, targets the initialization protocol
type Bootstrap interface {
	StartGarbageCollection()
	ListenAndServe(kubeCfg *kubeletconfiginternal.KubeletConfiguration, tlsOptions *server.TLSOptions, auth server.AuthInterface, tp trace.TracerProvider)
	...
}

接口在 Bootstrap 中有定义于是就容易找到具体实现了。于是我们就找到了 StartGarbageCollection 的具体实现,同样的,我们去掉不想干的日志和分支。主干如下:

代码语言:javascript
复制
// pkg/kubelet/kubelet.go:1395
// StartGarbageCollection starts garbage collection threads.
func (kl *Kubelet) StartGarbageCollection() {
	loggedContainerGCFailure := false
	go wait.Until(func() {
		ctx := context.Background()
		if err := kl.containerGC.GarbageCollect(ctx); err != nil {
			// ...
		} else {
			// ...
		}
	}, ContainerGCPeriod, wait.NeverStop)

	prevImageGCFailed := false
	go wait.Until(func() {
		ctx := context.Background()
		if err := kl.imageManager.GarbageCollect(ctx); err != nil {
			// ...
		} else {
			// ...
		}
	}, ImageGCPeriod, wait.NeverStop)
}

显然,这里启动了两个定时任务,一个是 ContainerGC 一个是 ImageGCContainerGCPeriod 是 1 分钟,ImageGCPeriod 是 5 分钟。从名字来看这里我们已经可以看出一些端倪了。一个是对于容器的 GC,也就是回收哪些停止但是没有回收资源的容器;另一个就是我们开头关心的镜像了。

那么,我们接下来就分别看看 containerGC.GarbageCollectimageManager.GarbageCollect 做了什么吧。

containerGC.GarbageCollect

首先,一条路往下走,GarbageCollect -> cgc.runtime.GarbageCollect -> m.containerGC.GarbageCollect

代码语言:javascript
复制
// pkg/kubelet/kuberuntime/kuberuntime_gc.go:407
func (cgc *containerGC) GarbageCollect(ctx context.Context, gcPolicy kubecontainer.GCPolicy, allSourcesReady bool, evictNonDeletedPods bool) error {
	ctx, otelSpan := cgc.tracer.Start(ctx, "Containers/GarbageCollect")
	defer otelSpan.End()
	errors := []error{}
	// Remove evictable containers
	if err := cgc.evictContainers(ctx, gcPolicy, allSourcesReady, evictNonDeletedPods); err != nil {
		errors = append(errors, err)
	}

	// Remove sandboxes with zero containers
	if err := cgc.evictSandboxes(ctx, evictNonDeletedPods); err != nil {
		errors = append(errors, err)
	}

	// Remove pod sandbox log directory
	if err := cgc.evictPodLogsDirectories(ctx, allSourcesReady); err != nil {
		errors = append(errors, err)
	}
	return utilerrors.NewAggregate(errors)
}

这里特别明确的写出了三个清理的步骤: evictContainers 容器、evictSandboxes 沙盒、evictPodLogsDirectories 日志。当然,我们更关心容器的回收,那我们就来看看 evictContainers 是如何实现的。

代码语言:javascript
复制
// pkg/kubelet/kuberuntime/kuberuntime_gc.go:226
// evict all containers that are evictable
func (cgc *containerGC) evictContainers(ctx context.Context, gcPolicy kubecontainer.GCPolicy, allSourcesReady bool, evictNonDeletedPods bool) error {
	// Separate containers by evict units.
	evictUnits, err := cgc.evictableContainers(ctx, gcPolicy.MinAge)
	if err != nil {
		return err
	}

	// Remove deleted pod containers if all sources are ready.
	if allSourcesReady {
		for key, unit := range evictUnits {
			if cgc.podStateProvider.ShouldPodContentBeRemoved(key.uid) || (evictNonDeletedPods && cgc.podStateProvider.ShouldPodRuntimeBeRemoved(key.uid)) {
				cgc.removeOldestN(ctx, unit, len(unit)) // Remove all.
				delete(evictUnits, key)
			}
		}
	}

	// Enforce max containers per evict unit.
	if gcPolicy.MaxPerPodContainer >= 0 {
		cgc.enforceMaxContainersPerEvictUnit(ctx, evictUnits, gcPolicy.MaxPerPodContainer)
	}

	// Enforce max total number of containers.
	if gcPolicy.MaxContainers >= 0 && evictUnits.NumContainers() > gcPolicy.MaxContainers {
		// Leave an equal number of containers per evict unit (min: 1).
		numContainersPerEvictUnit := gcPolicy.MaxContainers / evictUnits.NumEvictUnits()
		if numContainersPerEvictUnit < 1 {
			numContainersPerEvictUnit = 1
		}
		cgc.enforceMaxContainersPerEvictUnit(ctx, evictUnits, numContainersPerEvictUnit)

		// If we still need to evict, evict oldest first.
		numContainers := evictUnits.NumContainers()
		if numContainers > gcPolicy.MaxContainers {
			flattened := make([]containerGCInfo, 0, numContainers)
			for key := range evictUnits {
				flattened = append(flattened, evictUnits[key]...)
			}
			sort.Sort(byCreated(flattened))

			cgc.removeOldestN(ctx, flattened, numContainers-gcPolicy.MaxContainers)
		}
	}
	return nil
}

可以看到,关键就是通过 evictableContainers 找到所有可驱逐的容器,然后通过 removeOldestN 方法来实现删除。在 removeContainer 其实就是去排序,然后通过之前我们看过的 killContainerremoveContainer 来操作容器,具体的操作人是 kubeGenericRuntimeManager。相类似的 evictSandboxes 沙盒、evictPodLogsDirectories 日志 这里就不再具体描述了,有兴趣的可以继续追一下。

imageManager.GarbageCollect

重点来了,镜像其实对于我们来说是比较重要需要关注的。由于镜像过多很容易占满磁盘,那么 k8s 是如何知道需要删除哪些镜像的呢?

如果是 docker,我们常常会使用 docker system prune 命令来进行清理

原理其实不复杂,让我们直接来看源码吧

代码语言:javascript
复制
func (im *realImageGCManager) GarbageCollect(ctx context.Context) error {
	ctx, otelSpan := im.tracer.Start(ctx, "Images/GarbageCollect")
	defer otelSpan.End()
	// Get disk usage on disk holding images.
	fsStats, err := im.statsProvider.ImageFsStats(ctx)
	if err != nil {
		return err
	}

	var capacity, available int64
	if fsStats.CapacityBytes != nil {
		capacity = int64(*fsStats.CapacityBytes)
	}
	if fsStats.AvailableBytes != nil {
		available = int64(*fsStats.AvailableBytes)
	}

	if available > capacity {
		klog.InfoS("Availability is larger than capacity", "available", available, "capacity", capacity)
		available = capacity
	}

	// ....

	// If over the max threshold, free enough to place us at the lower threshold.
	usagePercent := 100 - int(available*100/capacity)
	if usagePercent >= im.policy.HighThresholdPercent {
		amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available
		klog.InfoS("Disk usage on image filesystem is over the high threshold, trying to free bytes down to the low threshold", "usage", usagePercent, "highThreshold", im.policy.HighThresholdPercent, "amountToFree", amountToFree, "lowThreshold", im.policy.LowThresholdPercent)
		freed, err := im.freeSpace(ctx, amountToFree, time.Now())
		if err != nil {
			return err
		}

		// ....
	}

	return nil
}

其中需要注意几点:

  1. 通过 im.statsProvider.ImageFsStats 得到磁盘的使用率,也就是用了多少磁盘
  2. 通过对比 usagePercentHighThresholdPercent 来判断是否需要 GC,HighThresholdPercent 默认 85%
  3. 通过 im.freeSpace 来清理镜像

最后来看 im.freeSpace 是如何做的

代码语言:javascript
复制
func (im *realImageGCManager) freeSpace(ctx context.Context, bytesToFree int64, freeTime time.Time) (int64, error) {
	imagesInUse, err := im.detectImages(ctx, freeTime)
	// ...

	im.imageRecordsLock.Lock()
	defer im.imageRecordsLock.Unlock()

	// Get all images in eviction order.
	images := make([]evictionInfo, 0, len(im.imageRecords))
	for image, record := range im.imageRecords {
		if isImageUsed(image, imagesInUse) {
			klog.V(5).InfoS("Image ID is being used", "imageID", image)
			continue
		}
		// Check if image is pinned, prevent garbage collection
		if record.pinned {
			klog.V(5).InfoS("Image is pinned, skipping garbage collection", "imageID", image)
			continue

		}
		images = append(images, evictionInfo{
			id:          image,
			imageRecord: *record,
		})
	}
	sort.Sort(byLastUsedAndDetected(images))

	// Delete unused images until we've freed up enough space.
	var deletionErrors []error
	spaceFreed := int64(0)
	for _, image := range images {
		// ....

		// Remove image. Continue despite errors.
		klog.InfoS("Removing image to free bytes", "imageID", image.id, "size", image.size)
		err := im.runtime.RemoveImage(ctx, container.ImageSpec{Image: image.id})
		if err != nil {
			deletionErrors = append(deletionErrors, err)
			continue
		}
		// ....
	}

	// ....
	return spaceFreed, nil
}
  1. 获取正在使用的 images
  2. 判断所有镜像的是否在使用,不使用的添加到待删除的 images 里面
  3. 根据最近使用时间排序,最后 RemoveImage

很好理解的逻辑,正常人也都是这样想的,找到那些不用的,然后最久没有使用的先移除。其中有一个关键点是这个过程中 imageRecordsLock 是锁了的,防止了并发记录的修改。

码后解答

  1. K8S 的 GC 回收哪些资源?容器(Container)资源和镜像(Image)资源
  2. K8S 的 GC 什么时候运行?容器 GC 默认是 1 分钟,镜像 GC 默认是 5 分钟
  3. K8S 的 GC 是谁运行的?由于还是需要在 node 上操作容器等所以最后的苦力还是交给了 kubelet 来完成

额外扩展

GC 的参数有一些可以配置的值如 --image-gc-high-threshold 根据磁盘使用空间的百分比来判断是否需要 GC。这些配置项可以在 https://kubernetes.io/zh-cn/docs/reference/command-line-tools-reference/kubelet/ 找到。

总结提升

实际经验

k8s 的 GC 设计很大程度上避免了磁盘资源使用带来的意外,所以平常正常使用的情况下一般不会出现问题,并且现在都上云了,磁盘一旦有任何问题,即将满了,会报警,运维的反应会更快。那么在实际的使用中,最容易出现问题的,不是镜像而是日志。遇到最多的就是意外是:有 pod 坏种(资源占用过多),导致节点资源不够,开始被驱逐,然后不断污染各个节点,导致雪崩的时候。过程中会导致 pod 不断创建或销毁,并且会出现各种 OOM 的日志 (The node had condition: [DiskPressure]),导致节点磁盘满。这里的节点还不一定是 worker 先满的,master 也有可能哦。避免方式一个是限制 resources,一个是定期关注或直接监控日志,并不一定是 docker 的日志,有时是 k8s 本身的日志。

编码上

在编码上,有一个地方值得我们学习学习。是在启动 StartGarbageCollection 的时候,看到了一个方法是

代码语言:javascript
复制
go wait.Until(fn, ContainerGCPeriod, wait.NeverStop)

这里的这个 wait.Until 封装的很有意思,非常值得我们学习。比如,如果让你写一个不会因为 panic 而停止,一直定时运行的 goroutine 启动方法,你会如何封装呢?在没有看到这个方法之前,我的封装无外乎就是利用 defer + recover 的方式,然后用个 ticker 就完事了。因为一旦启动一个 goroutine 意味着就有 panic 的风险,所以一定会有一个 recover 去捕获。但是,这样一旦 panic 之后,就会停止,再也不运行了,那么势必就需要在 recover 之后重新启动一个新的 goroutine 去运行(有一点递归的意思在里面了)。这样的封装其实不够优雅。而 k8s 这里的封装就很有意思了。他不仅在 最后的 BackoffUntil 做了抽离,让运行是运行,定时是定时,然后通过 defer runtime.HandleCrash() 来捕获。并且在其中还实现支持了 Backoff。所以这个封装其实很值得我们去学习和使用。

代码语言:javascript
复制
// vendor/k8s.io/apimachinery/pkg/util/wait/backoff.go:210
func BackoffUntil(f func(), backoff BackoffManager, sliding bool, stopCh <-chan struct{}) {
	var t clock.Timer
	for {
		select {
		case <-stopCh:
			return
		default:
		}

		if !sliding {
			t = backoff.Backoff()
		}

		func() {
			defer runtime.HandleCrash()
			f()
		}()

		if sliding {
			t = backoff.Backoff()
		}

		// NOTE: b/c there is no priority selection in golang
		// it is possible for this to race, meaning we could
		// trigger t.C and stopCh, and t.C select falls through.
		// In order to mitigate we re-check stopCh at the beginning
		// of every loop to prevent extra executions of f().
		select {
		case <-stopCh:
			if !t.Stop() {
				<-t.C()
			}
			return
		case <-t.C():
		}
	}
}

是的,这个代码其实可以直接被抄过来用的,当作一个工具封装起来,这样 goroutine 用的会很放心。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-03-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 心路历程
  • 码前提问
  • 源码分析
    • containerGC.GarbageCollect
      • imageManager.GarbageCollect
      • 码后解答
      • 额外扩展
      • 总结提升
        • 实际经验
          • 编码上
          相关产品与服务
          容器服务
          腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档