Garbage Collector Controller 深度解析

1. 概述

Garbage Collector Controller(以下简称 GC Controller)是 Volcano Controller Manager 中负责自动清理已完成生命周期的 Job 资源的专用控制器。在大规模批处理场景下,集群中会产生大量已完成、失败或被终止的 Job 对象,如果不及时清理,这些残留资源将持续占用 etcd 存储空间和 API Server 的内存,最终影响集群性能。

GC Controller 通过 TTL (Time-To-Live) 机制实现了优雅的资源回收策略。用户可以在 Job 的 Spec 中设置 ttlSecondsAfterFinished 字段,指定 Job 在进入终态(Completed、Failed、Terminated)后存活的秒数。一旦 TTL 到期,GC Controller 会自动级联删除该 Job 及其关联的子资源(如 Pod、PodGroup 等)。

1.1 核心职责

职责说明
监听 Job 事件通过 Informer 监听 Job 的创建和更新事件
TTL 计算根据 Job 完成时间和配置的 TTL 计算过期时间
延迟入队对未过期的 Job 设置延迟重新检查
安全删除通过双重检查模式确保删除操作的正确性
级联清理使用 Foreground 级联删除策略清理 Job 及其子资源

1.2 在 Volcano 系统中的位置

graph TB subgraph cm["Controller Manager"] direction TB JC["Job Controller"] QC["Queue Controller"] PGC["PodGroup Controller"] JFC["JobFlow Controller"] CJC["CronJob Controller"] GC["GC Controller"] style GC fill:#e74c3c,stroke:#c0392b,color:#fff end subgraph api["API Server / etcd"] Jobs["Volcano Jobs"] Pods["Pods"] PGs["PodGroups"] end JC -->|"创建/管理"| Jobs JC -->|"创建/管理"| Pods JC -->|"创建/管理"| PGs GC -->|"监听 Job 事件"| Jobs GC -->|"级联删除过期 Job"| Jobs Jobs -.->|"级联删除"| Pods Jobs -.->|"级联删除"| PGs

GC Controller 独立于 Job Controller 运行,遵循关注点分离的设计原则。Job Controller 负责 Job 的全生命周期管理(创建、调度、运行、状态更新),而 GC Controller 专注于已完成 Job 的清理回收。这种分离使得两个控制器可以独立演进,降低了代码耦合度。

1.3 源码位置

文件说明
pkg/controllers/garbagecollector/garbagecollector.goGC Controller 核心实现
pkg/controllers/garbagecollector/garbagecollector_test.go单元测试
cmd/controller-manager/app/options/options.go命令行参数定义
pkg/controllers/framework/interface.goController 框架接口定义

2. 核心架构

2.1 架构总览

graph TB subgraph apiserver["API Server"] JobAPI["Volcano Job API"] end subgraph gcctrl["GC Controller"] direction TB subgraph informer["Informer 层"] JI["Job Informer"] JL["Job Lister (缓存)"] end subgraph handler["事件处理层"] AddH["addJob()"] UpdH["updateJob()"] end subgraph queue_layer["工作队列层"] WQ["Rate-Limited WorkQueue"] end subgraph worker_layer["Worker 处理层"] W1["Worker 1"] W2["Worker 2"] WN["Worker N"] end subgraph logic["核心逻辑层"] PJ["processJob()"] PT["processTTL()"] TL["timeLeft()"] NC["needsCleanup()"] end end JobAPI -->|"Watch"| JI JI -->|"缓存"| JL JI -->|"Add 事件"| AddH JI -->|"Update 事件"| UpdH AddH -->|"enqueue"| WQ UpdH -->|"enqueue"| WQ WQ --> W1 WQ --> W2 WQ --> WN W1 --> PJ W2 --> PJ WN --> PJ PJ --> PT PT --> TL PT --> NC PJ -->|"TTL 过期 - 读取最新副本"| JobAPI PJ -->|"确认过期 - 删除 Job"| JobAPI

2.2 gccontroller 结构体

// 源码: pkg/controllers/garbagecollector/garbagecollector.go

type gccontroller struct {
    vcClient    vcclientset.Interface                          // Volcano API 客户端,用于直接访问 API Server
    jobInformer batchinformers.JobInformer                     // Job Informer,监听 Job 事件
    vcInformerFactory vcinformer.SharedInformerFactory          // Informer 工厂,管理 Informer 生命周期
    jobLister   batchlisters.JobLister                         // Job Lister,从本地缓存读取 Job
    jobSynced   func() bool                                    // 缓存同步状态检查函数
    queue       workqueue.TypedRateLimitingInterface[string]   // 带速率限制的工作队列
    workers     uint32                                         // Worker goroutine 数量
}

各字段的职责如下:

字段类型职责
vcClientvcclientset.Interface与 API Server 直接通信,用于获取最新 Job 副本和执行删除操作
jobInformerbatchinformers.JobInformer注册事件处理函数,监听 Job 的创建和更新
vcInformerFactoryvcinformer.SharedInformerFactory管理所有 Informer 的启动和缓存同步
jobListerbatchlisters.JobLister从本地 Informer 缓存中读取 Job,避免直接访问 API Server
jobSyncedfunc() bool检查 Informer 缓存是否已完成初始同步
queueworkqueue.TypedRateLimitingInterface[string]存储待处理的 Job key,支持延迟入队和速率限制
workersuint32并发 Worker 数量,默认为 1,可通过 --worker-threads-for-gc 配置

