Каждый kubectl apply на Pod с политикой disallow-latest-tag сегодня уходит через сеть в отдельный под Kyverno или Gatekeeper и возвращается обратно — и так на каждый ресурс, каждый раз, пока кластер жив. ValidatingAdmissionPolicy (VAP) убирает этот прыжок: CEL-выражение компилируется и выполняется прямо внутри kube-apiserver, без внешнего webhook. Это не экспериментальная фича — VAP стабилен с Kubernetes 1.30, а Kyverno 1.17 уже депрекейтит классические ClusterPolicy в пользу CEL-нативных ValidatingPolicy. Разбираем, когда VAP полностью заменяет вебхук, а когда без него всё ещё не обойтись.
Содержание
Открыть содержание
Почему это не игнорировать в 2026
VAP прошёл обычный для Kubernetes путь взросления: alpha в 1.26, beta в 1.28, GA в 1.30. То есть на подавляющем большинстве продовых кластеров он уже доступен из коробки, без включения feature gate. Параллельно происходит второе, более важное для практики движение: Kyverno официально сворачивает старую модель политик. С версии 1.17 ClusterPolicy и CleanupPolicy помечены deprecated в пользу CEL-нативных ValidatingPolicy и DeletingPolicy, а удаление запланировано на v1.20 (октябрь 2026, источник). Если вы пишете и поддерживаете политики на Kyverno сегодня, миграция на CEL — это не «можно попробовать в свободное время», а горизонт в пару релизов.
Задержка — не абстрактная метрика для графиков. Каждый вебхук встраивается в критический путь kubectl apply, helm upgrade, реконсиляции контроллера — то есть в путь, по которому CI/CD ждёт ответа, прежде чем считать деплой успешным. Даже если сам под Kyverno отвечает за единицы миллисекунд, к этому добавляются TLS-хендшейк, сериализация объекта в JSON туда и обратно, и риск, что под окажется недоступен именно в момент пиковой нагрузки на кластер.
Разница на диаграмме — не косметика. Webhook-путь требует, чтобы под контроллера был Ready, прошёл TLS-хендшейк и успел ответить в пределах timeoutSeconds — иначе решает failurePolicy (Fail отклонит запрос, Ignore пропустит его непроверенным, что на практике означает дыру в защите ровно тогда, когда она нужнее всего). CEL в апи-сервере ничего этого не знает: правило скомпилировано в байт-код ещё при kubectl apply и выполняется в том же процессе, который уже держит объект в памяти для остальных этапов admission-цепочки.
Модель CEL-выражений: что видит правило
ValidatingAdmissionPolicy даёт CEL-выражению четыре именованные переменные — от них зависит, что вообще можно написать в expression, и это стоит понимать до того, как переносить первую реальную политику.
object— входящий ресурс целиком, тот же JSON, что был бы записан в etcd. Основная переменная почти для всех validation-правил: лимиты ресурсов, обязательные labels, запрещённые поля.oldObject— предыдущая версия ресурса приUPDATE;nullприCREATE. Нужна для правил вида «нельзя менятьspec.storageClassNameпосле создания PVC» или «нельзя понижатьreplicasбольше чем на 50% за один апдейт» — без неё такое сравнение просто невозможно выразить.params— опциональный объект-параметр, на который ссылаетсяparamRefвValidatingAdmissionPolicyBinding. Позволяет держать одинValidatingAdmissionPolicyи разные лимиты для разных namespace через разные CR с параметрами — прямой аналогConstraintиз Gatekeeper, только без второго языка.variables— именованные под-выражения вspec.variables, вычисляются один раз и переиспользуются в несколькихvalidations. Полезно, когда одно и то же тяжёлое выражение — например, фильтрация списка контейнеров по условию — нужно сразу в трёх правилах: безvariablesCEL пересчитывал бы его трижды на каждый admission-запрос.
Область действия правила задаётся отдельно от логики — в matchConstraints.resourceRules (какие apiGroups, resources, operations) и опционально в objectSelector/namespaceSelector (по labels). Это тот же принцип, что match в Kyverno или matchLabels в Gatekeeper Constraint, просто ближе к нативному API Kubernetes.
Каждое выражение в validations[].expression обязано вернуть bool. Никакого «выполнения с побочными эффектами» — компилятор CEL проверяет типы уже при kubectl apply, а не при первом сработавшем запросе, так что опечатка в имени поля объекта роняется сразу. Более того, начиная с 1.30 апи-сервер умеет делать type checking по установленным CRD и сообщать о потенциальных проблемах прямо в status.typeChecking политики — то есть можно поймать «это поле не существует у этого типа ресурса» ещё до того, как правило кого-то заблокирует или молча пропустит всё подряд.
Kyverno ValidatingPolicy vs голый ValidatingAdmissionPolicy
Здесь легко перепутать два разных решения с одинаковым CEL внутри.
Голый ValidatingAdmissionPolicy — родной API Kubernetes, никакого Kyverno или Gatekeeper в кластере не нужно вообще. Пишете CEL руками, применяете kubectl apply, отладка — через kubectl describe и логи апи-сервера. Полный контроль, ноль дополнительных компонентов и точек отказа, но и ноль привычных удобств: нет kyverno test для локального прогона в CI, нет PolicyReport с готовой сводкой нарушений по всему кластеру, нет общей библиотеки готовых правил под этот конкретный формат — всё пишется с нуля или копируется из чужих репозиториев.
ValidatingPolicy в Kyverno — CEL-нативный CRD, пришедший на смену ClusterPolicy, — это надстройка поверх той же CEL-модели, но с прежним тулингом вокруг Kyverno: kyverno test для прогона политики против фикстур в CI, PolicyReport с агрегированным аудитом по кластеру, единый CLI для отладки. Ключевая деталь — Kyverno умеет автоматически сгенерировать нативный ValidatingAdmissionPolicy и ValidatingAdmissionPolicyBinding из ValidatingPolicy, когда правило укладывается в возможности CEL, и по-прежнему маршрутизирует запрос через собственный webhook, если правило выходит за эти границы. Оператору не нужно решать это вручную на каждую политику — движок сам выбирает путь исполнения и делает это прозрачно.
Вебхук остаётся обязательным ровно в трёх случаях, которые CEL не выражает в принципе, а не потому что кто-то поленился их реализовать:
- mutation — CEL в
ValidatingAdmissionPolicyне может изменить объект, только разрешить или отклонить его; отдельный CEL-механизм для мутаций (MutatingAdmissionPolicy) существует, но заметно моложе по возможностям, чем DSL мутаций у Kyverno; - generation — создание сопутствующих ресурсов (
NetworkPolicyна новыйNamespace,ResourceQuotaпо шаблону) — у CEL нет побочных эффектов: он только читает объект и возвращаетbool, ничего не создаёт; - внешние вызовы — проверка подписи образа через
cosign, обращение к внешнему сервису репутации или к базе разрешённых registry — CEL синхронный и не умеет ждать сетевой ответ, весь расчёт должен уместиться в объекте запроса.
Таблица сравнения
| Параметр | Webhook (Kyverno ClusterPolicy) | CEL (ValidatingAdmissionPolicy) |
|---|---|---|
| Где выполняется | отдельный под, сетевой вызов | внутри kube-apiserver |
| Задержка admission | сеть + сериализация на каждый запрос | in-process, без сетевого прыжка |
| Точка отказа | под должен быть Ready, влияет failurePolicy | нет отдельного компонента, который может упасть |
| Mutation / generation | ✅ | ❌ (нужен webhook или MutatingAdmissionPolicy) |
| Внешние вызовы (cosign, API) | ✅ | ❌ |
| Проверка типов правила | во время выполнения | статически, при apply (status.typeChecking) |
| Локальное тестирование | kyverno test, PolicyReport | kubectl, логи апи-сервера (в Kyverno-обёртке — тот же kyverno test) |
| Параметризация | переменные Kyverno | params + paramRef |
| Требует компонент в кластере | да (под Kyverno/Gatekeeper) | нет |
Что нужно, чтобы перевести политику на CEL
Берём disallow-latest-tag — ту же политику, что разбиралась в сравнении Kyverno и OPA Gatekeeper, — и переписываем как нативный ValidatingAdmissionPolicy без единого пода Kyverno в кластере.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: disallow-latest-tag
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system"]
validations:
- expression: >
object.spec.containers.all(c, !c.image.endsWith(':latest'))
message: "Теги :latest запрещены. Укажите точный тег или SHA-дайджест."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: disallow-latest-tag-binding
spec:
policyName: disallow-latest-tag
validationActions: ["Deny"]
ValidatingAdmissionPolicy описывает правило, область действия и исключения (здесь — kube-system, где системные образы часто держат другой цикл выпуска тегов), а ValidatingAdmissionPolicyBinding — включает правило и задаёт режим (Deny — блокировать запрос, Warn — вернуть предупреждение клиенту, но пропустить, Audit — только записать в AdmissionReview, вообще без влияния на запрос). Разделение на два объекта позволяет держать одно правило и несколько привязок с разным validationActions для разных окружений — например, Audit в staging, пока не убедились, что правило не ловит ложных срабатываний, и Deny в production.
kubectl apply -f disallow-latest-tag-vap.yaml
kubectl get validatingadmissionpolicies
# NAME AGE
# disallow-latest-tag 12s
kubectl get validatingadmissionpolicy disallow-latest-tag \
-o jsonpath='{.status.typeChecking.expressionWarnings}'
# (пусто — CEL-выражение прошло проверку типов без замечаний)
Разница в задержке видна без специального бенчмарк-стенда — достаточно замерить время между отправкой запроса и ответом апи-сервера до и после миграции:
for i in {1..20}; do
t0=$(date +%s%N)
kubectl run "probe-$i" --image=nginx:1.27.0 --restart=Never --dry-run=server >/dev/null
t1=$(date +%s%N)
echo "$(( (t1 - t0) / 1000000 )) ms"
done
--dry-run=server прогоняет запрос через весь admission-путь, включая политику, но не создаёт ресурс — удобно гонять пробы в цикле, не засоряя namespace тестовыми Pod. На локальном kind-кластере вебхук-путь у Kyverno добавляет заметный сетевой прыжок к каждому запросу; на CEL в апи-сервере этот прыжок пропадает полностью, и порядок задержки падает с десятков миллисекунд до единиц. Точные цифры зависят от сети, ресурсов узла и загрузки пода контроллера, но направление стабильно в любой среде: меньше сетевых прыжков — меньше задержка, и разброс (p99) сокращается заметнее, чем медиана.
Как проверить, что политика работает
kubectl run test --image=nginx:latest --restart=Never
# Error from server: ValidatingAdmissionPolicy 'disallow-latest-tag' with binding
# 'disallow-latest-tag-binding' denied request: Теги :latest запрещены.
# Укажите точный тег или SHA-дайджест.
kubectl run ok --image=nginx:1.27.0 --restart=Never
# pod/ok created
kubectl run legacy --image=nginx:latest --restart=Never -n kube-system
# pod/legacy created — namespaceSelector исключил kube-system, как и задумано
Сообщение об ошибке приходит прямо от апи-сервера, без упоминания вебхука или пода-контроллера — политика теперь часть самого Kubernetes API, а не внешней надстройки над ним. Если нужно быстро посмотреть, сколько запросов правило заблокировало за последнее время без включения Deny в проде, начните с validationActions: ["Audit"] и смотрите структурированные записи AdmissionReview в аудит-логе апи-сервера — это тот же механизм, которым Kyverno сам заполняет PolicyReport, только без промежуточного CRD.
Итог
Для простых validation-правил — «запрещённый тег», «обязательный label», «лимиты CPU заданы» — CEL в апи-сервере теперь новый дефолт: меньше задержка, меньше движущихся частей, меньше поводов держать failurePolicy: Ignore в проде «на всякий случай». Kyverno остаётся там, где CEL принципиально не дотягивается — mutation, generation ресурсов и проверки с внешним вызовом вроде верификации подписи образа. Разумный путь — не выбирать один инструмент раз и навсегда, а мигрировать validation-правила по мере депрекейта ClusterPolicy в Kyverno 1.17–1.20, оставив webhook только под то, что CEL действительно не умеет.