news 2026/10/20 7:26:28

K8s升级后NFS动态存储失效?selfLink报错全解与修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
K8s升级后NFS动态存储失效?selfLink报错全解与修复

最近接手了一个很有意思的排障,某开发者的集群从 Kubernetes 1.18 升到 1.20 之后,原来一直很稳的 NFS 动态存储突然全部罢工。新创建的 PVC 一律卡在 Pending,kubectl describe里反复出现一行错误:selfLink was empty, can't make reference。这个组合拳“NFS + StorageClass + selfLink”如果你也见过,大概率问题不在 NFS 服务器,也不在某台节点网络不通,而是 provisioning 链条里某个环节还在依赖已经被 Kubernetes 1.20 移走的旧字段。

这篇文章我会把这个报错的来龙去脉讲透,然后给出一个可以直接复制的替代方案,把旧的外置 provisioner 换成现在社区还在维护的版本。看完之后你不仅能修好这个报错,还能搞清楚为什么升级控制面会连带把存储链路震断。

1. 事故现场:PVC 卡在 Pending,日志里全是 selfLink

1.1 故障表现像一个“假 NFS 故障”

先说现象。这个环境里用的是经典的 NFS 动态供给方案:一个运行在集群里的nfs-client-provisioner,配合自定义 StorageClass,PVC 一创建就自动在 NFS 服务器上划目录、生成 PV。这套方案在旧集群里跑了很久,PV 创建、扩容、回收都很正常。

然后集群控制面升到 v1.20,问题马上冒出来。新建一个测试 PVC,状态变化是这样的:

$ kubectl get pvc test-pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE test-pvc Pending 5m

注意VOLUME列是空的,说明 PV 根本没被创建出来。看kubectl describe pvc,事件里会出现类似这样的内容:

Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning ProvisioningFailed 3s (x2 over 7s) persistentvolume-controller selfLink was empty, can't make reference

最开始很多人会下意识先去查 NFS 服务器通不通、权限对不对、export 路径写没写错。但这里 NFS 挂载本身没有任何问题,因为如果网络或权限挂了,报错通常是mount: permission denied或者no route to host,而不是这种“selfLink was empty”的指令级错误。

1.2 报错源头在 PV 绑定链路,而不是 NFS 挂载

顺着日志来源看,persistentvolume-controller是 kube-controller-manager 里面的一个控制器,不是 NFS 客户端本身。它负责将 PV 和 PVC 绑定起来,动态供给的流程是这样的:外置 provisioner 收到 PVC 请求,去 NFS 服务器上创建目录,然后生成一个 PV 对象返回给 apiserver;PV controller 再接住这个 PV,把它和请求的 PVC 完成绑定。

在这个绑定过程中,PV controller 需要在 PV 对象里写入一个claimRef,也就是指向 PVC 的引用。构造这个引用时,旧版 Kubernetes 代码里有一步要拿 PVC 的metadata.selfLink来生成 ObjectReference。而 Kubernetes v1.20 起,apiserver 默认不再自动填充selfLink,字段直接是空字符串。引用构造就挂了,PV 创建流程被中止。

所以问题本质上不是“NFS 存储服务不可用”,而是“旧版 provisioning 代码用了一个被新版本 API 删掉的字段”。这个区别很重要,能帮你节约大量排查时间。

2. “selfLink was empty”到底是谁在报错,底层链路是怎么断的

2.1 selfLink 是什么,为什么会被移除

metadata.selfLink是一个用于表示“对象自身在 API 中的规范访问路径”的字段。比如某个 PVC 的 selfLink 长这样:

/api/v1/namespaces/default/persistentvolumeclaims/test-pvc

在 Kubernetes 1.20 之前,apiserver 会主动为每个资源对象填充这个值。相当于每个对象一生下来就自带一张“地址卡片”,上面写清楚“我在哪条 API 路径上”。很多控制器代码会直接抄这张卡片上的信息去构造资源引用。

