Finalizer:让资源"删不掉"的隐形守护者

如果你在K8s运维生涯中从未遇到过 kubectl delete 卡住不动的情况,要么你用的集群太小,要么你的运气太好了。

几乎每个K8s运维人员都经历过这个场景:删除一个Namespace、一个PVC、一个Custom Resource,命令执行后光标闪烁了半天,资源依然存在, Terminating 状态像一块狗皮膏药粘在那里,怎么都撕不掉。

罪魁祸首通常是一个叫做 Finalizer 的机制。

Finalizer是什么?为什么需要它?

在K8s中,删除一个资源通常是瞬间完成的——apiserver收到DELETE请求,从etcd中移除对象,完事。但这有个问题:如果这个资源关联了外部资源,直接删除K8s对象会导致外部资源泄漏。

比如一个PVC绑定了云盘,如果你直接删除PVC对象,云盘不会被自动释放,你会在云厂商的账单上看到一笔不必要的开销。再比如一个Service创建了外部负载均衡器,直接删除Service对象,负载均衡器会残留在云上继续计费。

Finalizer就是为解决这个问题而生的。它本质上是一个"删除前钩子"——在资源被真正删除之前,K8s会先执行Finalizer中注册的清理逻辑,确保外部资源被正确释放。

Finalizer的工作机制

Finalizer是资源对象 metadata.finalizers 字段中的一个字符串列表。当这个列表非空时,K8s不会从etcd中真正删除该对象。

整个删除流程如下:

1. 用户执行 kubectl delete pvc my-pvc
2. apiserver收到DELETE请求
3. 检查 metadata.finalizers 是否为空
   ├── 为空 → 直接从etcd删除对象,完成
   └── 非空 → 进入Terminating状态,不删除对象
4. 设置 deletionTimestamp(标记为正在删除)
5. 对应的Controller观察到deletionTimestamp
6. Controller执行清理逻辑(释放云盘等)
7. Controller清理完成后,从finalizers列表中移除自己的Finalizer
8. 当finalizers列表变为空 → apiserver从etcd中删除对象

用一段YAML来看更直观:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-pvc
  finalizers:
  - kubernetes.io/pvc-protection    # PVC保护Finalizer
  deletionTimestamp: "2026-07-12T10:00:00Z"
  deletionGracePeriodSeconds: 0
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi
  storageClassName: ceph-block

这个PVC有一个 kubernetes.io/pvc-protection 的Finalizer。只要这个Finalizer存在,PVC就不会被真正删除。只有当PVC不再被任何Pod使用时,PVC Controller才会移除这个Finalizer,PVC才会被删除。

常见的Finalizer

K8s内置了一些常用的Finalizer:

Finalizer 作用 添加者
kubernetes.io/pvc-protection 防止PVC在Pod使用中被删除 PVC Controller
kubernetes.io/pv-protection 防止PV在PVC绑定中被删除 PV Controller
kubernetes.io/service-protection 确保Service的负载均衡器被正确清理 Service Controller
foregroundDeletion 前台删除模式,等待所有依赖资源先删除 apiserver
orphan 孤儿删除模式,不自动删除依赖资源 apiserver

第三方Operator也会添加自己的Finalizer,比如:

# Cert-Manager的Certificate
metadata:
  finalizers:
  - cert-manager.io/finalizer

# ArgoCD的Application
metadata:
  finalizers:
  - resources-finalizer.argocd.argoproj.io

# 自定义Operator
metadata:
  finalizers:
  - my-operator.example.com/cleanup

资源"删不掉"的五大场景

场景一:Controller已不存在

这是最常见的情况。某个Operator创建了一堆Custom Resource,每个都带着它的Finalizer。后来Operator被卸载了,但Custom Resource还在。现在你删除这些CR,它们的Finalizer永远不会被移除,因为负责移除Finalizer的Controller已经不在了。

# 删除CR卡住
kubectl delete mysqlcluster my-db
# 等待...等待...一直等待...

# 查看状态
kubectl get mysqlcluster my-db -o yaml | grep finalizers
# finalizers:
# - mysql.operator.example.com/finalizer

场景二:Controller清理逻辑报错