3. Controller 注册与初始化

3.1 注册流程

GC Controller 采用 Volcano Controller 框架的标准注册模式,通过 init() 函数在包加载时自动注册:

// 源码: pkg/controllers/garbagecollector/garbagecollector.go

func init() {
    framework.RegisterController(&gccontroller{})
}

Controller 的名称通过 Name() 方法返回:

func (gc *gccontroller) Name() string {
    return "gc-controller"
}

3.2 初始化流程

Initialize() 方法负责设置所有依赖组件:

func (gc *gccontroller) Initialize(opt *framework.ControllerOption) error {
    gc.vcClient = opt.VolcanoClient

    factory := opt.VCSharedInformerFactory
    jobInformer := factory.Batch().V1alpha1().Jobs()

    gc.vcInformerFactory = factory
    gc.jobInformer = jobInformer
    gc.jobLister = jobInformer.Lister()
    gc.jobSynced = jobInformer.Informer().HasSynced
    gc.queue = workqueue.NewTypedRateLimitingQueue(
        workqueue.DefaultTypedControllerRateLimiter[string](),
    )
    gc.workers = opt.WorkerThreadsForGC

    jobInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc:    gc.addJob,
        UpdateFunc: gc.updateJob,
        // 注意: 没有 DeleteFunc,GC Controller 不关心删除事件
    })

    return nil
}
sequenceDiagram participant CM as Controller Manager participant GC as GC Controller participant Opt as ControllerOption participant IF as InformerFactory participant JI as Job Informer participant WQ as WorkQueue CM->>GC: Initialize(opt) GC->>Opt: 获取 VolcanoClient GC->>IF: factory.Batch().V1alpha1().Jobs() IF-->>GC: jobInformer GC->>JI: Lister() - 获取缓存查询接口 GC->>JI: Informer().HasSynced - 获取同步检查函数 GC->>WQ: NewTypedRateLimitingQueue() GC->>Opt: 获取 WorkerThreadsForGC GC->>JI: AddEventHandler(Add + Update) GC-->>CM: 初始化完成 CM->>GC: Run(stopCh) GC->>IF: Start(stopCh) - 启动 Informer GC->>IF: WaitForCacheSync(stopCh) IF-->>GC: 缓存同步完成 loop 每个 Worker (i = 0 到 N-1) GC->>GC: go wait.Until(worker, 1s, stopCh) end Note over GC: 阻塞等待 stopCh 关闭

3.3 启动流程 - Run()

Run() 方法是 Controller 的主入口,负责启动 Informer 并创建 Worker goroutine:

func (gc *gccontroller) Run(stopCh <-chan struct{}) {
    defer gc.queue.ShutDown()

    klog.Infof("Starting garbage collector")
    defer klog.Infof("Shutting down garbage collector")

    // 1. 启动 Informer
    gc.vcInformerFactory.Start(stopCh)

    // 2. 等待缓存同步
    for informerType, ok := range gc.vcInformerFactory.WaitForCacheSync(stopCh) {
        if !ok {
            klog.Errorf("caches failed to sync: %v", informerType)
            return
        }
    }

    // 3. 启动 N 个 Worker goroutine
    for i := 0; i < int(gc.workers); i++ {
        go wait.Until(gc.worker, time.Second, stopCh)
    }

    // 4. 阻塞直到收到停止信号
    <-stopCh
}

关键设计点

  • wait.Until 确保 Worker 在 panic 后自动重启(每秒重试一次)
  • defer gc.queue.ShutDown() 确保所有 Worker 在 Controller 退出时优雅终止
  • 缓存同步失败时直接返回,不会启动 Worker

4. Job 事件处理

4.1 事件处理器注册

GC Controller 仅注册了 AddFuncUpdateFunc 两个事件处理器,没有注册 DeleteFunc。这是因为 GC Controller 的职责是删除 Job,而非响应 Job 的删除事件。已删除的 Job 无需再进行清理处理。

4.2 addJob 与 updateJob

这两个方法的逻辑完全相同:

func (gc *gccontroller) addJob(obj interface{}) {
    job := obj.(*v1alpha1.Job)
    klog.V(4).Infof("Adding job %s/%s", job.Namespace, job.Name)

    if job.DeletionTimestamp == nil && needsCleanup(job) {
        gc.enqueue(job)
    }
}

func (gc *gccontroller) updateJob(old, cur interface{}) {
    job := cur.(*v1alpha1.Job)
    klog.V(4).Infof("Updating job %s/%s", job.Namespace, job.Name)

    if job.DeletionTimestamp == nil && needsCleanup(job) {
        gc.enqueue(job)
    }
}

入队前置条件(两个条件必须同时满足):

  1. DeletionTimestamp == nil - Job 未被标记为删除(正在被其他控制器删除的 Job 无需重复处理)
  2. needsCleanup(job) - Job 已完成且配置了 TTL
