Kubernetes容器和镜像GC原理讲解

容器GC

退出容器继续使用系统资源,例如在文件系统上存储大量数据以及 Docker 应用程序用于维护这些容器的 CPU 和内存。

Docker 本身不会自动删除现有的容器,因此 kubelet 承担了这个责任。kubelet 容器回收用于删除现有容器,以节省节点空间并提高性能。

虽然容器 GC 对空间和性能都有好处,但删除容器也会导致错误站点被清理,这对调试和错误定位不利,因此不建议删除所有退出的容器。所以容器清理需要一定的策略,主要是告诉kubelet你想保留多少个退出的容器。与容器 GC 相关的可配置 kubelet 启动参数包括

  • minimum-container-ttl-duration: 容器结束后多久可以回收,默认是一分钟
  • maximum-dead-containers-per-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 也会定期清理镜像。

与容器不同,镜像的清理是基于它们占用的空间大小,用户可以配置镜像在清理之前占用的存储空间百分比。清理将优先考虑最长未使用的图像,并在它被拉下或被容器使用时更新其最近的使用时间。

在启动 kubelet 时,您可以配置这些参数来控制镜像清理的策略。

  • image-gc-high-threshold:达到此使用量时将触发图像清理的磁盘使用量上限。默认值为 90%。
  • image-gc-low-threshold:磁盘使用下限,每次清理不会停止,直到使用率低于此值或没有更多图像需要清理。默认值为 80%。
  • minimum-image-ttl-duration:只有在至少没有使用这么长时间的情况下才会清理图像,可配置为 h(小时)、m(分钟)、s(秒)和 ms(毫秒)时间单位,默认为 2m(两分钟) )

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

 

 

参数配置

用户可以使用以下 kubelet 参数调整相关阈值以优化图像垃圾收集。

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

 

垃圾回收期间可能会报告以下事件。

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

 

 

图像GC管理器

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 启动后开始异步执行两个任务。

  • 每 5 分钟更新一次有关正在使用的图像列表的信息。
  • 每 30 秒更新一次图像缓存。
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 启动时,它会打开一个垃圾收集异步线程。它会

  • 每 1 分钟执行一次容器垃圾回收,如果失败则报告事件 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
  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. 调用运行时接口删除无用的图像,直到释放足够的空间。
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, 0, len(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
}

 

发表评论