Kubernetes для самых маленьких. Часть 23: Подключаем NFS через csi-driver-nfs
Все части серии «Kubernetes для самых маленьких»В прошлой части мы подготовили nfs.example.com: только NFSv4, экспорт /srv/nfs/k8s на отдельном диске, отданный трём узлам кластера с no_root_squash, manage-gids=n, файрвол и политика Calico, которая не пускает к серверу поды. Сегодня — клиентская сторона: csi-driver-nfs, драйвер, который превращает этот экспорт в StorageClass. Как устроен CSI-драйвер вообще, было в части 21 (controller- и node-плагины, external-provisioner, node-driver-registrar, NodePublish); здесь — что именно делает этот драйвер на сервере и на узле, какие параметры StorageClass выбрать и почему, как выставить права, чтобы приложение не работало от root, как включить шифрование в пути и что происходит с подами, когда сервер пропадает.
Установка
Драйвер разрабатывается в организации kubernetes-csi, актуальный выпуск на момент написания — v4.13.6. Проект публикует для каждого выпуска готовые манифесты в каталоге deploy/v4.13.6/ репозитория; скрипт install-driver.sh из того же каталога делает ровно это — четыре kubectl apply. Применим их сами, чтобы видеть, что ставится. Файлы скачаны на рабочей станции и скопированы на мастер в ~/p23:
$ V=v4.13.6
$ for f in rbac-csi-nfs.yaml csi-nfs-driverinfo.yaml csi-nfs-controller.yaml csi-nfs-node.yaml; do curl -sLO https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/$V/deploy/$V/$f; done
$ kubectl apply -f rbac-csi-nfs.yaml -f csi-nfs-driverinfo.yaml -f csi-nfs-controller.yaml -f csi-nfs-node.yaml
serviceaccount/csi-nfs-controller-sa created
serviceaccount/csi-nfs-node-sa created
clusterrole.rbac.authorization.k8s.io/nfs-external-provisioner-role created
clusterrolebinding.rbac.authorization.k8s.io/nfs-csi-provisioner-binding created
clusterrole.rbac.authorization.k8s.io/nfs-external-resizer-role created
clusterrolebinding.rbac.authorization.k8s.io/nfs-csi-resizer-role created
csidriver.storage.k8s.io/nfs.csi.k8s.io created
deployment.apps/csi-nfs-controller created
daemonset.apps/csi-nfs-node created
Версия в адресе указана явно: каталог deploy/ без версии в ветке master меняется с каждым коммитом, и два кластера, развёрнутые с разницей в месяц, получили бы разные драйверы. Тот же драйвер ставится и Helm-чартом из репозитория (helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs --namespace kube-system --version 4.13.6, у чарта версия без буквы v начиная с 4.11.0); Helm — менеджер пакетов Kubernetes, о нём часть 27.
Для снапшотов нужны CRD и snapshot-controller — те же, что в части 21, из выпуска external-snapshotter v8.7.0; там мы их после опытов удалили, поэтому ставим снова теми же командами:
$ V=v8.7.0
$ for f in client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml; do curl -sLO https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/$V/$f; done
$ kubectl apply -f snapshot.storage.k8s.io_volumesnapshotclasses.yaml -f snapshot.storage.k8s.io_volumesnapshotcontents.yaml -f snapshot.storage.k8s.io_volumesnapshots.yaml
customresourcedefinition.apiextensions.k8s.io/volumesnapshotclasses.snapshot.storage.k8s.io created
customresourcedefinition.apiextensions.k8s.io/volumesnapshotcontents.snapshot.storage.k8s.io created
customresourcedefinition.apiextensions.k8s.io/volumesnapshots.snapshot.storage.k8s.io created
$ kubectl apply -f rbac-snapshot-controller.yaml -f setup-snapshot-controller.yaml
serviceaccount/snapshot-controller created
clusterrole.rbac.authorization.k8s.io/snapshot-controller-runner created
clusterrolebinding.rbac.authorization.k8s.io/snapshot-controller-role created
role.rbac.authorization.k8s.io/snapshot-controller-leaderelection created
rolebinding.rbac.authorization.k8s.io/snapshot-controller-leaderelection created
deployment.apps/snapshot-controller created
В репозитории драйвера есть и свои манифесты CRD снапшотов (crd-csi-snapshot.yaml), но они заметно старше выпуска external-snapshotter; если снапшоты не нужны, этот шаг можно пропустить. Что появилось в кластере:
$ kubectl -n kube-system get pods -l 'app in (csi-nfs-controller,csi-nfs-node)' -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
csi-nfs-controller-d5b8c6f76-tsdm7 5/5 Running 2 (14s ago) 2m31s 192.168.10.20 k8s-node-1 <none> <none>
csi-nfs-node-5w8tv 3/3 Running 0 2m31s 192.168.10.20 k8s-node-1 <none> <none>
csi-nfs-node-b592c 3/3 Running 0 2m31s 192.168.10.21 k8s-node-2 <none> <none>
csi-nfs-node-dht5v 3/3 Running 0 2m31s 192.168.10.17 k8s-master-1 <none> <none>
$ kubectl get csidriver nfs.csi.k8s.io -o yaml | sed -n '/^spec:/,$p'
spec:
attachRequired: false
fsGroupPolicy: File
podInfoOnMount: false
requiresRepublish: false
seLinuxMount: false
storageCapacity: false
volumeLifecycleModes:
- Persistent
csi-nfs-controller— Deployment с одной репликой и пятью контейнерами:csi-provisioner(external-provisioner v6.3.0, следит за PVC и вызываетCreateVolume/DeleteVolume),csi-resizer(расширение томов),csi-snapshotter(снапшоты),liveness-probeи сам драйверnfs(образnfsplugin:v4.13.6). Под работает вhostNetwork: адрес у него — адрес узла (колонка IP), поэтому сервер пускает его по правилу для узлов, а политика Calico из прошлой части его не видит. У контроллера есть tolerations для taint'ов мастера, так что он может оказаться и наk8s-master-1— поэтому мастер и есть в/etc/exports. Два перезапуска при первом старте — не ошибка: sidecar-контейнеры стартовали раньше, чем скачался образ драйвера, не дождались его сокета за отведённое время и были перезапущены kubelet'ом.csi-nfs-node— DaemonSet на каждом узле, включая мастер (он допускает любые taints), тоже вhostNetwork, с тремя контейнерами:node-driver-registrarрегистрирует драйвер в kubelet через сокет/var/lib/kubelet/plugins_registry/nfs.csi.k8s.io-reg.sock,liveness-probeиnfs, который по команде kubelet выполняетmount. Утилиты монтирования (mount.nfsиз пакетаnfs-common, Debian 12) лежат в образе драйвера, поэтому на самих узлах пакетnfs-commonне нужен.- CSIDriver — объект, через который kubelet узнаёт свойства драйвера.
attachRequired: false: у NFS нет шага подключения диска к узлу, поэтому объектов VolumeAttachment (запись «том подключён к такому-то узлу») не будет.fsGroupPolicy: File— kubelet сам меняет группу файлов тома наfsGroupпода. Остальные поля — значения по умолчанию, их дописал API-сервер.
Что происходит при создании тома
PVC demo/data ──► csi-provisioner ──► CreateVolume (nfs, контроллер)
│
│ mount nfs.example.com:/srv/nfs/k8s → /tmp/pvc-…
│ mkdir /tmp/pvc-…/demo-data-pvc-…
│ umount
▼
PV pvc-… (volumeHandle, server, share, subDir) ── Bound ── PVC
Под на k8s-node-1 ──► kubelet ──► NodePublishVolume (nfs, csi-nfs-node)
│
│ mount -t nfs nfs.example.com:/srv/nfs/k8s/demo-data-pvc-…
│ /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~csi/pvc-…/mount
▼
kubelet: chgrp/chmod под fsGroup, bind в контейнер
Контроллер не держит экспорт смонтированным: на каждый CreateVolume он монтирует корень экспорта во временный каталог внутри своего контейнера — с теми же mountOptions, что указаны в StorageClass, — создаёт подкаталог тома от root (поэтому и нужен no_root_squash) и размонтирует. Так это выглядит в журнале контейнера nfs контроллера (-v=5 в манифесте):
$ kubectl -n kube-system logs deploy/csi-nfs-controller -c nfs | grep -A3 'Controller/CreateVolume'
I1030 11:29:45.243675 1 utils.go:121] GRPC call: /csi.v1.Controller/CreateVolume
I1030 11:29:45.243709 1 utils.go:122] GRPC request: {"capacity_range":{"required_bytes":5368709120},"name":"pvc-9640d114-1423-4c1e-9a9f-144d406e860a","parameters":{"csi.storage.k8s.io/pv/name":"pvc-9640d114-1423-4c1e-9a9f-144d406e860a","csi.storage.k8s.io/pvc/name":"data","csi.storage.k8s.io/pvc/namespace":"demo","onDelete":"archive","server":"nfs.example.com","share":"/srv/nfs/k8s","subDir":"${pvc.metadata.namespace}-${pvc.metadata.name}-${pv.metadata.name}"},"volume_capabilities":[{"AccessType":{"Mount":{"mount_flags":["nfsvers=4.2","hard","timeo=600","retrans=3","nconnect=4","nosuid","nodev"]}},"access_mode":{"mode":5}}]}
I1030 11:29:45.243918 1 controllerserver.go:570] internally mounting nfs.example.com:/srv/nfs/k8s at /tmp/pvc-9640d114-1423-4c1e-9a9f-144d406e860a
I1030 11:29:45.244041 1 nodeserver.go:176] NodePublishVolume: volumeID(nfs.example.com#srv/nfs/k8s#demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a#pvc-9640d114-1423-4c1e-9a9f-144d406e860a#archive) source(nfs.example.com:/srv/nfs/k8s) targetPath(/tmp/pvc-9640d114-1423-4c1e-9a9f-144d406e860a) mountflags([nfsvers=4.2 hard timeo=600 retrans=3 nconnect=4 nosuid nodev])
Внутри контроллер монтирует экспорт той же функцией, что и node-плагин (отсюда NodePublishVolume в журнале контроллера), а через полсекунды размонтирует и удаляет временный каталог. access_mode 5 — MULTI_NODE_MULTI_WRITER, то есть RWX. DeleteVolume делает то же, но с rm -rf или переименованием каталога.
На узле у драйвера нет шага NodeStage: каждый под получает своё монтирование подкаталога прямо в каталог тома пода (kubelet всё равно создаёт пустой каталог globalmount в /var/lib/kubelet/plugins/kubernetes.io/csi/nfs.csi.k8s.io/, как у драйвера hostpath в части 21). Ядро при этом не открывает новых TCP-соединений на каждое монтирование: все монтирования одного сервера на узле делят один клиент NFS и его соединения — к этому мы ещё вернёмся, потому что у этой экономии есть обратная сторона.
StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs
provisioner: nfs.csi.k8s.io
parameters:
server: nfs.example.com
share: /srv/nfs/k8s
subDir: ${pvc.metadata.namespace}-${pvc.metadata.name}-${pv.metadata.name}
onDelete: archive
reclaimPolicy: Retain
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions:
- nfsvers=4.2
- hard
- timeo=600
- retrans=3
- nconnect=4
- nosuid
- nodev
server и share
server — имя или адрес сервера, share — путь экспорта. Имя удобнее адреса: при переезде сервера меняется одна запись DNS. Но имя попадает в каждый PV, и разрешают его оба плагина драйвера: и контроллер, и node-плагин работают в сети узла с dnsPolicy: ClusterFirstWithHostNet, то есть спрашивают CoreDNS кластера (в /etc/resolv.conf контейнера nfs — nameserver 10.96.0.10 и домены поиска kube-system.svc.cluster.local, svc.cluster.local, cluster.local). mount.nfs в контейнере разрешает имя в адрес и передаёт ядру уже адрес. Внешние имена CoreDNS пересылает DNS-серверам узла (часть 16), поэтому имя сервера должно разрешаться там; и если CoreDNS недоступен, новые тома не создаются и не монтируются, даже когда сам NFS-сервер в порядке.
subDir: читаемо и уникально
Без subDir драйвер называет каталог тома по имени PV: pvc-9640d114-…. Глядя на сервер, невозможно понять, чьи это данные. Шаблоны ${pvc.metadata.namespace}, ${pvc.metadata.name} и ${pv.metadata.name} делают имена читаемыми, но тут есть ловушка. Если каталог уже существует, драйвер берёт его без ошибки. Значит, при subDir: ${pvc.metadata.namespace}-${pvc.metadata.name} и сохранении каталога после удаления новый PVC с тем же именем в том же неймспейсе получит старые данные — возможно, данные другого приложения, если неймспейс за это время отдали другой команде. Проверено классом с таким subDir и onDelete: retain: под записал файл, PVC удалили, создали заново с тем же именем — и новый под прочитал старый файл, хотя PV у него новый. Поэтому в шаблоне всегда есть ${pv.metadata.name}: имя PV содержит UID заявки и не повторяется. Получается demo-data-pvc-9640d114-… — видно, чьё, и не совпадёт с прошлым.
Каталог одноуровневый, через дефис, а не demo/data/…. При onDelete: delete драйвер после удаления каталога убирает и опустевшие родительские, но при archive он переименовывает вложенный путь в archived-demo/data/…, и пустые demo/data/ остаются на сервере; плоское имя таких хвостов не оставляет.
onDelete и reclaimPolicy
Это два разных уровня.
reclaimPolicy— уровень Kubernetes: что делать с PV, когда удалён PVC.Delete— external-provisioner вызывает у драйвераDeleteVolumeи удаляет PV.Retain— PV остаётся в состоянииReleased, драйвер никто не зовёт, данные не трогаются; дальше решает администратор.onDelete— уровень драйвера: что делаетDeleteVolumeс каталогом.delete(по умолчанию) — стирает,retain— оставляет,archive— переименовывает вarchived-<subDir>.
Отсюда важное следствие: при reclaimPolicy: Retain параметр onDelete не срабатывает никогда, даже если администратор потом удалит PV руками, — DeleteVolume вызывается только для PV с политикой Delete. Поэтому onDelete записывается в volumeHandle каждого PV (его видно ниже, в «Смотрим руками»): он понадобится, когда администратор после проверки переключит освобождённый PV на Delete, чтобы драйвер убрал данные сам. С archive и тогда ничего не стирается — каталог получает понятное имя, и удалить его можно руками, когда точно не нужен.
Для ценных данных — reclaimPolicy: Retain: случайный kubectl delete pvc или удалённый вместе с неймспейсом PVC не уничтожит ничего. onDelete: archive — второй пояс на случай, когда PV всё-таки удаляют через Delete. Для временных данных (кэши, промежуточные файлы) — отдельный класс nfs-scratch с Delete и onDelete: delete, иначе сервер зарастёт мусором.
reclaimPolicy существующего PV меняется на лету — так спасают том, созданный классом с Delete, перед опасной операцией:
$ kubectl patch pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
persistentvolume/pvc-9640d114-1423-4c1e-9a9f-144d406e860a patched (no change)
У нашего PV политика и так Retain из класса, отсюда (no change); у PV из класса с Delete будет просто patched.
mountPermissions: не 0777
Драйвер создаёт каталог тома от root, и права у него получаются 0755 root:root. Параметр mountPermissions (по умолчанию 0 — ничего не делать) заставляет драйвер выполнить chmod каталога: при создании тома и после каждого монтирования. Во многих примерах ставят mountPermissions: "0777", чтобы приложение, работающее не от root, могло писать. Работает, но писать в том сможет любой UID с любого узла. Правильно — fsGroup пода (ниже): группа тома становится группой приложения, и права получает только она. Для подов без fsGroup в документации драйвера есть ещё параметры uid и gid: контроллер делает chown нового каталога при CreateVolume; fsGroup пода, если он задан, группу всё равно перезапишет.
mountOptions
Опции из StorageClass копируются в каждый новый PV (spec.mountOptions) и передаются в mount на узле как есть — и при монтировании тома в под, и при внутреннем монтировании в контроллере.
nfsvers=4.2— версия явно. Без неё клиент договаривается сам, начиная с самой новой, но явная версия делает поведение предсказуемым и сразу покажет ошибку, если сервер её не поддерживает.hard— при недоступности сервера запросы повторяются бесконечно, процесс ждёт.soft— послеretransповторов запрос завершается ошибкойEIO.softкажется удобнее («не зависнет»), но приложения редко ожидают ошибку наwriteилиclose: файл окажется записанным наполовину, а приложение решит, что всё в порядке. Документацияnfs(5)прямо предупреждает, чтоsoftможет привести к тихой порче данных. Для томов с данными — толькоhard; что с ним происходит при отказе, разобрано ниже.timeo=600— время ожидания ответа перед первым повтором в десятых долях секунды (60 с, значение по умолчанию для TCP); после каждого повтора интервал растёт ещё наtimeo, до 600 секунд.retrans=3— сколько повторов до сообщенияserver not respondingв журнале ядра (по умолчанию для TCP — 2; приhardзапрос после этого продолжает повторяться). Уменьшатьtimeoна TCP не стоит: TCP сам повторяет потерянные сегменты, и короткий таймаут RPC только добавит лишних повторов.nconnect=4— число TCP-соединений к серверу (до 16). Одно соединение упирается в одну очередь и одно ядро процессора на сервере; несколько распределяют нагрузку. Для 1–10 Гбит/с 2–4 достаточно.nosuid,nodev— не исполнять setuid-файлы и не открывать файлы устройств с этого тома. Приложениям на томе с данными это не нужно, а запись такого файла одним подом не должна давать привилегии другому.
Опции соединения — общие на узел. Клиент NFS в ядре один на пару «узел — сервер», и все монтирования этого сервера на узле ходят через его соединения. Поэтому опции, относящиеся к соединению, — nconnect и xprtsec (шифрование, о нём ниже) — задаёт первое монтирование, а следующие молча к нему присоединяются, что бы ни было в их собственных опциях. На стенде статический том без nconnect в опциях, смонтированный на узел, где уже был том этого класса, получил в findmnt nconnect=4; а монтирование без xprtsec, пришедшее после зашифрованного, — xprtsec=tls. Опции уровня монтирования (ro, retrans, noatime, nosuid) у каждого монтирования свои. Вывод: у всех классов и статических PV одного сервера опции соединения должны совпадать, иначе поведение узла зависит от того, какой под приехал на него первым.
Что можно менять в StorageClass
Поля provisioner, parameters, reclaimPolicy и volumeBindingMode у существующего StorageClass неизменяемы:
$ kubectl patch sc nfs -p '{"parameters":{"onDelete":"delete"}}'
The StorageClass "nfs" is invalid: parameters: Invalid value: {"onDelete":"delete","server":"nfs.example.com","share":"/srv/nfs/k8s","subDir":"${pvc.metadata.namespace}-${pvc.metadata.name}-${pv.metadata.name}"}: field is immutable
$ kubectl patch sc nfs -p '{"reclaimPolicy":"Delete"}'
The StorageClass "nfs" is invalid: reclaimPolicy: Invalid value: "Delete": field is immutable
Их меняют так: удалить класс и создать заново под тем же именем (на уже созданные PV это не влияет — у них своя копия параметров) или создать новый класс с новым именем и переводить на него приложения. А вот mountOptions и allowVolumeExpansion у класса меняются обычным kubectl patch или apply — но только для будущих PV. Существующим PV новые опции дописывают отдельно, поле spec.mountOptions у PV изменяемое:
$ kubectl patch pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a --type=json \
-p '[{"op":"replace","path":"/spec/mountOptions","value":["nfsvers=4.2","hard","timeo=600","retrans=3","nconnect=4","nosuid","nodev","noatime"]}]'
persistentvolume/pvc-9640d114-1423-4c1e-9a9f-144d406e860a patched
Опции применяются только при новом монтировании. Каждый под монтирует том сам, поэтому достаточно пересоздать поды, которые его используют (kubectl rollout restart): на стенде после рестарта Deployment у нового пода в findmnt стоял noatime, а у ещё не завершившегося старого — прежний relatime. Исключение — опции соединения из абзаца выше: новый nconnect или xprtsec начнёт действовать на узле только после того, как там не останется ни одного монтирования этого сервера, то есть после kubectl drain узла.
Том для приложения и права
PVC и Deployment из двух реплик, которые пишут в один том, — в неймспейсе demo из части 11. Приложение здесь — busybox, который каждые 10 секунд дописывает в файл время и имя своего пода: у distroless-образа hello нет ни оболочки, ни записи на диск, а нам нужно видеть, кто и как пишет.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
namespace: demo
spec:
accessModes: ["ReadWriteMany"]
storageClassName: nfs
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: writer
namespace: demo
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: writer
template:
metadata:
labels:
app.kubernetes.io/name: writer
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: writer
image: busybox:1.37
command: ["sh", "-c", "while true; do echo \"$(date -u +%FT%TZ) $HOSTNAME\" >> /data/visits.log; sleep 10; done"]
volumeMounts:
- name: data
mountPath: /data
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: data
$ kubectl apply -f writer.yaml
persistentvolumeclaim/data created
deployment.apps/writer created
$ kubectl -n demo get pvc data -w
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data Pending nfs <unset> 0s
data Pending pvc-9640d114-1423-4c1e-9a9f-144d406e860a 0 nfs <unset> 1s
data Bound pvc-9640d114-1423-4c1e-9a9f-144d406e860a 5Gi RWX nfs <unset> 2s
$ kubectl -n demo get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
writer-7d568d48b4-jwtsz 1/1 Running 0 46s 172.20.211.48 k8s-node-1 <none> <none>
writer-7d568d48b4-k68j9 1/1 Running 0 46s 172.20.172.1 k8s-node-2 <none> <none>
Том создан за две секунды, реплики разъехались по двум воркерам.
ReadWriteMany (RWX) — том можно смонтировать на запись на многих узлах сразу; NFS это умеет естественно, и обе реплики видят одни и те же файлы. Но NFS не делает за приложение координацию: если две реплики пишут один и тот же файл, порядок записи определяют они сами (через блокировки или разделение файлов). Здесь обе дописывают строки в конец, и строки перемежаются:
user@nfs:~$ head -3 /srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a/visits.log
2026-10-30T11:29:52Z writer-7d568d48b4-jwtsz
2026-10-30T11:29:54Z writer-7d568d48b4-k68j9
2026-10-30T11:30:03Z writer-7d568d48b4-jwtsz
runAsUser и runAsGroup — процесс работает от UID и GID 10001; на сервере его файлы принадлежат 10001 (числовые UID, как в прошлой части). fsGroup: 10001 — при монтировании kubelet от root меняет группу корня тома и всех файлов в нём на 10001, добавляет группе права на чтение и запись и ставит каталогам бит setgid, чтобы новые файлы наследовали группу; кроме того, процесс получает 10001 в дополнительных группах. Итог — каталог drwxrwsr-x root 10001, и приложение пишет в него, не будучи root и без 0777. Это работает при трёх условиях: драйвер объявил fsGroupPolicy: File; экспорт с no_root_squash; на сервере manage-gids=n (иначе, как мы видели в прошлой части, сервер заменит группы клиента своими, и группа 10001 до него не дойдёт).
Что бывает при root_squash, проверено, временно переключив экспорт: под fsg (runAsUser, runAsGroup и fsGroup 20002, том того же класса смонтирован в /v) на k8s-node-2. chown от kubelet приходит на сервер от nobody и отклоняется. kubelet пишет ошибку в свой журнал и всё равно запускает под — без права записи в том:
user@k8s-node-2:~$ sudo journalctl -u kubelet | grep 'Lchown failed' | tail -1
Oct 30 11:36:13 k8s-node-2 kubelet[1270]: E1030 11:36:13.884814 1270 volume_linux.go:151] "Lchown failed" err="lchown /var/lib/kubelet/pods/e0f56626-56d0-4950-a987-d9e95513a941/volumes/kubernetes.io~csi/pvc-05a5de9e-5819-48b1-847c-c878043f7579/mount: operation not permitted" path="/var/lib/kubelet/pods/e0f56626-56d0-4950-a987-d9e95513a941/volumes/kubernetes.io~csi/pvc-05a5de9e-5819-48b1-847c-c878043f7579/mount"
$ kubectl -n demo exec fsg -- sh -c 'ls -lnd /v; echo x >> /v/visits.log'
drwxr-xr-x 2 0 0 24 Oct 30 11:29 /v
sh: can't create /v/visits.log: Permission denied
command terminated with exit code 1
Под в статусе Running, событий нет — ошибку видно только в журнале kubelet на узле и по отказу приложения. Новые тома при root_squash тоже не создаются: mkdir контроллера в корне 0755 root:root выполняется от nobody, и PVC висит в Pending с событием ProvisioningFailed: failed to make subdirectory: mkdir /tmp/pvc-…/demo-squashed-pvc-…: permission denied.
fsGroupChangePolicy: OnRootMismatch — без неё (значение Always) kubelet обходит весь том рекурсивно при каждом монтировании, то есть при каждом старте пода. На томе с миллионом файлов по NFS это минуты, в течение которых под в ContainerCreating. С OnRootMismatch kubelet смотрит только на корень тома: если группа и права уже правильные, обхода нет. Обратная сторона — файлы, созданные внутри тома с другой группой (например, вручную на сервере), исправлены не будут. И ещё: если в один том RWX монтируют поды с разными fsGroup, они будут перетягивать группу друг у друга; у одного тома должна быть одна группа.
Размер PVC — не квота. storage: 5Gi записывается в PV, но ничего не ограничивает: приложение может записать 50 ГиБ, пока на диске сервера есть место. df в поде показывает весь экспорт, а не 5 ГиБ:
$ kubectl -n demo exec deploy/writer -- df -h /data
Filesystem Size Used Available Use% Mounted on
nfs.example.com:/srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
15.9G 344.8M 15.6G 2% /data
Ограничить реально — квотами файловой системы на сервере (проектные квоты XFS из прошлой части) и мониторингом занятого места.
Статический PV для готовых данных
Если на сервере уже есть данные — архив фотографий, справочники, — их подключают без контроллера: администратор пишет PV руками. В прошлой части для таких данных предложен отдельный экспорт /srv/nfs/static, отданный воркерам только на чтение и с root_squash:
apiVersion: v1
kind: PersistentVolume
metadata:
name: photos-archive
spec:
capacity:
storage: 100Gi
accessModes: ["ReadOnlyMany"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions: ["nfsvers=4.2", "hard", "nosuid", "nodev", "ro"]
csi:
driver: nfs.csi.k8s.io
volumeHandle: nfs.example.com#srv/nfs/static/photos
readOnly: true
volumeAttributes:
server: nfs.example.com
share: /srv/nfs/static/photos
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: photos-archive
namespace: demo
spec:
accessModes: ["ReadOnlyMany"]
storageClassName: ""
volumeName: photos-archive
resources:
requests:
storage: 100Gi
volumeHandle — уникальный в кластере идентификатор тома; драйвер предлагает формат сервер#путь#подкаталог, но для статического тома с Retain это просто метка: DeleteVolume для него никто не вызовет. Два PV с одинаковым volumeHandle kubelet считает одним томом, поэтому идентификаторы не повторяют. storageClassName: "" в обоих объектах и volumeName в PVC связывают их напрямую, без динамического выделения. persistentVolumeReclaimPolicy: Retain — обязательно: статический том никто не должен удалять через Kubernetes. Только для чтения здесь трижды: режим доступа, readOnly, ro в опциях — и четвёртый раз на сервере, в экспорте. Экспорт отдан только воркерам, поэтому и под с таким томом должен работать на воркере.
Под с этим томом (readOnly: true и в volumeMounts, и в persistentVolumeClaim) на k8s-node-2:
$ kubectl get pv photos-archive
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
photos-archive 100Gi ROX Retain Bound demo/photos-archive <unset> 16s
$ kubectl -n demo exec photos -- sh -c 'cat /photos/0001.txt; touch /photos/x'
photo-0001
touch: /photos/x: Read-only file system
command terminated with exit code 1
user@k8s-node-2:~$ findmnt -t nfs4 -n -o TARGET,SOURCE,OPTIONS
/var/lib/kubelet/pods/907fd370-7c5e-4aad-a533-9fdf1235e845/volumes/kubernetes.io~csi/photos-archive/mount nfs.example.com:/srv/nfs/static/photos ro,nosuid,nodev,relatime,vers=4.2,rsize=262144,wsize=262144,namlen=255,hard,fatal_neterrors=none,proto=tcp,nconnect=4,timeo=600,retrans=2,sec=sys,clientaddr=192.168.10.21,local_lock=none,addr=192.168.10.30
/var/lib/kubelet/pods/82a68469-6370-4735-a128-2ba0766baca4/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount nfs.example.com:/srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a rw,nosuid,nodev,noatime,vers=4.2,rsize=262144,wsize=262144,namlen=255,hard,fatal_neterrors=none,proto=tcp,nconnect=4,timeo=600,retrans=3,sec=sys,clientaddr=192.168.10.21,local_lock=none,addr=192.168.10.30
Здесь видно то, о чём шла речь в разделе про опции: у статического тома nconnect в опциях нет, но в findmnt он есть — том присоединился к клиенту NFS, который уже открыл четыре соединения для тома writer. А retrans=2 (значение по умолчанию) и ro у него свои.
Снапшоты, клоны и расширение
Снапшот в этом драйвере — не мгновенный снимок файловой системы, а архив: на CreateSnapshot контроллер упаковывает каталог тома в .tar.gz на том же сервере. Нужны CRD и snapshot-controller (поставлены выше) и класс снапшотов:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: nfs
driver: nfs.csi.k8s.io
deletionPolicy: Delete
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: data-2026-10-30
namespace: demo
spec:
volumeSnapshotClassName: nfs
source:
persistentVolumeClaimName: data
$ kubectl apply -f snap.yaml
volumesnapshotclass.snapshot.storage.k8s.io/nfs created
volumesnapshot.snapshot.storage.k8s.io/data-2026-10-30 created
$ kubectl -n demo get volumesnapshot
NAME READYTOUSE SOURCEPVC SOURCESNAPSHOTCONTENT RESTORESIZE SNAPSHOTCLASS SNAPSHOTCONTENT CREATIONTIME AGE
data-2026-10-30 true data 421 nfs snapcontent-568b66d7-402c-48ca-9ac1-d8f567956142 0s 1s
user@nfs:~$ sudo ls -l /srv/nfs/k8s/snapshot-568b66d7-402c-48ca-9ac1-d8f567956142
total 4
-rw-r--r-- 1 root root 421 Oct 30 11:34 pvc-9640d114-1423-4c1e-9a9f-144d406e860a.tar.gz
Архив лежит в каталоге snapshot-<uid> рядом с томами, и RESTORESIZE — это его размер в байтах (421), а не объём тома. deletionPolicy: Delete значит, что удаление VolumeSnapshot удалит и архив; с Retain останутся объект VolumeSnapshotContent и файл на сервере.
Восстановление — новый PVC с dataSource, указывающим на VolumeSnapshot; драйвер распакует архив в каталог нового тома:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-restored
namespace: demo
spec:
accessModes: ["ReadWriteMany"]
storageClassName: nfs
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: data-2026-10-30
resources:
requests:
storage: 10Gi
$ kubectl -n demo get pvc data-restored
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-restored Bound pvc-05a5de9e-5819-48b1-847c-c878043f7579 10Gi RWX nfs <unset> 2s
user@nfs:~$ ls -ln /srv/nfs/k8s/demo-data-restored-pvc-05a5de9e-5819-48b1-847c-c878043f7579
total 4
-rw-r--r-- 1 0 0 2970 Oct 30 11:35 visits.log
Владелец восстановленных файлов — root (0 0), а не 10001, как в исходном томе: архив распаковывает контроллер от root. Под с fsGroup: 10001 вернёт группу и право записи для неё (-rw-rw-r-- 0 10001), но владелец останется root; если приложению нужен именно владелец, его выставляют параметрами uid/gid класса или вручную.
Следствия такого устройства. Архив создаётся долго и пропорционально объёму, а не мгновенно. Он не согласован: если приложение пишет во время упаковки, в архиве окажется смесь старых и новых файлов, поэтому снимать стоит при остановленной записи. И лежит он на том же сервере и том же диске — это защита от ошибки приложения, но не резервная копия.
Клон — PVC с dataSource на другой PVC того же класса: драйвер объявляет эту возможность (CLONE_VOLUME в ответе ControllerGetCapabilities) и копирует каталог исходного тома в новый. Ограничения те же, что у снапшота: копия не мгновенная и не согласованная.
Расширение тома (allowVolumeExpansion: true, увеличить storage в PVC) проходит мгновенно: ControllerExpandVolume у драйвера только возвращает новый размер, на диске ничего не меняется — ограничения ведь и не было, а шага на узле (NodeExpandVolume) нет:
$ kubectl -n demo patch pvc data -p '{"spec":{"resources":{"requests":{"storage":"10Gi"}}}}'
persistentvolumeclaim/data patched
$ kubectl -n demo describe pvc data | tail -3
Normal ExternalExpanding 5s volume_expand waiting for an external controller to expand this PVC
Normal Resizing 5s external-resizer nfs.csi.k8s.io External resizer is resizing volume pvc-9640d114-1423-4c1e-9a9f-144d406e860a
Normal VolumeResizeSuccessful 5s external-resizer nfs.csi.k8s.io Resize volume succeeded
Шифрование в пути: xprtsec через mountOptions
В прошлой части NFS поверх TLS заработал при ручном монтировании, и остался вопрос, пройдёт ли он через драйвер. Проходит, но с тремя условиями, которые проверены на стенде.
1. tlshd нужен на каждом узле. Монтирует контейнер nfs драйвера, но рукопожатие TLS ядро отдаёт службе tlshd через netlink в своём сетевом пространстве имён. Плагины драйвера работают в hostNetwork, то есть в сетевом пространстве узла, и до tlshd узла достают. Значит, на каждом узле — пакет ktls-utils, сертификат CA в /etc/tlshd/config ([authenticate.client], x509.truststore=, как в прошлой части) и запущенный tlshd, а в mountOptions класса — xprtsec=tls. Без tlshd монтирование падает с непонятной на первый взгляд ошибкой:
Warning FailedMount 13s (x6 over 29s) kubelet MountVolume.SetUp failed for volume "photos-tls" : rpc error: code = Internal desc = mount failed: exit status 32
Mounting command: mount
Mounting arguments: -t nfs -o nfsvers=4.2,hard,nosuid,nodev,ro,xprtsec=tls,ro nfs.example.com:/srv/nfs/static/photos /var/lib/kubelet/pods/fba28700-48d9-40a0-916a-49a97d702c46/volumes/kubernetes.io~csi/photos-tls/mount
Output: mount.nfs: No such process
No such process — ядро не нашло, кому передать рукопожатие. С tlshd в его журнале появляется Handshake with '…' succeeded, а в findmnt — xprtsec=tls. Если же экспорт требует TLS, а в опциях тома xprtsec нет, сервер отвечает тем же, что и при ручном монтировании: mount.nfs: Operation not permitted.
2. Контроллер тоже монтирует. Под csi-nfs-controller при CreateVolume монтирует экспорт с опциями класса, поэтому tlshd нужен и на том узле, где он работает (а работать он может на любом, включая мастер). Хуже с удалением и снапшотами: в запросах DeleteVolume и CreateSnapshot спецификация CSI опций монтирования не передаёт, и драйвер монтирует экспорт вовсе без опций. Сервер, требующий TLS, такой запрос отвергает, и PV с политикой Delete застревает в Released:
Warning VolumeFailedDelete 9s nfs.csi.k8s.io_k8s-node-1_fb0a5b52-01af-4dae-9c5c-bae0ce50e165 rpc error: code = Internal desc = failed to mount nfs server: rpc error: code = Internal desc = mount failed: exit status 32
Mounting command: mount
Mounting arguments: -t nfs nfs.example.com:/srv/nfs/k8s /tmp/pvc-0380c1d0-e878-4a4e-8081-f950749307e5
Output: mount.nfs: rpc.statd is not running but is required for remote locking.
mount.nfs: Either use '-o nolock' to keep locks local, or start statd.
mount.nfs: Operation not permitted
mount.nfs без версии сначала попробовал NFSv4 (отказ), затем откатился на NFSv3 — отсюда строки про rpc.statd. Обход описан в документации драйвера: Secret с опциями и ссылка на него в параметрах класса.
apiVersion: v1
kind: Secret
metadata:
name: nfs-mount-options
namespace: kube-system
stringData:
mountOptions: "nfsvers=4.2,xprtsec=tls"
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-tls
provisioner: nfs.csi.k8s.io
parameters:
server: nfs.example.com
share: /srv/nfs/k8s
subDir: ${pvc.metadata.namespace}-${pvc.metadata.name}-${pv.metadata.name}
onDelete: archive
csi.storage.k8s.io/provisioner-secret-name: nfs-mount-options
csi.storage.k8s.io/provisioner-secret-namespace: kube-system
reclaimPolicy: Retain
allowVolumeExpansion: true
mountOptions: ["nfsvers=4.2", "hard", "timeo=600", "retrans=3", "nconnect=4", "nosuid", "nodev", "xprtsec=tls"]
external-provisioner записывает имя Secret в аннотации PV (volume.kubernetes.io/provisioner-deletion-secret-name и …-namespace) и при удалении передаёт его драйверу, а тот монтирует с nfsvers=4.2,xprtsec=tls. На стенде это проверено классом с reclaimPolicy: Delete и onDelete: delete: в журнале контроллера Mounting cmd (mount) with arguments (-t nfs -o nfsvers=4.2,xprtsec=tls …), затем removing subdirectory, и PV исчез. С Retain до DeleteVolume дело дойдёт, только когда администратор сам переключит PV на Delete, — и тогда Secret тоже понадобится. Аннотация ставится при создании PV, поэтому для томов, созданных классом без Secret, обход задним числом не сработает. Для снапшотов то же делается параметром mountOptions в VolumeSnapshotClass (или Secret csi.storage.k8s.io/snapshotter-secret-name).
3. TLS — на все тома узла сразу. Из-за общего клиента NFS (см. «Опции соединения — общие на узел») узел не может держать к одному серверу и шифрованные, и открытые монтирования: что приехало первым, то и определяет соединение. Поэтому включают TLS для узла целиком: xprtsec=tls в строке узла в /etc/exports, во всех классах и статических PV этого сервера и в Secret. Переход на работающем кластере — по узлу: kubectl drain, правка строки узла в /etc/exports, exportfs -ra, kubectl uncordon; классы и PV с xprtsec=tls должны быть готовы до первого такого узла.
xprtsec=mtls (клиент тоже предъявляет сертификат) работает по той же схеме, только в /etc/tlshd/config узла добавляются сертификат и ключ узла; на стенде он не проверялся. Kerberos (sec=krb5p) через драйвер тоже передаётся опцией монтирования, но требует keytab и rpc.gssd на каждом узле и билетов для процессов в подах — на стенде не проверялся.
Блокировки и базы данных
NFSv4 поддерживает байтовые блокировки протоколом: fcntl-блокировку в поде клиент передаёт на сервер операцией LOCK, и её видят все клиенты. Блокировки привязаны к аренде клиента (90 секунд); если узел потерял связь с сервером дольше аренды, сервер освобождает его блокировки, и после восстановления связи процесс получит ошибку на операциях с файлом. flock на NFS в Linux эмулируется через ту же байтовую блокировку на весь файл (local_lock=none в опциях монтирования), поэтому, в отличие от локального диска, flock и fcntl на одном файле конфликтуют между собой. После перезапуска сервера действует период отсрочки (grace period, тоже 90 секунд), и новые блокировки и открытия какое-то время ждут.
Отсюда понятно, почему базы данных на NFS не кладут:
- Prometheus в документации прямо говорит, что файловые системы, не соответствующие POSIX, в том числе NFS, его локальным хранилищем не поддерживаются и возможна неисправимая порча данных: его база временных рядов (TSDB) рассчитывает на семантику локальной файловой системы.
- etcd — каждое изменение ждёт
fsync, и задержка записи на диск прямо определяет задержку всего кластера; документация etcd требует быстрый локальный диск, а сетевой уровень добавляет задержку к каждомуfsync. Подробно — в части 34. - PostgreSQL официально допускает каталог данных на NFS при монтировании
hardи синхронной записи на сервере, но предупреждает о рисках. В кластере это вдобавок означает: том RWX, два пода PostgreSQL на одних файлах при ошибке в развёртывании, задержка каждогоfsyncчерез сеть. Практика — локальный диск и репликация средствами самой СУБД (часть 25). - SQLite, которую многие приложения используют молча, в документации предупреждает, что на сетевых файловых системах блокировки могут работать неправильно, и база портится. Если приложение хранит SQLite на томе, этот том не должен быть NFS с несколькими писателями.
NFS хорош для того, что действительно файлы: загрузки пользователей, сгенерированные отчёты, общие ресурсы нескольких реплик, статика.
Когда сервер недоступен
При hard всё, что обращается к тому, ждёт. Проверим: на сервере отбросим пакеты NFS от k8s-node-2 правилом в начале цепочки INPUT (правило ufw с deny здесь не годится: ufw пропускает уже установленные соединения раньше своих правил), подождём семь минут и вернём как было. На узлах — по реплике writer, каждая пишет раз в 10 секунд.
user@nfs:~$ sudo iptables -I INPUT 1 -s 192.168.10.21 -p tcp --dport 2049 -j DROP
Через 25 секунд процесс пода на k8s-node-2 висит в ядре, а kubectl exec в этот под с ls /data не возвращается:
user@k8s-node-2:~$ ps -eo pid,stat,wchan:30,cmd | grep 'while true' | grep -v grep
309395 Ds - sh -c while true; do echo "$(date -u +%FT%TZ) $HOSTNAME" >> /data/visits.log; sleep 10; done
$ timeout 15 kubectl -n demo exec writer-7cddd4c9c7-f29ff -- ls -l /data; echo rc=$?
rc=124
Состояние D — сон в ядре, который не прерывается обычными сигналами: процесс ждёт ответа на свою запись. Реплика на k8s-node-1 тем временем пишет как ни в чём не бывало: проблема у клиента NFS конкретного узла, а не у тома. Удалим зависший под:
$ kubectl -n demo delete pod writer-7cddd4c9c7-f29ff --wait=false
pod "writer-7cddd4c9c7-f29ff" deleted from demo namespace
$ kubectl -n demo get pods -o wide # через 30 секунд
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
writer-7cddd4c9c7-67nfp 1/1 Running 0 2m40s 172.20.211.57 k8s-node-1 <none> <none>
writer-7cddd4c9c7-f29ff 0/1 Error 0 2m40s 172.20.172.8 k8s-node-2 <none> <none>
writer-7cddd4c9c7-t524g 0/1 ContainerCreating 0 34s <none> k8s-node-2 <none> <none>
Процесс умер: через 30 секунд (terminationGracePeriodSeconds по умолчанию) kubelet послал SIGKILL, и ожидание NFS его пропустило — клиент NFS в Linux с ядра 2.6.25 ждёт так, что SIGKILL прерывает ожидание (поэтому старая опция intr в nfs(5) помечена как игнорируемая). А вот под из API не уходит: kubelet не может отмонтировать том, и в журнале node-плагина каждые две минуты одно и то же:
E1030 11:52:50.324715 1 utils.go:126] GRPC error: rpc error: code = Internal desc = failed to unmount target "/var/lib/kubelet/pods/b7027368-976f-4c2f-a3f6-a7e0c86b2b0f/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount": unmount failed: exit status 16
Output: umount.nfs4: /var/lib/kubelet/pods/b7027368-976f-4c2f-a3f6-a7e0c86b2b0f/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount: device is busy
Замена, которую Deployment сразу создал, попала на тот же узел и висит в ContainerCreating: монтирование не завершается, драйвер обрывает его по своему таймауту (mount volume nfs.example.com:/srv/nfs/k8s/demo-data-pvc-… to /var/lib/kubelet/pods/…/mount timeout after 110s), и kubelet повторяет попытку. В журнале ядра узла сообщение о сервере появилось только через четыре минуты после начала отказа — после retrans=3 повторов с растущим интервалом:
user@k8s-node-2:~$ sudo dmesg -T | grep nfs
[Fri Oct 30 11:54:14 2026] nfs: server nfs.example.com not responding, still trying
[Fri Oct 30 11:55:03 2026] nfs: server nfs.example.com not responding, timed out
[Fri Oct 30 11:55:17 2026] nfs: server nfs.example.com not responding, still trying
still trying — запрос тома с hard, он будет повторяться дальше; timed out — запрос, у которого свой срок, здесь это прерванная по таймауту попытка монтирования. Убираем правило на сервере:
user@nfs:~$ sudo iptables -D INPUT -s 192.168.10.21 -p tcp --dport 2049 -j DROP
user@k8s-node-2:~$ sudo dmesg -T | grep nfs | tail -2
[Fri Oct 30 11:57:17 2026] nfs: server nfs.example.com OK
[Fri Oct 30 11:57:21 2026] nfs: server nfs.example.com OK
$ kubectl -n demo get pods -o wide # через 40 секунд
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
writer-7cddd4c9c7-67nfp 1/1 Running 0 9m11s 172.20.211.57 k8s-node-1 <none> <none>
writer-7cddd4c9c7-t524g 1/1 Running 0 7m5s 172.20.172.6 k8s-node-2 <none> <none>
Через секунды после возврата сервера старый под отмонтировался и исчез, новый смонтировал том и запустился — kubelet записал ему время запуска 6 минут 53 секунды. В файле видно, как это выглядело для данных: реплика с k8s-node-2 писала до 11:50:09, затем семь минут только k8s-node-1, затем снова обе. Запись, на которой процесс завис, потеряна вместе с процессом — write так и не вернулся, и приложение не успело решить, что она прошла.
Что из этого следует:
- поды с томом перестают отвечать, а проверки
livenessProbe, которые касаются тома, проваливаются, и kubelet перезапускает контейнер; процесс SIGKILL снимет, но новый контейнер повиснет на том же томе; - удаление пода зависает: объект остаётся в API (в статусе
ErrorилиTerminating), пока kubelet не сможет отмонтировать том; - замены приезжают на тот же узел и висят в
ContainerCreating; планировщик о проблеме NFS ничего не знает; - любая команда, которая обращается к этому монтированию, тоже ждёт: на стенде —
lsчерезkubectl exec, а на узле так же повиснутdfи другие утилиты, которые опрашивают все смонтированные файловые системы.
Как выходить. Лучший выход — вернуть сервер. Как только он ответит, все запросы завершатся, монтирования освободятся, и поды восстановятся сами — ради этого и нужен hard. Поэтому сначала чинят сервер или сеть, а не поды.
Если сервер не вернётся (диск умер, переезд на новый сервер), на каждом затронутом узле:
kubectl cordonузла, чтобы на него не попадали новые поды с томом.- Найти монтирования:
findmnt -t nfs4. - Отмонтировать принудительно:
sudo umount -f <путь>прерывает ожидающие запросы, при неудачеsudo umount -l <путь>отцепляет монтирование от дерева. - Удалить поды; если какой-то висит в
Terminating,kubectl delete pod --grace-period=0 --forceудаляет объект из API, но не процессы на узле — они остаются, пока их не отпустит ядро. - Если что-то осталось — перезагрузить узел (
kubectl drain, затем reboot). Это единственный гарантированный способ.
На стенде шаги 3–5 не понадобились (сервер вернули), они — по документации umount(8) и Kubernetes. kubectl delete --force без отмонтирования опасен ещё и тем, что контроллер может запустить новый под на другом узле, пока старый процесс на первом ещё жив и, как только сервер вернётся, допишет свои данные. Для подов StatefulSet это прямой путь к двум писателям на одном томе.
Смотрим руками
PVC и PV — сразу после создания, до расширения и правки опций, о которых шла речь выше:
$ kubectl -n demo get pvc data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data Bound pvc-9640d114-1423-4c1e-9a9f-144d406e860a 5Gi RWX nfs <unset> 46s
$ kubectl get pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a -o yaml
apiVersion: v1
kind: PersistentVolume
metadata:
annotations:
pv.kubernetes.io/provisioned-by: nfs.csi.k8s.io
volume.kubernetes.io/provisioner-deletion-secret-name: ""
volume.kubernetes.io/provisioner-deletion-secret-namespace: ""
creationTimestamp: "2026-10-30T11:29:46Z"
finalizers:
- kubernetes.io/pv-protection
name: pvc-9640d114-1423-4c1e-9a9f-144d406e860a
resourceVersion: "1123868"
uid: 452f009f-4330-481d-81a8-71c300ce0884
spec:
accessModes:
- ReadWriteMany
capacity:
storage: 5Gi
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: data
namespace: demo
resourceVersion: "1123842"
uid: 9640d114-1423-4c1e-9a9f-144d406e860a
csi:
driver: nfs.csi.k8s.io
volumeAttributes:
csi.storage.k8s.io/pv/name: pvc-9640d114-1423-4c1e-9a9f-144d406e860a
csi.storage.k8s.io/pvc/name: data
csi.storage.k8s.io/pvc/namespace: demo
onDelete: archive
server: nfs.example.com
share: /srv/nfs/k8s
storage.kubernetes.io/csiProvisionerIdentity: 1793359746544-2025-nfs.csi.k8s.io
subDir: demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
volumeHandle: nfs.example.com#srv/nfs/k8s#demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a#pvc-9640d114-1423-4c1e-9a9f-144d406e860a#archive
mountOptions:
- nfsvers=4.2
- hard
- timeo=600
- retrans=3
- nconnect=4
- nosuid
- nodev
persistentVolumeReclaimPolicy: Retain
storageClassName: nfs
volumeMode: Filesystem
status:
lastPhaseTransitionTime: "2026-10-30T11:29:46Z"
phase: Bound
volumeHandle собран из сервера, пути экспорта, подкаталога, имени PV и политики удаления: по нему контроллер при DeleteVolume поймёт, что и как удалять, даже если StorageClass к тому времени удалят или пересоздадут. mountOptions скопированы из класса, шаблон subDir уже раскрыт, имя PV — это pvc- и UID заявки. Финализатор только kubernetes.io/pv-protection: свой финализатор external-provisioner.volume.kubernetes.io/finalizer провизионер ставит лишь на PV с политикой Delete, а этот при Retain удалять ему не придётся.
Каталог на сервере:
user@nfs:~$ ls -la /srv/nfs/k8s
total 4
drwxr-xr-x 3 root root 64 Oct 30 11:29 .
drwxr-xr-x 3 root root 4096 Oct 30 10:41 ..
drwxrwsr-x 2 root 10001 24 Oct 30 11:29 demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
user@nfs:~$ ls -ln /srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
total 4
-rw-r--r-- 1 10001 10001 540 Oct 30 11:30 visits.log
Корень 0755 root:root, каталог тома создан контроллером от root, группа 10001 и бит s проставлены kubelet по fsGroup, файл приложения принадлежит UID 10001 — без единого 0777.
На узле — монтирование и путь в каталоге kubelet:
user@k8s-node-1:~$ findmnt -t nfs4 -o TARGET,SOURCE,FSTYPE,OPTIONS
TARGET SOURCE FSTYPE OPTIONS
/var/lib/kubelet/pods/a122b643-da5f-4495-b4f0-ac8cb9066e1d/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount nfs.example.com:/srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a nfs4 rw,nosuid,nodev,relatime,vers=4.2,rsize=262144,wsize=262144,namlen=255,hard,fatal_neterrors=none,proto=tcp,nconnect=4,timeo=600,retrans=3,sec=sys,clientaddr=192.168.10.20,local_lock=none,addr=192.168.10.30
user@k8s-node-1:~$ D=/var/lib/kubelet/pods/a122b643-da5f-4495-b4f0-ac8cb9066e1d/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a
user@k8s-node-1:~$ sudo ls $D
mount vol_data.json
user@k8s-node-1:~$ sudo cat $D/vol_data.json
{"attachmentID":"csi-31a7b5c86a9cfa94c305f5b44219d9a216c5977b8b868f54088678a8ae59c966","driverName":"nfs.csi.k8s.io","nodeName":"k8s-node-1","specVolID":"pvc-9640d114-1423-4c1e-9a9f-144d406e860a","volumeHandle":"nfs.example.com#srv/nfs/k8s#demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a#pvc-9640d114-1423-4c1e-9a9f-144d406e860a#archive","volumeLifecycleMode":"Persistent"}
$ kubectl get volumeattachment
No resources found
/var/lib/kubelet/pods/<UID пода>/volumes/kubernetes.io~csi/<PV>/mount — точка, которую kubelet затем пробрасывает в контейнер bind-монтированием на /data. vol_data.json — заметка kubelet для самого себя: какому драйверу и какому тому принадлежит каталог, чтобы после перезапуска kubelet размонтировать его правильно. attachmentID kubelet вычисляет всегда (хеш от тома, драйвера и узла), но объекта VolumeAttachment с таким именем нет — attachRequired: false. Все опции из mountOptions на месте, rsize/wsize 256 КиБ выбрал сервер (как в прошлой части), fatal_neterrors=none — значение по умолчанию в ядре 7.0: сетевые ошибки вроде «сеть недоступна» не считаются окончательными, запрос повторяется. clientaddr — адрес узла.
Соединения узла с сервером при nconnect=4:
user@k8s-node-1:~$ sudo ss -tn -4 dst 192.168.10.30
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 192.168.10.20:720 192.168.10.30:2049
ESTAB 0 0 192.168.10.20:757 192.168.10.30:2049
ESTAB 0 0 192.168.10.20:918 192.168.10.30:2049
ESTAB 0 0 192.168.10.20:990 192.168.10.30:2049
Четыре соединения на все тома этого сервера на узле, все с привилегированных портов. После rollout restart новый под получил своё монтирование, а соединения остались те же четыре.
Сам под до сервера не достаёт — политика Calico из прошлой части на месте, — а том у него работает:
$ kubectl -n demo exec deploy/writer -- nc -zv -w 3 192.168.10.30 2049
nc: 192.168.10.30 (192.168.10.30:2049): Connection timed out
command terminated with exit code 1
$ kubectl -n demo exec deploy/writer -- sh -c 'id; tail -2 /data/visits.log'
uid=10001 gid=10001 groups=10001
2026-10-30T11:31:44Z writer-7d568d48b4-jwtsz
2026-10-30T11:31:44Z writer-7d568d48b4-k68j9
Счётчики клиента NFS. На узлах nfs-common не ставили, но nfsstat и nfsiostat есть в образе драйвера, а контейнер nfs node-плагина работает в сети узла и видит монтирования kubelet (каталог /var/lib/kubelet/pods проброшен в него с mountPropagation: Bidirectional), поэтому смотреть можно оттуда:
$ P=$(kubectl -n kube-system get pod -l app=csi-nfs-node --field-selector spec.nodeName=k8s-node-1 -o name)
$ kubectl -n kube-system exec $P -c nfs -- nfsstat -c -4 | head -3
Client rpc stats:
calls retrans authrefrsh
117 0 117
$ kubectl -n kube-system exec $P -c nfs -- nfsiostat 5 2 /var/lib/kubelet/pods/a122b643-da5f-4495-b4f0-ac8cb9066e1d/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount
nfs.example.com:/srv/nfs/k8s/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a mounted on /var/lib/kubelet/pods/a122b643-da5f-4495-b4f0-ac8cb9066e1d/volumes/kubernetes.io~csi/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/mount:
ops/s rpc bklog
0.208 0.000
read: ops/s kB/s kB/op retrans avg RTT (ms) avg exe (ms) avg queue (ms) errors
0.000 0.000 0.000 0 (0.0%) 0.000 0.000 0.000 0 (0.0%)
write: ops/s kB/s kB/op retrans avg RTT (ms) avg exe (ms) avg queue (ms) errors
0.104 0.045 0.435 0 (0.0%) 116.625 116.750 0.000 0 (0.0%)
...
Первый блок nfsiostat — средние с момента монтирования, следующие — за интервал. retrans выше нуля — первый признак проблем с сетью или сервером, задолго до того, как приложение что-то заметит.
И наконец, освобождение тома. Удаляем приложение и заявку — PV остаётся Released, каталог на сервере нетронут:
$ kubectl -n demo delete deploy writer
deployment.apps "writer" deleted from demo namespace
$ kubectl -n demo delete pvc data
persistentvolumeclaim "data" deleted from demo namespace
$ kubectl get pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-9640d114-1423-4c1e-9a9f-144d406e860a 10Gi RWX Retain Released demo/data nfs <unset> 30m
user@nfs:~$ ls -l /srv/nfs/k8s | grep demo-data-pvc
drwxrwsr-x 2 root 10001 24 Oct 30 11:29 demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
Ёмкость 10Gi — PVC расширяли выше. Данные проверены и больше не нужны — переключаем PV на Delete, и тут впервые срабатывает onDelete: archive из volumeHandle:
$ kubectl patch pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a -p '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}'
persistentvolume/pvc-9640d114-1423-4c1e-9a9f-144d406e860a patched
$ kubectl get pv pvc-9640d114-1423-4c1e-9a9f-144d406e860a
Error from server (NotFound): persistentvolumes "pvc-9640d114-1423-4c1e-9a9f-144d406e860a" not found
$ kubectl -n kube-system logs deploy/csi-nfs-controller -c nfs | grep -E 'Controller/DeleteVolume|archived subdirectory' | tail -2
I1030 12:00:15.300056 1 utils.go:121] GRPC call: /csi.v1.Controller/DeleteVolume
I1030 12:00:15.897611 1 controllerserver.go:314] archived subdirectory /tmp/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a --> /tmp/pvc-9640d114-1423-4c1e-9a9f-144d406e860a/archived-demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
user@nfs:~$ ls -l /srv/nfs/k8s | grep demo-data-pvc
drwxrwsr-x 2 root 10001 24 Oct 30 11:29 archived-demo-data-pvc-9640d114-1423-4c1e-9a9f-144d406e860a
PV исчез за несколько секунд, а каталог стал archived-demo-data-pvc-…: удалить его окончательно — rm -rf на сервере, когда точно не нужен. В журнале контроллера между этими строками — монтирование экспорта с mountflags([]): те самые опции, которых DeleteVolume не получает.
Источники
- csi-driver-nfs: репозиторий, выпуск v4.13.6, манифесты выпуска, установка v4.13.6 и Helm-чарт — состав драйвера, образы, версия чарта без
v. - csi-driver-nfs: параметры драйвера —
server,share,subDirи его шаблоны,onDelete,mountPermissions,uid/gidиfsGroup, форматvolumeHandle,mountOptionsдляDeleteVolumeчерез Secret и для снапшотов; примеры — статический PV, снапшот, клон. - Kubernetes CSI: CSIDriver Object и fsGroup Support —
attachRequired,fsGroupPolicy: File; external-provisioner и Secrets and Credentials — параметрыcsi.storage.k8s.io/provisioner-secret-*и аннотации удаления на PV; Snapshot Controller и external-snapshotter v8.7.0 — CRD снапшотов и контроллер. - Container Storage Interface Specification —
CreateVolumeсvolume_capabilities,DeleteVolumeиCreateSnapshotбез опций монтирования, возможности контроллера (CLONE_VOLUME,EXPAND_VOLUME). - Kubernetes: Storage Classes — поля класса,
reclaimPolicy,mountOptions,allowVolumeExpansion; Persistent Volumes — освобождение (Retain,Delete), опции монтирования, расширение; Volume Snapshots, Volume Snapshot Classes и CSI Volume Cloning — снапшоты,deletionPolicy, клоны черезdataSource. - Kubernetes: Configure a Security Context for a Pod or Container —
runAsUser,fsGroup,fsGroupChangePolicy: OnRootMismatch. - Linux: nfs(5) —
nfsvers,hardиsoft,timeo,retrans,nconnect,local_lock,xprtsec,intr; exports(5) —root_squash,xprtsecв экспорте; umount(8) —-fдля недоступного NFS-сервера и-l; nfsstat(8) и nfsiostat(8) — счётчики клиента. - Протокол: RFC 7530 и RFC 8881 — блокировки NFSv4, аренда и период отсрочки; RFC 9289 — RPC поверх TLS.
- NFS поверх TLS: ktls-utils, tlshd.conf(5) и In-Kernel TLS Handshake — служба рукопожатия и как ядро передаёт ей сокет.
- Базы данных на NFS: Prometheus Storage — NFS не поддерживается, etcd: Hardware recommendations — диски и задержка
fsync, PostgreSQL: Creating a Database Cluster — каталог данных на NFS иhard, SQLite: How To Corrupt An SQLite Database File и SQLite FAQ — блокировки на сетевых файловых системах.
Дальше — csi-driver-smb: тома с Windows-сервера и Samba, SMB3 с подписью и шифрованием, учётные данные в Secret, опции cifs построчно и разбор реального случая, когда поды получали Host is down из-за того, как клиент читал ответы NAS.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.