flowchart TD A["Job 事件到达"] --> B{"事件类型?"} B -->|"Add"| C["addJob()"] B -->|"Update"| D["updateJob()"] B -->|"Delete"| E["无处理器 - 忽略"] C --> F{"DeletionTimestamp == nil?"} D --> F F -->|"否 - 正在删除中"| G["跳过 - 不入队"] F -->|"是"| H{"needsCleanup(job)?"} H -->|"否 - 未完成或无 TTL"| G H -->|"是 - 已完成且有 TTL"| I["enqueue(job)"] I --> J["MetaNamespaceKeyFunc 生成 key"] J --> K["queue.Add(key)"] style E fill:#95a5a6,stroke:#7f8c8d,color:#fff style G fill:#95a5a6,stroke:#7f8c8d,color:#fff style K fill:#27ae60,stroke:#1e8449,color:#fff

4.3 enqueue 与 enqueueAfter

// 立即入队
func (gc *gccontroller) enqueue(job *v1alpha1.Job) {
    key, err := cache.MetaNamespaceKeyFunc(job)
    if err != nil {
        klog.Errorf("couldn't get key for object %#v: %v", job, err)
        return
    }
    gc.queue.Add(key)
}

// 延迟入队 - 用于 TTL 尚未到期的场景
func (gc *gccontroller) enqueueAfter(job *v1alpha1.Job, after time.Duration) {
    key, err := cache.MetaNamespaceKeyFunc(job)
    if err != nil {
        klog.Errorf("couldn't get key for object %#v: %v", job, err)
        return
    }
    gc.queue.AddAfter(key, after)
}

MetaNamespaceKeyFunc 生成的 key 格式为 namespace/name(例如 default/my-job),对于集群级别的资源则只有 name

enqueueAfter 是 GC Controller 实现精确定时清理的关键方法。当发现一个 Job 的 TTL 尚未到期时,不是简单地丢弃它,而是计算出精确的剩余时间,然后延迟重新入队。这样可以在 TTL 到期的瞬间触发清理,既不会过早删除也不会延迟太久。


5. TTL 清理核心算法

这是 GC Controller 最核心的部分,包含了双重检查、时间计算、安全删除等关键逻辑。

5.1 Worker 处理流程

Worker 的执行链路如下:

worker() → processNextWorkItem() → processJob() → handleErr()
func (gc *gccontroller) worker() {
    for gc.processNextWorkItem() {
    }
}

func (gc *gccontroller) processNextWorkItem() bool {
    key, quit := gc.queue.Get()  // 阻塞获取下一个待处理的 key
    if quit {
        return false             // 队列已关闭,退出 Worker
    }
    defer gc.queue.Done(key)     // 标记 key 处理完成

    err := gc.processJob(key)    // 处理 Job
    gc.handleErr(err, key)       // 处理错误

    return true                  // 继续处理下一个
}
sequenceDiagram participant WQ as WorkQueue participant W as Worker participant PJ as processJob participant JL as Job Lister (缓存) participant API as API Server participant HE as handleErr loop 持续处理 W->>WQ: Get() - 阻塞获取 key WQ-->>W: key (namespace/name) W->>PJ: processJob(key) PJ->>JL: Jobs(ns).Get(name) - 查缓存 alt Job 不存在 JL-->>PJ: NotFound PJ-->>W: nil (无需处理) else Job 存在 JL-->>PJ: cached job PJ->>PJ: processTTL(cached) - 第一次检查 alt TTL 未过期 PJ->>WQ: enqueueAfter(remaining) PJ-->>W: nil else TTL 已过期 PJ->>API: Jobs(ns).Get(name) - 获取最新副本 API-->>PJ: fresh job PJ->>PJ: processTTL(fresh) - 第二次检查 alt TTL 仍然过期 PJ->>API: Jobs(ns).Delete(name, opts) API-->>PJ: 删除结果 else TTL 未过期 (被修改) PJ->>WQ: enqueueAfter(remaining) PJ-->>W: nil end end end W->>HE: handleErr(err, key) alt 无错误 HE->>WQ: Forget(key) - 清除重试计数 else 有错误 HE->>WQ: AddRateLimited(key) - 指数退避重试 end W->>WQ: Done(key) - 标记处理完成 end

5.2 processJob - 双重检查模式

processJob 是 GC Controller 中最重要的方法,实现了**双重检查模式 (Double-Check Pattern)**来确保删除操作的安全性:

func (gc *gccontroller) processJob(key string) error {
    namespace, name, err := cache.SplitMetaNamespaceKey(key)
    if err != nil {
        return err
    }

    // ============ 第一阶段: 缓存检查 ============
    // 从本地 Informer 缓存获取 Job,避免给 API Server 施加压力
    job, err := gc.jobLister.Jobs(namespace).Get(name)
    if apierrors.IsNotFound(err) {
        return nil  // Job 已不存在,无需处理
    }
    if err != nil {
        return err
    }

    // 第一次 TTL 检查 (基于缓存数据)
    if expired, err := gc.processTTL(job); err != nil {
        return err
    } else if !expired {
        return nil  // TTL 未过期,已通过 enqueueAfter 延迟重新入队
    }

    // ============ 第二阶段: API Server 确认 ============
    // TTL 看起来已过期,但缓存数据可能是过时的
    // 从 API Server 获取最新副本进行最终确认
    fresh, err := gc.vcClient.BatchV1alpha1().Jobs(namespace).Get(
        context.TODO(), name, metav1.GetOptions{},
    )
    if apierrors.IsNotFound(err) {
        return nil  // Job 在此期间已被删除
    }
    if err != nil {
        return err
    }

    // 第二次 TTL 检查 (基于最新数据)
    if expired, err := gc.processTTL(fresh); err != nil {
        return err
    } else if !expired {
        return nil  // TTL 被修改,实际上未过期
    }

    // ============ 第三阶段: 安全删除 ============
    policy := metav1.DeletePropagationForeground
    options := metav1.DeleteOptions{
        PropagationPolicy: &policy,
        Preconditions:     &metav1.Preconditions{UID: &fresh.UID},
    }
    err = gc.vcClient.BatchV1alpha1().Jobs(fresh.Namespace).Delete(
        context.TODO(), fresh.Name, options,
    )
    if apierrors.IsNotFound(err) {
        return nil  // 已被删除,视为成功
    }
    return err
}

双重检查的详细流程图

flowchart TD Start["processJob(key)"] --> Parse["解析 namespace/name"] Parse --> CacheGet["从 jobLister 缓存获取 Job"] CacheGet --> CacheNotFound{"缓存中 Job 不存在?"} CacheNotFound -->|"是 (NotFound)"| RetNil1["return nil - 无需处理"] CacheNotFound -->|"否"| CacheErr{"缓存查询出错?"} CacheErr -->|"是"| RetErr1["return err"] CacheErr -->|"否"| TTL1["第一次 processTTL(cached job)"] TTL1 --> TTL1Err{"processTTL 出错?"} TTL1Err -->|"是"| RetErr2["return err"] TTL1Err -->|"否"| TTL1Expired{"TTL 已过期?"} TTL1Expired -->|"否 - 已延迟入队"| RetNil2["return nil"] TTL1Expired -->|"是 - 缓存显示已过期"| APIGet["从 API Server 获取最新 Job"] APIGet --> APINotFound{"API Server 中不存在?"} APINotFound -->|"是 (NotFound)"| RetNil3["return nil - 已被删除"] APINotFound -->|"否"| APIErr{"API 查询出错?"} APIErr -->|"是"| RetErr3["return err"] APIErr -->|"否"| TTL2["第二次 processTTL(fresh job)"] TTL2 --> TTL2Err{"processTTL 出错?"} TTL2Err -->|"是"| RetErr4["return err"] TTL2Err -->|"否"| TTL2Expired{"TTL 仍然过期?"} TTL2Expired -->|"否 - TTL 被修改"| RetNil4["return nil"] TTL2Expired -->|"是 - 确认过期"| Delete["Foreground 级联删除 Job"] Delete --> DelNotFound{"已被删除 (NotFound)?"} DelNotFound -->|"是"| RetNil5["return nil - 视为成功"] DelNotFound -->|"否"| DelErr{"删除出错?"} DelErr -->|"是"| RetErr5["return err"] DelErr -->|"否"| RetNil6["return nil - 删除成功"] style Start fill:#3498db,stroke:#2980b9,color:#fff style TTL1 fill:#f39c12,stroke:#e67e22,color:#fff style TTL2 fill:#f39c12,stroke:#e67e22,color:#fff style Delete fill:#e74c3c,stroke:#c0392b,color:#fff style RetNil1 fill:#27ae60,stroke:#1e8449,color:#fff style RetNil2 fill:#27ae60,stroke:#1e8449,color:#fff style RetNil3 fill:#27ae60,stroke:#1e8449,color:#fff style RetNil4 fill:#27ae60,stroke:#1e8449,color:#fff style RetNil5 fill:#27ae60,stroke:#1e8449,color:#fff style RetNil6 fill:#27ae60,stroke:#1e8449,color:#fff

为什么需要双重检查?

在分布式系统中,Informer 缓存与 API Server 之间存在数据延迟。考虑以下竞态场景:

  1. Informer 缓存中的 Job TTL 显示已过期
  2. 用户在 GC Controller 检查缓存之后、删除之前修改了 Job 的 ttlSecondsAfterFinished 字段
  3. 如果不从 API Server 获取最新数据进行二次确认,可能会错误删除一个 TTL 被延长的 Job

双重检查模式通过两次独立的 TTL 验证有效解决了这个问题。

5.3 processTTL 逻辑

func (gc *gccontroller) processTTL(job *v1alpha1.Job) (expired bool, err error) {
    // 前置检查: 正在删除中或不需要清理的 Job 直接跳过
    if job.DeletionTimestamp != nil || !needsCleanup(job) {
        return false, nil
    }

    now := time.Now()
    t, err := timeLeft(job, &now)
    if err != nil {
        return false, err
    }

    // TTL 已过期 (剩余时间 <= 0)
    if *t <= 0 {
        return true, nil
    }

    // TTL 未过期,延迟入队,在 TTL 到期时重新检查
    gc.enqueueAfter(job, *t)
    return false, nil
}

processTTL 的返回值语义:

expirederr含义
falsenil不需要清理(未完成/无 TTL/未过期/正在删除中)
truenilTTL 已过期,应该删除
falsenon-nil处理过程中发生错误

5.4 TTL 时间计算

TTL 时间计算涉及四个关联函数,形成完整的计算链路:

flowchart LR NC["needsCleanup()"] -->|"确认需要清理"| GFE["getFinishAndExpireTime()"] GFE -->|"计算完成/过期时间"| TL["timeLeft()"] subgraph nc_detail["needsCleanup 逻辑"] NC1{"TTLSecondsAfterFinished != nil?"} NC2{"isJobFinished()?"} NC1 -->|"是"| NC2 NC1 -->|"否"| NCF["return false"] NC2 -->|"是"| NCT["return true"] NC2 -->|"否"| NCF end subgraph gfe_detail["getFinishAndExpireTime 逻辑"] GFE1["jobFinishTime() - 获取完成时间"] GFE2["finishTime + TTL = expireTime"] GFE1 --> GFE2 end subgraph tl_detail["timeLeft 逻辑"] TL1["expireTime - now = remaining"] TL2{"remaining > 0?"} TL1 --> TL2 TL2 -->|"是"| TL3["未过期 - 返回剩余时间"] TL2 -->|"否"| TL4["已过期 - 返回负值"] end

needsCleanup()

func needsCleanup(j *v1alpha1.Job) bool {
    return j.Spec.TTLSecondsAfterFinished != nil && isJobFinished(j)
}

两个条件缺一不可:

  • TTLSecondsAfterFinished 必须被设置(非 nil 指针)
  • Job 必须已经进入终态

isJobFinished()

func isJobFinished(job *v1alpha1.Job) bool {
    return job.Status.State.Phase == v1alpha1.Completed ||
        job.Status.State.Phase == v1alpha1.Failed ||
        job.Status.State.Phase == v1alpha1.Terminated
}

只有三种 Phase 被视为 Job 的终态:CompletedFailedTerminated

jobFinishTime()

func jobFinishTime(finishedJob *v1alpha1.Job) (metav1.Time, error) {
    if finishedJob.Status.State.LastTransitionTime.IsZero() {
        return metav1.Time{}, fmt.Errorf(
            "unable to find the time when the Job %s/%s finished",
            finishedJob.Namespace, finishedJob.Name,
        )
    }
    return finishedJob.Status.State.LastTransitionTime, nil
}

使用 Status.State.LastTransitionTime 作为 Job 的完成时间。这是 Job 状态最后一次发生变迁的时间戳,当 Job 进入终态时,该字段记录的就是进入终态的精确时间。

getFinishAndExpireTime()

func getFinishAndExpireTime(j *v1alpha1.Job) (*time.Time, *time.Time, error) {
    if !needsCleanup(j) {
        return nil, nil, fmt.Errorf("job %s/%s should not be cleaned up", j.Namespace, j.Name)
    }
    finishAt, err := jobFinishTime(j)
    if err != nil {
        return nil, nil, err
    }
    finishAtUTC := finishAt.UTC()
    expireAtUTC := finishAtUTC.Add(
        time.Duration(*j.Spec.TTLSecondsAfterFinished) * time.Second,
    )
    return &finishAtUTC, &expireAtUTC, nil
}

计算公式: 过期时间 = 完成时间 + TTLSecondsAfterFinished

timeLeft()

func timeLeft(j *v1alpha1.Job, since *time.Time) (*time.Duration, error) {
    finishAt, expireAt, err := getFinishAndExpireTime(j)
    if err != nil {
        return nil, err
    }
    // 时钟偏移检测
    if finishAt.UTC().After(since.UTC()) {
        klog.Warningf("Warning: Found Job %s/%s finished in the future. "+
            "This is likely due to time skew in the cluster. "+
            "Job cleanup will be deferred.", j.Namespace, j.Name)
    }
    remaining := expireAt.UTC().Sub(since.UTC())
    return &remaining, nil
}

计算公式: 剩余时间 = 过期时间 - 当前时间

时钟偏移处理: 如果检测到 Job 的完成时间在当前时间之后(未来时间),会输出警告日志。这种情况通常由集群中节点间的时钟不同步造成。此时清理操作会被自然延迟,因为计算出的剩余时间会大于实际 TTL 值。

完整时间线示意

完成时间                  当前时间              过期时间
   |________________________|____________________|
   |                        |                    |
   |<----- 已经过去 ------->|<--- remaining --->|
   |                                             |
   |<----------- TTLSecondsAfterFinished ------->|

6. Job 终态定义

Volcano Job 有 10 种状态(Phase),其中只有 3 种被视为终态(GC Controller 可以清理的状态)。

6.1 状态图

stateDiagram-v2 [*] --> Pending: Job 创建 Pending --> Running: 最小可用 Task 开始运行 Pending --> Aborting: 触发中止 Running --> Completing: 所有必需 Task 完成 Running --> Aborting: 触发中止 Running --> Restarting: 触发重启 Running --> Terminating: 触发终止 Completing --> Completed: 清理完成 Aborting --> Aborted: Pod 释放完成 Restarting --> Pending: Pod 释放并重建 Terminating --> Terminated: Pod 释放完成 Aborted --> Restarting: 触发重启 Aborted --> Running: 恢复运行 state terminal <> Completed --> terminal Failed --> terminal Terminated --> terminal terminal --> [*]: GC Controller 清理 note right of Completed: 终态 - 所有 Task 成功完成 note right of Failed: 终态 - 达到最大重试次数 note right of Terminated: 终态 - 被外部事件终止

