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 дважды.
- Контроллер создаёт и удаляет каталоги томов. На каждый новый PVC его контейнер временно монтирует корень экспорта и делает
mkdirдля подкаталога тома, а при удалении —rm -rf. Он работает от root, в сети узла (hostNetwork: true) и сSYS_ADMIN, нужным дляmount. Приroot_squashэти операции выполнятся отnobodyи не пройдут, если корень экспорта принадлежит root, а при удалении не смогут стереть файлы, которые приложения создали под своими UID. - kubelet выставляет права под
fsGroup. У драйвераfsGroupPolicy: File, и при монтировании тома в под сsecurityContext.fsGroup: 2000kubelet от 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 на всех интерфейсах |
лишние службы с динамическими портами, справочная служба открыта всем, отдельные протоколы блокировок |
| нет сетевой политики | любой под кластера обращается к серверу с адресом узла |
План перехода без простоя
Порядок выбран так, чтобы каждый шаг можно было проверить и откатить отдельно, а кластер продолжал работать.
- Инвентаризация. Кто подключён и по какой версии:
ss -tn sport = :2049(адреса клиентов),ls /proc/fs/nfsd/clients/и файлыinfoв нём (клиенты NFSv4),nfsstat -s(есть ли ненулевые счётчикиServer nfs v3). Всё, что не узел кластера, — выяснить, чьё это и нужно ли оно. - Файрвол. Правила для SSH администраторов и 2049 от узлов (и от остальных найденных легальных клиентов, если они есть), затем
ufw enable. Проверка: тома в кластере работают,ss -tnпоказывает те же соединения узлов. - Сетевая политика Calico из п. 5. Проверка: тестовый под до сервера не достаёт, поды с томами работают — их трафик к серверу идёт от kubelet.
/etc/exports:*заменить адресами узлов, убратьinsecure,exportfs -ra. Подключённые узлы ничего не заметят. Проверка:exportfs -v, монтирование с узла.- Права корня экспорта:
chmod 0755 /data. Каталоги томов внутри сохраняют свои права. Проверка: создание нового PVC проходит (контроллер работает от root). - Клиенты на NFSv4.2. В
mountOptionsStorageClass и существующих PV —nfsvers=4.2(как — в следующей части). Новые опции применяются только при новом монтировании, поэтому узлы по одному выводятся из работы (kubectl drain) и возвращаются. Проверка: на узлахmount | grep nfsпоказываетvers=4.2, на сервере счётчики v3 перестают расти. - Выключить v3,
rpcbind,statd, сетевые портыmountd(п. 2). Проверка:/proc/fs/nfsd/versions,ss -tulnp. - Отдельный диск. Единственный шаг, которому нужно короткое окно: при переносе данных на другую файловую систему меняются файловые дескрипторы 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 — стоит делать после любого изменения на сервере и время от времени без повода.
Источники
- RFC 5531: RPC: Remote Procedure Call Protocol Specification Version 2 — ONC RPC, формат вызова и удостоверение AUTH_SYS с его 16 группами.
- Протокол NFS: RFC 1813 (NFSv3 и программа MOUNT), RFC 7530 (NFSv4.0: псевдодерево, аренда, делегирования), RFC 8881 (NFSv4.1: сессии, pNFS), RFC 7862 (NFSv4.2:
COPY, разреженные файлы, метки), RFC 8276 — расширенные атрибуты. - Безопасность RPC: RFC 2203 (RPCSEC_GSS), RFC 4120 (Kerberos 5), RFC 9289 — RPC поверх TLS и проверка
AUTH_TLS. - Страницы man nfs-utils: exports(5) — опции экспорта (
secure,root_squash,mp,sec,xprtsec), exportfs(8) —-ra,-v, nfs.conf(5) — секции[nfsd]и[mountd], rpc.mountd(8) —--no-tcp,--no-udp,--manage-gids, rpc.nfsd(8) и nfsdctl(8) — запуск сервера и число потоков, nfs(5) — опции монтирования (vers,hard,sec,xprtsec), nfsstat(8), nfsidmap(8) — отображение имён. - Ubuntu Server: Network File System (NFS) — установка
nfs-kernel-serverи/etc/nfs.conf.d/. - Ubuntu Server: Firewall и ufw(8) — правила, политики по умолчанию, журналирование.
- xfs_quota(8) — проектные квоты XFS.
- NFS поверх TLS в Linux: ktls-utils, tlshd(8) и tlshd.conf(5) — служба рукопожатия и её настройки, In-Kernel TLS Handshake и Kernel TLS — как ядро передаёт рукопожатие и шифрует само.
- Calico: Tiered policy и Tier — уровни, порядок,
defaultAction: Pass, GlobalNetworkPolicy — глобальные политики иnamespaceSelector, Configure outgoing NAT —natOutgoing, Protect Kubernetes nodes — автоматические host endpoints. - containerd: CRI Plugin Config Guide —
enable_unprivileged_portsиnet.ipv4.ip_unprivileged_port_start=0по умолчанию в containerd 2. - Kubernetes: Volumes: nfs — встроенный тип тома, Configure a Security Context —
runAsUser,fsGroup, capabilities, Using sysctls in a Kubernetes Cluster — безопасные sysctl, включаяip_unprivileged_port_start, Pod Security Standards — профилиbaselineиrestricted. - csi-driver-nfs: репозиторий, параметры драйвера —
mountPermissions, требованияfsGroupк экспорту, манифест контроллера —hostNetwork,SYS_ADMIN, tolerations.
Дальше — csi-driver-nfs: как он устроен и ставится, StorageClass с subDir, onDelete и правильными mountOptions, права томов через fsGroup, статические PV и что происходит с подами, когда NFS-сервер недоступен.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.