Skip to content

Finalizer 与资源清理

本篇讲解 Operator 中处理「删除」的关键机制——Finalizer。前面章节我们依赖 ownerReferences 实现了集群内资源的级联删除,但现实世界里很多 Operator 管理的资源不在 K8s 集群内:云上的 RDS 实例、S3 桶、外部数据库里的 schema、第三方系统的账号。这些资源 K8s 的垃圾回收器管不到,删 CR 时它们会变成「孤儿」甚至「泄漏」。Finalizer 就是用来解决这个问题的。本篇会讲清楚它的工作原理、实现模式,并给出一个带 Finalizer 的完整 Redis Operator 示例。

一、为什么需要 Finalizer

1. 级联删除的局限

回顾第四章讲的 ownerReferences 级联删除:当 Owner(如 RedisCluster)被删除时,K8s GC 会删除所有 ownerReferences 指向它的对象(Deployment、Service 等)。这套机制只能清理集群内的 K8s 资源

但 Operator 经常要管理集群外的资源,比如:

  • 在外部 MySQL 里创建了一个 appdb 数据库,CR 删除时该删掉它吗?
  • 在 AWS 上创建了一个 S3 桶,CR 删除时不删桶会持续计费。
  • 在外部监控系统注册了一个采集目标,CR 删除时该注销。

这些外部资源没有 ownerReferences 概念,K8s GC 完全管不到。如果只在 Reconcile 的「创建」分支里创建外部资源,删 CR 时外部资源就成了孤儿——这就是资源泄漏,在云环境里直接意味着持续的费用和安全风险。

2. 删除顺序问题

即使都是集群内资源,有时也需要控制删除顺序。比如:

  • 必须先删「数据导出 Job」,再删 PVC,否则数据没导出就被清了。
  • 必须先从负载均衡注销,再删 Pod,否则短暂 502。

级联删除是并发的、无序的,无法保证顺序。Finalizer 让你有机会在 CR 真正消失前,按自己想要的顺序执行清理。

3. Finalizer 解决什么

Finalizer 本质上是「删不掉的标记」。它是一个放在 metadata.finalizers 里的字符串列表。只要这个列表非空,K8s 就不会真正删除这个对象——只会给它打上 deletionTimestamp,然后等控制器主动把 finalizer 移除。这给了控制器一个「在对象消失前做清理」的机会窗口。

二、Finalizer 工作原理

1. 三个关键概念

理解 Finalizer 要抓住三个东西:

  • finalizers 列表metadata.finalizers,一个字符串数组。空表示「可以删了」。
  • DeletionTimestampmetadata.deletionTimestamp,对象被请求删除时由 API Server 设置。非空表示「正在被删除」。
  • 控制器逻辑:控制器看到 DeletionTimestamp 非空,就执行清理,清理完移除自己负责的那个 finalizer。

2. 完整的删除流程

当用户执行 kubectl delete rediscluster my-redis,发生这些事:

1. API Server 收到 DELETE 请求
2. 检查 metadata.finalizers:
   - 非空 → 设置 metadata.deletionTimestamp,返回 202(已接受)
     对象进入 "Terminating" 状态,但不会从 etcd 删除
   - 空 → 直接从 etcd 删除,返回 200
3. (finalizers 非空的情况)Controller 的 Watch 收到 Update 事件
   (因为加了 deletionTimestamp 算一次 update)
4. Controller 在 Reconcile 里检查 deletionTimestamp:
   - 非空 → 执行清理逻辑(删外部资源等)
   - 清理完成 → 从 finalizers 移除自己的那一项
   - 调用 Update 写回
5. 当 finalizers 变成空,API Server 真正从 etcd 删除对象

关键点:对象不会在 DELETE 请求时立即消失,而是先进入 Terminating,等所有 finalizer 都被移除才真正消失。这个窗口期就是控制器做清理的机会。

3. 多个 Finalizer 的协作

一个对象可以有多个 finalizer,来自不同控制器。比如:

yaml
metadata:
  finalizers:
    - cache.example.com/redis-finalizer      # Redis Operator 清理 Redis 数据
    - backup.example.com/backup-finalizer     # Backup Operator 做最后一次备份

删除时,两个控制器各自处理自己的 finalizer,各自移除。最后一个 finalizer 被移除后,对象才消失。如果某个控制器的清理失败,它不移除 finalizer,对象会一直卡在 Terminating——这是 Finalizer 的「保证清理」语义。

