📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
作为 k8s 的使用者而非维护者来说,对于 k8s 的 GC 其实是很难接触到的(几乎是无感的)。这也就是为什么标题写的惊讶 “原来 k8s 也有 GC”。GC 这个概念在很多语言中都有的,比如 Java 和 Golang,它就是帮助我们来回收垃圾的。在编程语言中,GC 主要是回收那些垃圾对象;那么相对于的 k8s 中,GC 需要回收哪些资源呢?今天的内容不复杂,源码里面都是那种很符合直觉的实现。
其实,我一开始最好奇的就是镜像,由于 docker 镜像的大小我们是可想而知的。就算是我们常常使用的本地电脑,磁盘都有可能被占用很多,更别提是服务器这种动不动就更新镜像的情况了。
今天的入口还是比较好找的,因为很明确的命名 GarbageCollection
找到它,肯定就是了。首先,我们依旧先来看接口
// 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
的具体实现,同样的,我们去掉不想干的日志和分支。主干如下:
// 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
一个是 ImageGC
,ContainerGCPeriod
是 1 分钟,ImageGCPeriod
是 5 分钟。从名字来看这里我们已经可以看出一些端倪了。一个是对于容器的 GC,也就是回收哪些停止但是没有回收资源的容器;另一个就是我们开头关心的镜像了。
那么,我们接下来就分别看看 containerGC.GarbageCollect
和 imageManager.GarbageCollect
做了什么吧。
首先,一条路往下走,GarbageCollect
-> cgc.runtime.GarbageCollect
-> m.containerGC.GarbageCollect
// 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
是如何实现的。
// 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
其实就是去排序,然后通过之前我们看过的 killContainer
和 removeContainer
来操作容器,具体的操作人是 kubeGenericRuntimeManager
。相类似的 evictSandboxes
沙盒、evictPodLogsDirectories
日志 这里就不再具体描述了,有兴趣的可以继续追一下。
重点来了,镜像其实对于我们来说是比较重要需要关注的。由于镜像过多很容易占满磁盘,那么 k8s 是如何知道需要删除哪些镜像的呢?
如果是 docker,我们常常会使用
docker system prune
命令来进行清理
原理其实不复杂,让我们直接来看源码吧
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
}
其中需要注意几点:
im.statsProvider.ImageFsStats
得到磁盘的使用率,也就是用了多少磁盘usagePercent
和 HighThresholdPercent
来判断是否需要 GC,HighThresholdPercent
默认 85%im.freeSpace
来清理镜像最后来看 im.freeSpace
是如何做的
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
}
RemoveImage
很好理解的逻辑,正常人也都是这样想的,找到那些不用的,然后最久没有使用的先移除。其中有一个关键点是这个过程中 imageRecordsLock 是锁了的,防止了并发记录的修改。
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
的时候,看到了一个方法是
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
。所以这个封装其实很值得我们去学习和使用。
// 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 用的会很放心。