假设有如下三个节点的 K8S 集群:
k8s31master 是控制节点
k8s31node1、k8s31node2 是工作节点
容器运行时是 containerd
一、背景分析
阅读本文,默认您有 PV-PVC、hostPath 相关知识。
由于安全方面的考虑,K8S 官方并不推荐 hostPath + 节点选择器 来作为 Pod 的持久化方案。

转而提倡 local 持久卷的方式。我们今天就来实践一下 local 持久卷。
二、no-provisioner 方式(无供应商方式)
1)创建 StorageClass
javascript
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage # 名称需与 PV 中的 storageClassName 对应
provisioner: kubernetes.io/no-provisioner # 关键:本地卷不支持动态供应
volumeBindingMode: WaitForFirstConsumer # 关键:延迟绑定直到 Pod 调度
reclaimPolicy: Delete # 卷回收策略(Retain/Delete)
provisioner=kubernetes.io/no-provisioner:本地卷还不支持动态制备; 然而还是需要创建 StorageClass 以延迟卷绑定,直到 Pod 被实际调度到合适的节点。
volumeBindingMode=WaitForFirstConsumer:延迟 PVC 与 PV 的绑定。延迟卷绑定使得调度器在为 PersistentVolumeClaim 选择一个合适的 PersistentVolume 时能考虑到所有 Pod 的调度限制。

2)创建挂载目录
因为本地卷不支持动态制备,所以需要在每个工作节点上手工创建挂载目录。
bash
# node1 创建
[root@k8s31node1 ~]# mkdir -p /data/local_storage
# node2 创建
[root@k8s31node2 ~]# mkdir -p /data/local_storage
3)创建 PV
javascript
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-rwo-pv
labels:
type: local
spec:
capacity:
storage: 1Gi # 定义存储容量
volumeMode: Filesystem # 可选:Filesystem 或 Block
accessModes:
- ReadWriteOnce # 关键:仅允许单节点读写挂载
persistentVolumeReclaimPolicy: Delete # 可选:Retain/Recycle/Delete
storageClassName: local-storage # 引用上面创建的存储类
local:
path: /data/local_storage # 节点上的实际路径
nodeAffinity: # 限制卷只能被特定节点使用
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k8s31node1 # 替换为你的节点名
accessModes=ReadWriteOnce:因为是本地存储,所以只允许单节点挂载。
nodeAffinity:节点亲和性,限制卷只能被具有标签 kubernetes.io/hostname 且值为 k8s31node1 的节点使用。可以通过命令 kubectl get node --show-labels 查看节点标签。
- 查看 pv

STATUS=Available:实现可用状态。
4)创建 PVC
javascript
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: local-rwo-pvc
spec:
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
- 查看 pv,pvc

因为是延迟绑定,所以 PVC 显示为 Pending 状态。只有当 Pod 挂载了这个 PVC,K8S 才会寻找合适的 PV 进行绑定。
5)创建部署
javascript
apiVersion: apps/v1
kind: Deployment
metadata:
name: local-rwo-deploy
spec:
replicas: 2
selector:
matchLabels:
app: local-rwo-pod
template:
metadata:
labels:
app: local-rwo-pod
spec:
volumes:
- name: local-rwo-volumes
persistentVolumeClaim:
claimName: local-rwo-pvc
containers:
- name: nginx
image: nginx:1.14.2
imagePullPolicy: IfNotPresent
volumeMounts:
- name: local-rwo-volumes
mountPath: /usr/share/nginx/html
readOnly: true
ports:
- containerPort: 80
protocol: TCP
- 查看部署的 Pod

可以看到,因为 PV 节点亲和性的设置,所有的 Pod 会被调度到同一个节点 node1 上。