Controller存在,但清理逻辑执行失败。比如一个Operator的Finalizer逻辑需要调用外部API删除一个云资源,但API凭证过期了,清理逻辑一直报错,Finalizer一直无法被移除。

# 查看Controller日志
kubectl logs -n operator-system deploy/mysql-operator

# 常见报错
ERROR: failed to delete cloud resource: Unauthorized
ERROR: Reconciler error: failed to cleanup: timeout calling API

场景三:Namespace卡在Terminating

删除Namespace时,Namespace内的所有资源都需要先被删除。如果其中任何一个资源因为Finalizer卡住,整个Namespace都会卡在Terminating状态。

kubectl get namespace old-project
NAME          STATUS        AGE
old-project   Terminating   120d

# 检查Namespace内残留资源
kubectl api-resources --verbs=list --namespaced -o name | \
  xargs -n 1 kubectl get -n old-project --ignore-not-found

场景四:finalizers和ownerReferences冲突

一个资源有Finalizer,同时它又是另一个资源的Owner。当你删除父资源时,子资源因为Finalizer无法删除,父资源因为子资源未删除也无法删除,形成循环依赖。

场景五:APIServer无法到达Controller

Controller运行在Pod中,如果Pod所在节点Not Ready,或者网络策略阻断了Controller与apiserver的通信,Controller无法处理Finalizer逻辑。

诊断与解决

诊断步骤

# 第一步:查看资源的Finalizer
kubectl get <resource> <name> -o jsonpath='{.metadata.finalizers}'
echo

# 第二步:查看deletionTimestamp
kubectl get <resource> <name> -o jsonpath='{.metadata.deletionTimestamp}'
echo

# 第三步:查看关联的Controller是否在运行
kubectl get pods -n <controller-namespace> -l app=<controller-name>

# 第四步:查看Controller日志中的错误
kubectl logs -n <controller-namespace> deploy/<controller-name> | \
  grep -i "finalizer\|cleanup\|error"

# 第五步:检查Controller是否注册了该资源的watch
kubectl get <resource> <name> -o yaml | grep -A5 finalizers

安全移除Finalizer

如果确认Controller已不存在或清理逻辑无法修复,可以手动移除Finalizer:

# 方法一:kubectl patch
kubectl patch <resource> <name> --type=merge -p '{"metadata":{"finalizers":null}}'

# 方法二:kubectl edit
kubectl edit <resource> <name>
# 手动删除 finalizers 列表中的所有条目

# 方法三:JSON Patch(精确移除特定Finalizer)
kubectl patch <resource> <name> --type=json \
  -p='[{"op":"remove","path":"/metadata/finalizers/0"}]'

注意:手动移除Finalizer意味着跳过了清理逻辑,可能导致外部资源泄漏。 只有在确认外部资源已手动清理完毕,或Controller确实不存在时,才应该这样做。

批量清理卡住的资源

#!/bin/bash
# 批量清理某个Namespace中所有卡在Terminating的资源

NAMESPACE="old-project"

# 获取所有带Finalizer的Terminating资源
kubectl api-resources --verbs=list --namespaced -o name | while read resource; do
  kubectl get "$resource" -n "$NAMESPACE" -o json | \
    jq -r '.items[] | select(.metadata.deletionTimestamp != null) | "\(.kind)/\(.metadata.name)"' | \
    while read item; do
      echo "Removing finalizers from $item..."
      kubectl patch "$item" -n "$NAMESPACE" --type=merge \
        -p '{"metadata":{"finalizers":null}}'
    done
done

Namespace卡在Terminating的终极修复

# 方法一:导出Namespace的JSON,移除finalizers和spec,重新PUT
kubectl get namespace <name> -o json | \
  jq 'del(.spec.finalizers, .metadata.finalizers)' | \
  kubectl replace --raw "/api/v1/namespaces/<name>/finalize" -f -

# 方法二:使用krew插件
kubectl krew install ns-remove-finalizer
kubectl ns-remove-finalizer <namespace>

编写自己的Finalizer

如果你在开发Operator,正确实现Finalizer是非常重要的。以下是一个使用controller-runtime的完整示例:

package controllers

import (
    "context"
    "fmt"

    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    apierrors "k8s.io/apimachinery/pkg/api/errors"

    examplev1 "example.com/api/v1"
)

