Kubernetes для самых маленьких. Часть 22: Настраиваем NFS-сервер для кластера

Все части серии «Kubernetes для самых маленьких»

В прошлой части мы разобрали PersistentVolume, PersistentVolumeClaim и StorageClass, посмотрели, из чего состоит CSI-драйвер, и встретили поле fsGroupPolicy, которым драйвер говорит kubelet, менять ли группу файлов тома под fsGroup пода. Следующие две части — о самом распространённом хранилище для кластера без облака: NFS-сервере, с которого драйвер csi-driver-nfs нарезает тома. Сегодня — серверная сторона, и смотреть на неё будем глазами администратора, который отвечает за сохранность данных: как настроить nfs.example.com (192.168.10.30, Ubuntu Server 26.04, в кластер не входит) так, чтобы кластер получил тома, а больше никто — ничего. Только NFSv4, экспорт на отдельном диске, построчный разбор /etc/exports, файрвол, шифрование в пути. И один неочевидный момент: почему ограничить экспорт адресами узлов необходимо, но недостаточно и при чём здесь сетевая политика из части 20.

NFS-сервер для кластера часто поднимают по первому найденному рецепту, и он годами работает в виде «всем, на запись, root без ограничений». Работает он действительно хорошо, и именно поэтому его не трогают. В конце части такая конфигурация разобрана по пунктам вместе с планом перехода к правильной без остановки кластера.


NFS коротко

NFS (Network File System) — протокол доступа к файлам по сети. Клиент монтирует удалённый каталог, и ядро превращает каждое open, read, write, stat в запрос к серверу. Под NFS лежит ONC RPC (RFC 5531) — протокол удалённого вызова процедур: каждый запрос несёт номер программы (у NFS — 100003), версию, номер процедуры, идентификатор транзакции и удостоверение (credential) — сведения о том, от чьего имени он сделан. Сегодня RPC ходит поверх TCP (часть 10 сетевой серии): в одном соединении идут записи, каждая с четырёхбайтовым префиксом длины.

NFSv3 и его помощники

NFSv3 (RFC 1813) — протокол без состояния: каждый запрос самодостаточен, сервер не помнит клиентов. Всё, что требует состояния или справок, вынесено в отдельные RPC-программы со своими демонами и портами:

Программа Демон Порт Зачем
portmapper (rpcbind) rpcbind 111 TCP и UDP справочная: на каком порту работает программа N версии V
NFS ядро (nfsd) 2049 сами файловые операции
MOUNT rpc.mountd динамический проверить, разрешён ли клиенту экспорт, и выдать дескриптор корня
NLM (блокировки) ядро (lockd) динамический байтовые блокировки fcntl
NSM (статус) rpc.statd динамический сообщить клиентам о перезагрузке сервера, чтобы они заново взяли блокировки

Клиент сначала идёт на 111, узнаёт порты MOUNT, NLM и NSM, затем обращается к каждому. Динамические порты неудобны для файрвола (их приходится фиксировать в настройках), а rpcbind, доступный из чужих сетей, раздаёт список служб сервера и годами использовался как усилитель для DDoS-атак по UDP. Чем меньше портов слушает сервер, тем меньше поверхность атаки.

NFSv4: один порт и состояние на сервере

NFSv4 (RFC 7530) переделал протокол целиком:

  • Один порт 2049. Программа MOUNT не нужна: сервер собирает все экспорты в одно псевдодерево, и клиент спускается от его корня обычными запросами LOOKUP. Блокировки встроены в сам протокол (операции LOCK, LOCKU), поэтому не нужны ни NLM, ни NSM, ни rpcbind. На сервере остаётся одно TCP-соединение на клиента и один порт.
  • Состояние на сервере. Сервер помнит клиентов, открытые файлы и блокировки. Клиент держит аренду (lease, по умолчанию 90 секунд) и продлевает её любым запросом; если клиент пропал дольше аренды, сервер освобождает его блокировки. После перезапуска сервер объявляет период отсрочки (grace period, тоже 90 секунд), в течение которого клиенты восстанавливают свои открытия и блокировки, а новые не выдаются — поэтому первое монтирование после systemctl restart nfs-server может ждать до полутора минут.
  • Делегирования. Сервер может отдать клиенту файл «в управление» и кэшировать его локально, а при конфликте — отозвать. В версии 4.0 для отзыва сервер сам открывает соединение обратно к клиенту (callback), и этот обратный канал плохо проходит через NAT и файрволы.
  • NFSv4.1 (RFC 8881) добавил сессии: обратный канал идёт внутри того же соединения, которое открыл клиент, а таблица слотов гарантирует, что повтор запроса после разрыва не выполнится дважды (exactly-once semantics). Там же появился pNFS — параллельный доступ к данным, разнесённым по нескольким серверам.
  • NFSv4.2 (RFC 7862) — возможности поверх 4.1: копирование на стороне сервера (COPY, данные не гоняются через клиента), разреженные файлы (SEEK, ALLOCATE, DEALLOCATE), расширенные атрибуты (RFC 8276), передача меток SELinux — системы мандатного контроля доступа, — чтобы сервер и клиент видели одну и ту же метку файла.

Для кластера это означает: на сервере достаточно включить 4.1 и 4.2, а 3 и 4.0 выключить. Клиенты Ubuntu 26.04 и любого современного Linux умеют 4.2 и выбирают её сами.

Имена пользователей и числовые UID

В NFSv3 владелец файла передаётся числом. В NFSv4 атрибуты owner и owner_group — строки вида user@domain, и перевод имён в UID на обеих сторонах делает служба idmapping (nfsidmap, rpc.idmapd, домен — в /etc/idmapd.conf). Но в запросе RPC при sec=sys удостоверение всё равно числовое, и ядро Linux по умолчанию при sec=sys передаёт в атрибутах тоже числа: клиент и сервер обмениваются строками "1000", а не "alice@example.com" (параметр nfs4_disable_idmapping модулей nfs и nfsd, по умолчанию Y у обоих).

Отсюда практическое правило для кластера: права решают числовые UID и GID. Под с runAsUser: 1000 создаёт файлы, которые на сервере принадлежат UID 1000, кто бы этот UID ни носил в /etc/passwd сервера, а на узле ls -l покажет имя, которое у UID 1000 на узле. Поэтому UID приложений выбирают осознанно и не пересекают с UID реальных пользователей сервера.


Модель доверия sec=sys

Удостоверение по умолчанию — AUTH_SYS, в опциях экспорта и монтирования — sec=sys. Клиент кладёт в каждый запрос UID, GID и до 16 дополнительных групп процесса, и сервер их не проверяет: проверить нечем, пароля или ключа в запросе нет. Сервер проверяет только адрес клиента — разрешён ли он в /etc/exports, — а дальше верит тому, что клиент сообщил о пользователе. Вся безопасность NFS без Kerberos держится на предположении, что к серверу обращаются только ядра разрешённых машин и что они честно передают, от чьего имени работает процесс.