这个时候的 PV、PVC 显示绑定状态。
6)RWO 验证
此时,我们再创建一个 Pod 挂载这个 PVC,然后通过节点选择器,让它被调度到 node2 上,看看会发生什么?
javascript
apiVersion: v1
kind: Pod
metadata:
name: local-node2-pod
spec:
nodeName: k8s31node2 # 节点选择器
volumes:
- name: storage
persistentVolumeClaim:
claimName: local-rwo-pvc # 需提前创建对应的 PVC
containers:
- name: nginx
image: nginx:1.14.2
imagePullPolicy: IfNotPresent
volumeMounts:
- name: storage
mountPath: /usr/share/nginx/html
readOnly: true
ports:
- containerPort: 80
protocol: TCP
会发现 Pod 创建不成功。

- 查看描述
bash
kubectl describe pod local-node2-pod

Warning FailedMount 9s (x7 over 40s) kubelet MountVolume.NodeAffinity check failed for volume "local-rwo-pv" : no matching NodeSelectorTerms
从这也能看出,本地存储实现了严格的 RWO。
7)数据持久性验证
在 node1 本地目录创建文件
bash
[root@k8s31node1 ~]# echo "LocalStorage" > /data/local_storage/index.html

数据在 Pod1 跟 Pod2 中是共享的。
- 删除部署再重新执行
bash
kubectl delete -f deploy.yaml
kubectl apply -f deploy.yaml

pod 被删除后重新部署,数据还在。
8)Delete 自动回收验证
删除部署、PVC 看看有没有帮我们自动删除 PV
bash
kubectl delete -f deploy.yaml
kubectl delete -f pvc.yaml
kubectl get pv

会发现 PV 的状态是 Failed。说明自动回收失败了。
- 查看 PV 描述

Warning VolumeFailedDelete 2m51s persistentvolume-controller error getting deleter volume plugin for volume "local-rwo-pv": no volume plugin matched
卷的自动删除失败,当前的卷插件并不支持 自动回收。
9)手工回收
bash
kubectl delete -f pv.yaml
在 node1 上删除数据文件

三、provisioner 方式(静态供应商方式)
1)介绍
kubernetes-sigs/sig-storage-local-static-provisioner 是 K8S 本地存储的静态外部供应商。
所谓静态,是说,虽然它可以帮我们创建 PV 与清理 PV 上的数据,但是真实存储目录的创建,还是需要集群管理员来做。本质上,它还是一种 PV 的静态供应方式。
英文原文:
There is one provisioner instance on each node in the cluster. Each instance is responsible for monitoring and managing the local volumes on its node.
The basic components of the provisioner are as follows:
Discovery: The discovery routine periodically reads the configured discovery directories and looks for new mount points that don't have a PV, and creates a PV for it.
Deleter: The deleter routine is invoked by the Informer when a PV phase changes. If the phase is Released, then it cleans up the volume and deletes the PV API object.
Cache: A central cache stores all the Local PersistentVolumes that the provisioner has created. It is populated by a PV informer that filters out the PVs that belong to this node and have been created by this provisioner. It is used by the Discovery and Deleter routines to get the existing PVs.
Controller: The controller runs a sync loop that coordinates the other components. The discovery and deleter run serially to simplify synchronization with the cache and create/delete operations.
中文翻译:
在每个集群节点上都有一个供应者实例。每个实例负责监控和管理其节点上的本地卷。供应者的基本组件如下:
- 发现模块 :
- 发现程序会定期读取配置好的发现目录,寻找没有 PV 的新挂载点,并为它们创建 PV。
- 删除模块 :
- 当 PV 状态发生变化时,Informer 会调用删除程序。
- 如果状态是"已释放",它会清理卷并删除 PV API 对象。
- 缓存 :
- 中央缓存存储供应者创建的所有本地持久卷。
- 它由一个 PV Informer 填充,该 Informer 过滤出属于该节点且由该供应者创建的 PV。
- 发现和删除程序使用它来获取现有 PV。
- 控制器 :
- 控制器运行一个同步循环来协调其他组件。
- 发现和删除程序串行运行,以简化与缓存的同步以及创建/删除操作。
简单来说:sig-storage-local-static-provisioner 会用 DaemonSet 控制器的方式,在每个集群工作节点上,都创建出一个 pod,pod 名字叫 provisioner,它会负责监听发现目录(默认是 /mnt/fast-disks)有没有新的挂载点进来,有的话,就以这个挂载点创建一个新的 PV。
我们将基于 开始文档 来进行配置
2)创建并挂载发现目录
在所有工作节点上创建发现目录(/mnt/fast-disks)和存储目录(/data/local_storage/fast-disks)
bash
# 创建发现目录和其子目录 v1
[root@k8s31node1 ~]# mkdir -p /mnt/fast-disks/v1
# 创建实际存储目录
[root@k8s31node1 ~]# mkdir -p /data/local_storage/fast-disks/v1
# 执行绑定挂载
[root@k8s31node1 ~]# mount --bind /data/local_storage/fast-disks/v1 /mnt/fast-disks/v1
# 创建发现目录和其子目录 v1
[root@k8s31node2 ~]# mkdir -p /mnt/fast-disks/v1
# 创建实际存储目录
[root@k8s31node2 ~]# mkdir -p /data/local_storage/fast-disks/v1
# 执行绑定挂载
[root@k8s31node2 ~]# mount --bind /data/local_storage/fast-disks/v1 /mnt/fast-disks/v1
mount 命令常用来将外部设备如磁盘、U盘等挂载进 Linux 系统中,我手头没有多余的硬盘来演示,所以我把文件系统目录 /data/local_storage/fast-disks/v1 通过绑定挂载的方式,挂载到 /mnt/fast-disks/v1。
3)创建 StorageClass
执行 sig-storage-local-static-provisioner\deployment\kubernetes\example\default_example_storageclass.yaml
bash
kubectl apply -f default_example_storageclass.yaml
javascript
# Only create this for K8s 1.9+
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-disks
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
# Supported policies: Delete, Retain
reclaimPolicy: Delete
- 创建名为 fast-disks 的 StorageClass:

4)镜像准备
bash
# 找个国内好访问的镜像站,拉取镜像
docker pull swr.cn-north-4.myhuaweicloud.com/ddn-k8s/registry.k8s.io/sig-storage/local-volume-provisioner:v2.6.0
# 名字太长,改短一点
docker tag 镜像ID registry.k8s.io/sig-storage/local-volume-provisioner:v2.6.0
# 导出镜像
docker save -o local-volume-provisioner.tar.gz registry.k8s.io/sig-storage/local-volume-provisioner:v2.6.0
# 上传到工作节点
scp local-volume-provisioner.tar.gz 192.168.40.20:/root
scp local-volume-provisioner.tar.gz 192.168.40.30:/root
在工作节点导入镜像
[root@k8s31node1 ~]# ctr -n=k8s.io images import local-volume-provisioner.tar.gz
[root@k8s31node1 ~]# ctr -n=k8s.io images ls | grep local
[root@k8s31node2 ~]# ctr -n=k8s.io images import local-volume-provisioner.tar.gz
[root@k8s31node2 ~]# ctr -n=k8s.io images ls | grep local
5)部署 provisioner
修改 sig-storage-local-static-provisioner\deployment\kubernetes\example\default_example_provisioner_generated.yaml
javascript
---
# Source: provisioner/templates/provisioner.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: local-provisioner-config
namespace: default
data:
storageClassMap: |
fast-disks:
hostDir: /mnt/fast-disks
mountDir: /mnt/fast-disks
blockCleanerCommand:
- "/scripts/shred.sh"
- "2"
volumeMode: Filesystem
fsType: ext4
namePattern: "*"
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: local-volume-provisioner
namespace: default
labels:
app: local-volume-provisioner
spec:
selector:
matchLabels:
app: local-volume-provisioner
template:
metadata:
labels:
app: local-volume-provisioner
spec:
serviceAccountName: local-storage-admin
containers:
- image: "registry.k8s.io/sig-storage/local-volume-provisioner:v2.6.0" # 修改镜像版本
imagePullPolicy: "IfNotPresent" # 修改镜像拉取策略
name: provisioner
securityContext:
privileged: true
env:
- name: MY_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- mountPath: /etc/provisioner/config
name: provisioner-config
readOnly: true
- mountPath: /mnt/fast-disks
name: fast-disks
mountPropagation: "HostToContainer"
volumes:
- name: provisioner-config
configMap:
name: local-provisioner-config
- name: fast-disks
hostPath:
path: /mnt/fast-disks
---
# Source: provisioner/templates/provisioner-service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: local-storage-admin
namespace: default
---
# Source: provisioner/templates/provisioner-cluster-role-binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: local-storage-provisioner-pv-binding
namespace: default
subjects:
- kind: ServiceAccount
name: local-storage-admin
namespace: default
roleRef:
kind: ClusterRole
name: system:persistent-volume-provisioner
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: local-storage-provisioner-node-clusterrole
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: local-storage-provisioner-node-binding
namespace: default
subjects:
- kind: ServiceAccount
name: local-storage-admin
namespace: default
roleRef:
kind: ClusterRole
name: local-storage-provisioner-node-clusterrole
apiGroup: rbac.authorization.k8s.io
修改镜像版本与镜像拉取策略。
provisioner 利用 DaemonSet 控制器在每个工作节点上,都创建出一个 pod,监听发现目录(默认是 /mnt/fast-disks)挂载点,创建对应 PV。pod 要操作 node,要调用 K8S API Server,所以脚本的内容,就是创建 ServiceAccount、创建集群角色、创建账号角色绑定。
bash
kubectl apply -f default_example_provisioner_generated.yaml
执行成功后,会创建一个 local-volume-provisioner DaemonSet,并在每个节点上创建一个 Pod。

