Appearance
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,一个字符串数组。空表示「可以删了」。 - DeletionTimestamp:
metadata.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 提供了 ContainsFinalizer、AddFinalizer、RemoveFinalizer 三个工具函数,省得自己操作 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.NamespacedName2. 验证 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-redisCR 被真正删除,关联的 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 机制。要点回顾:
- 为什么需要 Finalizer:级联删除只能清集群内资源,外部资源(云资源、外部 DB)需要 Finalizer 来保证清理。
- 工作原理:
metadata.finalizers非空时,DELETE 只设置deletionTimestamp,不真正删除。控制器在 Reconcile 里检查 deletionTimestamp,执行清理,成功后移除 finalizer,对象才被删除。 - 实现四步法:定义 finalizer 名 → 创建时添加 → 删除时执行清理 → 清理成功移除。
controllerutil提供工具函数。 - 核心契约:清理失败不移除 finalizer,让对象卡在 Terminating 等重试。绝不能「失败也移除」。
- 清理逻辑必须幂等:忽略「不存在」错误,能重复执行。
- 常见坑:finalizer 改名要兼容、Operator 卸载前先清 CR、外部调用加超时、避免与 blockOwnerDeletion 死锁。
- 应急手段:
kubectl patch清空 finalizers 可强制删除,但会泄漏外部资源,仅应急用。
下一篇讲 Leader Election——让 Operator 多副本部署实现高可用,同时避免多个 Controller 同时操作造成冲突。