Kubernetes 容器和镜像 GC 原理解析

云原生实验室

共 24728字,需浏览 50分钟

 · 2022-06-17


本文转自王勇的博客,原文:https://www.jianshu.com/p/f33b44456c01,版权归原作者所有。欢迎投稿,投稿请添加微信好友:cloud-native-yang

容器 GC

退出的容器也会继续占用系统资源,比如还会在文件系统存储很多数据、Docker 应用也要占用 CPU 和内存去维护这些容器。Docker 本身并不会自动删除已经退出的容器,因此 kubelet 就负起了这个责任。kubelet 容器的回收是为了删除已经退出的容器以节省节点的空间,提升性能。

容器 GC 虽然有利于空间和性能,但是删除容器也会导致错误现场被清理,不利于 debug 和错误定位,因此不建议把所有退出的容器都删除。因此容器的清理需要一定的策略,主要是告诉 kubelet 你要保存多少已经退出的容器。和容器 GC 有关的可以配置的 kubelet 启动参数包括:

  • minimum-container-ttl-duration:container 结束多长时间之后才能够被回收,默认是一分钟
  • maximum-dead-containers-per-container:每个 container 最终可以保存多少个已经结束的容器,默认是 1,设置为负数表示不做限制
  • maximum-dead-containers:节点上最多能保留多少个结束的容器,默认是 -1,表示不做限制

也就是说默认情况下,kubelet 会自动每分钟去做容器 GC,容器退出一分钟之后就可以被删除,而且每个容器做多只会保留一个已经退出的历史容器。

type containerGC struct {
    // client 用来和 docker API 交互,比如获取容器列表、查看某个容器的详细信息等
    client           DockerInterface
    podGetter        podGetter
    containerLogsDir string
}

func NewContainerGC(client DockerInterface, podGetter podGetter, containerLogsDir string) *containerGC {
    return &containerGC{
        client:           client,
        podGetter:        podGetter,
        containerLogsDir: containerLogsDir,
    }
}