这些 Pod 会监视每个节点发现目录的挂载点,然后创建相应的 PV:

这些 PV 都配置对应节点的节点亲和性,后续我们的业务 Pod 使用这些 PV 存储的时候,就会被调度到 PV 对应的节点上------哪个 Pod 使用了 PV,就会被调度到 PV 对应的节点上。
6)创建 PVC
javascript
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: pvc1
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
storageClassName: fast-disks
storageClassName: fast-disks
bash
kubectl apply -f pvc1.yaml

7)创建 Pod,使用 PVC
javascript
apiVersion: v1
kind: Pod
metadata:
name: local-pod1
spec:
volumes:
- name: storage
persistentVolumeClaim:
claimName: pvc1 # 使用上面的 PVC
containers:
- name: nginx
image: nginx:1.14.2
imagePullPolicy: IfNotPresent
volumeMounts:
- name: storage
mountPath: /usr/share/nginx/html
readOnly: true
ports:
- containerPort: 80
protocol: TCP
此时,PV local-pv-4df7cdd7 已经被绑定给了 pvc1。

local-pv-4df7cdd7 位于 node2,pod 也被调度到 node2。

此时,在 node2 存储目录 /data/local_storage/fast-disks/v1 创建文件 index.html。
是可以被挂载进我们的 Pod local-pod1 的。


8)Delete 自动回收验证
bash
# 删除 pod
kubectl delete -f pod1.yaml
# 删除 PVC
kubectl delete -f pvc1.yaml
此时可以看到,local-pv-4df7cdd7 PV 又被释放了出来。

node2 上的数据,也被清理掉了。

9)后续运维
后续运维,就只需要在每个节点上创建更多的挂载点,然后再创建对应数量的 PVC,以供开发建 Pod 使用。可以使用脚本,提高创建效率。
bash
for i in $(seq 1 5); do
mkdir -p /mnt/fast-disks/v${i}
mkdir -p /data/local_storage/fast-disks/v${i}
mount --bind /data/local_storage/fast-disks/v${i} /mnt/fast-disks/v${i}
done
后续运维,还可以把临时挂载改为永久挂载,以防节点重启,挂载丢失。