6.2 状态详解

终态(GC Controller 处理的状态)

Phase含义触发条件
Completed所有 Task 成功完成所有必需 Task 运行成功并完成清理
FailedJob 达到最大重试次数后失败重试次数耗尽,无法恢复
TerminatedJob 被外部事件异常终止外部干预或不可恢复错误

非终态(GC Controller 忽略的状态)

Phase含义
PendingJob 在队列中等待调度
Running最小可用 Task 正在运行
AbortingJob 正在中止,等待释放 Pod
AbortedJob 已被中止(可恢复)
RestartingJob 正在重启,等待 Pod 释放和重建
Completing必需 Task 已完成,正在清理
TerminatingJob 正在终止,等待释放 Pod

注意: Aborted 虽然看起来像终态,但它是可恢复的(可以重启或恢复运行),因此不被 GC Controller 视为终态。同理,CompletingTerminating 是过渡态,分别会转为 CompletedTerminated


7. 删除策略

7.1 Foreground 级联删除

GC Controller 使用 DeletePropagationForeground 策略删除 Job:

policy := metav1.DeletePropagationForeground
options := metav1.DeleteOptions{
    PropagationPolicy: &policy,
    Preconditions:     &metav1.Preconditions{UID: &fresh.UID},
}

Kubernetes 提供三种级联删除策略:

策略行为GC Controller 选择
Orphan只删除父资源,子资源变为孤儿
Background立即删除父资源,后台异步删除子资源
Foreground先删除子资源,再删除父资源

选择 Foreground 策略的原因:

  1. 确保子资源先被清理: Pod、PodGroup 等子资源在 Job 删除前被清理,避免孤儿资源
  2. 删除顺序可预测: 管理员可以观察到清晰的删除过程
  3. 避免资源泄漏: 如果使用 Background 策略,子资源的异步删除可能因 Controller 重启而中断

7.2 UID Precondition 安全机制

Preconditions: &metav1.Preconditions{UID: &fresh.UID}

UID Precondition 是一种乐观并发控制机制。它确保删除请求的目标 Job 的 UID 与 GC Controller 检查过的 Job UID 一致。

防止误删场景

时间线:
T1: GC Controller 确认 Job "my-job" (UID=abc123) TTL 已过期
T2: 另一个进程删除了旧的 "my-job"
T3: 用户创建了新的 "my-job" (UID=def456)
T4: GC Controller 发送删除请求 (带 UID=abc123 precondition)
T5: API Server 拒绝删除,因为当前 "my-job" 的 UID=def456 != abc123

没有 UID Precondition 的话,T4 步骤会错误删除用户新创建的同名 Job。

7.3 IsNotFound 处理

err = gc.vcClient.BatchV1alpha1().Jobs(fresh.Namespace).Delete(
    context.TODO(), fresh.Name, options,
)
if apierrors.IsNotFound(err) {
    return nil  // 已被删除,视为成功
}
return err

如果在发送删除请求时 Job 已经不存在(可能被用户手动删除或其他 Controller 删除),GC Controller 将 NotFound 错误视为成功,因为最终目标(Job 被清理)已经达成。这种幂等性设计避免了不必要的错误重试。


8. 错误处理与重试

8.1 handleErr 机制

func (gc *gccontroller) handleErr(err error, key string) {
    if err == nil {
        gc.queue.Forget(key)  // 处理成功,清除重试计数器
        return
    }

    klog.Errorf("error cleaning up Job %v, will retry: %v", key, err)
    gc.queue.AddRateLimited(key)  // 处理失败,指数退避重试
}

8.2 重试策略

GC Controller 使用 workqueue.DefaultTypedControllerRateLimiter 创建的速率限制器,其行为基于两个内置限制器的组合:

限制器参数行为
BucketRateLimiter10 QPS, Burst 100全局速率限制,防止短时间内产生过多 API 请求
ItemExponentialFailureRateLimiterbaseDelay=5ms, maxDelay=1000s对单个 key 的指数退避

指数退避的重试间隔:

重试次数延迟时间
第 1 次5ms
第 2 次10ms
第 3 次20ms
第 4 次40ms
第 5 次80ms
第 N 次min(5ms * 2^(N-1), 1000s)
flowchart TD PNW["processNextWorkItem()"] --> Get["queue.Get() - 获取 key"] Get --> PJ["processJob(key)"] PJ --> HE["handleErr(err, key)"] HE --> ErrCheck{"err == nil?"} ErrCheck -->|"是 - 处理成功"| Forget["queue.Forget(key)"] Forget --> FDesc["清除重试计数器"] ErrCheck -->|"否 - 处理失败"| Log["记录错误日志"] Log --> AddRL["queue.AddRateLimited(key)"] AddRL --> RLDesc["指数退避后重新入队"] HE --> Done["queue.Done(key)"] Done --> Next["继续下一个 key"] style Forget fill:#27ae60,stroke:#1e8449,color:#fff style AddRL fill:#e74c3c,stroke:#c0392b,color:#fff