4. DeletionTimestamp 与 Reconcile 的关系

DeletionTimestamp 一旦设置就不会变(只会被读)。控制器每次 Reconcile 都要检查它:

go
if !rc.ObjectMeta.DeletionTimestamp.IsZero() {
    // 正在被删除,走清理逻辑
    return r.cleanup(ctx, rc)
}
// 正常 Reconcile

注意 DeletionTimestamp.IsZero() 的判断:零值表示「未设置 deletionTimestamp」,即「没在删除」。非零表示「正在删除」。

三、实现 Finalizer

1. 标准实现模式

Finalizer 的实现有一个固定套路,分四步:

步骤 1:定义 finalizer 名称

finalizer 名字用「域名/用途」格式,保证全局唯一:

go
const redisFinalizer = "cache.example.com/redis-finalizer"

步骤 2:在创建时添加 finalizer

第一次 Reconcile 时(CR 刚创建,还没有 finalizer),给它加上:

go
import "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

// 在 Reconcile 开头,正常逻辑之前
if !controllerutil.ContainsFinalizer(rc, redisFinalizer) {
    controllerutil.AddFinalizer(rc, redisFinalizer)
    if err := r.Update(ctx, rc); err != nil {
        return ctrl.Result{}, err
    }
    // 更新后本次 Reconcile 可以继续,也可以 return 让下次再处理
}

controllerutil 提供了 ContainsFinalizerAddFinalizerRemoveFinalizer 三个工具函数,省得自己操作 slice。

步骤 3:检查 DeletionTimestamp,执行清理

go
if !rc.ObjectMeta.DeletionTimestamp.IsZero() {
    // 正在被删除
    if controllerutil.ContainsFinalizer(rc, redisFinalizer) {
        // 执行清理
        if err := r.cleanupExternalResources(ctx, rc); err != nil {
            // 清理失败,不移除 finalizer,返回错误让框架重试
            return ctrl.Result{}, err
        }
        // 清理成功,移除 finalizer
        controllerutil.RemoveFinalizer(rc, redisFinalizer)
        if err := r.Update(ctx, rc); err != nil {
            return ctrl.Result{}, err
        }
    }
    return ctrl.Result{}, nil
}

步骤 4:移除 finalizer

清理成功后移除 finalizer。移除后如果 finalizers 变空,API Server 会真正删除对象。

2. 关键原则:清理失败不移除 finalizer

这是 Finalizer 的核心契约:

  • 清理成功 → 移除 finalizer → 对象可被删除。
  • 清理失败不移除 finalizer → 对象卡在 Terminating,等下次 Reconcile 重试。

这保证了「要么清理成功,要么对象永远删不掉(强制人工介入)」。绝不能「清理失败也移除 finalizer」,那等于偷偷泄漏资源。

3. 清理逻辑的位置

清理逻辑必须放在 Reconcile 里(因为 Reconcile 是幂等且可重试的),而不是某个一次性回调。整个 Reconcile 的结构变成:

go
func (r *RedisClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var rc cachev1.RedisCluster
    if err := r.Get(ctx, req.NamespacedName, &rc); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // ① 如果正在删除,走清理流程
    if !rc.ObjectMeta.DeletionTimestamp.IsZero() {
        return r.reconcileDelete(ctx, &rc)
    }

    // ② 确保 finalizer 存在(创建时添加)
    if !controllerutil.ContainsFinalizer(&rc, redisFinalizer) {
        controllerutil.AddFinalizer(&rc, redisFinalizer)
        if err := r.Update(ctx, &rc); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{Requeue: true}, nil
    }

    // ③ 正常调谐逻辑
    // ... reconcileDeployment / reconcileService / reconcileStatus ...
    return ctrl.Result{}, nil
}

四、常见清理场景

1. 删除外部数据库

场景:Operator 在外部 MySQL 里为每个 CR 创建了一个数据库,CR 删除时该删库。

go
func (r *RedisClusterReconciler) cleanupExternalResources(ctx context.Context, rc *cachev1.RedisCluster) error {
    logger := log.FromContext(ctx)

    // 连接外部 MySQL(连接信息从 Secret 读)
    db, err := r.connectMySQL(ctx, rc)
    if err != nil {
        return err
    }
    defer db.Close()

    dbName := fmt.Sprintf("redis_%s", rc.Name)
    // 删除数据库
    if _, err := db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName)); err != nil {
        logger.Error(err, "删除外部数据库失败", "db", dbName)
        return err
    }
    logger.Info("已删除外部数据库", "db", dbName)
    return nil
}