Единственное, что сервер может сделать с удостоверением, — подменить его (squash):

Опция Что делает
root_squash (по умолчанию) запросы от UID 0 выполняются от имени анонимного пользователя (anonuid, по умолчанию 65534, nobody); остальные UID — как есть
no_root_squash UID 0 клиента — это root на сервере: может менять владельца любого файла экспорта, читать и писать всё
all_squash все UID и GID, включая root, заменяются на anonuid/anongid; права внутри экспорта фактически одни на всех
anonuid, anongid какой UID и GID подставлять вместо подменённых, например anonuid=2000,anongid=2000

root_squash защищает от самого очевидного: root на любой разрешённой машине не становится root на сервере. Права обычных UID при этом остаются на совести клиента.


Почему CSI-драйвер просит no_root_squash

csi-driver-nfs, о котором следующая часть, работает с экспортом от имени root дважды.

  1. Контроллер создаёт и удаляет каталоги томов. На каждый новый PVC его контейнер временно монтирует корень экспорта и делает mkdir для подкаталога тома, а при удалении — rm -rf. Он работает от root, в сети узла (hostNetwork: true) и с SYS_ADMIN, нужным для mount. При root_squash эти операции выполнятся от nobody и не пройдут, если корень экспорта принадлежит root, а при удалении не смогут стереть файлы, которые приложения создали под своими UID.
  2. kubelet выставляет права под fsGroup. У драйвера fsGroupPolicy: File, и при монтировании тома в под с securityContext.fsGroup: 2000 kubelet от root меняет группу файлов тома на 2000 и добавляет группе права на запись. chown в чужую группу при root_squash невозможен: сервер видит не root, а nobody. Документация драйвера прямо пишет, что для fsGroup экспорт должен разрешать смену владельца: no_root_squash или подменный UID, которому принадлежит каталог.

Обойти это можно, но с потерями. Корень экспорта, отданный nobody, позволит контроллеру создавать каталоги и при root_squash, но fsGroup перестанет работать, и права на запись придётся давать через mountPermissions: 0777 — то есть всем. all_squash с anonuid приложения сделает все файлы собственностью одного UID: это рабочий вариант для одного приложения, но не для общего хранилища кластера. Поэтому для экспорта под CSI-драйвер практический выбор — no_root_squash, а безопасность переносится на вопрос кто вообще может достучаться до сервера. Этому и посвящена вся остальная часть.


Адрес узла — это ещё не kubelet

Сервер проверяет адрес клиента, и первая мысль — разрешить экспорт только адресам узлов. Это правильно, но вот что надо держать в голове.

 k8s-node-1, 192.168.10.20
 ┌──────────────────────────────────────────────────┐
 │  kubelet, csi-nfs-node     под 172.20.211.46     │
 │  (сеть узла)                      │              │
 │        │                  SNAT (natOutgoing)     │
 │        │                          │              │
 │        └── src 192.168.10.20 ─────┘              │
 │                  │                               │
 └──────────────────┼───────────────────────────────┘
                    ▼
        nfs.example.com:2049: клиент 192.168.10.20

Исходящий трафик подов к адресам вне пулов Calico проходит через NAT: при natOutgoing: true узел подменяет адрес пода 172.20.x.x своим (это обычный SNAT в netfilter, подробно — в части 6 сетевой серии, а маскарадинг Calico в nftables мы видели в части 14). Для NFS-сервера соединение от процесса в любом поде выглядит точно так же, как соединение от kubelet: тот же адрес узла. Адресный фильтр в /etc/exports и в файрволе отделяет узлы кластера от остальной сети, но не отделяет kubelet от подов, работающих на этих узлах.

Не помогает и опция secure, которая требует от клиента привилегированного порта (меньше 1024): её смысл в том, что такой порт на обычной машине может занять только root. В подах это не так. containerd 2 по умолчанию выставляет контейнерам без сети узла net.ipv4.ip_unprivileged_port_start=0 (параметр enable_unprivileged_ports в настройках CRI), то есть любой процесс в поде — и не root, и без capabilities — может открыть соединение с порта 700. А NAT узла сохраняет класс порта: исходный порт меньше 1024 заменяется тоже портом меньше 1024. Проверим подом, который запущен по всем правилам безопасности:

apiVersion: v1
kind: Pod
metadata:
  name: nonroot
  namespace: demo
spec:
  nodeName: k8s-node-1        # чтобы знать, с какого узла придёт соединение
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: busybox
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
$ kubectl apply -f nonroot.yaml
pod/nonroot created
$ kubectl -n demo exec nonroot -- sh -c 'id; grep CapEff /proc/self/status; cat /proc/sys/net/ipv4/ip_unprivileged_port_start'
uid=1000 gid=1000 groups=1000
CapEff:	0000000000000000
0
$ kubectl -n demo exec nonroot -- nc -zv -w 3 -p 700 192.168.10.30 2049
192.168.10.30 (192.168.10.30:2049) open

А на сервере в этот момент:

user@nfs:~$ sudo tcpdump -ni ens18 -c 1 'tcp dst port 2049 and tcp[tcpflags] == tcp-syn'
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ens18, link-type EN10MB (Ethernet), snapshot length 262144 bytes
10:56:53.114900 IP 192.168.10.20.990 > 192.168.10.30.2049: Flags [S], seq 3462405522, win 64860, options [mss 1410,sackOK,TS val 731856867 ecr 0,nop,wscale 9], length 0
1 packet captured
1 packet received by filter
0 packets dropped by kernel

Адрес узла и привилегированный порт 990 — для сервера это неотличимо от ядра узла. Дальше вступает модель sec=sys: удостоверение в запросе собирает клиент. Ядро честно кладёт туда UID процесса, но программа, которая говорит по протоколу NFS сама, в пространстве пользователя (такие библиотеки есть), пишет в удостоверение то, что захочет, в том числе UID 0, и root для этого ей не нужен. С no_root_squash такой запрос получит на экспорте права root. Иначе говоря, при такой конфигурации граница доверия проходит не по узлам, а по всем рабочим нагрузкам кластера, включая чужие образы и уязвимые приложения.

Вывод: адресный фильтр необходим, но недостаточен. Нужен второй слой, который отделяет трафик подов от трафика узла. Он есть: это сетевая политика Calico, которая применяется к трафику пода до NAT, пока у пакета ещё адрес пода. Ниже все меры по порядку.


1. Отдельный диск под экспорт

NFS ничего не знает о размере PVC: том на 1 ГиБ — это просто каталог, и приложение может записать в него сколько угодно, пока не кончится файловая система сервера. Если экспорт лежит на корневом разделе, переполнение тома одного приложения останавливает сервер целиком: журналы, обновления, сам nfsd. Поэтому под экспорт — отдельный диск или раздел, у нас второй диск /dev/sdb:

user@nfs:~$ sudo mkfs.xfs -L k8s-data /dev/sdb
user@nfs:~$ sudo mkdir -p /srv/nfs/k8s
user@nfs:~$ echo 'LABEL=k8s-data  /srv/nfs/k8s  xfs  defaults,nodev,nosuid  0 2' | sudo tee -a /etc/fstab
LABEL=k8s-data  /srv/nfs/k8s  xfs  defaults,nodev,nosuid  0 2
user@nfs:~$ sudo systemctl daemon-reload && sudo mount /srv/nfs/k8s
user@nfs:~$ sudo chown root:root /srv/nfs/k8s && sudo chmod 0755 /srv/nfs/k8s
user@nfs:~$ findmnt /srv/nfs/k8s
TARGET       SOURCE   FSTYPE OPTIONS
/srv/nfs/k8s /dev/sdb xfs    rw,nosuid,nodev,relatime,inode64,logbufs=8,logbsize=32k,noquota

nodev и nosuid запрещают на этой файловой системе файлы устройств и биты setuid: на экспорт пишут клиенты, и ничто из записанного ими не должно действовать на самом сервере. XFS выбран потому, что у него есть проектные квоты (project quota): каталогу можно назначить жёсткий лимит. csi-driver-nfs их не ставит, но для отдельных крупных томов администратор может сделать это руками (xfs_quota).

В /etc/exports у этого экспорта будет опция mp (mountpoint): экспортировать каталог, только если он действительно точка монтирования. Если диск однажды не смонтируется, без mp сервер экспортирует пустой каталог на корневом разделе, кластер начнёт создавать в нём тома, и корневой раздел заполнится. С mp экспорт просто не отдаётся клиентам: rpc.mountd пишет в журнал Cannot export path '/srv/nfs/k8s': not a mountpoint, клиент получает No such file or directory, и ошибку видно сразу. С такой строкой в /etc/fstab, без nofail, сервер с пропавшим диском и вовсе не загрузится дальше аварийного режима; если добавить nofail, чтобы он загружался и был доступен по SSH, от пустого экспорта защищает именно mp.


2. Только NFSv4

Сервер NFS в Ubuntu — пакет nfs-kernel-server; вместе с ним ставятся nfs-common, rpcbind, keyutils и libnfsidmap1:

user@nfs:~$ sudo apt install nfs-kernel-server

Сразу после установки включены все версии и все помощники NFSv3:

user@nfs:~$ sudo cat /proc/fs/nfsd/versions
+3 +4 +4.1 +4.2
user@nfs:~$ rpcinfo -p | grep -v portmapper
   program vers proto   port  service
    100024    1   udp  59836  status
    100024    1   tcp  37909  status
    100005    1   udp  40287  mountd
    100005    1   tcp  36865  mountd
    100005    2   udp  35330  mountd
    100005    2   tcp  52015  mountd
    100005    3   udp  54414  mountd
    100005    3   tcp  47415  mountd
    100003    3   tcp   2049  nfs
    100003    4   tcp   2049  nfs
    100227    3   tcp   2049  nfs_acl
    100021    1   udp  33818  nlockmgr
    100021    3   udp  33818  nlockmgr
    100021    4   udp  33818  nlockmgr
    100021    1   tcp  35093  nlockmgr
    100021    3   tcp  35093  nlockmgr
    100021    4   tcp  35093  nlockmgr

Вся таблица из начала части: сам portmapper на 111 (его строки отфильтрованы), status (NSM), mountd, nlockmgr (NLM) на случайных портах, плюс nfs_acl — вспомогательный протокол POSIX ACL для NFSv3.

Ubuntu хранит настройки сервера в /etc/nfs.conf и в каталоге /etc/nfs.conf.d/, где удобно держать свои изменения отдельным файлом:

# /etc/nfs.conf.d/k8s.conf
[nfsd]
vers3=n
vers4.0=n
vers4.1=y
vers4.2=y

[mountd]
manage-gids=n

vers3=n выключает третью версию, vers4.0=n — версию без сессий, которой нужен обратный канал от сервера к клиенту. Строку vers4= не трогают: она выключила бы всю четвёртую версию. Число потоков nfsd по умолчанию — 16 (так их запускает nfsdctl, которым служба nfs-server стартует сервер), для большого кластера его поднимают строкой threads= в той же секции.

manage-gids=n — строка, без которой не заработает fsGroup. В /etc/nfs.conf Ubuntu стоит manage-gids=y: сервер выбрасывает список дополнительных групп, пришедший от клиента, и подставляет группы этого UID из своей базы пользователей. Так обходят предел в 16 групп AUTH_SYS, и это удобно, когда пользователи у клиентов и сервера общие. В кластере их нет: под с runAsUser: 1000 и fsGroup: 2000 приходит с группой 2000, а у сервера UID 1000 либо никто, либо кто-то посторонний, и группы 2000 у него нет. Проверено так: в каталог root:2000 с правами 2775 процесс с UID 1000, 1001 или 5000 и дополнительной группой 2000 с manage-gids=y получал Permission denied, с manage-gids=n — записывал файл.

Без NFSv3 больше не нужны rpcbind и rpc.statd. Их маскируют, чтобы ни одна зависимость не подняла их снова:

user@nfs:~$ sudo systemctl mask --now rpc-statd.service rpcbind.service rpcbind.socket
Created symlink '/etc/systemd/system/rpc-statd.service' → '/dev/null'.
Created symlink '/etc/systemd/system/rpcbind.service' → '/dev/null'.
Created symlink '/etc/systemd/system/rpcbind.socket' → '/dev/null'.

rpc.mountd остаётся: ядро и в NFSv4 спрашивает у него, разрешён ли клиент и как отображается экспорт (через /proc/net/rpc/, без сети). Но по умолчанию он ещё и слушает сетевые порты для клиентов NFSv3 — даже после vers3=n у него остаются слушающие сокеты MOUNT версий 1 и 2. Это отключается ключами --no-tcp --no-udp через drop-in systemd:

# /etc/systemd/system/nfs-mountd.service.d/v4only.conf
[Service]
ExecStart=
ExecStart=/usr/sbin/rpc.mountd --no-tcp --no-udp
user@nfs:~$ sudo systemctl daemon-reload
user@nfs:~$ sudo systemctl restart nfs-server
user@nfs:~$ sudo cat /proc/fs/nfsd/versions
-3 +4 -4.0 +4.1 +4.2
user@nfs:~$ sudo ss -tulnp -4 | grep -v 127.0.0
Netid State  Recv-Q Send-Q       Local Address:Port Peer Address:PortProcess
udp   UNCONN 0      0      192.168.10.30%ens18:68        0.0.0.0:*    users:(("systemd-network",pid=962,fd=33))
tcp   LISTEN 0      4096               0.0.0.0:2049      0.0.0.0:*
tcp   LISTEN 0      4096               0.0.0.0:22        0.0.0.0:*    users:(("sshd",pid=1076,fd=3),("systemd",pid=1,fd=262))