8.3 边界情况处理

场景处理方式
Job 在缓存中不存在返回 nil,从队列中移除
Job 在 API Server 中不存在返回 nil,视为已清理
删除时 Job 已不存在返回 nil,幂等处理
API Server 请求失败返回 err,触发指数退避重试
LastTransitionTime 为零值返回错误信息,触发重试
时钟偏移(完成时间在未来)输出警告日志,延迟清理(剩余时间自然增大)
TTL 在双重检查之间被修改第二次 processTTL 检测到变更,取消删除
同名 Job 被重建UID Precondition 阻止误删

9. 配置参数

9.1 命令行参数

GC Controller 通过 Controller Manager 的命令行参数进行配置:

# 源码: cmd/controller-manager/app/options/options.go

--worker-threads-for-gc uint32    # GC Worker 线程数 (默认: 1)

参数定义:

// 源码: cmd/controller-manager/app/options/options.go

const (
    defaultGCWorkers = 1
)

// ServerOption 中的字段
type ServerOption struct {
    // ...
    WorkerThreadsForGC uint32
    // ...
}

// 注册命令行参数
fs.Uint32Var(&s.WorkerThreadsForGC, "worker-threads-for-gc",
    defaultGCWorkers,
    "The number of threads for recycling jobs. "+
    "The larger the number, the faster the job recycling, "+
    "but requires more CPU load.")

调优建议

集群规模推荐 Worker 数说明
小型 (< 100 Job/天)1 (默认)默认值即可满足需求
中型 (100-1000 Job/天)2-3适度增加以加快清理速度
大型 (> 1000 Job/天)3-5需要更多 Worker 处理积压

9.2 Job Spec 字段

// 源码: staging/src/volcano.sh/apis/pkg/apis/batch/v1alpha1/job.go

type JobSpec struct {
    // ...

    // ttlSecondsAfterFinished limits the lifetime of a Job that has finished
    // execution (either Completed or Failed). If this field is set,
    // ttlSecondsAfterFinished after the Job finishes, it is eligible to be
    // deleted immediately after it finishes.
    // +kubebuilder:validation:Minimum=0
    // +optional
    TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"`

    // ...
}

字段特性

特性说明
类型*int32 (指针类型)
默认值nil (不自动清理)
最小值0 (Job 完成后立即清理)
单位
可选性Optional,不设置则 GC Controller 不会处理该 Job

常用 TTL 值参考

TTL 值持续时间适用场景
0立即删除不需要保留记录的临时 Job
3005 分钟调试结束后快速清理
36001 小时允许短暂回顾
864001 天标准日志保留
6048001 周较长的审计保留期
nil永不删除需要手动清理或永久保留

10. 使用示例

10.1 基本用法 - 完成后 1 小时自动清理

apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: training-job
  namespace: default
spec:
  ttlSecondsAfterFinished: 3600  # 完成后 1 小时自动清理
  minAvailable: 1
  schedulerName: volcano
  queue: default
  tasks:
    - replicas: 1
      name: trainer
      template:
        spec:
          containers:
            - name: tensorflow
              image: tensorflow/tensorflow:2.12.0-gpu
              command: ["python", "train.py"]
              resources:
                limits:
                  nvidia.com/gpu: 1
          restartPolicy: Never

10.2 立即清理 - TTL 设为 0

apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: data-preprocessing
  namespace: default
spec:
  ttlSecondsAfterFinished: 0  # 完成后立即清理
  minAvailable: 3
  schedulerName: volcano
  tasks:
    - replicas: 3
      name: preprocessor
      template:
        spec:
          containers:
            - name: spark
              image: apache/spark:3.4.0
              command: ["spark-submit", "--class", "Main", "app.jar"]
          restartPolicy: Never

10.3 不设置 TTL - 手动管理

apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: critical-job
  namespace: production
spec:
  # 不设置 ttlSecondsAfterFinished,GC Controller 不会自动清理
  # 需要管理员手动删除或通过外部脚本清理
  minAvailable: 2
  schedulerName: volcano
  tasks:
    - replicas: 2
      name: worker
      template:
        spec:
          containers:
            - name: app
              image: my-app:latest
              command: ["./run.sh"]
          restartPolicy: OnFailure

10.4 配置 Controller Manager

# 启动 Controller Manager 时配置 GC Worker 数量
volcano-controller-manager \
  --worker-threads-for-gc=3 \
  --worker-threads-for-podgroup=5 \
  --worker-threads-for-queue=5

11. 与 Kubernetes TTL Controller 的对比

Kubernetes 原生也提供了类似的 TTL 清理机制,即 TTLAfterFinished Controller(从 K8s 1.23 开始 GA)。Volcano GC Controller 在设计上与其高度相似,但专门处理 Volcano Job 资源。

11.1 对比表

