查看源文件

Kubernetes Finalizer:资源删除为何要等待控制器

执行 kubectl delete 后,对象可能仍然存在,并带上 deletionTimestamp。PVC 还被 Pod 使用、Operator 正在删除外部数据库,或者负责清理的控制器已停止,都可能让删除过程停下来。

Finalizer 为这类异步清理保留对象。本文先解释删除协议,再按常见阻塞原因排查,最后展示 controller-runtime 中的实现方式。阅读前需要了解资源对象、Controller 和 ownerReferences。诊断命令可以先读取状态;文中的移除 finalizer、批量清理及 /finalize 示例会绕过保护,只用于已核实依赖的人工恢复。

删除请求之后,为什么还要保留对象

DELETE 请求与“对象已经消失”不是同一时刻。Pod 可能需要优雅终止,垃圾回收器可能需要处理依赖对象,控制器也可能需要先释放外部资源。Finalizer 是 API 对象上的删除等待标记,为这些工作保留执行机会。

例如,PVC 的保护 finalizer 用于避免卷仍被 Pod 使用时删除声明;底层云盘是否释放还取决于 PV 回收策略和存储驱动。LoadBalancer Service 则可能需要等待云控制器清理负载均衡器。不能从“删除 PVC”直接推断“云盘一定保留”或“一定释放”。

Finalizer 本身只是字符串,不包含可执行代码。API Server 标记删除并保留对象;观察该标记的 Controller 才负责执行清理并移除自己管理的条目。没有对应 Controller 的自定义字符串,会让对象一直等待。

从删除标记到清理完成

Finalizer 记录在资源对象的 metadata.finalizers 字符串列表中。删除开始后,只要列表仍有条目,API server 就会保留对象,等待相应控制器完成清理。

整个删除流程如下:

Finalizer 与异步资源删除

下面是正在删除中的 PVC 状态示意。deletionTimestamp 由服务端设置,不能把这个片段当作新建 PVC 的 manifest。

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。负责保护的控制器确认不再有 Pod 使用它后,才移除该条目;若还有其他 finalizer,删除仍需继续等待。

常见 Finalizer 分别由谁处理

内置控制器使用不同的条目处理存储保护与级联删除:

Finalizer作用添加者
kubernetes.io/pvc-protection防止PVC在Pod使用中被删除PVC Controller
kubernetes.io/pv-protection防止PV在PVC绑定中被删除PV Controller
service.kubernetes.io/load-balancer-cleanup确保Service的负载均衡器被正确清理Service Controller
foregroundDeletion前台级联删除时等待阻塞删除的依赖对象API Server/垃圾回收控制器
orphan孤儿删除模式,不自动删除依赖资源apiserver

第三方 Operator 也会添加自己的条目。具体名称随版本和资源类型变化,应读取实际对象确认。下面以 Argo CD Application 和自定义 Operator 为例:

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

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

按阻塞位置区分五种删除故障

场景一:负责清理的 Controller 已卸载

某个 Operator 创建了带有自定义 finalizer 的资源,卸载时却没有先清理这些对象。之后再删除它们,API server 仍会等待相应控制器移除条目。此时应先确认能否恢复 Operator,完成它负责的外部清理。

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

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

场景二:Controller 的清理调用失败

Controller 虽然运行着,清理步骤仍可能失败。例如删除云资源时凭据过期,外部 API 返回未授权。查看日志中的实际错误,比反复提交删除请求更容易找到阻塞点。

# 查看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 控制器需要清理命名空间内的资源。某个对象的 finalizer 尚未完成时,整个命名空间也可能继续等待;应枚举残留对象,找到具体阻塞项。

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

场景四:级联删除等待带 finalizer 的子资源

在前台级联删除中,父对象可能等待子对象完成删除,而子对象的 finalizer 尚未移除。这通常是删除依赖链,不是仅因同时存在 ownerReferences 与 finalizer 就形成循环。只有清理代码反过来等待父对象消失等情况下,才可能产生实际死锁。先定位最下游阻塞对象及其 Controller。

场景五:Controller 无法访问 API Server

Controller 通常主动 watch API Server,而不是 API Server 回调 Controller。Controller Pod 所在节点异常、凭据失效、RBAC 不足或网络不通,都可能让它收不到事件或无法更新 finalizers。Webhook 的回调链路则是另一类问题,需要单独排查。

诊断与解决

诊断步骤

# 第一步:查看资源的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"

# 第五步:读取完整对象,确认 finalizer、ownerReferences 和删除状态
kubectl get <resource> <name> -o yaml
# 是否建立 watch 需要结合 Controller 配置、日志和实现判断,不能靠这一输出证明

何时才考虑人工移除 Finalizer

优先恢复 Controller 或修复外部依赖。Controller 不存在只解释了为什么卡住,不能证明可以跳过清理。人工移除前,应保存完整对象、识别每个条目对应的责任,并确认外部资源已处理,或者明确接受数据丢失、费用残留及孤立对象风险。

下面前两种方式会移除所有条目,影响其他控制器的保护;通常应只移除已经核实的那一个。

# 方法一: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":"test","path":"/metadata/finalizers/0","value":"my-operator.example.com/cleanup"},{"op":"remove","path":"/metadata/finalizers/0"}]'

JSON Patch 示例先用 test 验证索引 0 的值,防止并发修改后删错条目;应替换成对象中的真实值与索引。删除正在使用的卷相关保护,除了泄漏外还可能带来数据风险。

先盘点,再决定是否批量移除

批量移除会跳过多个控制器的清理流程。下面的脚本默认只列出候选对象;只有逐项核对后,才在隔离恢复环境中显式设置 ALLOW_REMOVE_FINALIZERS=true。它不是日常清理脚本。

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

