沿镜像与快照引用图回收无用资源
containerd 从镜像、有效租约等根资源出发,用显式栈遍历引用图;保留可达内容与快照,再扫描并清理不可达元数据。
工程问题#
镜像层和快照会被多个镜像或容器共享,下载中的内容还可能由租约临时保护。删除一个镜像时立即递归删除它引用的对象,会误删仍被其他根引用的数据;只看对象年龄也无法判断是否仍在使用。
实际实现#
GarbageCollect 先取得元数据写锁,getMarked 在一致的数据库视图中收集根,并把资源引用解析函数交给 gc.Tricolor。灰色栈保存已发现但未完成的节点,初始根原样入栈,seen 对随后展开的引用去重;每次弹出一个节点、展开其引用,再写入 reachable。随后在数据库更新事务中扫描全部资源,跳过已标记者,删除其余元数据并登记需要清理的后端。内容与 snapshotter 的实际清理另行执行,不能把标记完成等同于磁盘空间已经释放。
处理流程#
- 取得元数据写锁,收集未过期镜像、租约保护对象等根资源
- 将根放入显式栈,并标记为已发现
- 弹出节点,通过类型、命名空间和键读取其引用,把未发现的目标压栈
- 完成所有可达节点后,在事务中扫描全体资源,保留 marked 集合成员
- 删除不可达元数据,触发内容库与 snapshotter 的后续清理
源码片段#
固定版本 · 原文摘录371func (m *DB) GarbageCollect(ctx context.Context) (gc.Stats, error) {372 m.wlock.Lock()373 t1 := time.Now()374 c := startGCContext(ctx, m.collectors)375376 marked, err := m.getMarked(ctx, c) // Pass in gc context377 if err != nil {378 m.wlock.Unlock()379 c.cancel(ctx)380 return nil, err381 }382383 events := []namespacedEvent{}384 if err := m.db.Update(func(tx *bolt.Tx) error {385 ctx, cancel := context.WithCancel(ctx)386 defer cancel()387388 rm := func(ctx context.Context, n gc.Node) error {389 if _, ok := marked[n]; ok {390 return nil先持有 wlock,再求可达集合;扫描阶段遇到 marked 节点直接保留。后续 remove 和 scanAll 在同一更新事务中执行。
506 refs := func(n gc.Node) ([]gc.Node, error) {507 var sn []gc.Node508 if err := c.references(ctx, tx, n, func(nn gc.Node) { // From gc context509 sn = append(sn, nn)510 }); err != nil {511 return nil, err512 }513 return sn, nil514 }515516 reachable, err := gc.Tricolor(nodes, refs)517 if err != nil {518 return err519 }520 marked = reachable521 return nil把数据库引用解析包装为 refs,再实际调用 gc.Tricolor;这里不是仅在通用库中找到了一个未使用的图算法。
380 ibkt := nbkt.Bucket(bucketKeyObjectImages)381 if ibkt != nil {382 if err := ibkt.ForEach(func(k, v []byte) error {383 if v != nil {384 return nil385 }386387 if !isExpiredImage(ctx, k, ibkt.Bucket(k), expThreshold) {388 fn(gcnode(ResourceImage, ns, string(k)))389 }390 return nil391 }); err != nil {392 return err393 }394 }有效镜像是保留资源的一个入口。租约、进行中的内容等也有各自的根扫描分支,不是只保留被运行中容器引用的数据。
529 case ResourceImage, resourceImageFlat:530 bkt := getBucket(tx, bucketKeyVersion, []byte(node.Namespace), bucketKeyObjectImages, []byte(node.Key))531 if bkt == nil {532 // Node may be created from dead edge533 return nil534 }535 target := bkt.Bucket(bucketKeyTarget)536 if target != nil {537 ctype := ResourceContent538 if node.Type == resourceImageFlat {539 // For flat leases, keep the target content only540 ctype = resourceContentFlat541 }542 contentKey := string(target.Get(bucketKeyDigest))543 fn(gcnode(ctype, node.Namespace, contentKey))544 }镜像节点通过 target digest 关联内容节点;资源类型区分普通引用与 flat 引用,命名空间也保存在节点身份中。
64func Tricolor(roots []Node, refs func(ref Node) ([]Node, error)) (map[Node]struct{}, error) {65 var (66 grays []Node // maintain a gray "stack"67 seen = map[Node]struct{}{} // or not "white", basically "seen"68 reachable = map[Node]struct{}{} // or "black", in tri-color parlance69 )7071 grays = append(grays, roots...)72 for _, root := range roots {73 seen[root] = struct{}{} // pre-mark this as not-white74 }7576 for len(grays) > 0 {77 // Pick any gray object78 id := grays[len(grays)-1] // effectively "depth first" because first element79 grays = grays[:len(grays)-1]80 rs, err := refs(id)81 if err != nil {82 return nil, err83 }根先入栈并记入 seen,随后从栈尾取出,实现深度优先展开;refs 错误会终止标记,调用者不会继续按不完整结果清扫。
85 // mark all the referenced objects as gray86 for _, target := range rs {87 if _, ok := seen[target]; !ok {88 grays = append(grays, target)89 seen[target] = struct{}{}90 }91 }9293 // strip bits above max resource type94 id.Type = id.Type & ResourceMax95 // mark as black when done96 reachable[id] = struct{}{}97 }9899 return reachable, nil100}随后发现的引用目标在入栈时立即加入 seen,避免因共享边或环重复入栈;初始根列表未去重,重复根仍会展开。完成节点时去掉类型高位控制标记,得到用于实际资源保留的身份。
六段原文连接实际调用、根与引用、显式栈标记以及清扫决策。DB.GarbageCollect 后半段的后端清理与锁释放另附完整来源。
收益与取舍#
工程分析带来的收益
- 共享层只需从任意一个有效根可达即可保留,不依赖删除请求逐个维护共享计数
- seen 集合使引用环不会导致无限递归;显式栈避免调用栈随引用深度增长
- 资源类型和命名空间参与节点身份,统一处理内容、镜像、快照等不同对象
付出的代价
- 成本随根条目数和实际展开的引用数增长;重复根可能重复读取其出边,不能无条件声称每条可达边只展开一次。随后还需扫描全部资源,数据库查询和后端删除的成本另计。
- R 为根条目数(含重复),Vr 为按遍历身份计算的可达节点数;seen、reachable 和显式栈合计占用 O(R+Vr) 空间,读取单个节点的引用列表还需要临时空间。flat 类型标记可使同一底层对象以不同遍历身份出现。
- 此路径在标记与元数据删除期间保持写锁,不是应用可自由修改引用的并发或增量 GC;后端删除和事件通知还有额外时延
可比较的选择
引用计数可在计数归零时局部回收,但每次修改引用都需要维护计数,引用环还需其他策略。
按访问时间或保留期淘汰适合容量管理策略,却不能单凭年龄保证共享资源和进行中的下载仍然有效。
版本与适用范围#
固定 containerd v2.0.0,实际调用的是 gc.Tricolor,而非同文件中的 ConcurrentMark。这里回收容器运行时持久化资源,不是 Go 进程的堆内存 GC。根集合还包括进行中的资源、显式根标签和扩展收集器;本文用镜像与租约说明主要路径,完整规则见 scanRoots。 初始根列表没有先行去重,不能把后续引用去重的性质直接推广到根集合。
参考来源#
源码、官方文档与原始论文core/metadata/db.go · GarbageCollect · scanAll 与 remove
core/metadata/gc.go · references · 内容、快照、镜像的完整引用规则
相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
HNSW 从稀疏上层快速定位区域,再在底层扩大候选搜索。