Привет, Хабр!

Команда закрыла тикет по сетевой изоляции: написали политики на все неймспейсы, применили, аудитор поставил галочку. Через полгода на пентесте выяснилось, что под из тестового неймспейса спокойно ходит в продовую базу.

Политики при этом лежали в кластере, 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‑правила.

  • А еще политики только складываются. Механизма запрета в них нет вовсе, разрешения из нескольких политик объединяются, и одна политика не может отменить то, что разрешила другая. Изоляция строится тем, что для пода существует хоть какая‑то политика в нужном направлении — всё, что она явно не разрешила, запрещено.

Как все это раскатывать

  1. Начните с проверки, что плагин вообще применяет политики — тестом с deny-all и curl из соседнего пода, а не по названию плагина в списке подов. Пока этот тест не прошёл, всё остальное бессмысленно.

  2. Затем в одном коммите с default-deny-egress кладите разрешение DNS. Отдельными коммитами это делать нельзя: между ними неймспейс будет лежать.

  3. Раскатывайте на стенде и снимайте, что сломалось. У 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». Записаться

Полный список бесплатных уроков августа смотрите в дайджесте.

Комментарии (0)