注意:DROP DATABASE 是破坏性操作,生产环境通常先备份再删。清理逻辑里要先做备份,备份成功才删。

2. 删除云资源(S3、RDS)

场景:CR 创建时在 AWS 上建了 S3 桶,删除时要清空并删桶。

go
import (
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

func (r *RedisClusterReconciler) cleanupS3Bucket(ctx context.Context, rc *cachev1.RedisCluster) error {
    bucket := fmt.Sprintf("redis-%s-%s", rc.Namespace, rc.Name)

    // S3 桶必须先清空才能删
    listInput := &s3.ListObjectsV2Input{Bucket: &bucket}
    for {
        out, err := r.s3Client.ListObjectsV2(ctx, listInput)
        if err != nil {
            // 桶不存在也算成功(可能之前已删)
            return nil
        }
        for _, obj := range out.Contents {
            r.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
                Bucket: &bucket, Key: obj.Key,
            })
        }
        if out.IsTruncated == nil || !*out.IsTruncated {
            break
        }
    }
    // 删桶
    _, err := r.s3Client.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: &bucket})
    if err != nil {
        return err
    }
    return nil
}

云资源清理的关键点:

  • 幂等:重复调用不能报错(桶已删、DB 已不存在都算成功)。
  • 依赖顺序:S3 要先清空再删桶;RDS 要先删从库再删主库。
  • 超时:云操作可能很慢,配合 RequeueAfter 重试,别阻塞 Reconcile。

3. 发送通知

场景:CR 删除时通知运维或监控系统。

go
func (r *RedisClusterReconciler) notifyDeletion(ctx context.Context, rc *cachev1.RedisCluster) error {
    payload := map[string]string{
        "event":     "redis-cluster-deleted",
        "name":      rc.Name,
        "namespace": rc.Namespace,
    }
    body, _ := json.Marshal(payload)
    resp, err := http.Post(r.webhookURL, "application/json", bytes.NewReader(body))
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 300 {
        return fmt.Errorf("webhook 返回 %d", resp.StatusCode)
    }
    return nil
}

通知失败通常不该阻塞删除(否则 CR 永远删不掉)。一个折中:通知失败记日志但不返回 error,让 finalizer 照常移除。或者分两个 finalizer,一个负责通知(失败也移除),一个负责关键清理(失败不移除)。

五、防止资源泄漏

Finalizer 本身是防泄漏的机制,但实现不当反而会制造新问题。

1. 清理逻辑必须幂等

Reconcile 可能因失败被重试多次,清理逻辑必须能重复执行而不出错:

go
// 错误:第二次调用会因为桶已删而报错
func cleanup(bucket string) error {
    return s3.DeleteBucket(bucket)  // 第二次会 NoSuchBucket
}

// 正确:忽略「不存在」错误
func cleanup(bucket string) error {
    err := s3.DeleteBucket(bucket)
    if isNotFound(err) {
        return nil  // 已删,视为成功
    }
    return err
}

2. 清理超时与卡死

外部操作可能永久卡住(网络分区、对端宕机)。对策:

  • 所有外部调用加 context.WithTimeout
  • 设一个最大重试次数,超过后把状态写进 status.conditions(如 CleanupFailed=True),让运维介入。
  • 不要无限 RequeueAfter,给个上限。

3. finalizer 名称变更

如果你升级 Operator 改了 finalizer 名字,老 CR 上还是旧名字,新代码不认识 → 永远删不掉。处理方式:

  • 保留对旧 finalizer 的兼容(清理时检查新旧两个名字)。
  • 提供迁移脚本,把旧 finalizer 批量改成新的。

4. Operator 自己被卸载

如果 Operator(Controller Pod)被卸载了,但有 finalizer 的 CR 还在,这些 CR 会永远卡在 Terminating——因为没人来移除 finalizer。对策:

  • 卸载 Operator 前,先手动 kubectl patch 移除 finalizer。
  • 或保留一个「cleanup job」专门处理残留 finalizer。
  • 文档里明确告知运维:卸载顺序是「先删所有 CR → 等 Terminating 消失 → 再卸 Operator」。

5. 阻塞删除的风险