set -euo pipefail
NAMESPACE="old-project"
ALLOW_REMOVE_FINALIZERS="${ALLOW_REMOVE_FINALIZERS:-false}"

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

Namespace 的特殊 finalize 接口

Namespace 还可能因 API 发现失败或 APIService 不可用而卡住,先查看 Namespace 的 Conditions、残留资源和对应控制器。它的 spec.finalizers 通过 /finalize 接口处理;强行清空可能留下命名空间内的孤立对象。仅在这些风险已核实并接受后使用下列恢复方式。

# 方法一:保留对象结构,仅清空 Namespace 的 spec.finalizers
kubectl get namespace <name> -o json | \
  jq '.spec.finalizers = []' | \
  kubectl replace --raw "/api/v1/namespaces/<name>/finalize" -f -

# 方法二:如使用第三方 Krew 插件,先确认名称、来源和实现
kubectl krew search finalizer
# ns-remove-finalizer 不是 Kubernetes 内置命令,
# 不能假定当前 Krew 索引一定提供它;核实后才安装和使用

在 Controller 中实现自己的 Finalizer

下面给出 controller-runtime 的控制流程。examplev1.MyResource 是项目生成的 API 类型(Spec 需要有 DatabaseName string),DatabaseClient 是需要由项目实现并注入的外部数据库接口;因此这是一份带明确依赖的控制器骨架,不是独立可运行程序。实现顺序可对照 Kubebuilder 的 finalizer 示例。

package controllers
 
import (
    "context"
    "fmt"
    "errors"
    "time"
 
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"
    "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"
 
// 实际 SDK 适配层应把外部“数据库不存在”转换成这个错误。
var ErrDatabaseNotFound = errors.New("database not found")
 
type DatabaseClient interface {
    DeleteDatabase(ctx context.Context, name string) error
}
 
type MyResourceReconciler struct {
    client.Client
    DB DatabaseClient
}
 
// +kubebuilder:rbac:groups=example.com,resources=myresources,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=example.com,resources=myresources/finalizers,verbs=update
// markers 的 API group/resource 请替换成项目实际定义。
 
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) {
    if !controllerutil.ContainsFinalizer(resource, myFinalizerName) {
        return ctrl.Result{}, nil
    }
 
    // 1. 执行外部资源清理
    if err := r.cleanupExternalResources(ctx, resource); err != nil {
        // 清理失败,不移除Finalizer,等待下次调谐重试
        log.FromContext(ctx).Error(err, "failed to cleanup external resources")
        return ctrl.Result{RequeueAfter: 30 * time.Second}, nil  // 30秒后重试
    }
 
    // 2. 清理成功,移除Finalizer
    controllerutil.RemoveFinalizer(resource, myFinalizerName)
    if err := r.Update(ctx, resource); err != nil {
        return ctrl.Result{}, err
    }
 
    log.FromContext(ctx).Info("resource cleaned up successfully", "name", resource.Name)
    return ctrl.Result{}, nil
}
 
func (r *MyResourceReconciler) cleanupExternalResources(ctx context.Context, resource *examplev1.MyResource) error {
    // 清理外部资源,例如删除云盘、释放负载均衡器等
    // 必须是幂等的!因为可能被多次调用
    
    // 示例:删除外部数据库
    if r.DB == nil {
        return fmt.Errorf("database client is not configured")
    }
    err := r.DB.DeleteDatabase(ctx, resource.Spec.DatabaseName)
    if err != nil && !errors.Is(err, ErrDatabaseNotFound) {
        return fmt.Errorf("delete database: %w", err)
    }
    
    return nil
}

这段控制流程还需要满足几个条件:

清理调用必须幂等。Controller 可能在请求成功但状态尚未更新时重启,下次调用同一操作时,应能把“目标已经不存在”视为清理完成。

清理失败时保留条目,并记录错误后重试。示例使用 RequeueAfter 安排下一次调谐,不能在外部 API 返回失败时直接移除 finalizer。

自定义名称采用 域名/功能 格式。控制器只维护自己的条目,避免覆盖其他 Operator 已写入的保护。

如果清理涉及多个外部资源,还应保存足够的进度或让每一步都可重复执行,防止中途失败后无法继续。

Finalizer 与容器优雅退出怎样配合

Finalizer、preStop 和终止宽限期分别处理 API 对象保留与容器退出,排障时应先区分各自的作用:

机制作用层级触发时机
preStop hookPod级别Pod被删除时
terminationGracePeriodSecondsPod 级别约束终止宽限期,到期后仍运行的容器可能被强制结束
Finalizer资源对象级别资源对象被删除时
OwnerReference + Garbage Collection资源关系级别父资源删除级联子资源

Finalizer 保留 API 对象供 Controller 完成清理,preStop 和终止宽限期控制容器退出。带 finalizer 的 Pod 在被标记删除后也可能开始终止,不能把两者理解为严格串行的“先 finalizer、后停进程”。kubectl delete --force 也不等于自动清空 finalizers。

从删除状态定位下一步

看到 deletionTimestamp 后,先记录 finalizers 和 ownerReferences,再找到负责该条目的 Controller。Controller 正常时检查其权限与外部 API;Controller 已卸载时,优先恢复同一版本并完成清理。只有外部依赖已核对、正常路径无法恢复时,才考虑精确移除已确认的条目。

开发控制器时则要反向检查这条链路:先持久化自己的 finalizer,再创建外部依赖;清理必须可重试,失败保留条目,成功后只移除自己管理的条目。这样即使 Controller 在中途重启,也能从 API 状态继续处理。