资讯动态

K8s 1.20+ NFS动态供给selfLink报错原理与解决路径

发布时间:2026/10/20 16:44:50 来源:尧图企业网站定制
一旦集群升到 Kubernetes 1.20 以上还沿用老一套 NFS 动态供给方案nfs-client-provisionerPVC 创建时十有八九会摔在这一句上selfLink was empty, cant make reference我第一次看见这个报错时第一反应是去查 NFS 权限、查 StorageClass 参数、查网络连通性折腾了半天才发现问题根本不在这些地方。这个报错本质上是 Kubernetes 版本演进带来的兼容性断裂1.20 之后 API 不再自动填充metadata.selfLink而老版 provisioner 在构造 PV 与 PVC 的绑定引用时严重依赖这个字段字段一空整个动态供给链路就崩了。这篇文章把背后的机制、报错的完整链路、三种可落地的解决办法和我在实际环境里踩过的坑一次讲清楚给正在升级集群或者刚接手 NFS 存储方案的运维、开发同学做个参考。1. 问题现象与版本背景1.1 报错到底长什么样这个报错通常会出现在两个地方。第一个是 provisioner 自己的 Pod 日志排错时kubectl logs -n kube-system nfs-client-provisioner-xxx能看到类似下面这种内容E0115 14:22:01.123456 1 controller.go:131] provision default/nginx-data class nfs: unexpected error getting claim reference: selfLink was empty, cant make reference第二个是 kube-controller-manager 里 PersistentVolume 控制器的日志看起来像E0115 14:22:01.987654 1 pv_controller.go:521] Failed to provision volume for PVC default/nginx-data: storageclass nfs not found实际情况里报错重心还是集中在 provisioner Pod 日志那一条。此时执行kubectl get pvcPVC 会一直停在 Pending 状态事件里写的就是selfLink was empty或者unexpected error getting claim reference。理解这个报错的关键在于它发生在动态供给的前期阶段也就是 provisioner 尝试为 PVC 构造 PV 对象、准备绑定关系的时候。根本还没走到后面挂载 NFS、mount 目录那一步所以你去调 NFS 导出权限、改 /etc/exports怎么调都解决不了。1.2 为什么偏偏是 1.20 这个版本线Kubernetes 1.20 做了一次破坏性变更metadata.selfLink字段不再由 API Server 自动填充。这个字段以前是什么简单说它是对象在 API 里的绝对路径比如一个 Pod 的 selfLink 长这样/api/v1/namespaces/default/pods/nginx-7c8b9d6d5f-abcde在 1.19 及更老的版本里API Server 会为每个对象自动生成这个路径。所以哪怕业务代码、控制器代码里从来不主动写 selfLink你拿到的对象里也一定有这个字段。这就让很多比较早期的 controller 产生了一个偷懒依赖构造对象引用时不去自己拼 UID、Name、APIVersion而是直接调用 client-go 的ref.GetReference()从对象里读 selfLink。1.20 之后 API Server 不再填充这个字段selfLink 变成了空值。client-go 的GetReference()函数遇到空 selfLink 时直接就返回了标题里那句错误。简单说不是 NFS 配置错了也不是网络问题是上游把 provisioner 依赖的基础设施抽走了。1.3 最容易踩坑的场景我梳理了自己遇到和帮别人处理的几个场景基本就两类。第一类集群升级。原本 K8s 1.18 或 1.19 跑得好好的NFS 动态供给每天创建几十个 PV 都没问题升级到 1.20 之后新创建的 PVC 全部卡在 Pending。这类最让人困惑因为什么配置都没改升级前还能用升级后直接瘫痪。第二类新集群搭建。新建的 1.20 集群运维同学还是按照以前那套部署文档装 nfs-client-provisioner装完测试 PVC马上撞上这个错。网上搜到的很多教程因为发布时间早并没有覆盖这个兼容性问题导致照着抄作业的人白折腾半天。这两类场景在实际处理时有个共同的难点大家会习惯性先怀疑 NFS 服务器的权限、IP 地址、导出路径这些基础配置结果排查一圈之后才会发现是版本兼容性的事。2. 原理拆解selfLink 与 NFS 动态供给的绑定链路2.1 selfLink 在 provisioner 里的真实用途要理解为什么一个路径字段空了会导致整个供给失败得先看动态供给的过程。StorageClass 动态供给大致分几步用户创建 PVC指定storageClassName: nfskube-controller-manager 里的 PV Controller 发现这个 PVC 没有匹配的 PV而且对应的 StorageClass 配置了 provisionerController 把这个 PVC 对象交给 provisioner让 provisioner 去生产一个 PVprovisioner 在 NFS 服务器上创建目录然后构造一个 PV 对象写进 etcdPV Controller 把 PVC 和这个新 PV 做绑定问题出在第 4 步。老版 nfs-client-provisioner 在构造 PV 时需要为 PV 设置一个ClaimRef也就是指向上游 PVC 的引用。它构造引用的方式不是手动写Name、Namespace、UID而是调用 client-go 里的ref.GetReference()让这个函数根据传入对象自动生成引用。而GetReference()的实现里有一段逻辑它会检查对象是否实现了 SelfLink 感知接口如果 selfLink 为空就直接报错if selfLink, ok : obj.(SelfLinkAwareObject); ok { if selfLink.GetSelfLink() { return nil, fmt.Errorf(selfLink was empty, cant make reference) } }所以报错信息的字面意思很直白你这个对象没有 selfLink我没办法用它构造引用。而旧版 client-go 之所以这么写是因为它默认API Server 一定会填充 selfLink这个假设在 1.19 之前成立在 1.20 之后被打破了。2.2 为什么新版 provisioner 不踩这个坑后来社区维护的新版解决方案nfs-subdir-external-provisioner在写 PV 的 ClaimRef 时不再依赖 selfLink 了而是直接用 PVC 的基本信息手动构造引用claimRef : v1.ObjectReference{ Kind: PersistentVolumeClaim, APIVersion: v1, Namespace: pvc.Namespace, Name: pvc.Name, UID: pvc.UID, }看到差别了吗Name、Namespace、UID这些字段本来就在 PVC 对象上不需要通过 selfLink 推导。只要构造引用时不走GetReference()那条老路问题就不存在了。这也是为什么我强烈建议不要试图把 selfLink找回来而是应该把依赖 selfLink 的旧方案换掉。前者是跟上游对着干后者是顺着上游的方向走。2.3 版本变更的完整时间线再补充一点版本背景方便你判断自己会踩到哪一种情况。Kubernetes 1.20 是一个分水岭。从这个版本开始selfLink 不再被自动填充但字段本身还在对象的 Schema 里只是值为空。到了 1.24 之后上游进一步把RemoveSelfLink这个特性开关默认打开selfLink 字段直接从 API 对象里移除。这意味着几个事情在 1.20 ~ 1.23 的集群里你可能会在一些对象里看到selfLink: 但字段还占着位置在 1.24 的集群里除非显式关闭RemoveSelfLink否则字段根本不存在无论哪个阶段GetReference()拿到的 selfLink 都是空该报错还是报错有同学问过那我是不是在 API Server 或 Controller Manager 里加一个--feature-gatesRemoveSelfLinkfalse就能恢复答案是不能。就算你在 1.24 里把这个特性关闭字段也只是保留在 Schema 里API Server 依然不会自动填充它。真正让旧 provisioner 跑不起来的不是字段不存在而是字段是空的。3. 首选方案迁移到新版 NFS 动态供给方案3.1 新旧方案的主要区别既然老方案已经确定被上游抛弃第一步就该考虑替换。目前社区共识是转向 nfs-subdir-external-provisioner。这个项目相当于老版 nfs-client-provisioner 的继承者解决的问题正好就是 1.20 的兼容性。新旧方案的区别主要体现在几个方面对比项老版 nfs-client-provisioner新版 nfs-subdir-external-provisioner维护状态已停止维护持续更新镜像位置老仓库新仓库selfLink 依赖有直接导致报错无PV 目录命名简单支持模板化 pathPattern删除策略可选归档更灵活的 onDelete 策略如果你现在还在用老版且集群版本是 1.20 以上我的建议是别做任何兼容性修补直接迁移到新版。3.2 使用 Helm 快速部署手工部署需要准备的 RBAC、Deployment、StorageClass 文件比较多用 Helm 是最快的路径。装之前确认 Helm 版本推荐用 Helm 3它不需要 Tiller权限模型也更干净。添加仓库并安装helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm repo update helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ --namespace kube-system \ --set nfs.server192.168.10.20 \ --set nfs.path/data/nfs \ --set storageClass.namenfs-storage \ --set storageClass.provisionerNamek8s-sigs.io/nfs-subdir-external-provisioner \ --set storageClass.archiveOnDeletetrue几个参数值得单独说明。nfs.server填 NFS 服务器的 IP 或域名nfs.path填 NFS 导出的根目录。storageClass.name指定要创建的 StorageClass 名字你后续创建 PVC 时引用这个名字即可。archiveOnDelete表示 PVC 删除后目录不直接删除而改成归档默认是 true生产环境建议保留这个能力万一误删 PVC 还能找回数据。安装完成之后确认 provisioner Pod 起来了kubectl get pods -n kube-system | grep nfs-subdir然后创建一个 PVC 测试apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-nfs-pvc spec: accessModes: - ReadWriteMany storageClassName: nfs-storage resources: requests: storage: 1Gi如果 PV 能在几秒内创建并绑定说明链路已经恢复正常。3.3 手工部署时的关键配置参考如果不方便用 Helm手工部署也很直接。Deployment 的核心配置如下apiVersion: apps/v1 kind: Deployment metadata: name: nfs-subdir-external-provisioner namespace: kube-system spec: replicas: 1 selector: matchLabels: app: nfs-subdir-external-provisioner template: metadata: labels: app: nfs-subdir-external-provisioner spec: serviceAccountName: nfs-subdir-external-provisioner containers: - name: nfs-subdir-external-provisioner image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2 volumeMounts: - name: nfs-client-root mountPath: /persistentvolumes env: - name: PROVISIONER_NAME value: k8s-sigs.io/nfs-subdir-external-provisioner - name: NFS_SERVER value: 192.168.10.20 - name: NFS_PATH value: /data/nfs volumes: - name: nfs-client-root nfs: server: 192.168.10.20 path: /data/nfs注意一个关键点Deployment 环境变量里的PROVISIONER_NAME必须和 StorageClass 里的provisioner字段完全一致。比如这里都是k8s-sigs.io/nfs-subdir-external-provisioner如果两边不一致PV Controller 会找不到对应的 provisionerPVC 一样会卡住。RBAC 配置主要给 provisioner 授权创建、删除 PV绑定 PVC以及查看事件等权限我通常直接参考项目文档里的 ClusterRole 定义这里不再整段贴。手工部署时最容易漏的就是 ServiceAccount 没绑定好导致 provisioner 没有权限创建 PV排错时记得看一下 RBAC 事件。StorageClass 的推荐写法apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-storage provisioner: k8s-sigs.io/nfs-subdir-external-provisioner reclaimPolicy: Delete volumeBindingMode: Immediate mountOptions: - hard - nolock - timeo30 - retrans3 parameters: onDelete: retain archiveOnDelete: truemountOptions里的参数是从生产环境摸出来的组合。hard表示 NFS 挂载后如果服务端短暂不可达客户端会持续重试而不是报错对数据库这类应用来说硬挂载比软挂载靠谱。nolock关闭 NFS 锁相关机制减少异常场景下锁文件残留的问题。timeo30设置超时时间retrans3设置重传次数这两个组合起来能在网络波动时提供一个合理的容忍窗口。3.4 存量业务如何平滑切换如果你的系统已经在用老版 provisioner 创建的 PV切换到新版 provisioner 后存量 PV 不会自动迁移到新命名规则下。这里有两种做法可以选择。第一种旧 PVC 能重建就重建。如果业务无状态或者数据不重要直接删除旧 PVC让业务重新挂载新 PVC让新版 provisioner 重新创建目录。这是最省事的方式。第二种数据必须保留时停止业务把 NFS 服务器上旧目录里的数据拷贝到新版 provisioner 创建的目录下然后让业务 PVC 切换到新 PV。实际操作时先创建一个测试 PVC到 NFS 服务器上看它生成的目录名然后做数据同步同步完再切换业务引用。有个细节提醒一下如果原来的 PVC 已经绑定 PV 且数据正在被业务使用不要直接删除 PVC 再重建那样 PV 也会随之进入 Released 状态如果 reclaimPolicy 是 DeleteNFS 上的数据目录可能直接没了。切换前先把 PV 的 reclaimPolicy 临时改成 Retain确认数据安全后再做后续操作。4. 应急兜底用静态 PV 手动绑定绕过动态供给4.1 什么场景需要走静态绑定有时候没法立刻换 provisioner。比如线上业务已经受影响PVC 卡在 Pending 半天了开发同学等着用环境这时候来不及走完整的迁移流程可以先用手动创建 PV 的方式把业务捞起来。静态绑定的思路很朴素不去依赖动态供给直接在集群里创建一个 PersistentVolume指向 NFS 服务器上已经准备好的目录然后让 PVC 绑定到这个 PV 上。整个过程完全绕开 provisioner自然也就不涉及 selfLink 的问题。这种方式适合做应急处理或者那些本身不需要弹性扩展、PV 数量有限的场景。缺点也很明显失去了按需创建的灵活性每增加一个目录你都得手动建 PV。所以我把它定位成兜底方案而不是长期方案。4.2 手动绑定完整操作步骤第一步在 NFS 服务器上创建目录。假设 NFS 根目录是/data/nfs现在给业务创建一个独立目录mkdir -p /data/nfs/app-data-manual chown -R nobody:nogroup /data/nfs/app-data-manual chmod 777 /data/nfs/app-data-manual这里的权限设置要跟 NFS 导出配置匹配。很多环境用的是no_root_squash这个权限很容易踩坑后面我会单独说。第二步创建 PV。注意storageClassName可以设为空避免系统去匹配动态供给的存储类。apiVersion: v1 kind: PersistentVolume metadata: name: pv-manual-nfs-001 spec: capacity: storage: 5Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs: server: 192.168.10.20 path: /data/nfs/app-data-manual第三步创建 PVC 并手动指定volumeName指向这个 PVapiVersion: v1 kind: PersistentVolumeClaim metadata: name: app-data-pvc namespace: production spec: accessModes: - ReadWriteMany storageClassName: volumeName: pv-manual-nfs-001 resources: requests: storage: 5Gi执行kubectl apply -f pvc.yaml后PVC 应该会直接进入 Bound 状态。如果一直 Pending执行kubectl describe pvc app-data-pvc看事件常见原因包括 PV 的容量小于 PVC 请求、accessModes 不匹配、或者volumeName写错了。这里有个关键点PVC 的storageClassName必须显式设置为空字符串注意是空字符串不是不设置。如果你不写这个字段它会被赋予集群默认的 StorageClass从而触发动态供给又走回老路去了。这是新手最容易犯的错误。4.3 静态绑定方式的注意事项静态绑定虽然绕开了 selfLink 报错但引入了另外几个问题需要提前知道。第一个是 PV 容量约束。PV 的capacity.storage是静态声明的PVC 请求的容量只要超过它就会绑定失败。但 NFS 本身是共享目录应用实际能写多少并不受 PV 声明限制。所以这里容易出现声明了 5Gi实际写了几十 Gi的假象建议业务侧自己做好配额限制否则数据写满了隐患会很大。第二个是目录权限的一致性问题。前面我在 NFS 服务器上用了chmod 777这是应急场景的粗暴做法。如果生产环境要求更严格建议用no_root_squash配合正确的 UID/GID 做访问控制容器内进程是什么用户NFS 目录就设置成什么属主。很多同学在容器里遇到Permission denied八成是 NFS 服务器的 squash 规则和容器用户 UID 对不上。第三个是回收策略要设成 Retain。动态供给的 StorageClass 通常配 Delete 策略删除 PVC 时连 PV 一起删。但静态 PV 的目录是你手动创建的如果策略设成 DeleteK8s 会尝试按 PV 配置去删除后端存储NFS 类型的 PV 删除行为很模糊为了防止误删数据一律建议设成 Retain。5. 自研/老旧 provisioner 的代码修复思路5.1 问题定位到 client-go 的 GetReference有些团队用的是公司内部基于老版 nfs-client-provisioner 改过的自研 controller或者依赖的某些组件里调用了GetReference()。这种场景下换方案的成本可能比改代码更高那可以考虑直接修掉这个依赖。第一步是定位到报错的直接来源。在 Go 语言里这个错误来自k8s.io/client-go/tools/reference/ref.go里的GetReference函数。如果在编译时锁定了旧版 client-go这个函数长这样func GetReference(metaScheme runtime.ObjectConvertor, obj runtime.Object) (*v1.ObjectReference, error) { if selfLink, ok : obj.(SelfLinkAwareObject); ok { if selfLink.GetSelfLink() { return nil, fmt.Errorf(selfLink was empty, cant make reference) } } // ... }它在自检对象有没有 selfLink没有就抛错。最简单的修法就是不调用这个函数改成手动构造ObjectReference。5.2 一个最小改动的示例假设原代码里类似这样claimRef, err : ref.GetReference(pvc) if err ! nil { return nil, err }改成claimRef : v1.ObjectReference{ Kind: PersistentVolumeClaim, APIVersion: v1, Namespace: pvc.Namespace, Name: pvc.Name, UID: pvc.UID, }关键点在于ObjectReference里真正用来标识一个对象的字段是Namespace、Name、UID这几个selfLink 本身只是辅助信息。手动构造时把这些字段填好效果完全等价而且不依赖 API Server 是否填充 selfLink。如果项目里其他地方也用到了GetReference可以写一个自己的辅助函数比如func objectRef(obj metav1.Object, gvk schema.GroupVersionKind) *v1.ObjectReference { return v1.ObjectReference{ APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, Namespace: obj.GetNamespace(), Name: obj.GetName(), UID: obj.GetUID(), } }这样既绕开了 selfLink 检查又保留了对任意对象构造引用的能力。5.3 修复后的回归验证清单改完代码重新编译部署后不能只看一条 PVC 能不能创建就算完事。我习惯按下面这个清单做回归验证创建 PVC确认 PV 自动生成PVC 进入 Bound 状态删除 PVC确认 PV 按 reclaimPolicy 被回收或进入 Released没有残留半绑定状态批量创建多个 PVC确认并发场景下不会因为缺 selfLink 报错模拟 K8s Controller Manager 重启确认 PV/PVC 绑定关系不会丢失如果客户端连接方式有变化再确认重建 Pod 后挂载正常在自研 controller 的场景里还要注意一个问题GetReference可能不只被 PV 供给链路调用也可能在事件上报、日志记录里用到。如果在事件上报逻辑里复用了这个函数改成手动构造 ObjectReference 之后要确认事件里的引用信息仍然完整别修好了供给又弄坏了事件展示。6. 常见问题与排查实录6.1 一个典型的排错过程实录有次帮一个团队排查类似问题他们的集群是 1.22NFS 存储用了老版 provisioner。现象是测试环境所有 PVC 都无法创建事件一直报错。我的排查顺序是这样的第一步看 provisioner Pod 日志确认报错就是selfLink was empty。第二步看 provisioner 镜像 tag。他们用的还是很早以前的镜像不是新版这已经基本坐实了原因。第三步看 PVC 事件时间点。他们反馈的是升级集群之后出现的时间线也能对上。第四步直接给出两步方案临时用静态 PV 把关键业务捞起来然后排期迁移到新版 provisioner。整个排查过程其实很快因为报错信息本身已经把原因指向了 client-go 的引用构造逻辑。真正花时间的是业务恢复和方案切换而不是找到问题。6.2 错误日志快速对照表日志关键字可能原因处理方向selfLink was empty旧版 provisioner 或旧 client-go 依赖换新版 provisioner 或改代码unexpected error getting claim referenceselfLink 引发的连锁报错同上storageclass xxx not foundSC 与 provisioner 名不匹配检查 SC 的 provisioner 字段unexpected error getting claim reference: object was nilPVC 对象没传对查 controller 逻辑确认 PVC 来源failed to provision volume with StorageClass多种原因需要结合具体错误信息继续看这个表不是我编的都是实际环境里出现过的组合。前两行是这个版本的典型症状后几行是有时会被同时带出来的其他问题。6.3 排错中的三个高频误区第一个误区反复调 NFS 服务端。就像开头说的selfLink was empty错误发生在 PV 对象构造阶段和 NFS 服务端根本还没打交道。我见过有人花了两小时改 /etc/exports最后发现一点用都没有。判断标准很简单如果 provisioner Pod 日志里根本没有 mount 相关操作那问题一定在更早的环节。第二个误区尝试用--feature-gatesRemoveSelfLinkfalse找回 selfLink。前面原理部分说过就算保留字段API Server 也不会自动填充这条路走不通。除非你愿意在 API Server 前面加一个自定义逻辑去伪造 selfLink那属于另一种不可持续的 hack不建议碰。第三个误区创建完新 StorageClass但业务 PVC 还在引用旧的 StorageClass 名字。这种情况排错时会发现新 SC 明明没问题为什么 PVC 还是 Pending。操作时把 PVC 的storageClassName一并改掉或者旧 SC 名字直接删掉避免混淆。6.4 镜像拉取和部署环境的现实问题部署新版 provisioner 时有一个现实问题需要提前考虑新版的镜像在registry.k8s.io/sig-storage/路径下有些内网环境访问这个仓库会比较吃力。如果你也遇到镜像拉不下来可以在一台能访问外网的机器上把镜像拉下来docker pull registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2 docker save registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2 -o nfs-provisioner.tar然后把 tar 包传到目标集群的节点上用docker load导入再配合镜像私有仓库做分发。如果集群里用的是 containerd 而不是 Docker操作命令略有差异但拉取-导出-导入这个思路是通用的。这里想多说一句环境越是受限越建议提前把镜像备好别等线上出故障了才去找网络通道。生产环境的时间窗口往往很窄预先准备好离线镜像能省掉很多焦虑。7. 写在最后一点经验之谈处理过几次selfLink was empty之后我的一个体会是这类问题的根源往往不是某个配置写错了而是 K8s 的版本迭代把默认行为悄悄改掉了。老版 provisioner 依赖 API Server 自动填充 selfLink这在 1.19 之前是顺理成章的事到了 1.20 突然变成空值所有依赖它的组件就一起崩溃。面对这种情况最务实的心态是顺着上游的方向改而不是逆着它去修补。换新版 provisioner 或者改一行代码绕过 GetReference都是顺着方向走去找 feature gate、去伪造 selfLink都是逆着方向走可能短时间能顶一下但后续版本更新又会让你重新面对同样的问题。如果你现在正被这个报错卡住我建议先从 provisioner Pod 日志确认错误然后直接走迁移方案。如果业务急着跑先用静态绑定捞起来后续再安排平滑切换。这个顺序基本不会走弯路。最后再留一个小技巧排查任何升级之后突然不好使的存储问题都可以先看一眼目标组件用到的 client-go 版本再翻一下对应 K8s 版本的 Release Notes。很多兼容性问题在官方变更记录里写得清清楚楚只是大家一开始不习惯往那个方向翻。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