blockOwnerDeletion: true 在 ownerReference 里会让从属资源「阻塞」Owner 删除。Finalizer 配合这个要小心:如果 CR 有 finalizer,从属资源设了 blockOwnerDeletion,删 CR 时会卡在等从属删完,但从属的删除可能依赖 CR 还在……死锁。原则:有 finalizer 的 CR,其从属资源不要设 blockOwnerDeletion

六、完整示例:带 Finalizer 的 Redis Operator

下面给出完整的 Reconcile,整合了正常调谐和 Finalizer 清理。这个例子假设 Redis Operator 还会在外部一个「配置中心」注册每个集群的元信息,删除时要注销。

1. Reconcile 主函数

go
package controller

import (
    "context"
    "fmt"
    "time"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/types"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/log"

    cachev1 "example.com/redis-operator/api/v1"
)

const redisFinalizer = "cache.example.com/redis-finalizer"

type RedisClusterReconciler struct {
    client.Client
    Scheme *runtime.Scheme
    // ConfigCenterClient 是外部配置中心的客户端(演示用)
    ConfigCenterClient ConfigCenterClient
}

// ConfigCenterClient 接口抽象外部依赖,便于测试 mock
type ConfigCenterClient interface {
    Register(ctx context.Context, name, namespace string) error
    Deregister(ctx context.Context, name, namespace string) error
}

func (r *RedisClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    var rc cachev1.RedisCluster
    if err := r.Get(ctx, req.NamespacedName, &rc); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // ① 正在删除 → 走清理流程
    if !rc.ObjectMeta.DeletionTimestamp.IsZero() {
        return r.reconcileDelete(ctx, &rc)
    }

    // ② 确保 finalizer 存在
    if !controllerutil.ContainsFinalizer(&rc, redisFinalizer) {
        logger.Info("添加 finalizer", "name", rc.Name)
        controllerutil.AddFinalizer(&rc, redisFinalizer)
        if err := r.Update(ctx, &rc); err != nil {
            return ctrl.Result{}, err
        }
        // 加完 finalizer 后,先注册外部资源,再进入正常调谐
        if err := r.ConfigCenterClient.Register(ctx, rc.Name, rc.Namespace); err != nil {
            // 注册失败返回错误,框架重试;此时 finalizer 已在,删 CR 时会触发清理
            return ctrl.Result{}, fmt.Errorf("注册配置中心失败: %w", err)
        }
        return ctrl.Result{Requeue: true}, nil
    }

    // ③ 正常调谐
    needRequeue, err := r.reconcileDeployment(ctx, &rc)
    if err != nil {
        return ctrl.Result{}, err
    }
    if err := r.reconcileService(ctx, &rc); err != nil {
        return ctrl.Result{}, err
    }
    if err := r.reconcileStatus(ctx, &rc); err != nil {
        return ctrl.Result{}, err
    }

    if needRequeue {
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }
    return ctrl.Result{}, nil
}

// reconcileDelete 执行清理逻辑
func (r *RedisClusterReconciler) reconcileDelete(ctx context.Context, rc *cachev1.RedisCluster) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    if !controllerutil.ContainsFinalizer(rc, redisFinalizer) {
        // 没有 finalizer,无需清理(可能是老资源)
        return ctrl.Result{}, nil
    }

    logger.Info("开始清理 RedisCluster", "name", rc.Name)

    // 步骤 1:注销外部配置中心(幂等)
    if err := r.ConfigCenterClient.Deregister(ctx, rc.Name, rc.Namespace); err != nil {
        logger.Error(err, "注销配置中心失败,将重试")
        return ctrl.Result{RequeueAfter: 30 * time.Second}, err
    }
    logger.Info("已注销配置中心", "name", rc.Name)

    // 步骤 2:可选——执行最后一次数据备份
    // 这里演示用,真实场景调备份 Job
    // if err := r.runFinalBackup(ctx, rc); err != nil {
    //     return ctrl.Result{}, err
    // }

    // 步骤 3:集群内资源(Deployment/Service)会被 ownerReferences 级联删除,无需手动删
    // 但如果想确保顺序(比如先删 Service 再删 Deployment),可以在这里手动删

    // 步骤 4:移除 finalizer
    controllerutil.RemoveFinalizer(rc, redisFinalizer)
    if err := r.Update(ctx, rc); err != nil {
        if errors.IsConflict(err) {
            // 并发冲突,重试
            return ctrl.Result{Requeue: true}, nil
        }
        return ctrl.Result{}, err
    }
    logger.Info("已移除 finalizer,CR 将被删除", "name", rc.Name)

    return ctrl.Result{}, nil
}