func (cgc *containerGC) GarbageCollect(gcPolicy kubecontainer.ContainerGCPolicy, allSourcesReady bool) error {
    // 找到可以清理的容器列表,条件是不在运行并且创建时间超过 MinAge。
    // 这个步骤会过滤掉不是 kubelet 管理的容器,并且把容器按照创建时间进行排序(也就是说最早创建的容器会先被删除)
    // evictUnits 返回的是需要被正确回收的,第二个参数是 kubelet 无法识别的容器
    evictUnits, unidentifiedContainers, err := cgc.evictableContainers(gcPolicy.MinAge)
    ......

    // 删除无法识别的容器
    for _, container := range unidentifiedContainers {
        glog.Infof("Removing unidentified dead container %q with ID %q", container.name, container.id)
        err = cgc.client.RemoveContainer(container.id, dockertypes.ContainerRemoveOptions{RemoveVolumes: true})
        if err != nil {
            glog.Warningf("Failed to remove unidentified dead container %q: %v", container.name, err)
        }
    }

    // 如果 pod 已经不存在了,就删除其中所有的容器
    if allSourcesReady {
        for key, unit := range evictUnits {
            if cgc.isPodDeleted(key.uid) {
                cgc.removeOldestN(unit, len(unit)) // Remove all.
                delete(evictUnits, key)
            }
        }
    }

    // 执行 GC 策略,保证每个 POD 最多只能保存 MaxPerPodContainer 个已经退出的容器
    if gcPolicy.MaxPerPodContainer >= 0 {
        cgc.enforceMaxContainersPerEvictUnit(evictUnits, gcPolicy.MaxPerPodContainer)
    }

    // 执行 GC 策略,保证节点上最多有 MaxContainers 个已经退出的容器
    // 先把最大容器数量平分到 pod,保证每个 pod 在平均数量以下;如果还不满足要求的数量,就按照时间顺序先删除最旧的容器
    if gcPolicy.MaxContainers >= 0 && evictUnits.NumContainers() > gcPolicy.MaxContainers {
        // 先按照 pod 进行删除,每个 pod 能保留的容器数是总数的平均值
        numContainersPerEvictUnit := gcPolicy.MaxContainers / evictUnits.NumEvictUnits()
        if numContainersPerEvictUnit < 1 {
            numContainersPerEvictUnit = 1
        }
        cgc.enforceMaxContainersPerEvictUnit(evictUnits, numContainersPerEvictUnit)

        // 如果还不满足数量要求,按照容器进行删除,先删除最老的
        numContainers := evictUnits.NumContainers()
        if numContainers > gcPolicy.MaxContainers {
            flattened := make([]containerGCInfo, 0, numContainers)
            for uid := range evictUnits {
                flattened = append(flattened, evictUnits[uid]...)
            }
            sort.Sort(byCreated(flattened))

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

    ......
    return nil
}

这段代码才是容器 GC 的核心逻辑,它做的事情是这样的:

  • 先从正在运行的容器中找到可以被清理的,包括符合清理条件或者不被 kubelet 识别的容器
  • 直接删除不能识别的容器,以及 pod 信息已经不存在的容器
  • 根据配置的容器删除策略,对剩下的容器进行删除

镜像 GC

镜像主要占用磁盘空间,虽然 docker 使用镜像分层可以让多个镜像共享存储,但是长时间运行的节点如果下载了很多镜像也会导致占用的存储空间过多。如果镜像导致磁盘被占满,会造成应用无法正常工作。docker 默认也不会做镜像清理,镜像一旦下载就会永远留在本地,除非被手动删除。

其实很多镜像并没有被实际使用,这些不用的镜像继续占用空间是非常大的浪费,也是巨大的隐患,因此 kubelet 也会周期性地去清理镜像。

镜像的清理和容器不同,是以占用的空间作为标准的,用户可以配置当镜像占据多大比例的存储空间时才进行清理。清理的时候会优先清理最久没有被使用的镜像,镜像被 pull 下来或者被容器使用都会更新它的最近使用时间。

启动 kubelet 的时候,可以配置这些参数控制镜像清理的策略:

  • image-gc-high-threshold:磁盘使用率的上限,当达到这一使用率的时候会触发镜像清理。默认值为 90%
  • image-gc-low-threshold:磁盘使用率的下限,每次清理直到使用率低于这个值或者没有可以清理的镜像了才会停止. 默认值为 80%
  • minimum-image-ttl-duration:镜像最少这么久没有被使用才会被清理,可以使用 h(小时)、m(分钟)、s(秒)和 ms(毫秒)时间单位进行配置,默认是 2m(两分钟)

也就是说,默认情况下,当镜像占满所在盘 90% 容量的时候,kubelet 就会进行清理,一直到镜像占用率低于 80% 为止。

参数配置

用户可以使用以下 kubelet 参数调整相关阈值来优化镜像垃圾回收:

  1. image-gc-high-threshold,触发镜像垃圾回收的磁盘使用率百分比。默认值为 8。如果该值设置为 100,则会停止镜像垃圾回收。
  2. image-gc-low-threshold,镜像垃圾回收试图释放资源后达到的磁盘使用率百分比。默认值为 80。
  3. minimum-image-ttl-duration,默认 2m0s,回收 image 最小年龄

垃圾回收过程中可能上报的相关事件有:

  • ContainerGCFailed:每 1min 执行一次容器垃圾回收,如果执行失败,则上报该事件
  • ImageGCFailed:每 5min 执行一次镜像垃圾回收,如果执行失败,则上报该事件
  • FreeDiskSpaceFailed:如果执行镜像垃圾回收时,被清理空间不满足要求,则上报该异常事件
  • InvalidDiskCapacity:如果 image disk 容量为 0,则上报该异常事件

接口定义

type ImageGCManager interface {
  // 执行垃圾回收策略,如果根据垃圾回收策略不能释放足够的空间,则会返回 error
    GarbageCollect() error
    // 启动异步垃圾镜像回收
    Start()

    GetImageList() ([]container.Image, error)
    // 删除所有无用镜像
    DeleteUnusedImages() error
}

初始化

ImageGCManager 是在 kubelet.NewMainKubelet()方法中完成初始化的:

// setup imageManager
imageManager, err := images.NewImageGCManager(klet.containerRuntime, klet.StatsProvider, kubeDeps.Recorder, nodeRef, imageGCPolicy, crOptions.PodSandboxImage)
if err != nil {
    return nil, fmt.Errorf("failed to initialize image manager: %v", err)
}
klet.imageManager = imageManager

realImageGCManager.Start()

ImageGCManager 的启动是在 kubelet.initializeModules() 方法中完成的。ImageGCManager 在启动后开始异步执行两个任务:

  • 每 5min 更新一次正在使用的镜像列表信息
  • 每 30s 更新一次镜像缓存
func (im *realImageGCManager) Start() {
    go wait.Until(func() {
        var ts time.Time
        if im.initialized {
            ts = time.Now()
        }
        _, err := im.detectImages(ts) // 更新缓存镜像列表,并返回正在使用的镜像列表
        if err != nil {
            klog.Warningf("[imageGCManager] Failed to monitor images: %v", err)
        } else {
            im.initialized = true
        }
    }, 5*time.Minute, wait.NeverStop) // 每5min探测一次

    // 每30s更新一次镜像缓存
    go wait.Until(func() {
        images, err := im.runtime.ListImages()
        if err != nil {
            klog.Warningf("[imageGCManager] Failed to update image list: %v", err)
        } else {
            im.imageCache.set(images)
        }
    }, 30*time.Second, wait.NeverStop)

}

启动垃圾回收

kubelet 启动完成后, 会开启垃圾回收异步线程。它会

  • 每 1min 执行一次容器垃圾回收,如果执行失败,则上报事件 ContainerGCFailed
func (kl *Kubelet) StartGarbageCollection() {
    loggedContainerGCFailure := false
    go wait.Until(func() {
        if err := kl.containerGC.GarbageCollect(); err != nil {  // 每 1min 执行一次容器垃圾回收,如果执行失败,则上报事件 ContainerGCFailed
            klog.Errorf("Container garbage collection failed: %v", err)
            kl.recorder.Eventf(kl.nodeRef, v1.EventTypeWarning, events.ContainerGCFailed, err.Error())
            loggedContainerGCFailure = true
        } else {
            var vLevel klog.Level = 4
            if loggedContainerGCFailure {
                vLevel = 1
                loggedContainerGCFailure = false
            }

            klog.V(vLevel).Infof("Container garbage collection succeeded")
        }
    }, ContainerGCPeriod, wait.NeverStop)

    // 如果 --image-gc-high-threshold=100,则会停止镜像垃圾回收。
    if kl.kubeletConfiguration.ImageGCHighThresholdPercent == 100 {
        klog.V(2).Infof("ImageGCHighThresholdPercent is set 100, Disable image GC")
        return
    }

    prevImageGCFailed := false
    go wait.Until(func() {
        if err := kl.imageManager.GarbageCollect(); err != nil { // 每 5min 执行一次镜像垃圾回收,如果执行失败,则上报 ImageGCFailed 事件
            if prevImageGCFailed {
                klog.Errorf("Image garbage collection failed multiple times in a row: %v", err)
                // Only create an event for repeated failures
                kl.recorder.Eventf(kl.nodeRef, v1.EventTypeWarning, events.ImageGCFailed, err.Error())
            } else {
                klog.Errorf("Image garbage collection failed once. Stats initialization may not have completed yet: %v", err)
            }
            prevImageGCFailed = true
        } else {
            var vLevel klog.Level = 4
            if prevImageGCFailed {
                vLevel = 1
                prevImageGCFailed = false
            }

            klog.V(vLevel).Infof("Image garbage collection succeeded")
        }
    }, ImageGCPeriod, wait.NeverStop)
}

realImageGCManager.GarbageCollect()

镜像垃圾回收的执行过程为:

  1. cadvisor 获取 image 磁盘信息
  2. 计算磁盘容量和磁盘利用率
  3. 如果磁盘利用率达到 --image-gc-high-threshold 设置的上限,则进行镜像垃圾回收
  4. 如果执行完镜像垃圾回收后,释放的空间没达到预期值,则上报 FreeDiskSpaceFailed异常事件
func (im *realImageGCManager) GarbageCollect() error {
    // 从 cadvisor 获取 image 磁盘信息
    fsStats, err := im.statsProvider.ImageFsStats()
    if err != nil {
        return err
    }

    var capacity, available int64
    if fsStats.CapacityBytes != nil { // image 磁盘容器
        capacity = int64(*fsStats.CapacityBytes)
    }
    if fsStats.AvailableBytes != nil { // image 磁盘可用空间
        available = int64(*fsStats.AvailableBytes)
    }

    if available > capacity { // 修正磁盘容量大小
        klog.Warningf("available %d is larger than capacity %d", available, capacity)
        available = capacity
    }

    // Check valid capacity.
    if capacity == 0 { // 如果磁盘容量为0,则上报 InvalidDiskCapacity 异常时间
        err := goerrors.New("invalid capacity 0 on image filesystem")
        im.recorder.Eventf(im.nodeRef, v1.EventTypeWarning, events.InvalidDiskCapacity, err.Error())
        return err
    }

    usagePercent := 100 - int(available*100/capacity) // 磁盘使用率达到上限
    if usagePercent >= im.policy.HighThresholdPercent {
        amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available // 计算要清理的磁盘空间大小
        klog.Infof("[imageGCManager]: Disk usage on image filesystem is at %d%% which is over the high threshold (%d%%). Trying to free %d bytes down to the low threshold (%d%%).", usagePercent, im.policy.HighThresholdPercent, amountToFree, im.policy.LowThresholdPercent)
        freed, err := im.freeSpace(amountToFree, time.Now()) // 清理镜像,并返回清理的空间大小
        if err != nil {
            return err
        }

        if freed < amountToFree { // 如果被清理空间不满足要求,则上报 FreeDiskSpaceFailed 异常事件
            err := fmt.Errorf("failed to garbage collect required amount of images. Wanted to free %d bytes, but freed %d bytes", amountToFree, freed)
            im.recorder.Eventf(im.nodeRef, v1.EventTypeWarning, events.FreeDiskSpaceFailed, err.Error())
            return err
        }
    }

    return nil
}

释放磁盘空间(freeSpace)

这里记录了镜像垃圾回收的详细过程:

  1. 列出所有未在使用的镜像
  2. 安装最后使用时间和探测时间由远到近排序
  3. 遍历列表,按时间由远到近清理镜像
  4. 再次判断,如果镜像正在使用,则不进行清理。判断镜像初次被探测时间,避免清理拉取时间较短的镜像,因为这些镜像可能刚被拉取下来,马上要被某个容器使用
  5. 调用 runtime 接口删除无用镜像,直到释放足够的空间为止
func (im *realImageGCManager) freeSpace(bytesToFree int64, freeTime time.Time) (int64, error) {
    imagesInUse, err := im.detectImages(freeTime) // 更新正在使用的镜像列表,并返回正在使用的镜像列表
    if err != nil {
        return 0, err
    }

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

    // 列出所有没在使用的镜像
    images := make([]evictionInfo, 0len(im.imageRecords))
    for image, record := range im.imageRecords {
        if isImageUsed(image, imagesInUse) {
            klog.V(5).Infof("Image ID %s is being used", image)
            continue
        }
        images = append(images, evictionInfo{
            id:          image,
            imageRecord: *record,
        })
    }
    sort.Sort(byLastUsedAndDetected(images))  // 按照最后使用时间和探测时间排序
    // 删除无用的镜像,直到释放足够的空间为止
    var deletionErrors []error
    spaceFreed := int64(0)
    for _, image := range images {
        klog.V(5).Infof("Evaluating image ID %s for possible garbage collection", image.id)
        // 再次判断镜像是否正在使用
        if image.lastUsed.Equal(freeTime) || image.lastUsed.After(freeTime) {
            klog.V(5).Infof("Image ID %s has lastUsed=%v which is >= freeTime=%v, not eligible for garbage collection", image.id, image.lastUsed, freeTime)
            continue
        }
    // 避免清理拉取时间较短的镜像,因为这些镜像可能刚被拉取下来,马上要被某个容器使用
        if freeTime.Sub(image.firstDetected) < im.policy.MinAge {
            klog.V(5).Infof("Image ID %s has age %v which is less than the policy's minAge of %v, not eligible for garbage collection", image.id, freeTime.Sub(image.firstDetected), im.policy.MinAge)
            continue
        }
        // 清理镜像,即便发生error
        klog.Infof("[imageGCManager]: Removing image %q to free %d bytes", image.id, image.size)
        err := im.runtime.RemoveImage(container.ImageSpec{Image: image.id})
        if err != nil {
            deletionErrors = append(deletionErrors, err)
            continue
        }
        delete(im.imageRecords, image.id)
        spaceFreed += image.size

        if spaceFreed >= bytesToFree {
            break
        }
    }

    if len(deletionErrors) > 0 {
        return spaceFreed, fmt.Errorf("wanted to free %d bytes, but freed %d bytes space with errors in image deletion: %v", bytesToFree, spaceFreed, errors.NewAggregate(deletionErrors))
    }
    return spaceFreed, nil
}



你可能还喜欢

点击下方图片即可阅读

构建我的第一个 22TB 容量的家庭存储服务器

2022-06-11

如何在 Mac 上愉快地使用 Docker

2022-06-10

理解 Kubernetes 中的 NUMA 架构

2022-06-08


云原生是一种信仰 🤘

关注公众号

后台回复◉k8s◉获取史上最方便快捷的 Kubernetes 高可用部署工具,只需一条命令,连 ssh 都不需要!



点击 "阅读原文" 获取更好的阅读体验!


发现朋友圈变“安静”了吗?

浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报