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

前言

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

心路历程

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

码前提问

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

源码分析

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

1
2
3
4
5
6
7
// 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 的具体实现,同样的,我们去掉不想干的日志和分支。主干如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 是如何实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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 命令来进行清理

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 是如何做的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 的时候,看到了一个方法是

1
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。所以这个封装其实很值得我们去学习和使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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 用的会很放心。