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. Поэтому сначала чинят сервер или сеть, а не поды.

Если сервер не вернётся (диск умер, переезд на новый сервер), на каждом затронутом узле:

  1. kubectl cordon узла, чтобы на него не попадали новые поды с томом.
  2. Найти монтирования: findmnt -t nfs4.
  3. Отмонтировать принудительно: sudo umount -f <путь> прерывает ожидающие запросы, при неудаче sudo umount -l <путь> отцепляет монтирование от дерева.
  4. Удалить поды; если какой-то висит в Terminating, kubectl delete pod --grace-period=0 --force удаляет объект из API, но не процессы на узле — они остаются, пока их не отпустит ядро.
  5. Если что-то осталось — перезагрузить узел (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-smb: тома с Windows-сервера и Samba, SMB3 с подписью и шифрованием, учётные данные в Secret, опции cifs построчно и разбор реального случая, когда поды получали Host is down из-за того, как клиент читал ответы NAS.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.