Pod долго стартует с большим PVC: когда поможет fsGroupChangePolicy: OnRootMismatch
Ко мне периодически приходят с одним и тем же на первый взгляд странным симптомом: образ контейнера скачан за секунды, PersistentVolumeClaim в статусе Bound, узел свободен по CPU и памяти, а Pod минуты (а на некоторых томах — десятки минут) сидит в ContainerCreating. Разбирая такие кейсы у наших клиентов (юрлица до 50 рабочих мест, где кластеры Kubernetes чаще всего обслуживают внутренние сервисы и системы аналитики), я всегда прохожу один и тот же маршрут: fsGroup в securityContext Pod'а, политика fsGroupChangePolicy, поле fsGroupPolicy у CSIDriver и то, как это всё стыкуется с типом тома. В этой статье — наша методология разбора таких случаев на Kubernetes 1.36: когда OnRootMismatch реально решает проблему, а когда это лечение симптома вместо причины.
Симптом: том доступен, а время старта растёт вместе с числом файлов в нём
Смотрю на конкретный признак, который отличает эту проблему от десятка других причин долгого ContainerCreating: время запуска Pod'а линейно растёт с количеством файлов и каталогов на PVC, а не с объёмом данных в байтах. Если у вас на томе 200 файлов по 5 ГБ каждый, Pod стартует быстро. Если на томе того же объёма 20 миллионов файлов по несколько килобайт (типичная картина для очередей на диске, кэшей рендеринга, артефактов сборки или баз данных, разложенных по большому числу мелких объектов), старт может растягиваться на минуты и десятки минут.
Причина в том, что при каждом монтировании тома с заданным fsGroup kubelet по умолчанию рекурсивно проходит по всему дереву файлов и каталогов тома и выставляет владельца группы и биты прав (chown и chmod на каждый inode). Это последовательная операция на уровне файловой системы узла, она не параллелится автоматически и не имеет отношения к сети, диску NVMe или мощности CPU — она ограничена количеством системных вызовов, а не пропускной способностью.
Для крупного PVC с большим числом мелких файлов это может быть основным вкладом в время ContainerCreating — не подготовка образа, не PVC-provisioning, не сетевые задержки, а именно рекурсивная смена владельца тома, которую kubelet выполняет на каждом (или почти каждом) старте контейнера.
Что именно делает kubelet с правами тома при монтировании
Когда в securityContext Pod'а указан fsGroup, kubelet при подготовке тома (этап MountVolume.SetUp в его собственной терминологии operationExecutor) должен обеспечить, что процессы контейнера, работающие с этим GID через supplementalGroups, смогут читать и писать файлы на томе, даже если UID контейнера не совпадает с владельцем файлов. Механически это делается рекурсивным изменением группы-владельца и, при необходимости, битов прав группы на корне тома и на всём его содержимом.
До Kubernetes 1.20 (когда поле fsGroupChangePolicy вошло в API как бета, а затем получило статус stable) поведение было единственным и жёстким: при каждом монтировании тома kubelet безусловно проходил по всему дереву и переустанавливал права — независимо от того, были ли они уже корректны. Для тома с уже правильными правами (например, Pod просто перезапустился на том же узле после сбоя) это была чистая трата времени: тысячи или миллионы одинаковых chown, результат которых не менялся.
Начиная с 1.20 в securityContext Pod'а появилось поле fsGroupChangePolicy с двумя значениями:
- Always — поведение по умолчанию (если поле не указано явно): kubelet всегда выполняет рекурсивную смену владельца и прав при монтировании, независимо от текущего состояния тома.
- OnRootMismatch — kubelet сначала проверяет владельца и биты прав только КОРНЕВОГО каталога тома. Если они уже соответствуют ожидаемым (группа-владелец равна
fsGroup, права группы дают нужный доступ), рекурсивная операция по всему дереву пропускается целиком. Если корень не совпадает — выполняется полный рекурсивный проход, как при Always.
Ключевое для инженерного решения: проверка при OnRootMismatch — это одна операция stat на корневом каталоге тома, а не проход по дереву. Именно поэтому политика способна убрать многоминутную рекурсию на повторных монтированиях, но она принципиально не помогает на первом монтировании нового PVC — корень тома в этот момент почти всегда не соответствует ожидаемому fsGroup, проверка не проходит, и kubelet всё равно выполняет полный рекурсивный проход один раз.
Always vs OnRootMismatch: когда какая политика реально работает
Прежде чем менять политику в проде, я всегда прогоняю сценарий клиента по этой таблице — она снимает большинство ложных ожиданий вида «поставили OnRootMismatch, а старт всё равно долгий».
| Сценарий | Always | OnRootMismatch |
|---|---|---|
| Первый запуск Pod'а на новом PVC (том только создан провизионером) | Полный рекурсивный chown/chmod — время растёт с числом файлов (для нового тома их обычно 0, поэтому быстро) | Проверка корня не проходит (корень принадлежит provisioner'у/root, не ожидаемому fsGroup) → выполняется тот же полный проход, выигрыша нет |
| Повторный старт Pod'а на том же PVC с уже корректными правами (реплика StatefulSet, рестарт после OOM, rollout нового образа) | Полный рекурсивный chown/chmod при каждом старте — основная боль на больших томах | Проверка корня проходит за одну операцию → рекурсия пропускается, старт ускоряется |
| Том мигрировал между узлами/зонами (CSI-миграция, восстановление из снапшота на новый PV) | Полный проход всегда | Зависит от того, сохранил ли снапшот/миграция владельца корня — часто не сохраняет, тогда снова полный проход один раз |
| Ручное изменение fsGroup в манифесте на уже работающем сервисе | Полный проход, права перекладываются на новый GID | Корень не совпадает с новым fsGroup → один полный проход, дальше — быстрые старты |
Вывод, который я формулирую клиентам без прикрас: OnRootMismatch не ускоряет холодный старт нового тома. Он убирает повторяющуюся стоимость на каждом следующем перезапуске того же Pod'а или его реплик. Если у вас проблема именно с первым разворачиванием — нужен отдельный приём (initContainer или разовая подготовка тома), о котором ниже.
CSIDriver.spec.fsGroupPolicy — переключатель, который стоит выше fsGroupChangePolicy
Здесь чаще всего и находится причина, почему «мы поставили OnRootMismatch, а не помогло»: fsGroupChangePolicy — это пожелание Pod'а, но решает, применять ли fsGroup к тому вообще, поле fsGroupPolicy в объекте CSIDriver, которое публикует сам CSI-драйвер тома. Если драйвер сказал «не умею» или «не для этого случая» — kubelet не тронет права тома независимо от того, что написано в securityContext Pod'а.
| fsGroupPolicy в CSIDriver | Поведение kubelet | Где встречается |
|---|---|---|
| ReadWriteOnceWithFSType (значение по умолчанию, если поле CSIDriver не задано) | Права тома меняются только если у PersistentVolume задан fsType и accessModes содержит ReadWriteOnce. Для типового блочного тома с ФС ext4/xfs, смонтированного в один Pod — условие выполняется, fsGroupChangePolicy работает штатно. | Большинство блочных CSI-драйверов облачных провайдеров и локальных дисков |
| File | Драйвер поддерживает управление владением и правами через fsGroup независимо от fsType и accessMode — kubelet применяет fsGroupChangePolicy как обычно, в том числе на некоторых сетевых томах, если драйвер явно заявил такую поддержку. | Отдельные CSI-драйверы NFS/файловых шар с явной поддержкой (нужно проверять README конкретного драйвера — поведение не универсальное для всех NFS-реализаций) |
| None | kubelet не выполняет вообще никаких изменений владения/прав тома. fsGroupChangePolicy в манифесте Pod'а в этом случае существует, но не имеет эффекта — проверять её смысла нет, нужно искать другой механизм назначения прав. | Многие реализации многопользовательских (multi-writer) сетевых томов — общий доступ нескольких Pod'ов к одному тому плохо сочетается с идеей «владелец меняется под конкретный fsGroup» |
Практическая команда, с которой я начинаю разбор у клиента: узнать, каким CSI-драйвером обслуживается StorageClass, и посмотреть его CSIDriver.
kubectl get storageclass <имя-класса> -o jsonpath='{.provisioner}'
kubectl get csidriver <имя-провижнера> -o jsonpath='{.spec.fsGroupPolicy}'
Если вывод пустой — действует значение по умолчанию ReadWriteOnceWithFSType, и дальше важно accessMode PVC и fsType PV. Если вывод None — прекращаю разбор fsGroupChangePolicy сразу: правку нужно искать не в securityContext Pod'а.
volumeMode Block vs Filesystem и особый случай сетевых томов
Отдельно разбираю volumeMode тома, потому что здесь логика меняется полностью. Для volumeMode: Block (сырое блочное устройство без файловой системы, отдаётся контейнеру как /dev/...) понятия «рекурсивно обойти файлы» просто не существует — там нет дерева каталогов до тех пор, пока сам процесс в контейнере не создаст на устройстве файловую систему. Для блочных томов kubelet выставляет права на сам файл устройства и добавляет GID из fsGroup в supplementalGroups процесса — операция мгновенная, ContainerCreating из-за прав здесь не растягивается вне зависимости от fsGroupChangePolicy. Если у вас долгий старт при volumeMode: Block, причина не в fsGroup — ищите её в другом месте (инициализация ФС внутри контейнера, attach/detach на уровне облачного диска).
Для volumeMode: Filesystem (обычный случай, PV примонтирован как каталог) всё сказанное выше про рекурсивный chown применимо полностью — и именно тут решает связка fsGroupChangePolicy + fsGroupPolicy CSIDriver'а.
Сетевые файловые системы (NFS и аналогичные многопользовательские тома) — отдельная история, и я предупреждаю клиентов на старте, чтобы не тратить время на попытки её обойти политиками securityContext. У многих реализаций NFS-протокол и модель прав POSIX плохо ложатся на модель «каждый Pod предъявляет fsGroup, kubelet владение подгоняет»: сервер экспорта сам управляет squash-правилами (root_squash, all_squash), может игнорировать попытки chown с клиента, а рекурсивный обход дерева по сети — на порядок медленнее локального, потому что каждый chown — это отдельный сетевой round-trip. По этой причине многие CSI-драйверы для сетевых ФС по умолчанию объявляют fsGroupPolicy: None и права нужно либо готовить на стороне сервера экспорта, либо назначать через supplementalGroups без chown, либо чётко проверять в документации конкретного драйвера, поддерживает ли он File — и если да, закладывать время на рекурсивный проход по сети отдельно, OnRootMismatch тут спасает ещё меньше, чем на локальном диске, потому что даже проверка корня — это сетевой вызов.
Диагностика: как отличить эту причину от десятка похожих
Прежде чем что-то менять в манифесте, я фиксирую факты, а не гипотезы. Порядок действий, который у нас стал стандартным:
- kubectl describe pod — смотрю раздел Events целиком. Если том привязан и примонтирован нормально, там будут штатные записи об успешном attach/mount тома, а Pod всё равно висит в ContainerCreating без warning-событий — это сигнал, что задержка внутри самого шага SetUp/mount, а не в attach на уровне облака или CSI-контроллера. Если видите warning-событие вида «Unable to attach or mount volumes» с таймаутом — это другая причина (проблема на уровне attach, а не прав).
- Время в фазе ContainerCreating — беру timestamp события создания Pod'а и timestamp перехода в Running, вычитаю время старта самого приложения (если оно известно из логов контейнера). Остаток — это накладные расходы kubelet на подготовку тома.
- Логи kubelet на узле —
journalctl -u kubeletс фильтром по имени тома или Pod'а; ищу записи о смене владельца/прав тома и время между их началом и концом. - Метрика kubelet storage_operation_duration_seconds — гистограмма в
/metricskubelet'а с меткойoperation_name. У нас это уже собирается Zabbix/Prometheus-агентами на клиентских кластерах; смотрю квантиль 0.99 по операции монтирования конкретного тома/StorageClass и сравниваю с ожидаемым порядком величины для типа тома (локальный диск — заметно быстрее, сетевой том — заметно медленнее по природе протокола). Резкий выброс именно на подмонтировании конкретного PVC при нормальных цифрах у остальных — практически всегда означает рекурсивный chown на непривычно большом количестве файлов.
| Наблюдение | Вероятная причина | Что проверить дальше |
|---|---|---|
| Долго только на новых/пересозданных PVC, повтор быстрый | fsGroupChangePolicy: Always на первом монтировании — ожидаемо, дальше должно ускориться | Проверить, что fsGroupChangePolicy выставлен в OnRootMismatch и что повторный старт правда быстрее |
| Долго стабильно на каждом старте, включая повторные | fsGroupPolicy CSIDriver = None/File с сетевым томом, либо OnRootMismatch не подхватился (старая версия поля/API) | kubectl get csidriver, версия kube-apiserver и наличие поля в securityContext через kubectl get pod -o yaml |
| Долго независимо от fsGroup вообще (fsGroup не указан) | Проблема не в правах тома — attach на уровне облака, инициализация приложения, image pull с приватного registry | Events Pod'а, отдельно время pull образа |
Альтернатива и дополнение: initContainer с chown, runAsUser/runAsGroup, supplementalGroups
Когда fsGroupPolicy CSIDriver'а не позволяет kubelet управлять правами (None), либо когда нужно разово подготовить права на новом томе быстрее, чем это сделает полный рекурсивный проход при первом старте основного контейнера, мы используем отдельный initContainer, который выполняет chown точечно — например, только по подкаталогам, которые реально нужны приложению на старте, с параллелизацией через xargs -P, а не сплошной chown -R одним потоком.
initContainers:
- name: fix-volume-ownership
image: busybox:1.36
command: ["sh", "-c"]
args:
- "find /data -maxdepth 1 -mindepth 1 -print0 | xargs -0 -n1 -P8 chown -R 2000:2000"
volumeMounts:
- name: data
mountPath: /data
securityContext:
runAsUser: 0
Дальше основной контейнер идёт с обычным fsGroupChangePolicy: OnRootMismatch — после того как initContainer один раз привёл корень тома в соответствие, проверка корня у kubelet проходит, рекурсия на старте основного контейнера больше не запускается.
Важно не путать три разных механизма прав, которые часто смешивают в одном securityContext:
- runAsUser / runAsGroup — под каким UID/GID реально исполняется процесс контейнера. Не имеет отношения к владению файлами на томе, только к тому, чей это будет процесс с точки зрения ядра.
- fsGroup — GID, которым kubelet владеет (chown -R) файлы тома и который добавляется как дополнительная группа процессу. Это единственное поле, которое запускает рекурсивную операцию и которое регулируется fsGroupChangePolicy.
- supplementalGroups — список дополнительных GID, которые получает процесс контейнера без какого-либо изменения владения файлами на диске. Если файлы уже принадлежат нужной группе (например, их так создал провизионер или предыдущий initContainer), достаточно одних supplementalGroups и рекурсивный chown вообще не требуется — на некоторых сценариях это дешевле, чем городить fsGroupChangePolicy.
По нашей практике на клиентских кластерах: там, где мы стабильно поддерживаем один и тот же UID/GID образа между релизами и не пересоздаём PVC при каждом деплое, сочетание «fsGroupChangePolicy: OnRootMismatch» + разовый initContainer на первое разворачивание тома снимает почти всю задержку старта, связанную с правами, оставляя только естественное время attach/pull.
Pod Security Standards: где restricted-профиль мешает лечению
На части кластеров у клиентов пространства имён размечены под Pod Security Standards с профилем restricted (метка pod-security.kubernetes.io/enforce: restricted на namespace). Этот профиль требует runAsNonRoot: true, запрещённые повышения привилегий и явный seccompProfile — и initContainer из примера выше с runAsUser: 0 под таким профилем не пройдёт admission-проверку, Pod не будет создан вовсе.
В таких пространствах имён мы решаем задачу иначе, без запуска initContainer от root:
- Проверяем, можно ли добиться нужного владения на этапе провизионирования тома — часть CSI-драйверов поддерживает параметры StorageClass, которые задают владельца/права каталога уже на этапе создания PV, до первого монтирования каким-либо Pod'ом.
- Если initContainer всё же нужен, подбираем образ и UID так, чтобы chown выполнялся от непривилегированного пользователя, у которого уже есть достаточные права на каталог (например, потому что группа-владелец каталога выставлена на этапе создания тома равной ожидаемому GID) — тогда restricted-профиль не нарушается, а initContainer выполняет более тонкую операцию (например, выставление битов прав, а не смену владельца).
- Для пространств имён с профилем
baselineили собственным исключением мы держим явное, задокументированное решение владельца namespace — временное послабление до миграции сервиса на схему без root-initContainer, а не тихий обход политики.
Отдельно проверяем совместимость: сам fsGroupChangePolicy и fsGroup в securityContext Pod'а не конфликтуют ни с одним профилем Pod Security Standards — это ортогональные механизмы. Конфликт возникает именно вокруг вспомогательных initContainer'ов, которым для chown исторически давали root.
Наш чек-лист внедрения на клиентских кластерах
Когда клиент приходит с жалобой «Pod долго стартует на большом томе», мы проходим фиксированную последовательность — она экономит часы разбора и не даёт зациклиться на securityContext, если причина в другом:
- Подтверждаем симптом метриками (storage_operation_duration_seconds, время ContainerCreating), а не со слов — иногда «долго стартует» на деле означает долгий image pull или медленный readiness-probe приложения, и fsGroup здесь ни при чём.
- Смотрим, задан ли fsGroup в манифесте Pod'а вообще. Если не задан — рекурсивного chown не происходит, и вся эта методология неприменима, ищем причину в другом месте.
- Определяем StorageClass и её provisioner, смотрим CSIDriver.spec.fsGroupPolicy. Если
None— сразу переходим к initContainer/supplementalGroups, время на fsGroupChangePolicy не тратим. - Если
FileилиReadWriteOnceWithFSTypeи accessMode/fsType подходят — выставляемfsGroupChangePolicy: OnRootMismatchв securityContext Pod'а. - Проверяем volumeMode: для
Blockpolítica не нужна вовсе, разбираем задержку в другом месте. - Замеряем два прогона: первый старт на новом PVC (ожидаем полный проход, время не меняется) и повторный рестарт того же Pod'а (ожидаем резкое ускорение). Если ускорения нет на повторном старте — фиксируем это как аномалию и возвращаемся к диагностике CSIDriver/версии API, а не считаем задачу решённой.
- Для namespace с Pod Security Standards restricted заранее проверяем, что предполагаемый способ первичной подготовки тома (initContainer или параметр StorageClass) проходит admission-контроль.
- Кладём итоговое время ContainerCreating под мониторинг с порогом, специфичным для типа тома (локальный диск — секунды, сетевой том — до минуты по практике), чтобы регресс на будущих релизах driver'а или изменении числа файлов на томе не остался незамеченным.
Этот порядок мы применяем одинаково и на внутренних кластерах ITfresh, и в проектах, где клиент просит разобрать конкретный медленный сервис — он снимает наиболее частую ошибку, которую я вижу в чужих разборах: попытку лечить фиксированным fsGroupChangePolicy проблему, которая на самом деле упирается в fsGroupPolicy драйвера или в volumeMode тома.
Частые вопросы
- Достаточно ли просто добавить fsGroupChangePolicy: OnRootMismatch, чтобы ускорить первый деплой на новом PVC?
- Нет. На первом монтировании нового тома корень практически всегда не соответствует ожидаемому fsGroup, проверка не проходит, и kubelet всё равно выполняет полный рекурсивный chown/chmod один раз. OnRootMismatch убирает повторную стоимость на последующих рестартах того же тома, а не на первом развороте.
- Почему на нашем NFS-томе fsGroupChangePolicy вообще не влияет на время старта?
- Проверьте поле fsGroupPolicy в объекте CSIDriver вашего NFS-драйвера: kubectl get csidriver <имя> -o jsonpath='{.spec.fsGroupPolicy}'. Многие драйверы сетевых ФС объявляют None — в этом случае kubelet не меняет владение и права тома вообще, и настройка в securityContext Pod'а не имеет эффекта.
- Можно ли ускорить первый chown на большом томе, если initContainer от root запретил Pod Security Standards?
- Да, но не через root-initContainer. Мы либо переносим подготовку прав на этап провизионирования тома (параметры StorageClass конкретного CSI-драйвера), либо запускаем более узкую операцию от непривилегированного пользователя, у которого уже достаточно прав благодаря владению, выставленному на этапе создания PV.
- Как отличить в kubectl describe pod, что задержка именно в правах тома, а не в attach диска?
- Смотрим на события Pod'а и время между ними: если события об успешном присоединении и монтировании тома есть и идут быстро, а Pod всё равно долго остаётся в ContainerCreating без дополнительных warning-событий, задержка почти всегда в шаге SetUp — том смонтирован, но kubelet ещё выполняет рекурсивную смену прав. Это подтверждается логами kubelet и метрикой storage_operation_duration_seconds на узле.
- Меняет ли volumeMode: Block ситуацию с fsGroup?
- Да, принципиально. Для блочных томов (volumeMode: Block) файловой системы с деревом каталогов на момент монтирования ещё нет, поэтому рекурсивного обхода не происходит: kubelet добавляет GID из fsGroup в supplementalGroups процесса и меняет права самого файла устройства — это мгновенная операция. Если старт долгий при Block-томе, причина не в fsGroup.