const myFinalizerName = "my-operator.example.com/finalizer"

func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var resource examplev1.MyResource
    if err := r.Get(ctx, req.NamespacedName, &resource); err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }

    // 检查是否正在删除
    if !resource.DeletionTimestamp.IsZero() {
        // 资源正在被删除,执行清理逻辑
        return r.reconcileDelete(ctx, &resource)
    }

    // 资源正常存在,确保Finalizer已添加
    if !controllerutil.ContainsFinalizer(&resource, myFinalizerName) {
        controllerutil.AddFinalizer(&resource, myFinalizerName)
        if err := r.Update(ctx, &resource); err != nil {
            return ctrl.Result{}, err
        }
    }

    // 正常调谐逻辑...
    return ctrl.Result{}, nil
}

func (r *MyResourceReconciler) reconcileDelete(ctx context.Context, resource *examplev1.MyResource) (ctrl.Result, error) {
    // 1. 执行外部资源清理
    if err := r.cleanupExternalResources(ctx, resource); err != nil {
        // 清理失败,不移除Finalizer,等待下次调谐重试
        r.Log.Error(err, "failed to cleanup external resources")
        return ctrl.Result{RequeueAfter: 30}, nil  // 30秒后重试
    }

    // 2. 清理成功,移除Finalizer
    controllerutil.RemoveFinalizer(resource, myFinalizerName)
    if err := r.Update(ctx, resource); err != nil {
        return ctrl.Result{}, err
    }

    r.Log.Info("resource cleaned up successfully", "name", resource.Name)
    return ctrl.Result{}, nil
}

func (r *MyResourceReconciler) cleanupExternalResources(ctx context.Context, resource *examplev1.MyResource) error {
    // 清理外部资源,例如删除云盘、释放负载均衡器等
    // 必须是幂等的!因为可能被多次调用

    // 示例:删除外部数据库
    err := r.dbClient.DeleteDatabase(resource.Spec.DatabaseName)
    if err != nil && !isNotFound(err) {
        return fmt.Errorf("delete database: %w", err)
    }

    return nil
}

编写Finalizer时的关键原则:

第一,清理逻辑必须幂等。 Controller可能在清理失败后多次重试,清理逻辑必须能安全地执行多次。

第二,清理失败时不要移除Finalizer。 只有在确认所有外部资源都已清理完毕后,才移除Finalizer。失败时返回 RequeueAfter ,让Controller稍后重试。

第三,给Finalizer起一个唯一的名字。 使用 域名/功能 的格式,避免与其他Operator的Finalizer冲突。

第四,处理部分清理的情况。 如果有多个外部资源需要清理,记录清理进度,下次重试时跳过已清理的资源。

Finalizer与Graceful Shutdown的关系

Finalizer和 preStop 钩子、 terminationGracePeriodSeconds 经常被混淆,它们的作用层次不同:

| 机制                          | 作用层级         | 触发时机              |
|-------------------------------|-----------------|----------------------|
| preStop hook                  | Pod级别         | Pod被删除时           |
| terminationGracePeriodSeconds | Pod级别         | Pod删除超时强制杀死    |
| Finalizer                     | 资源对象级别      | 资源对象被删除时       |
| OwnerReference + Garbage Collection | 资源关系级别 | 父资源删除级联子资源    |

Finalizer是在apiserver层面拦截删除请求,它比Pod级别的优雅终止更早触发,也更难绕过。

总结

Finalizer是K8s资源管理中一个容易被忽视但极其重要的机制。它的存在确保了外部资源不会因为K8s对象的删除而泄漏,但同时也可能成为资源"删不掉"的元凶。

理解Finalizer的工作机制,掌握诊断和修复方法,是每个K8s运维人员和Operator开发者的必备技能。记住三个核心要点:

  1. 1. Finalizer列表非空时,资源不会被真正删除
  2. 2. 手动移除Finalizer等同于跳过清理逻辑,需谨慎
  3. 3. 开发Operator时必须正确实现Finalizer,且清理逻辑必须幂等

下次遇到资源卡在Terminating,不要慌——先看Finalizer,再看Controller,问题通常就在那里。