// reconcileDeployment / reconcileService / reconcileStatus 同第三章,略
func (r *RedisClusterReconciler) reconcileDeployment(ctx context.Context, rc *cachev1.RedisCluster) (bool, error) {
    // ... 同前 ...
    return false, nil
}
func (r *RedisClusterReconciler) reconcileService(ctx context.Context, rc *cachev1.RedisCluster) error {
    return nil
}
func (r *RedisClusterReconciler) reconcileStatus(ctx context.Context, rc *cachev1.RedisCluster) error {
    return nil
}

func (r *RedisClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&cachev1.RedisCluster{}).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Complete(r)
}

var _ types.NamespacedName

2. 验证 Finalizer 行为

创建 CR:

bash
kubectl apply -f config/samples/cache_v1_rediscluster.yaml

查看 finalizer:

bash
$ kubectl get rc my-redis -o jsonpath='{.metadata.finalizers}'
["cache.example.com/redis-finalizer"]

删除 CR:

bash
kubectl delete rc my-redis

观察日志:

开始清理 RedisCluster  name=my-redis
已注销配置中心          name=my-redis
已移除 finalizer,CR 将被删除  name=my-redis

CR 被真正删除,关联的 Deployment/Service 也被级联删除。

3. 模拟清理失败

Deregister 改成永远返回 error,再删 CR:

bash
kubectl delete rc my-redis
# rediscluster.cache.example.com "my-redis" deleted(实际是 Terminating)

查看:

bash
$ kubectl get rc my-redis
NAME       AGE
my-redis   5m    # 还在,PHASE 仍是 Running,处于 Terminating

$ kubectl get rc my-redis -o jsonpath='{.metadata.deletionTimestamp}'
2026-08-01T10:00:00Z

$ kubectl get rc my-redis -o jsonpath='{.metadata.finalizers}'
["cache.example.com/redis-finalizer"]   # finalizer 没被移除

CR 卡在 Terminating,finalizer 还在,等待重试。这就是 Finalizer 的「保证清理」语义——清理不成功绝不放行。

4. 强制移除 finalizer(应急)

如果外部资源确实无法清理(比如配置中心永久下线),运维可以手动移除 finalizer 强制删除:

bash
kubectl patch rc my-redis --type=merge -p '{"metadata":{"finalizers":[]}}'

⚠️ 这会绕过清理逻辑,外部资源会泄漏。仅作应急,且要手动善后外部资源。

七、Finalizer 与 Status 的协作

删除过程中也可以更新 Status,告诉用户「正在清理什么」:

go
func (r *RedisClusterReconciler) reconcileDelete(ctx context.Context, rc *cachev1.RedisCluster) (ctrl.Result, error) {
    // 更新 status 表示正在清理
    base := rc.DeepCopy()
    rc.Status.Phase = "Terminating"
    _ = r.Status().Patch(ctx, rc, client.MergeFrom(base))

    // ... 清理逻辑 ...
}

这样用户 kubectl get rc 能看到 phase=Terminating,知道正在清理。

八、小结

本篇完整讲解了 Finalizer 机制。要点回顾:

  1. 为什么需要 Finalizer:级联删除只能清集群内资源,外部资源(云资源、外部 DB)需要 Finalizer 来保证清理。
  2. 工作原理metadata.finalizers 非空时,DELETE 只设置 deletionTimestamp,不真正删除。控制器在 Reconcile 里检查 deletionTimestamp,执行清理,成功后移除 finalizer,对象才被删除。
  3. 实现四步法:定义 finalizer 名 → 创建时添加 → 删除时执行清理 → 清理成功移除。controllerutil 提供工具函数。
  4. 核心契约:清理失败不移除 finalizer,让对象卡在 Terminating 等重试。绝不能「失败也移除」。
  5. 清理逻辑必须幂等:忽略「不存在」错误,能重复执行。
  6. 常见坑:finalizer 改名要兼容、Operator 卸载前先清 CR、外部调用加超时、避免与 blockOwnerDeletion 死锁。
  7. 应急手段kubectl patch 清空 finalizers 可强制删除,但会泄漏外部资源,仅应急用。

下一篇讲 Leader Election——让 Operator 多副本部署实现高可用,同时避免多个 Controller 同时操作造成冲突。