但 Kubernetes 从很早开始就打算弱化这个字段。原因也很实际:它是 apiserver 内部路由的一种实现细节,不该成为外部代码的稳定依赖。1.16 开始引入RemoveSelfLink特性开关,默认关闭;1.20 开始默认开启,对象返回时 selfLink 就基本为空了;后续版本进一步把它彻底移除,想配置回来的入口也没了。

对普通使用场景来说,移除 selfLink 影响不大,因为访问资源本身不依赖这个字段。但对那些 1.20 之前写的、基于旧版 client-go 和外部存储库实现的 provisioner 来说,这就是一次不兼容升级。

2.2 claimRef 是整条链路上的脆弱点

要理解为什么偏偏是 PV 绑定环节崩掉,得看一下 PV 和 PVC 的嫁接方式。PV 绑定到 PVC 时,PV 的spec.claimRef指向 PVC 的引用信息,里面包含 Namespace、Name、UID 等。这个claimRef的类型就是ObjectReference,而旧代码里构造ObjectReference时会尝试读取对象的 selfLink,目的是把对象的 UID 等信息补全。

一旦 apiserver 返回的 PVC 对象里 selfLink 为空,旧代码里那个构造 ref 的函数就会直接返回“selfLink was empty, can't make reference”这个错误。这个错误在 Kubernetes 1.20+ 的代码里非常典型,因为它就是那处旧逻辑在空字段上的明确失败信号。

可以做个生活化类比:地址卡片从之前“系统自动发”变成了“系统不发了”。新程序都知道直接按门牌号找人,老程序却还按照印刷在卡片上的寄送路径去投递,发现卡片成了一张白纸,自然就报“投递失败”。

2.3 临时预案:靠特性开关拖时间可行吗

网上有一部分资料会建议用--feature-gates=RemoveSelfLink=false来临时恢复 selfLink。这个方案只在很有限的版本窗口可用,比如 Kubernetes 1.20.x 到 1.23.x 之间,并且你得能改 kube-apiserver 的启动参数。对于托管集群来说根本不具备操作条件;即便是自建集群,到了 1.24 之后这个开关入口也移除,拖再久也躲不过最终替换。

所以我的态度是:这个开关可以作为“今晚先让业务恢复”的应急手段,但千万别当作长期方案。真正的治本方法,是把外置 provisioner 迁移到不再依赖 selfLink 的新实现上。

3. 根治路线:把 StorageClass 切换到 nfs-subdir-external-provisioner

3.1 为什么首选社区还在维护的 nfs-subdir-external-provisioner

老牌的nfs-client-provisioner长期没有实质更新,代码已经很久没跟上 Kubernetes 存储体系。它依赖的external-storage项目也早已归档,继续留在老版本上的唯一办法就是打补丁,代价高且不划算。

社区现在的推荐替代品是nfs-subdir-external-provisioner,由 Kubernetes SIG Storage 维护,构建在较新的 external-provisioner 库上。它不再读取 selfLink,而是通过 PVC 对象的标准字段完成命名和绑定,所以在 Kubernetes 1.20+ 以及更新的 1.24、1.25、1.28 环境里都能正常跑。

它保留了你熟悉的 NFS 使用方式:还是把某个 NFS 根目录挂进容器,PVC 创建时就自动在根目录下按规矩生成一个子目录,并把子目录作为 PV 后端。存储路径的命名规则是<namespace>-<pvcName>这种可读性很高的结构,运维起来也不费劲。

3.2 完整部署清单:RBAC、Deployment、StorageClass

下面这套 yaml 是基于社区仓库的标准配置整理的,你可以直接套用,只需要替换三处真实环境变量:NFS_SERVER、NFS_PATH、以及StorageClass里的provisioner名称保持一致。

先创建 ServiceAccount 和 RBAC:

apiVersion: v1 kind: ServiceAccount metadata: name: nfs-client-provisioner namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nfs-client-provisioner-runner rules: - apiGroups: [""] resources: ["persistentvolumes"] verbs: ["get", "list", "watch", "create", "delete"] - apiGroups: [""] resources: ["persistentvolumeclaims"] verbs: ["get", "list", "watch", "update"] - apiGroups: ["storage.k8s.io"] resources: ["storageclasses"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["events"] verbs: ["list", "watch", "create", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: nfs-client-provisioner-rolebinding subjects: - kind: ServiceAccount name: nfs-client-provisioner namespace: kube-system roleRef: kind: ClusterRole name: nfs-client-provisioner-runner apiGroup: rbac.authorization.k8s.io

这里提醒一句:网上有些旧教程用的还是extensions/v1beta1的 RBAC 写法,在老集群上能过,放到 1.20+ 上大概率直接被 apiserver 拒绝。上面这份用的是稳定的rbac.authorization.k8s.io/v1,可以放心用。

然后创建 Deployment:

apiVersion: apps/v1 kind: Deployment metadata: name: nfs-client-provisioner namespace: kube-system spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: nfs-client-provisioner template: metadata: labels: app: nfs-client-provisioner spec: serviceAccountName: nfs-client-provisioner containers: - name: nfs-client-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: 10.10.10.60 - name: NFS_PATH value: /ifs/kubernetes volumes: - name: nfs-client-root nfs: server: 10.10.10.60 path: /ifs/kubernetes

注意PROVISIONER_NAME这个环境变量,它的值必须是后面 StorageClass 里provisioner字段的值。两者一旦不一致,PVC 就会一直处于 Pending,事件里出现no provisioner found之类的内容。

最后创建 StorageClass:

apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: "false" reclaimPolicy: Delete volumeBindingMode: Immediate

我这里把archiveOnDelete设为false,表示 PVC 删除后直接清理 NFS 子目录。如果你们旧环境里配的是true,那切换时一定要保持一致,否则删除 PVC 后数据可能被直接清掉,造成事故。更安全的方式是先设成true,确认行为符合预期后再决定要不要关。

3.3 用 Helm 部署会更省事

如果是测试环境或者不想维护一堆 yaml,社区还提供了 Helm Chart,一条命令就能装完:

helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ --set nfs.server=10.10.10.60 \ --set nfs.path=/ifs/kubernetes \ --set storageClass.name=nfs-client

需要让这个 StorageClass 成为默认存储时,可以多追加一个参数:

--set storageClass.defaultClass=true

Helm 的方式适合快速验证,但生产环境我更推荐直接把 yaml 提交到 Git 仓库走 GitOps,这样后续变更可审计、可回滚。

4. 切完之后怎么验证:从 PVC 绑定到真实写入 NFS

4.1 用一条 PVC 验证绑定链路

部署完新 provisioner 之后,不能只看 Deployment 变成 Running 就说完事,得让它真正处理一个 PVC。

创建测试 PVC:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc spec: accessModes: - ReadWriteMany storageClassName: nfs-client resources: requests: storage: 1Gi

正常情况下 PVC 应该在几秒内进入 Bound 状态:

$ kubectl get pvc test-pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE test-pvc Bound pvc-0e3c742f-xxxx-40f6-9e2a-09aa6a11c39d 1Gi RWX nfs-client 6s

这里绑定的 PV 名称已经是pvc-开头、后面接 UID 的格式,这是现代 provisioner 的标准命名方式,也就是它完全不依赖 selfLink 生成名称的直接表现。

4.2 真正把文件写进 NFS 目录才算数

PVC 状态是 Bound 只说明控制面和存储类通信正常,但 NFS 是否真的可写还要实测。起一个临时 Pod 往卷里写文件:

apiVersion: v1 kind: Pod metadata: name: nfs-test-pod spec: containers: - name: app image: busybox:1.36 command: ["/bin/sh", "-c", "date > /mnt/hello.txt && echo ok"] volumeMounts: - name: data mountPath: /mnt volumes: - name: data persistentVolumeClaim: claimName: test-pvc

等 Pod 启动完成后,检查容器内部文件:

$ kubectl logs nfs-test-pod ok $ kubectl exec nfs-test-pod -- cat /mnt/hello.txt Wed Feb 14 10:00:00 UTC 2024

再到 NFS 服务器上浏览目录,应该能看到新生成的子目录,目录名对应namespace-pvcname这种结构。比如上面创建的是test-pvc,那么 NFS 根路径下大概率就会出现default-test-pvc这样的目录,里面躺着刚才写入的hello.txt。

这一步验证非常关键,它确认了从 StorageClass 到 provisioner、再到 NFS 服务器磁盘上的整条写路径都是通的。任何一环有问题,比如 export 目录权限不对,或者 NFS 挂载参数缺失,在这里都会暴露出来。

5. 迁移中的现实阻力:旧 StorageClass 语义、回收与遗留数据

5.1 不要直接修改旧 StorageClass 的 provisioner 字段

如果你有一套已经在运行的旧 StorageClass,比如叫nfs-client,里面provisioner写的是老名称,千万别直接把旧 StorageClass 的provisioner字段改掉。原因很实际:旧 StorageClass 可能还托管着一批存量 PVC,直接改字段会让这些 PVC 和 PV 的对应关系变得混乱。

正确的做法是新建一个 StorageClass,比如nfs-client-new,让新业务 PVC 使用新 StorageClass。等所有存量 PVC 都被回收或迁移后,再考虑删除旧 StorageClass。如果集群中有默认 StorageClass 的概念,注意确认默认存储类指向哪一个,否则业务方不显式声明storageClassName时可能还是落到旧类上。

5.2 archiveOnDelete 和 reclaimPolicy 的语义必须对齐

archiveOnDelete这个参数控制的是:PVC 删除之后,NFS 目录是直接删除,还是把目录改名打成归档留下。旧版 provisioner 里大家用得比较多的值是"true",很多运维人员也习惯了“删了 PVC 数据还在 NFS 目录里,能紧急救回来”的状态。

新版里这个参数仍然存在,但迁移时如果直接照抄新配置而没有核对旧值,非常容易出问题。我见过一个案例:旧环境里archiveOnDelete="true",新环境换成了false,某天 PVC 被误删后整个数据目录立刻清空,根本来不及恢复。

建议在切换前先检查存量 StorageClass 的定义:

kubectl get storageclass <旧存储类名> -o yaml | grep archiveOnDelete

确认原值是什么,再在新建 StorageClass 里保持一致,或者至少在迁移计划里显式标注行为差异。

5.3 新 provisioner 不会自动接管旧 PV 和旧目录

另一个容易踩的坑是:认为换上新 provisioner 之后,旧 PV 就自动能继续使用。实际上不会。新 provisioner 只负责“未来新创建的 PVC”,那些已经处于 Bound 状态的旧 PV,还是由旧 volume 绑定关系继续挂在集群里。如果你把旧 provisioner 的 Deployment 直接删掉,存量 PV 虽然暂时不受影响,但命运相关的回收、存档操作就没人处理了,一旦 PVC 被删,PV 可能进入异常状态。

因此我建议采用一个分阶段策略。第一阶段:保留旧 provisioner Deployment 不删,只新增新 provisioner 和新 StorageClass,让新业务跑新类。第二阶段:逐步把业务 PVC 替换到新 StorageClass,或者提前将关键数据迁移到新 PV 上。第三阶段:确认所有旧 PVC 都清理干净后,再下线旧 provisioner。这个过程虽然稍慢,但每一步都可审计,不容易把存量数据搞丢。

5.4 镜像拉取问题:registry.k8s.io 在某些环境访问困难

registry.k8s.io是官方镜像仓库,但部分网络环境拉取可能比较慢,甚至直接超时。这个不是 Kubernetes 本身的问题,是环境网络策略差异。遇到这种情况,建议先把镜像转到你们自己的私有仓库,比如 Harbor,再改 Deployment 里的镜像地址:

image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2

改成:

image: harbor.<内部域名>/sig-storage/nfs-subdir-external-provisioner:v4.0.2

这种做法对任何外部镜像都适用,不只是 NFS provisioner。离线环境里尤其要提前把镜像导出再导入,不然 Deployment 会一直 ImagePullBackOff。

5.5 还是想留在老 provisioner?至少要认清维护成本

如果你因为业务流程原因暂时不能迁移,比如没有权限动生产 StorageClass,或者某些定制功能只在新版本里没法替代,那你至少要评估维护成本。老 provisioner 依赖的 external-storage 库已经停止更新,而 Kubernetes API 的兼容性只会在新版本里越来越收紧。每一次集群升级都可能带来新的断裂。

我见过有的团队选择了“在 1.20 时代用特性开关硬撑”,结果一年后集群升到 1.24 时还是被迫迁移,中间还多欠了一屁股技术债。时间拖得越久,镜像下载、依赖库兼容、文档参考都会更加困难。

从我实际操作的角度,遇到这种问题最省心的做法是:别修旧代码,直接换新 provisioner。迁移动作本身只需要一个小时左右,做完之后集群再升到更新版本也不会再碰到 selfLink 相关的坑。如果你现在正好被这个报错卡着,按照上面这套流程走一遍,基本就能把问题平掉了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 7:25:24

工程师深度学习指南:环境搭建与数据管道实战

1. 为什么“工程师深度学习”值得单独写一个系列“工程师深度学习指南”这个标题&#xff0c;乍一看像是又一本从感知机讲到反向传播的教科书。但我写这个系列&#xff0c;恰恰是因为市面上不缺教科书&#xff0c;缺的是从工程落地视角出发的深度学习知识梳理。我自己是从传统后…

作者头像 李华
网站建设 2026/10/20 7:25:24

STM32H7B0小内存跑FreeRTOS和FatFS的工业记录仪实践

1. 项目概述&#xff1a;为什么要用H7的这颗“小内存”芯片跑FreeRTOS和文件系统先说结论&#xff1a;STM32H7B0VBT6这颗芯片&#xff0c;很多人一看参数就皱眉——128KB RAM、128KB Flash&#xff0c;这在H7家族里就是个“乞丐版”。但如果你做的是数据采集、协议转换、小规模…

作者头像 李华
网站建设 2026/10/20 7:25:08

ARIMA实战:5分钟监控时间序列预测的清洗、定阶与评估

最近在做一个监控类项目&#xff0c;数据是按秒采集的&#xff0c;但业务上只需要看5分钟粒度&#xff0c;而且要求提前一个时段把指标预测出来&#xff0c;方便提前扩资源。折腾了一圈&#xff0c;最后还是用ARIMA把这事给办了。这个案例很适合拿来说一说&#xff0c;因为它的…

作者头像 李华
网站建设 2026/10/20 7:24:27

PMSM超螺旋滑模控制Simulink仿真:抑制抖振的转速环鲁棒方案

搞电机控制的仿真调试&#xff0c;我向来有个习惯&#xff1a;拿到一个新算法&#xff0c;先别急着看推导&#xff0c;先想明白它到底要治什么病。这次做的永磁同步电机&#xff08;PMSM&#xff09;超螺旋滑模控制算法仿真&#xff0c;说白了就是用一种叫超螺旋的结构&#xf…

作者头像 李华
网站建设 2026/10/20 7:24:27

SAP MM模块核心数据库表全解析:从物料主数据到库存凭证

1. 从项目上线第一天起&#xff0c;就该搞懂这些 MM 表干 MM 模块实施或者做 SAP 二次开发的朋友&#xff0c;一定有过这种经历&#xff1a;业务顾问跑过来丢给你一个需求——"帮我查一下这个物料在哪个工厂有多少库存""这张采购订单是谁在几号下的""…

作者头像 李华
网站建设 2026/10/20 7:24:17

2025届毕业论文降重实测:三类工具效果与避坑指南

每年到了毕业季&#xff0c;都有一大批2025届的同学拿着查重报告到处问同一个问题&#xff1a;网上那些"降重复率网站"到底靠不靠谱。前阵子我帮实验室几位学弟学妹改论文&#xff0c;顺手把市面最火的那几类降重工具和服务做了一轮实际效果测试&#xff0c;前前后后…

作者头像 李华