Привет, Хабр!
Команда закрыла тикет по сетевой изоляции: написали политики на все неймспейсы, применили, аудитор поставил галочку. Через полгода на пентесте выяснилось, что под из тестового неймспейса спокойно ходит в продовую базу.
Политики при этом лежали в кластере, kubectl get networkpolicy их показывал, ошибок в них не было. Просто их никто не применял.
В статье разберём пять ошибок, каждая из которых даёт этот результат — объект в API есть, а трафик ходит как ходил.
CNI, который политики игнорирует
Kubernetes сам по себе NetworkPolicy не применяет — он только хранит объекты. Реализует их сетевой плагин, и делает это не каждый. Calico, Cilium и Weave Net умеют, а голый Flannel и kubenet — нет.
Самое неприятное здесь в том, что API‑сервер такие объекты принимает без единого возражения:
kubectl apply -f deny-all.yaml
networkpolicy.networking.k8s.io/deny-all created
Создано, лежит, отображается в выводе. Не работает.
Проверяется это в две команды. Сначала смотрим, что за плагин стоит:
kubectl get pods -n kube-system | grep -E 'calico|cilium|weave|flannel|kube-router'
kube-flannel-ds-4x7km 1/1 Running 0 47d kube-flannel-ds-p2n8w 1/1 Running 0 47d
Flannel — значит политики декоративные. Но полагаться только на имя плагина не стоит, потому что бывают сборки с отключённым применением политик, так что адекватнее проверить трафиком.
Применяем запрет всего в тестовом неймспейсе и пробуем достучаться:
kubectl -n test apply -f - <<'EOF' apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: deny-all spec: podSelector: {} policyTypes: [Ingress, Egress] EOF kubectl -n test run probe --rm -it --image=nicolaka/netshoot --restart=Never -- \ curl -m 3 -s -o /dev/null -w '%{http_code}\n' http://api.production.svc.cluster.local:8080
Если в ответ прилетает 200, а не таймаут — политики не применяются. Такую проверку стоит держать в CI и гонять после каждого обновления кластера, потому что смена плагина или его конфигурации происходит незаметно для тех, кто пишет манифесты.
Запретили всё и уронили DNS
Дальше сценарий, который случается хотя бы раз в жизни каждого, кто впервые раскатывает изоляцию. Применяем разумную политику:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: production spec: podSelector: {} policyTypes: [Egress]
Через тридцать секунд весь неймспейс лежит, а в логах приложений что‑то невнятное:
java.net.UnknownHostException: postgres.production.svc.cluster.local dial tcp: lookup redis on 10.96.0.10:53: read udp: i/o timeout
Резолвинг имён — это тоже исходящий трафик, к CoreDNS на 53-й порт. Запретив весь egress, вы запретили и его, и теперь ни один под не может найти вообще ничего, включая соседа в том же неймспейсе.
Приложение не пишет «нет доступа к базе», оно пишет «не могу разрешить имя» или просто висит в таймауте, и первым делом все идут разбираться с CoreDNS, который в полном порядке.
Разрешение DNS кладут в тот же коммит, что и запрет:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-dns-egress namespace: production spec: podSelector: {} policyTypes: [Egress] egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system podSelector: matchLabels: k8s-app: kube-dns ports: - protocol: UDP port: 53 - protocol: TCP port: 53
Оба протокола здесь обязательны: UDP используется для обычных запросов, TCP — для ответов, которые в UDP не поместились, и без него часть резолвинга будет работать через раз.
Проверить, что дело именно в DNS, можно изнутри пода:
kubectl -n production exec -it api-7d9f-x4k2 -- nslookup postgres.production.svc.cluster.local
;; connection timed out; no servers could be reached
Таймаут на nslookup при живом CoreDNS означает, что до него не долетает исходящий трафик.
Один дефис, открывающий доступ всему кластеру
Эта ошибка похуже двух предыдущих, потому что не ломает ничего — она молча расширяет доступ, и заметить её можно только чтением манифеста.
Вот политика, которая должна пускать в базу только поды api из неймспейса production:
ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: production podSelector: matchLabels: app: api
А вот та же политика с одним лишним дефисом:
ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: production - podSelector: matchLabels: app: api
В первом варианте селекторы стоят под одним элементом списка, и условия объединяются через «и»: поды с меткой app: api, находящиеся в неймспейсе production. Во втором это два отдельных элемента, то есть «или»: любой под из production плюс любой под с меткой app: api из любого неймспейса кластера.
Второй вариант открывает базу всему, что кто‑нибудь когда‑нибудь пометит как app: api — включая тестовый неймспейс, стенд разработчика и всё, что попадёт в кластер потом.
kubectl describe покажет разницу, но её надо уметь прочитать:
kubectl describe networkpolicy db-allow -n production
Allowing ingress traffic: To Port: 5432/TCP From: NamespaceSelector: kubernetes.io/metadata.name=production From: PodSelector: app=api
Два блока From подряд означают «или». Один блок, где селекторы перечислены вместе, «и». На каждый ingress и egress‑блок в проде эту проверку стоит делать глазами при ревью, потому что синтаксически оба варианта корректны и никакой линтер про них не скажет.
namespaceSelector, который смотрит на метки, а не на имя
Политика просто не срабатывает.
Пишем разрешение для мониторинга:
ingress: - from: - namespaceSelector: matchLabels: name: monitoring ports: - port: 9090
Применяем, Prometheus перестаёт собирать метрики. Причина в том, что namespaceSelector сравнивает не имя неймспейса, а его метки, и метки name: monitoring там может просто не быть:
kubectl get ns --show-labels
NAME STATUS AGE LABELS monitoring Active 93d kubernetes.io/metadata.name=monitoring production Active 93d kubernetes.io/metadata.name=production
Метка одна, и это та, которую Kubernetes проставляет автоматически. Выхода два: селектор переписать на неё либо повесить свою метку руками.
kubectl label namespace monitoring name=monitoring
Автоматическая метка надёжнее, потому что её не забудут проставить на новом неймспейсе, а вот произвольную — забудут обязательно, и политика молча перестанет работать в тот момент, когда неймспейс пересоздадут.
Сюда же примыкает частая путаница: podSelector внутри блока from никогда не выходит за границы своего неймспейса. Запись без namespaceSelector означает «поды с такой меткой в том же неймспейсе, где живёт политика», а не «во всём кластере».
Путают, кого защищаем, а кому разрешаем
Последняя ошибка выглядит как опечатка, а даёт открытый доступ.
В спецификации политики есть два разных podSelector. Верхний, в spec, отвечает на вопрос «на какие поды эта политика распространяется». Вложенный, внутри from или to, — «кому разрешаем». Их регулярно меняют местами:
spec: podSelector: matchLabels: app: api # думают: "разрешить api" # значит: "защищать поды api" policyTypes: [Ingress] ingress: - from: [] # думают: "источник не важен" # значит: "разрешить всем"
Здесь два неверных прочтения складываются в политику, которая формально существует и не запрещает ничего. Пустой from разрешает трафик отовсюду.
Отличается это от близкого по виду варианта, где from вообще отсутствует:
ingress: [] # правил нет -> запретить всё входящее
Пустой список правил означает запрет, а правило с пустым списком источников — разрешение всем. Разница в один уровень вложенности, поведение противоположное.
Проверяется всё это тем же способом, что и в первой ошибке — не чтением манифеста, а трафиком:
kubectl -n test run probe --rm -it --image=nicolaka/netshoot --restart=Never -- \ sh -c 'nc -zv -w3 api.production.svc.cluster.local 8080'
Connection to api.production.svc.cluster.local 8080 port [tcp/*] succeeded!
Успешное соединение из неймспейса, которому туда нельзя, единственное надёжное доказательство того, что политика не делает того, что вы думали.
Внешний трафик перестал доходить
Может произойти после того, как ingress‑политики починили для внутренних вызовов: сервис перестаёт отвечать снаружи.
Причина в том, что запросы из интернета приходят не напрямую, а через контроллер входящего трафика, а он живёт в своём неймспейсе и с точки зрения политики выглядит обычным чужим подом.
ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx podSelector: matchLabels: app.kubernetes.io/name: ingress-nginx ports: - protocol: TCP port: 8080
Метки контроллера стоит смотреть у себя, а не копировать, они рзличаются между дистрибутивами и способами установки:
kubectl get pods -n ingress-nginx --show-labels
Обратная задача — выпустить трафик наружу кластера, решается блоком адресов, и тут есть ограничение, которое ломает планы. Селектор по IP работает только с подсетями, доменных имён он не понимает вовсе:
egress: - to: - ipBlock: cidr: 0.0.0.0/0 except: - 10.0.0.0/8 # закрываем внутреннюю сеть - 172.16.0.0/12 - 192.168.0.0/16 ports: - protocol: TCP port: 443
Разрешить обращение к api.partner.com штатной политикой невозможно — адрес за именем меняется, а перечислять диапазоны провайдера бессмысленно. Приходится либо выпускать весь внешний трафик по портам, как выше, либо брать расширения плагина: у Cilium для этого есть собственный тип политики с фильтрацией по именам, у Calico — свои сетевые наборы.
Про исключения стоит помнить и то, что они вырезаются из указанной подсети, а не из всего разрешения. Три приватных диапазона в примере закрывают внутреннюю сеть, но не закрывают адреса самих узлов, если те живут в публичном диапазоне.
Чего политики не покрывают вообще
Даже правильно написанные и применяемые политики оставляют несколько дыр, о которых полезно знать заранее.
Поды с
hostNetwork: trueживут в сетевом пространстве узла и под pod‑level политики не попадают — они обходят плагин целиком. Это одна из причин запрещать такие поды через Pod Security Admission, а не полагаться на то, что сетевые правила их поймают.Контейнеры внутри одного пода общаются через localhost, и никакие политики между ними не действуют — sidecar видит порты приложения независимо от любых правил.
Ответный трафик по установленному соединению разрешён автоматически, и отдельного правила на него не нужно. Реализации используют отслеживание соединений, поэтому под, которому разрешили входящий запрос, отвечает на него без egress‑правила.
А еще политики только складываются. Механизма запрета в них нет вовсе, разрешения из нескольких политик объединяются, и одна политика не может отменить то, что разрешила другая. Изоляция строится тем, что для пода существует хоть какая‑то политика в нужном направлении — всё, что она явно не разрешила, запрещено.
Как все это раскатывать
Начните с проверки, что плагин вообще применяет политики — тестом с
deny-allиcurlиз соседнего пода, а не по названию плагина в списке подов. Пока этот тест не прошёл, всё остальное бессмысленно.Затем в одном коммите с
default-deny-egressкладите разрешение DNS. Отдельными коммитами это делать нельзя: между ними неймспейс будет лежать.Раскатывайте на стенде и снимайте, что сломалось. У Calico и Cilium есть режим, в котором политики логируются, но не применяются, — он для этого и сделан:
# Cilium: смотрим отбрасываемые пакеты в реальном времени kubectl -n kube-system exec -it ds/cilium -- cilium monitor --type drop
xx drop (Policy denied) flow 0x9f2a to endpoint 1842, identity 25631->12045: 10.244.2.31:51234 -> 10.244.1.17:5432 tcp SYN
Каждая такая строка — соединение, которое вы только что запретили, с адресами обеих сторон. Прогон нагрузки на стенде с включённым монитором даёт список всего, что нужно разрешить, до того как это выяснится на проде.
И держите наготове откат — одну команду, которая снимает изоляцию целиком:
kubectl delete networkpolicy default-deny-ingress default-deny-egress -n production
Раскатывать в прод стоит в окно с низким трафиком и первый час смотреть на ошибки соединений, а не на дашборд с политиками. Объекты в API будут выглядеть одинаково хорошо в обоих случаях — и когда всё работает, и когда половина сервисов не может достучаться друг до друга.

Когда NetworkPolicy уже применены, но нет уверенности, что они действительно режут трафик, одной проверки манифестов недостаточно. Понимание того, как Kubernetes, CNI и инструменты диагностики работают вместе, помогает находить такие разрывы до продакшена и не путать наличие политики с реальной изоляцией.
Если хотите глубже разобраться в этих механизмах и инструментах, присоединяйтесь к открытым урокам OTUS:
10 августа, 20:00. «Kubernetes + CI/CD + GitOps — делаем стабильный деплой без выхода из кластера». Записаться
18 августа, 20:00. «Безопасный релиз на практике: SAST, SCA, контейнеры и security gates». Записаться
23 сентября, 20:00. «eBPF: рентгеновское зрение для production». Записаться
Полный список бесплатных уроков августа смотрите в дайджесте.