维度K8s TTLAfterFinished ControllerVolcano GC Controller
处理的资源类型Kubernetes batch/v1 JobVolcano batch.volcano.sh/v1alpha1 Job
TTL 字段spec.ttlSecondsAfterFinishedspec.ttlSecondsAfterFinished
终态判定Complete / FailedCompleted / Failed / Terminated
完成时间来源Job Conditions 中的 Complete/Failed ConditionStatus.State.LastTransitionTime
双重检查是 (cache + API Server)是 (cache + API Server)
级联删除策略BackgroundForeground
UID Precondition
延迟入队
Worker 可配置是 (--worker-threads-for-gc)

11.2 关键差异

1. 终态定义

Volcano Job 比 K8s Job 多了一个 Terminated 终态。这是因为 Volcano 支持更丰富的 Job 生命周期管理,包括外部中止、重启等操作,Terminated 表示 Job 被外部事件异常终止。

2. 完成时间来源

  • K8s Job 使用 Conditions 数组中的 CompleteFailed Condition 的时间戳
  • Volcano Job 使用 Status.State.LastTransitionTime,这是一个更简洁的设计

3. 级联删除策略

  • K8s TTL Controller 使用 Background 策略,父资源立即删除,子资源异步清理
  • Volcano GC Controller 使用 Foreground 策略,确保子资源先被清理。这对于包含 PodGroup 等调度资源的 Volcano Job 更为安全,可以避免调度器因为孤儿 PodGroup 而做出错误的调度决策

4. 独立 Controller vs 内置逻辑

两者都采用了独立 Controller 的设计,而非将清理逻辑嵌入 Job Controller。这种设计:

  • 保持了 Job Controller 的职责单一
  • 允许独立配置 Worker 数量
  • 便于未来扩展到其他可完成的资源类型

12. 总结

12.1 设计模式总结

GC Controller 虽然代码量不大(约 310 行),但体现了多个优秀的分布式系统设计模式:

设计模式在 GC Controller 中的应用
Work Queue 模式使用 Rate-Limited WorkQueue 解耦事件接收与处理
双重检查模式缓存检查 + API Server 确认,防止基于过时数据做出错误决策
乐观并发控制UID Precondition 防止 ABA 问题导致的误删
幂等性设计NotFound 错误视为成功,支持安全重试
指数退避失败时通过指数退避重试,避免雪崩效应
延迟入队精确计算 TTL 剩余时间,避免不必要的轮询
关注点分离独立于 Job Controller,专注于清理职责
级联删除Foreground 策略确保子资源被完整清理

12.2 最佳实践建议

对于 Job 用户

  1. 始终设置 TTL: 除非有明确的保留需求,否则应为所有 Job 设置合理的 ttlSecondsAfterFinished,避免资源堆积
  2. 根据需求选择 TTL 值: 调试环境可以设短(如 300 秒),生产环境根据审计需求设置(如 86400 秒)
  3. TTL=0 谨慎使用: 虽然可以实现立即清理,但 Job 完成后将无法查看其状态和日志
  4. 配合日志系统: 设置 TTL 前确保 Pod 日志已被外部日志系统采集,避免因 Job 清理导致日志丢失

对于集群管理员

  1. 监控 etcd 大小: 定期检查 etcd 中 Volcano Job 资源的数量,评估 TTL 配置的合理性
  2. 适当增加 Worker 数: 大规模集群中,如果发现清理延迟,可适度增加 --worker-threads-for-gc
  3. 制定 TTL 策略: 通过 Admission Webhook 或 Policy 强制要求用户设置 TTL,或设置默认值
  4. 关注时钟同步: 确保集群中所有节点的时钟同步(NTP),避免因时钟偏移导致清理行为异常

12.3 代码调用链路总结

init()
  └── framework.RegisterController(&gccontroller{})

Initialize(opt)
  ├── 设置 vcClient、jobInformer、jobLister、jobSynced
  ├── 创建 Rate-Limited WorkQueue
  ├── 设置 workers 数量
  └── 注册 AddFunc(addJob) + UpdateFunc(updateJob)

Run(stopCh)
  ├── 启动 InformerFactory
  ├── 等待缓存同步
  ├── 启动 N 个 Worker goroutine
  └── 阻塞等待停止信号

addJob(obj) / updateJob(old, cur)
  ├── 检查 DeletionTimestamp == nil
  ├── 检查 needsCleanup(job)
  └── enqueue(job) → queue.Add(key)

worker()
  └── processNextWorkItem()
        ├── queue.Get()
        ├── processJob(key)
        │     ├── jobLister.Get() ─── 缓存查询
        │     ├── processTTL(cached)
        │     │     ├── needsCleanup() → isJobFinished()
        │     │     ├── timeLeft() → getFinishAndExpireTime() → jobFinishTime()
        │     │     └── enqueueAfter() (TTL 未过期时)
        │     ├── vcClient.Get() ─── API Server 查询 (双重检查)
        │     ├── processTTL(fresh)
        │     └── vcClient.Delete() ─── Foreground 级联删除
        ├── handleErr(err, key)
        │     ├── queue.Forget(key)        (成功)
        │     └── queue.AddRateLimited(key) (失败)
        └── queue.Done(key)

GC Controller 以简洁而健壮的设计,解决了批处理系统中已完成 Job 的自动清理问题。它的双重检查模式和多层安全机制确保了在分布式环境下的正确性,而延迟入队和指数退避策略则兼顾了效率与稳定性。