/proc/fs/nfsd/versions — то, что действительно включено в ядре: минус у 3 и 4.0, плюс у 4.1 и 4.2. В ss (без локальных 127.0.0.x — это DNS-кэш systemd-resolved и chronyd) остались только 2049 (у сокета ядра нет процесса, поэтому колонка пуста), SSH и клиент DHCP на порту 68, через который сервер получает свой адрес (часть 11 сетевой серии). Ни 111, ни динамических портов. В журнале rpc.mountd при каждом старте теперь пишет mountd: No V2 or V3 listeners created! — так и задумано. Службы nfs-idmapd, nfsdcld (запоминает клиентов NFSv4 на диске, чтобы после перезапуска сервера знать, кому разрешено восстанавливать состояние) и fsidd продолжают работать, но в сеть не смотрят.


3. /etc/exports построчно

В свежем /etc/exports только комментарии с примерами. Заменяем его целиком:

# /etc/exports
# Тома кластера для csi-driver-nfs: только узлы кластера, root не подменяется
/srv/nfs/k8s  192.168.10.17(rw,sync,no_subtree_check,secure,no_root_squash,mp,sec=sys) \
              192.168.10.20(rw,sync,no_subtree_check,secure,no_root_squash,mp,sec=sys) \
              192.168.10.21(rw,sync,no_subtree_check,secure,no_root_squash,mp,sec=sys)

