В каждом кластере, который мне доставался, RBAC был настроен, и на этом всё заканчивалось. Service accounts ограничены, роли пересмотрены, биндинги аккуратные. И привилегированный контейнер под root в проде, созданный полностью по правилам.
Это не провал RBAC. RBAC ответил ровно на тот вопрос, который ему задали: может ли эта identity создать Pod в этом namespace. Может. Что было внутри Pod, в вопрос не входило.
Разрыв между “кому можно действовать” и “что вообще может существовать” закрывает admission control, и именно эту часть большинство команд пропускает.
Что такое Kyverno
Policy engine, который работает как admission webhook Kubernetes. API server вызывает его, пока запрос ещё в полёте, и Kyverno может провалидировать объект, изменить его или сгенерировать по нему сопутствующие ресурсы.
Меня в своё время зацепило то, что политики пишутся обычным Kubernetes YAML. Не надо учить отдельный язык, а это важно, когда политику поддерживает тот же человек, которого поднимут в три ночи. Это и был главный аргумент за Kyverno против OPA Gatekeeper, где вдобавок нужно осваивать Rego.
Что стоит знать до того, как вкладываться: от этого преимущества постепенно отказываются. Начиная с 1.19 API ClusterPolicy, который используется ниже, помечен как deprecated в пользу политик на CEL. Подробнее в конце, потому что предупреждение ты увидишь при первом же применении.
Что видит каждый слой
Запрос не попадает в кластер напрямую. Он проходит цепочку, и каждая стадия видит только своё:
Authentication спрашивает, кто ты. RBAC спрашивает, может ли эта identity выполнить такой verb над таким ресурсом в таком namespace. Ни один из них не открывает объект. Это не особенность Kyverno, это устройство самого API server.
После authorization запрос попадает в admission, а admission это не что-то одно. Внутри два слоя с очень разными возможностями:
Первой идёт mutating фаза: built-in mutators и mutating webhooks могут переписать объект. Ты уже на это опираешься, просто не думая об этом. ServiceAccount подставляет аккаунт и его токен в каждый Pod, который об этом не просил, а LimitRanger дописывает дефолтные requests и limits, которые ты не указал. К моменту, когда твой Pod кто-то валидирует, это уже не тот Pod, который ты отправил. Затем API server сверяет результат со схемой. Затем validating фаза решает, принимать его или нет.
Оба типа проверяющих живут в этой validating фазе бок о бок, и разница между ними в том, что они знают:
Built-in plugins идут в комплекте с Kubernetes и уже работают. Здесь важен PodSecurity, включённый по умолчанию с 1.25. Он читает spec целиком, но знает ровно один набор правил: Pod Security Standards. Ты выбираешь уровень на namespace, и на этом весь его словарь заканчивается.
Admission webhooks это внешние сервисы, к которым API server ходит сам. Они видят тот же полный объект и отвечают по правилам, которые написал ты. Kyverno регистрируется в обеих фазах: mutating webhooks переписывают, validating принимают или отклоняют.
То есть это не лесенка из трёх отдельных стадий. Это один шаг authorization, который не видит тело, и следом одна стадия admission, где голосуют и фиксированный встроенный стандарт, и твои собственные правила.
Так что “у нас есть RBAC” не является ответом на вопрос “как вы останавливаете привилегированные контейнеры”. Но и “поставь Kyverno” им не является, пока ты не знаешь, что встроенный слой уже закрывает бесплатно. В разборе ниже мы пройдём все три по порядку.
Внутри mutating фазы важен порядок
Схема выше рисует mutate одной точкой. Это не точка. Это очередь, и именно из очереди растёт всё непонятное поведение.
Три следствия, которые стоит знать до того, как выкатывать mutating политику.
Webhook работает не в одиночестве. Mutating webhooks вызываются по одному, поэтому тот, кто отработал последним, видит объект, уже переписанный предыдущими. Если твоя политика утверждает что-то про поле, которое подставляет другой контроллер, ответ зависит от того, кто пошёл первым.
Порядок определяется именем конфигурации, и это не контракт. Конфигурации обрабатываются в порядке имён, а webhooks внутри одной в порядке перечисления. Опираться на это значит опираться на деталь реализации, поэтому пиши правила, которые верны при любом порядке.
Reinvocation это ответ на вопрос, почему webhook отработал дважды. Дефолтный reinvocationPolicy: Never вызывает каждый webhook один раз. При IfNeeded webhook вызывается как минимум ещё раз, если после его первого вызова объект изменил другой admission plugin. Границу тут надо читать внимательно: API server не обещает, что дополнительный вызов случится ровно один, и вправе переупорядочить webhooks, чтобы сократить их число. Поэтому правило звучит не как «обработай второй проход», а как «будь идемпотентным», ровно этого документация поля и требует. А если нужно судить об объекте, когда все мутации уже улеглись, это работа validating webhook, а не mutating.
Чего здесь намеренно нет, так это failurePolicy, потому что это не место в цепочке, а поведение в момент, когда webhook вообще не ответил. Про него отдельная заметка ниже.
Проверь сам
Всё, что ниже, запускается на ноутбуке. Без облачного аккаунта и без затрат. Если ты никогда не трогал Kubernetes, это вполне нормальный первый час с ним.
Если нужно просто узнать, что admission скажет про уже готовый манифест, инструмент аудита прогоняет те же два профиля прямо в браузере, без всякого кластера. Мануал ниже это версия, которая доказывает это по-настоящему.
Нужен запущенный Docker, плюс kind и kubectl. На macOS:
brew install kind kubernetes-cli
1. Одноразовый кластер
kind create cluster --name policy-demo
kubectl cluster-info --context kind-policy-demo
kind поднимает полноценный кластер Kubernetes внутри Docker контейнеров. Он живёт, пока ты его не удалишь, и не может задеть ничего настоящего.
2. Убедиться, что дыра есть
До установки чего бы то ни было создай контейнер, которого быть не должно:
kubectl run rooty --image=nginx:latest \
--overrides='{"spec":{"containers":[{"name":"rooty","image":"nginx:latest","securityContext":{"privileged":true,"runAsUser":0}}]}}'
pod/rooty created
Создан. Привилегированный, под UID 0, на tag latest. Ты здесь cluster-admin, поэтому у RBAC не было повода возражать, а больше никто и не смотрел.
Прибрать за собой:
kubectl delete pod rooty
3. Закрыть простую половину, ничего не устанавливая
В Kubernetes уже есть admission plugin ровно под это. Он выключен в том смысле, что ни один namespace его не включил, а не в том, что его нет. Включается одним label:
kubectl create namespace demo
kubectl label namespace demo pod-security.kubernetes.io/enforce=restricted
Теперь попробуй тот же под в этом namespace:
kubectl run rooty -n demo --image=nginx:latest \
--overrides='{"spec":{"containers":[{"name":"rooty","image":"nginx:latest","securityContext":{"privileged":true,"runAsUser":0}}]}}'
Error from server (Forbidden): pods "rooty" is forbidden:
violates PodSecurity "restricted:latest": privileged (container "rooty" must not set
securityContext.privileged=true), allowPrivilegeEscalation != false, unrestricted
capabilities, runAsNonRoot != true, runAsUser=0, seccompProfile
Заблокирован, со списком всех нарушенных стандартов. Ни установки, ни webhook, ни лишнего компонента, который надо поддерживать живым. Если твоё требование звучит как “соблюдать Pod Security Standards”, то на этом всё, policy engine тебе не нужен.
Это стоит сказать прямо, потому что куча туториалов по Kyverno берёт ровно этот пример и ни словом не упоминает, что Kubernetes решает его сам.
4. Найти границу встроенного слоя
PodSecurity знает один словарь. Всё, что за пределами Pod Security Standards, для него невидимо. Image tags, например.
Собери под, который полностью удовлетворяет restricted, и прибей его к latest:
kubectl run webapp -n demo --image=nginx:latest \
--overrides='{"spec":{"containers":[{"name":"webapp","image":"nginx:latest","securityContext":{"runAsNonRoot":true,"runAsUser":1000,"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"seccompProfile":{"type":"RuntimeDefault"}}}]}}'
pod/webapp created
Принят. Security context безупречный, а image это движущаяся мишень, которая на следующем pull молча станет другой сборкой. У PodSecurity нет мнения на этот счёт, потому что tags не входят в стандарт, который он применяет.
Вот это и есть настоящий аргумент за policy engine: не “RBAC недостаточно”, а “встроенный стандарт не покрывает моё правило”.
kubectl delete pod webapp -n demo
5. Установить Kyverno
kubectl create -f https://github.com/kyverno/kyverno/releases/download/v1.19.0/install.yaml
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/component=admission-controller -n kyverno --timeout=180s
Проверь, что webhook реально зарегистрировался. Именно эта часть делает работу:
kubectl get validatingwebhookconfiguration | grep kyverno
kyverno-cel-exception-validating-webhook-cfg
kyverno-cleanup-validating-webhook-cfg
kyverno-exception-validating-webhook-cfg
kyverno-global-context-validating-webhook-cfg
kyverno-policy-validating-webhook-cfg
kyverno-resource-validating-webhook-cfg
kyverno-ttl-validating-webhook-cfg
Семь конфигураций, и работу здесь делает kyverno-resource-validating-webhook-cfg. Если этой строки нет, Kyverno установлен, но не подключён, и любая написанная политика будет молча игнорироваться.
Admission controller долго переходит в ready, потому что при первом старте генерирует сертификаты и регистрирует эти webhooks. У меня на свежем kind кластере ему понадобилось около пяти минут, и первый kubectl wait отвалился по таймауту раньше, чем всё устаканилось. Это нормально, просто запусти wait ещё раз.
6. Политика, сначала в режиме аудита
Правило, которое PodSecurity выразить не мог: никаких tags latest.
cat <<'EOF' | kubectl apply -f -
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-latest-tag
spec:
validationFailureAction: Audit
background: true
rules:
- name: require-explicit-tag
match:
any:
- resources:
kinds: [Pod]
exclude:
any:
- resources:
namespaces: [kube-system, kyverno, local-path-storage]
validate:
message: "Images must use an explicit tag, not latest."
pattern:
spec:
containers:
- image: "!*:latest"
EOF
При применении перед подтверждением печатается предупреждение:
Warning: ClusterPolicy (kyverno.io) is deprecated and will be removed in a future
release; migrate to ValidatingPolicy, MutatingPolicy, GeneratingPolicy or
ImageValidatingPolicy (policies.kyverno.io)
clusterpolicy.kyverno.io/disallow-latest-tag created
Пока не обращай внимания, политика работает. К этому вернёмся в конце.
Две детали в манифесте важнее самого правила.
validationFailureAction: Audit сообщает о нарушениях, ничего не блокируя. Включить новую политику сразу в enforce на живом кластере это способ узнать, какой системный компонент ты только что сломал, во время инцидента, в самый неподходящий момент.
Блок exclude выводит системные namespace из области действия. Без него политика может заблокировать компоненты, которые держат кластер живым, включая те, что нужны, чтобы эту политику убрать. local-path-storage в списке потому, что это storage provisioner самого kind, и он крутит image, который правило пометило бы.
Создай тот же под, что и раньше, и вместо ошибки читай report:
kubectl run webapp -n demo --image=nginx:latest \
--overrides='{"spec":{"containers":[{"name":"webapp","image":"nginx:latest","securityContext":{"runAsNonRoot":true,"runAsUser":1000,"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"seccompProfile":{"type":"RuntimeDefault"}}}]}}'
kubectl get policyreports -n demo
NAME KIND NAME PASS FAIL WARN ERROR SKIP AGE
3c01d18a-99a2-4435-acdb-b493be964f16 Pod webapp 0 1 0 0 0 0s
Одно нарушение, ничего не заблокировано. Reports живут по namespace, так что сужай запрос, а не читай все системные namespace разом. В таком состоянии политика и должна пожить несколько дней на настоящем кластере.
7. Переключить в enforce
kubectl patch clusterpolicy disallow-latest-tag \
--type merge -p '{"spec":{"validationFailureAction":"Enforce"}}'
kubectl delete pod webapp -n demo
kubectl run webapp -n demo --image=nginx:latest \
--overrides='{"spec":{"containers":[{"name":"webapp","image":"nginx:latest","securityContext":{"runAsNonRoot":true,"runAsUser":1000,"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"seccompProfile":{"type":"RuntimeDefault"}}}]}}'
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/demo/webapp was blocked due to the following policies
disallow-latest-tag:
require-explicit-tag: 'validation error: Images must use an explicit tag, not latest.
rule require-explicit-tag failed at path /spec/containers/0/image/'
Запрос не дошёл до etcd. Тот же namespace, тот же security context, которым PodSecurity был полностью доволен. Изменилось только то, что написанное тобой правило наконец получило право посмотреть.
8. Снести
kind delete cluster --name policy-demo
Что это даёт
Правило, которое жило на странице в вики, теперь живёт в кластере. Разница видна в том, как оно ломается: задокументированное правило молча падает через месяцы, когда кто-то, никогда не читавший вики, выкатывает манифест, а enforced правило падает сразу, в pull request, с сообщением, где написано, что чинить.
Это меняет и то, что ты можешь сказать на аудите. “Мы просим команды пинить image tags” и “кластер отклоняет непинованные images, вот политика и вот история reports” это разные утверждения.
Слоистость здесь важна не меньше инструмента. PodSecurity стоит один label и ноль движущихся частей, поэтому он должен тянуть всё, что может. Policy engine это второй компонент, который надо запускать, обновлять и по которому кого-то будут будить, и он оправдывает себя только на тех правилах, которые встроенный слой выразить не может.
На что смотреть в оба
Enforce это изменение в проде. Сначала аудит, читаешь reports, потом переключаешь. Относись к самому переключению как к деплою, а не как к правке конфига.
Исключай системные namespace с первого дня. Политика, которая блокирует kube-system, может отрезать тебя от возможности её починить. Исключение выше это минимум.
Разберись с failurePolicy. При дефолтном Fail недоступный Kyverno означает, что API server отклоняет всё, под что политика матчится. Держи хотя бы две реплики и ставь Ignore для правил, где падение политики хуже пропущенной проверки.
Mutation конфликтует с GitOps. Если Kyverno добавляет поле, а Argo CD сравнивает с git, приложение будет болтаться между synced и out of sync. Опиши эти пути в ignoreDifferences до того, как выкатишь mutating политику.
Не трать policy engine на то, что уже делает label. Если правило внутри Pod Security Standards, PodSecurity применит его без установки и без поддержки. За Kyverno тянись, когда правило выпадает из этого словаря, как правило про tags выше.
Зарегистрированный webhook это ещё не работающая политика. Webhook может быть на месте, пока политика не матчится ни на что: из-за селектора по namespace, опечатки в kinds или паттерна, который валиден как YAML и неверен по сути. Единственное доказательство это негативный тест: попробовать создать то, что должно быть отклонено.
API под ногами двигается
ClusterPolicy выше работает, и именно его до сих пор показывают почти все туториалы и ответы на Stack Overflow. Он же и на выходе. Kyverno 1.19 приносит второй policy API в группе policies.kyverno.io с ValidatingPolicy, MutatingPolicy, GeneratingPolicy и ImageValidatingPolicy, и правила там пишутся на CEL, а не YAML паттернами.
То же правило в новом API:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: disallow-privileged
spec:
validationActions: [Deny]
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
matchConditions:
- name: skip-system-namespaces
expression: "!(request.namespace in ['kube-system', 'kyverno'])"
validations:
- expression: >-
!object.spec.containers.exists(c,
has(c.securityContext) && has(c.securityContext.privileged) && c.securityContext.privileged)
message: "Privileged containers are not allowed."
Тот же под он блокирует:
Error from server: admission webhook
"vpol.validate.kyverno.svc-fail-finegrained-disallow-privileged" denied the request:
Policy disallow-privileged failed: Privileged containers are not allowed.
Здесь два момента. Системные namespace исключаются уже не блоком exclude, а CEL matchCondition, и я потерял на этом двадцать минут: в первой попытке я оставил старую форму excludeResourceRules, она исключила ровно то, что матчила, и получилась политика, которая молча не проверяла ничего. Выдал это kubectl get vpol <name> -o jsonpath='{.status}' со статусом generated: false.
Второй момент важнее. !object.spec.containers.exists(c, has(c.securityContext) && ...) это язык выражений. Аргумент про то, что Kyverno избавляет тебя от изучения такого языка, теперь стоит на таймере deprecation.
Если ты выбираешь policy engine сегодня, выбирай его по CEL API, а не по YAML паттернам, потому что поддерживать ты будешь именно его.
Итог
RBAC и admission control отвечают на разные вопросы, и только один из них открывает манифест. Если в твоей модели угроз есть то, что запускается, а не только то, кто деплоит, RBAC в одиночку оставляет интересную половину без охраны.
Какой бы движок ты ни выбрал, важное правило скучное: держи его в аудите, пока reports не станут тихими, и только потом разреши ему говорить нет.
Проверено от и до на Kyverno 1.19.0, Kubernetes 1.37.0 через kind 0.33.0, сентябрь 2026. Каждая команда и каждый вывод выше реально запускались, а не реконструировались.