Кому. Не * и не вся сеть 192.168.10.0/24, а адреса узлов. Мастер 192.168.10.17 в списке, потому что контроллер драйвера по умолчанию допускается на control plane (у него есть tolerations для taint'ов мастера) и может оказаться там. Компактная запись — подсеть 192.168.10.16/28, но у нас она охватывает .16–.31, а это и сам NFS-сервер, и files.example.com. Подсеть уместна, только если она отведена под узлы целиком; иначе — адреса по одному, а добавление узла в кластер включает в себя правку /etc/exports и файрвола (это пункт в чек-листе ввода узла).

rw — запись разрешена; по умолчанию экспорт только для чтения.

sync против async. При sync сервер отвечает на запись и на COMMIT только после того, как данные на диске. async отвечает сразу, а пишет потом; это быстрее, но при сбое питания сервера клиент считает записанным то, чего на диске нет, и узнать об этом не может. Для томов приложений — только sync (он и по умолчанию, но его пишут явно, чтобы читающий не сомневался).

no_subtree_check — не проверять при каждом запросе, что файл лежит внутри экспортированного подкаталога. Проверка имеет смысл, когда экспортирован подкаталог файловой системы, а не она целиком, и даёт сбои при переименовании открытых файлов. Здесь экспорт — вся файловая система (mp), проверка не нужна; это значение по умолчанию.

secure — принимать запросы только с привилегированных портов (меньше 1024). Клиент NFS ядра Linux и так подключается с такого порта, поэтому insecure, который часто встречается в рецептах, кластеру не нужен: он требуется только клиентам, которые работают не от root, — программам с NFS-клиентом в пространстве пользователя. secure — слабая проверка, и от подов, как мы видели, она не защищает, но выключать её незачем.

no_root_squash — только на этом экспорте и только потому, что его просит CSI-драйвер. Если на сервере есть другие экспорты, у них остаётся root_squash (п. 7).

mp — описан в п. 1. sec=sys — значение по умолчанию, записано явно, чтобы при переходе на Kerberos (п. 8) было видно, что менять.

Права корня экспорта — 0755 root:root: писать в него может только root, то есть контроллер драйвера; каталоги томов внутри он создаёт сам, и их права выставляет kubelet под fsGroup пода.

Применить без перезапуска сервера и проверить, что ядро видит на самом деле:

user@nfs:~$ sudo exportfs -ra
user@nfs:~$ sudo exportfs -v
/srv/nfs/k8s  	192.168.10.17(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)
/srv/nfs/k8s  	192.168.10.20(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)
/srv/nfs/k8s  	192.168.10.21(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)

exportfs -ra перечитывает файл и синхронизирует таблицу ядра: добавленные клиенты получают доступ, удалённые теряют его при следующем обращении, а подключённые и по-прежнему разрешённые клиенты ничего не замечают. В выводе -v видны и неявные значения: wdelay (группировать близкие по времени записи), hide, no_all_squash. Проверку mp таблица не показывает: экспорт в ней есть и при несмонтированном диске, отказ случается в момент обращения клиента.


4. Файрвол на сервере

/etc/exports решает, кому отдать экспорт, но сам порт 2049 открыт всем, кто дойдёт до сервера, а за ним — код ядра, разбирающий запросы от любого адреса. Файрвол сужает круг тех, кто вообще может открыть соединение. На Ubuntu удобен ufw — обёртка над iptables, которые в Ubuntu работают поверх nftables (iptables -V показывает nf_tables):

user@nfs:~$ sudo ufw default deny incoming
Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
user@nfs:~$ sudo ufw default allow outgoing
Default outgoing policy changed to 'allow'
(be sure to update your rules accordingly)
user@nfs:~$ sudo ufw allow from 192.168.10.240/28 to any port 22 proto tcp comment 'SSH: admin workstations'
Rules updated
user@nfs:~$ for ip in 192.168.10.17 192.168.10.20 192.168.10.21; do
>   sudo ufw allow from $ip to any port 2049 proto tcp comment 'NFSv4: k8s nodes'
> done
Rules updated
Rules updated
Rules updated
user@nfs:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
user@nfs:~$ sudo ufw status numbered
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp                     ALLOW IN    192.168.10.240/28          # SSH: admin workstations
[ 2] 2049/tcp                   ALLOW IN    192.168.10.17              # NFSv4: k8s nodes
[ 3] 2049/tcp                   ALLOW IN    192.168.10.20              # NFSv4: k8s nodes
[ 4] 2049/tcp                   ALLOW IN    192.168.10.21              # NFSv4: k8s nodes

192.168.10.240/28 здесь — условная сеть рабочих мест администраторов; подставьте свою. Правило SSH добавляется до ufw enable, иначе можно потерять доступ к серверу. UDP для NFS не нужен: NFSv4 работает только по TCP. Отброшенные пакеты ufw пишет в журнал ядра с пометкой [UFW BLOCK] (уровень журналирования low по умолчанию).

Два слоя — /etc/exports и файрвол — настраиваются одинаково, но защищают от разного: первый от ошибки в файрволе, второй от уязвимости в разборе запросов и от того, что кто-то однажды расширит экспорт до *.


5. Сетевая политика: подам к серверу нельзя

Теперь второй слой против подов. Подам не нужно ходить на NFS-сервер напрямую: тома монтирует kubelet на узле, а процесс в поде видит обычный каталог. Значит, весь трафик подов к 192.168.10.30 можно запретить, и ни одно приложение этого не заметит. Политика Calico проверяет пакет на интерфейсе пода (veth со стороны узла), до NAT: у пакета ещё адрес пода, и Calico точно знает, из какого он пода.

Политики Calico, о которых шла речь в части 20, группируются в уровни (Tier): упорядоченные группы, которые проверяются по очереди, от меньшего order к большему; встроенные — kube-admin (1000), default (1 000 000), где живут обычные NetworkPolicy, и kube-baseline (10 000 000), плюс calico-system (100), который оператор Calico создаёт для собственных политик. Внутри уровня политики применяются по order; правило Allow или Deny заканчивает проверку, а если пакет не совпал ни с одним правилом выбранных политик, срабатывает действие уровня по умолчанию. Здесь важная тонкость. Если положить одну политику с одним правилом Deny в уровень default, то для всех подов, которые она выбрала, всё прочее исходящее тоже окажется запрещено: политика выбрала под, ни одно разрешающее правило не совпало, и уровень завершит проверку неявным запретом. Поэтому запрет кладут в отдельный уровень с defaultAction: Pass: всё, что не совпало с запретом, передаётся следующим уровням и обычным NetworkPolicy так, будто этого уровня нет.

apiVersion: projectcalico.org/v3
kind: Tier
metadata:
  name: storage-guard
spec:
  order: 200          # после calico-system (100), раньше kube-admin (1000) и default
  defaultAction: Pass
---
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
  name: storage-guard.deny-nfs-server
spec:
  tier: storage-guard
  order: 10
  namespaceSelector: all()   # все неймспейсы, то есть только поды
  types:
    - Egress
  egress:
    - action: Deny
      destination:
        nets:
          - 192.168.10.30/32

GlobalNetworkPolicy действует во всех неймспейсах сразу, поэтому новый неймспейс не останется без защиты. Запрещён весь трафик к серверу, а не только порт 2049: SSH и любые другие порты подам там тоже незачем. Уровень стоит раньше default, так что разрешающая NetworkPolicy в неймспейсе приложения его не отменит. Префикс storage-guard. в имени политики Calico 3.32 не требует, но с ним в kubectl get сразу видно, из какого она уровня.

$ kubectl apply -f storage-guard.yaml
tier.projectcalico.org/storage-guard created
globalnetworkpolicy.projectcalico.org/storage-guard.deny-nfs-server created
$ kubectl get tiers.projectcalico.org
NAME            CREATED AT
calico-system   2026-10-24T10:36:50Z
default         2026-10-24T10:33:59Z
kube-admin      2026-10-24T10:33:59Z
kube-baseline   2026-10-24T10:33:59Z
storage-guard   2026-10-30T10:47:48Z

На узле Felix превращает это в цепочку nftables. В цепочке выхода пода (filter-cali-fw-<интерфейс>) появляется начало уровня и переход в цепочку политики, а строки «конец уровня — отбросить» нет — это и есть Pass:

user@k8s-node-1:~$ sudo nft list ruleset ip | grep -A3 'Start of tier storage-guard'
# Warning: table ip filter is managed by iptables-nft, do not touch!
		counter packets 4 bytes 240 meta mark set meta mark & 0xfffdffff comment "cali:H8y8Bg0SPeMX2TVV; Start of tier storage-guard"
		meta mark & 0x00020000 == 0x00000000 counter packets 4 bytes 240 jump filter-cali-po-gnp/storage-guard.deny-nfs-server comment "cali:I5XycXG0aqjdThzp;"
		meta mark & 0x00010000 == 0x00010000 counter packets 0 bytes 0 return comment "cali:Oa07qTVNuWtGvA2q; Return if policy accepted"
		counter packets 1 bytes 60 jump filter-cali-pro-kns.demo comment "cali:TJE0Mgsa5aM6ijZF;"
user@k8s-node-1:~$ sudo nft list chain ip calico 'filter-cali-po-gnp/storage-guard.deny-nfs-server'
table ip calico {
	chain filter-cali-po-gnp/storage-guard.deny-nfs-server {
		ip daddr 192.168.10.30 counter packets 3 bytes 180 meta mark set meta mark | 0x00040000 comment "cali:PrgeB-EQDo7dMLwl; GlobalNetworkPolicy storage-guard.deny-nfs-server egress"
		meta mark & 0x00040000 == 0x00040000 counter packets 3 bytes 180 drop comment "cali:ZtM_-7WTNjSkTrSx;"
	}
}

Первую строку печатает сам nft: таблицу filter ведёт iptables-nft. Пакет к серверу получает бит Deny (0x40000) и отбрасывается — три пакета здесь это SYN от nc и его повторы; всё прочее идёт дальше, к профилю неймспейса kns.demo, который разрешает всё, — как будто уровня нет.

Почему это не ломает тома. Политика выбирает рабочие конечные точки — интерфейсы подов. kubelet и контроллер драйвера работают в сети узла (hostNetwork), у них нет интерфейса пода, и политика их трафик не видит. Автоматические host endpoints (политики для самих интерфейсов узла) в нашем кластере выключены (autoCreate: Disabled в KubeControllersConfiguration); если вы их включаете, проверьте, что селектор политики узлы не выбирает.

Чего политика не закрывает. Под, запущенный с hostNetwork: true, тоже живёт в сети узла и мимо неё проходит. Такие поды должны быть только у системных компонентов, а остальным их запрещает Pod Security Admission — встроенный контроллер допуска, проверяющий поды по профилям безопасности; о нём в части 30.

Убирают политику в обратном порядке: сначала политику, потом уровень. Один kubectl delete -f storage-guard.yaml удаляет политику, а на уровне отвечает Cannot delete a non-empty tier — удаление политики к этому моменту ещё не закончилось; повторная команда проходит.


6. Поды не от root

Третий слой — то, что делает сам под. Процесс с runAsUser: 1000, без capabilities и с allowPrivilegeEscalation: false не смонтирует NFS ядром (для mount нужен CAP_SYS_ADMIN), не станет root на узле и не получит hostNetwork, если неймспейс работает по профилю restricted Pod Security Admission. Подробно — в части 30.

Но от прямого разговора с NFS-сервером он, как мы видели, сам по себе не защищает: порт ниже 1024 ему доступен, а удостоверение AUTH_SYS программа собирает сама. Порог портов можно вернуть: в containerd — enable_unprivileged_ports = false в настройках CRI для всех подов, а отдельному поду — sysctl net.ipv4.ip_unprivileged_port_start в securityContext.sysctls (он в списке безопасных, поэтому под может поставить его и сам, в любую сторону). Это закрывает только путь через secure, а не через сеть. Поэтому главный слой против подов — сетевая политика из п. 5, а non-root и restricted — то, что не даёт обойти её через сеть узла.


7. Разные экспорты для разных классов данных

Одному экспорту — один уровень доверия. Тома приложений, которые создаёт CSI-драйвер, требуют no_root_squash; другим данным он не нужен, и смешивать их не стоит.

# Справочные данные, которые кластер только читает: свой диск, только чтение, root подменяется
/srv/nfs/static  192.168.10.20(ro,sync,no_subtree_check,root_squash,mp) \
                 192.168.10.21(ro,sync,no_subtree_check,root_squash,mp)

# Резервные копии: не узлам кластера, а только серверу резервного копирования (адрес условный)
/srv/nfs/backup  192.168.10.40(rw,sync,no_subtree_check,root_squash,mp)

Особенно важны резервные копии. Если бэкапы базы лежат на том же экспорте, что и тома, то всё, что может испортить тома, может удалить и бэкапы. Экспорт, который узлам кластера вообще не отдан, переживёт и ошибку в приложении, и компрометацию кластера. Каждый экспорт — на своей файловой системе (отсюда mp): заполнение одной не останавливает другие, и NFS не придётся проверять границы подкаталогов.


8. Аутентификация и шифрование в пути

Всё сказанное выше не меняет главного свойства sec=sys: в сети между узлом и сервером идут открытые данные и неподписанные запросы. Против этого два механизма.

Kerberos: sec=krb5, krb5i, krb5p. Вместо UID в запросе — билет Kerberos (RPCSEC_GSS, RFC 2203): сервер проверяет, кто обращается, по ключу, а не на слово. krb5 — только аутентификация, krb5i — плюс подпись каждого запроса (защита от подмены в пути), krb5p — плюс шифрование. На сервере это опция экспорта sec=krb5p, на клиенте — опция монтирования, плюс KDC (центр распределения ключей, который выдаёт билеты), keytab (файл с ключами принципалов) с принципалом nfs/<имя узла> на каждом узле и сервере, служба rpc.gssd и синхронное время. Цена в кластере выше, чем на обычных машинах: Kerberos аутентифицирует пользователей, и root на узле ходит по ключу машины, а у процессов в подах своих билетов нет — для них доступ будет запрещён, если не выдавать билеты каждому приложению. Поддержка Kerberos в csi-driver-nfs из коробки не описана; это возможно (опции монтирования драйвер передаёт как есть), но требует проверки на своём стенде. Там, где Kerberos уже есть, krb5p даёт лучшую защиту NFS из возможных; строить его ради одного кластера обычно дорого.

NFS поверх TLS. RFC 9289 описывает шифрование RPC через TLS 1.3 на том же порту 2049: клиент первым запросом NULL с удостоверением AUTH_TLS спрашивает, умеет ли сервер TLS, и если да — проводит рукопожатие в том же соединении. В Linux рукопожатие делает не ядро, а служба tlshd из пакета ktls-utils: ядро передаёт ей сокет, она проводит рукопожатие и возвращает ключи, после чего шифрует уже ядро (kTLS). Сервер NFS умеет это с ядра 6.4, клиент — с 6.5. На сервере это опция экспорта xprtsec=tls (только шифрование) или xprtsec=mtls (клиент тоже предъявляет сертификат — это заодно и аутентификация узла), можно перечислить несколько через двоеточие (xprtsec=mtls:tls:none); на клиенте — опция монтирования xprtsec=tls и тот же tlshd. Сертификаты выпускаются обычным порядком, например внутренним CA (TLS и сертификаты — в части 23 сетевой серии).

В Ubuntu 26.04 всё нужное есть: ядро собрано с CONFIG_NET_HANDSHAKE=y, ktls-utils 1.3.0 в репозитории, настройки — в /etc/tlshd/config. На сервере — сертификат с именем nfs.example.com и его ключ:

# /etc/tlshd/config на сервере
[authenticate.server]
x509.certificate= /etc/tlshd/server.crt
x509.private_key= /etc/tlshd/server.key

На узле — сертификат CA, которым сервер проверяется (иначе берётся системное хранилище доверенных корней):

# /etc/tlshd/config на узле
[authenticate.client]
x509.truststore= /etc/tlshd/ca.crt

После systemctl restart tlshd на обеих сторонах и xprtsec=tls в строке узла k8s-node-1 в /etc/exports:

user@k8s-node-1:~$ sudo mount -t nfs4 -o vers=4.2 nfs.example.com:/srv/nfs/k8s /mnt
mount.nfs4: Operation not permitted for nfs.example.com:/srv/nfs/k8s on /mnt
user@k8s-node-1:~$ sudo mount -t nfs4 -o vers=4.2,xprtsec=tls nfs.example.com:/srv/nfs/k8s /mnt
user@k8s-node-1:~$ findmnt -n /mnt
/mnt nfs.example.com:/srv/nfs/k8s nfs4 rw,relatime,vers=4.2,rsize=262144,wsize=262144,namlen=255,hard,fatal_neterrors=none,proto=tcp,timeo=600,retrans=2,sec=sys,xprtsec=tls,clientaddr=192.168.10.20,local_lock=none,addr=192.168.10.30

Без TLS сервер этому узлу отказывает, с TLS монтирование проходит, и в опциях видно xprtsec=tls. В tcpdump на сервере — NFS request … null, затем записи TLS вместо читаемых операций NFS. Проверка сертификата сервера идёт по имени, поэтому монтировать по имени из сертификата, а не по адресу. Пройдёт ли xprtsec через опции монтирования csi-driver-nfs, где монтирует контейнер драйвера, а tlshd нужен на самом узле, — вопрос следующей части. Если заработает, xprtsec=mtls закрывает и подслушивание, и подключение машины без сертификата — то есть и вопрос «узел или не узел» решается ключом, а не адресом; поды же с адресом узла сертификата узла не имеют.

Если ни то ни другое недоступно, остаётся физическая изоляция: сеть узлов и сервера хранения — отдельный VLAN (часть 17 сетевой серии), куда не попадают рабочие станции и гостевые устройства.


Как бывает на практике: антипример

Конфигурация, которая встречается на практике и годами «просто работает»:

# /etc/exports
/data *(rw,insecure,sync,no_subtree_check,no_root_squash)

/data — каталог на корневом разделе с правами 0777, файрвол выключен, NFSv3 включён, rpcbind слушает на всех интерфейсах. По частям:

Что Почему плохо
* экспорт отдан любому адресу, который дойдёт до сервера: рабочим станциям, VPN-клиентам, гостевой сети
no_root_squash для * root на любой из этих машин — root на всех данных экспорта
insecure снято даже слабое требование привилегированного порта; клиентам ядра Linux оно не мешало
корневой раздел заполнение одного тома останавливает сервер; нет mp — нечему ловить несмонтированный диск
0777 на /data писать в корень экспорта может любой UID с любой разрешённой машины; права теряют смысл даже при root_squash
файрвол выключен ничто, кроме /etc/exports, не ограничивает, кто откроет соединение к 2049 и прочим портам
NFSv3 и rpcbind на всех интерфейсах лишние службы с динамическими портами, справочная служба открыта всем, отдельные протоколы блокировок
нет сетевой политики любой под кластера обращается к серверу с адресом узла

План перехода без простоя

Порядок выбран так, чтобы каждый шаг можно было проверить и откатить отдельно, а кластер продолжал работать.

  1. Инвентаризация. Кто подключён и по какой версии: ss -tn sport = :2049 (адреса клиентов), ls /proc/fs/nfsd/clients/ и файлы info в нём (клиенты NFSv4), nfsstat -s (есть ли ненулевые счётчики Server nfs v3). Всё, что не узел кластера, — выяснить, чьё это и нужно ли оно.
  2. Файрвол. Правила для SSH администраторов и 2049 от узлов (и от остальных найденных легальных клиентов, если они есть), затем ufw enable. Проверка: тома в кластере работают, ss -tn показывает те же соединения узлов.
  3. Сетевая политика Calico из п. 5. Проверка: тестовый под до сервера не достаёт, поды с томами работают — их трафик к серверу идёт от kubelet.
  4. /etc/exports: * заменить адресами узлов, убрать insecure, exportfs -ra. Подключённые узлы ничего не заметят. Проверка: exportfs -v, монтирование с узла.
  5. Права корня экспорта: chmod 0755 /data. Каталоги томов внутри сохраняют свои права. Проверка: создание нового PVC проходит (контроллер работает от root).
  6. Клиенты на NFSv4.2. В mountOptions StorageClass и существующих PV — nfsvers=4.2 (как — в следующей части). Новые опции применяются только при новом монтировании, поэтому узлы по одному выводятся из работы (kubectl drain) и возвращаются. Проверка: на узлах mount | grep nfs показывает vers=4.2, на сервере счётчики v3 перестают расти.
  7. Выключить v3, rpcbind, statd, сетевые порты mountd (п. 2). Проверка: /proc/fs/nfsd/versions, ss -tulnp.
  8. Отдельный диск. Единственный шаг, которому нужно короткое окно: при переносе данных на другую файловую систему меняются файловые дескрипторы NFS, и смонтированные тома получат ошибку Stale file handle. Порядок: подготовить диск, скопировать данные rsync -aHAX заранее, затем остановить приложения с томами (масштабировать до нуля), повторить rsync (докопируется разница), подмонтировать диск на место /data (или на новый путь с правкой PV), добавить mp, exportfs -ra, вернуть приложения. Проверка: findmnt, exportfs -v, поды с томами запущены.

Отдельно стоит решить, нужен ли вообще no_root_squash на этом экспорте: если клиентов, кроме CSI-драйвера, нет — оставить, теперь он ограничен узлами и политикой; если есть другие клиенты, вынести их данные в отдельный экспорт с root_squash (п. 7).


Смотрим руками

Монтирование с разрешённого адреса — с узла, вручную. Для этого на узле нужен пакет nfs-common с mount.nfs (sudo apt install nfs-common); драйверу из следующей части он на узле не понадобится — утилиты монтирования есть в его образе.

user@k8s-node-1:~$ sudo mount -t nfs4 -o vers=4.2 nfs.example.com:/srv/nfs/k8s /mnt
user@k8s-node-1:~$ findmnt /mnt
TARGET SOURCE                       FSTYPE OPTIONS
/mnt   nfs.example.com:/srv/nfs/k8s nfs4   rw,relatime,vers=4.2,rsize=262144,wsize=262144,namlen=255,hard,fatal_neterrors=none,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.10.20,local_lock=none,addr=192.168.10.30

rsize и wsize — наибольший размер одного чтения и записи; сервер выбирает свой предел по объёму памяти (/proc/fs/nfsd/max_block_size), у нашего с 2 ГБ — 256 КиБ. hard — при недоступном сервере запросы повторяются бесконечно, а не возвращают ошибку приложению. Создадим каталог «тома» так, как это сделали бы контроллер и kubelet: владелец — UID приложения, группа — fsGroup, бит setgid, чтобы новые файлы наследовали группу:

user@k8s-node-1:~$ sudo mkdir /mnt/pvc-test
user@k8s-node-1:~$ sudo chown 1000:2000 /mnt/pvc-test && sudo chmod 2775 /mnt/pvc-test
user@k8s-node-1:~$ sudo setpriv --reuid=1000 --regid=1000 --groups=2000 \
    sh -c 'echo hello > /mnt/pvc-test/app.txt; dd if=/dev/zero of=/mnt/pvc-test/blob bs=1M count=32 status=none'
user@k8s-node-1:~$ sudo umount /mnt

chown от root прошёл — это no_root_squash. На сервере владелец — те самые числа:

user@nfs:~$ ls -ln /srv/nfs/k8s /srv/nfs/k8s/pvc-test
/srv/nfs/k8s:
total 0
drwxrwsr-x 2 1000 2000 33 Oct 30 10:49 pvc-test

/srv/nfs/k8s/pvc-test:
total 65476
-rw-r--r-- 1 1000 2000        6 Oct 30 10:49 app.txt
-rw-r--r-- 1 1000 2000 33554432 Oct 30 10:49 blob

total 65476 при файле в 32 МиБ — XFS выделил место с запасом под растущий файл (спекулятивное выделение), лишнее он вернёт позже.

Псевдодерево NFSv4 видно, если смонтировать корень сервера: клиент спускается к экспорту по обычным каталогам.

user@k8s-node-1:~$ sudo mount -t nfs4 nfs.example.com:/ /mnt && find /mnt -maxdepth 3 && sudo umount /mnt
/mnt
/mnt/srv
/mnt/srv/nfs
/mnt/srv/nfs/k8s

Версия 4.0 выключена:

user@k8s-node-1:~$ sudo mount -t nfs4 -o vers=4.0 nfs.example.com:/srv/nfs/k8s /mnt
mount.nfs4: Protocol not supported for nfs.example.com:/srv/nfs/k8s on /mnt

С адреса, которого нет в /etc/exports. Удобнее всего проверить с самого сервера: локальный трафик файрвол пропускает, а адреса 192.168.10.30 в экспорте нет, так что срабатывает именно проверка /etc/exports:

user@nfs:~$ sudo mount -t nfs4 nfs.example.com:/srv/nfs/k8s /mnt
mount.nfs4: access denied by server while mounting nfs.example.com:/srv/nfs/k8s

С ноутбука 192.168.10.50, которого файрвол не пропускает к 2049, ответа не будет вовсе — соединение не устанавливается, и mount через какое-то время завершится по таймауту. На сервере такие попытки видны в журнале ядра:

user@nfs:~$ sudo journalctl -k | grep 'UFW BLOCK' | grep 'DPT=2049' | tail -1
Oct 30 10:44:46 nfs kernel: [UFW BLOCK] IN=ens18 OUT= MAC=52:54:00:3a:10:30:52:54:00:3a:10:50:08:00 SRC=192.168.10.50 DST=192.168.10.30 LEN=52 TOS=0x00 PREC=0x00 TTL=128 ID=63550 DF PROTO=TCP SPT=52189 DPT=2049 WINDOW=65535 RES=0x00 SYN URGP=0

Это разные слои, и проверять стоит оба.

Под до политики и после. Временный под с busybox в неймспейсе demo на k8s-node-1:

$ kubectl -n demo run nettest --image=busybox:1.37 --restart=Never \
    --overrides='{"spec":{"nodeName":"k8s-node-1"}}' -- sleep 3600
pod/nettest created
$ kubectl -n demo exec nettest -- nc -zv -w 3 192.168.10.30 2049
192.168.10.30 (192.168.10.30:2049) open

Соединение установилось, и на сервере оно пришло с адреса узла:

user@nfs:~$ sudo tcpdump -ni ens18 -c 2 'tcp port 2049 and tcp[tcpflags] & tcp-syn != 0'
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ens18, link-type EN10MB (Ethernet), snapshot length 262144 bytes
10:45:50.121387 IP 192.168.10.20.36220 > 192.168.10.30.2049: Flags [S], seq 3875367041, win 64860, options [mss 1410,sackOK,TS val 2778270908 ecr 0,nop,wscale 9], length 0
10:45:50.121425 IP 192.168.10.30.2049 > 192.168.10.20.36220: Flags [S.], seq 3403409359, ack 3875367042, win 65160, options [mss 1460,sackOK,TS val 4016543837 ecr 2778270908,nop,wscale 8], length 0
2 packets captured
2 packets received by filter
0 packets dropped by kernel

После kubectl apply -f storage-guard.yaml:

$ kubectl -n demo exec nettest -- 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 nettest -- nc -zv -w 3 192.168.10.30 22
nc: 192.168.10.30 (192.168.10.30:22): Connection timed out
command terminated with exit code 1
$ kubectl -n demo exec nettest -- nc -zv -w 3 192.168.10.17 6443
192.168.10.17 (192.168.10.17:6443) open
$ kubectl -n demo exec nettest -- nslookup kubernetes.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	kubernetes.default.svc.cluster.local
Address: 10.96.0.1

Пакеты пода к серверу отбрасываются на его veth и до NAT не доходят — ни на 2049, ни на SSH; остальной трафик, включая DNS и API, идёт как раньше.

А тома при этом продолжают работать. Проверим подом со встроенным типом тома nfs — без CSI-драйвера, kubelet монтирует его сам, через mount.nfs узла:

apiVersion: v1
kind: Pod
metadata:
  name: nfs-vol
  namespace: demo
spec:
  nodeName: k8s-node-1
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    supplementalGroups: [2000]
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      nfs:
        server: nfs.example.com
        path: /srv/nfs/k8s/pvc-test
$ kubectl apply -f nfs-vol.yaml
pod/nfs-vol created
$ kubectl -n demo exec nfs-vol -- sh -c 'cat /data/app.txt; echo from-pod > /data/pod.txt && ls -ln /data'
hello
total 32776
-rw-r--r--    1 1000     2000             6 Oct 30 10:49 app.txt
-rw-r--r--    1 1000     2000      33554432 Oct 30 10:49 blob
-rw-r--r--    1 1000     2000             9 Oct 30 10:57 pod.txt
$ kubectl -n demo exec nfs-vol -- 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

Под читает и пишет том на NFS-сервере, но сам до сервера не достаёт: монтирование сделал kubelet из сети узла, а политика выбирает только интерфейс пода. Новый файл получил группу 2000 от каталога (setgid).

Что подключено к серверу и откуда:

user@nfs:~$ ss -tn sport = :2049
State Recv-Q Send-Q   Local Address:Port    Peer Address:Port
ESTAB 0      0        192.168.10.30:2049   192.168.10.20:915

Порт клиента меньше 1024 — так ядро Linux подключается при secure. Одно соединение на узел: все тома узла с этого сервера идут через него (на узлах без смонтированных томов соединений нет). Здесь не должно быть ни одного адреса, кроме узлов; посторонний адрес — повод разбираться, а не только закрыть.

Какие клиенты NFSv4 сервер знает по именам:

user@nfs:~$ sudo sh -c 'cat /proc/fs/nfsd/clients/*/info'
clientid: 0xb450d8186ae477e0
address: "192.168.10.20:915"
status: confirmed
seconds from last renew: 10
name: "Linux NFSv4.2 k8s-node-1"
minor version: 2
Implementation domain: "kernel.org"
Implementation name: "Linux 7.0.0-38-generic #38-Ubuntu SMP PREEMPT_DYNAMIC Fri Sep  4 09:10:14 UTC 2026 x86_64"
Implementation time: [0, 0]
callback state: UP
callback address: "192.168.10.20:0"
admin-revoked states: 0
session slots: 64
session target slots: 64

minor version: 2 — клиент пришёл по 4.2, callback address с портом 0 — обратный канал идёт внутри сессии, отдельного соединения к клиенту сервер не открывает.

Статистика сервера: какие версии и операции реально используются.

user@nfs:~$ nfsstat -s
Server rpc stats:
calls      badcalls   badfmt     badauth    badclnt
751        12         0          12         0

Server nfs v4:
null             compound
22        2%     729      97%

Server nfs v4 operations:
op0-unused       op1-unused       op2-future       access           close
0         0%     0         0%     0         0%     91        3%     0         0%
commit           create           delegpurge       delegreturn      getattr
1         0%     2         0%     0         0%     11        0%     450      19%
getfh            link             lock             lockt            locku
96        4%     0         0%     0         0%     0         0%     0         0%
lookup           lookup_root      nverify          open             openattr
86        3%     0         0%     0         0%     12        0%     0         0%
...
read             readdir          readlink         remove           rename
1         0%     13        0%     0         0%     0         0%     0         0%
...
setcltid         setcltidconf     verify           write            rellockowner
0         0%     0         0%     0         0%     130       5%     0         0%
bc_ctl           bind_conn        exchange_id      create_ses       destroy_ses
0         0%     0         0%     28        1%     14        0%     13        0%
...
layoutget        layoutreturn     secinfononam     sequence         set_ssv
0         0%     0         0%     14        0%     660      28%     0         0%
...

Нет раздела Server nfs v3 — третья версия выключена. sequence открывает почти каждый составной запрос — признак сессий NFSv4.1 и новее; exchange_id и create_ses — установление клиента и сессии при каждом монтировании, а setcltid (так это делала 4.0) — ноль. badauth и badclnt — запросы с отвергнутым удостоверением и от неизвестных клиентов; ненулевые значения стоит объяснить. У нас 12 badauth — это две попытки монтирования с самого сервера, адреса которого нет в экспорте (каждая — 6 отказов).

Таблица экспортов ядра, ещё раз:

user@nfs:~$ sudo exportfs -v
/srv/nfs/k8s  	192.168.10.17(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)
/srv/nfs/k8s  	192.168.10.20(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)
/srv/nfs/k8s  	192.168.10.21(sync,wdelay,hide,no_subtree_check,mountpoint,sec=sys,rw,secure,no_root_squash,no_all_squash)

Эти четыре проверки — exportfs -v, ss -tn sport = :2049, тестовый под и nfsstat -s — стоит делать после любого изменения на сервере и время от времени без повода.


Источники


Дальше — csi-driver-nfs: как он устроен и ставится, StorageClass с subDir, onDelete и правильными mountOptions, права томов через fsGroup, статические PV и что происходит с подами, когда NFS-сервер недоступен.

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

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