Сеть в Kubernetes — тот слой, который «просто работает», пока не понадобится ограничить трафик между подами, увидеть, кто с кем общается, или добавить mTLS. Тогда всплывают CNI, NetworkPolicy, eBPF и, почти неизбежно, вопрос про service mesh. Разберём модель сети, что конкретно даёт Cilium в проде, и где проходит граница, за которой mesh перестаёт быть оверинжинирингом. По дороге разберём и самый частый сюжет отладки: политика написана правильно, доступ разрешён, а приложение всё равно не работает.
Это шестая статья серии «Kubernetes на практике». Тему входящего трафика — Ingress и переход на Gateway API — не дублируем: ей посвящена отдельная статья, здесь речь про трафик внутри кластера, политики, identity и наблюдаемость. Все выводы ниже сняты на живом примере kubernetes/networking в публичном репозитории digital-cookbook: Kubernetes 1.36.2 на кластере k8s-volga (узлы под управлением Talos Linux, SSH к ним нет — всё наблюдение идёт через API-сервер), CNI Cilium v1.19.4, CoreDNS v1.14.2, демо-сервер на nginx:1.27.3, клиенты на curlimages/curl:8.11.1. Пример живёт в отдельном namespace cookbook-net — ровно потому, что его центральное демо (default-deny) отбирает трафик у всех подов того namespace, где применено.
В статье
- Плоская сеть и роль CNI
- Cilium и eBPF-датаплейн
- default-deny: запретить всё, разрешить точечно
- Забытый DNS-egress
- Identity вместо адреса
- L7-правила и союз разрешений
- Hubble: вердикт вместо tcpdump
- Где заканчивается CNI и начинается service mesh
Плоская сеть и роль CNI
Спецификация Kubernetes требует от сети немногого, но это немногое строгое: у каждого пода свой IP-адрес, любой под может обратиться к любому другому по этому адресу напрямую, без NAT, независимо от того, на каком узле оба находятся. Сам Kubernetes ничего из этого не реализует — он только описывает контракт. Выполняет его плагин CNI: раздаёт адреса, строит маршруты между узлами — а в конфигурациях вроде той, на которой снят пример, ещё и программирует правила для Service (в стандартной модели это работа отдельного компонента, о нём — следующий раздел).
Что это значит на практике, видно в исходном состоянии примера — три пода, ни одной политики:
$ kubectl -n cookbook-net get pod \
-o custom-columns=NAME:.metadata.name,IP:.status.podIP,NODE:.spec.nodeName
NAME IP NODE
client-allowed 10.244.6.124 k8s-volga-wk03
client-denied 10.244.6.84 k8s-volga-wk03
server 10.244.3.127 k8s-volga-wk01
server живёт на другом узле, чем оба клиента, и это никак не мешает — обращение по имени сервиса проходит одинаково от обоих:
$ for c in client-allowed client-denied; do \
echo -n "$c -> http://server:8080/a : "; \
kubectl -n cookbook-net exec $c -- curl -sS -m 5 http://server:8080/a; done
client-allowed -> http://server:8080/a : ROUTE-A
client-denied -> http://server:8080/a : ROUTE-A
Демо-сервер отдаёт разное содержимое на разных путях (/a — ROUTE-A, /b — ROUTE-B); это пригодится дальше, когда речь пойдёт про правила уровня HTTP. Но сейчас важнее другое: лейблы у двух клиентов разные (role: allowed и role: denied), а поведение сети — одинаковое. Пока политик нет, сеть кластера плоская: любой под достаёт любой другой, и никакой встроенной сегментации по namespace, лейблам или ролям в этом слое нет. Ограничение трафика в Kubernetes — не поведение по умолчанию, а то, что нужно включить явно. Всё, что описано ниже, начинается с этой точки.
Cilium и eBPF-датаплейн
Классическая реализация сервисов в Kubernetes — kube-proxy: демон на каждом узле, который следит за объектами Service/EndpointSlice и программирует правила в iptables (или, в другом режиме, в IPVS). Про iptables-режим общеизвестно то, что здесь не измерялось и приводится как фон: каждый ClusterIP превращается в набор правил DNAT, на кластере с тысячами сервисов цепочки становятся длинными, а их перестройка при изменении эндпоинтов — заметной по времени. Режим IPVS устроен иначе — там соответствие ищется по хеш-таблицам, и претензия к длине цепочек к нему не относится.
Cilium предлагает другой датаплейн: логика обработки пакета живёт в eBPF-программах, подгружаемых в ядро, а балансировка сервисов делается по хеш-таблицам в ядре, а не проходом по цепочке правил. В кластере, на котором снят пример, эта замена сделана до конца — kube-proxy не установлен вовсе:
$ kubectl -n kube-system get ds kube-proxy
Error from server (NotFound): daemonsets.apps "kube-proxy" not found
Здесь нужна точная оговорка, чтобы не сделать неверный вывод. Отсутствие kube-proxy — это свойство конкретно этой установки Cilium, а не поведение Kubernetes по умолчанию и не свойство CNI вообще. Оно включается параметром в конфигурации Cilium, и в этом кластере включено:
$ for k in kube-proxy-replacement routing-mode enable-hubble enable-l7-proxy identity-allocation-mode; do \
echo "$k=$(kubectl -n kube-system get cm cilium-config -o jsonpath="{.data['$k']}")"; done
kube-proxy-replacement=true
routing-mode=tunnel
enable-hubble=true
enable-l7-proxy=true
identity-allocation-mode=crd
Эти пять строк стоит прочитать как список того, что дальше вообще возможно. kube-proxy-replacement=true — балансировку ClusterIP/NodePort делает Cilium, отдельного kube-proxy нет. routing-mode=tunnel — трафик между узлами инкапсулируется в оверлей. identity-allocation-mode=crd — идентификаторы, по которым работают правила, хранятся как объекты Kubernetes. А enable-l7-proxy=true и enable-hubble=true — это два параметра, без которых не будет ни HTTP-правил из раздела про L7, ни наблюдаемости из раздела про Hubble: и то и другое включается явно, а не даётся вместе с кластером. На другом CNI или на этом же Cilium с выключенными флагами часть показанного ниже просто не воспроизведётся — не потому, что где-то ошибка, а потому, что эти возможности принадлежат конкретному датаплейну, а не спецификации Kubernetes.
Сам DaemonSet раскатан на все узлы кластера — 6 желаемых, 6 готовых, образ quay.io/cilium/cilium:v1.19.4. Балансировку он ведёт по собственной таблице сервисов, и её можно посмотреть изнутри пода агента. Ниже — срез из этой таблицы: три записи, остальные опущены как посторонние примеру:
$ kubectl -n kube-system exec ds/cilium -- cilium-dbg service list | head -15
ID Frontend Service Type Backend
…
2 10.96.0.10:53/TCP ClusterIP 1 => 10.244.3.23:53/TCP (active)
2 => 10.244.3.143:53/TCP (active)
3 10.96.0.10:53/UDP ClusterIP 1 => 10.244.3.23:53/UDP (active)
2 => 10.244.3.143:53/UDP (active)
…
6 10.96.0.1:443/TCP ClusterIP 1 => 192.168.71.209:6443/TCP (active)
2 => 192.168.71.210:6443/TCP (active)
3 => 192.168.71.211:6443/TCP (active)
…
Знакомые адреса: 10.96.0.10:53 — ClusterIP кластерного DNS с двумя бэкендами CoreDNS, 10.96.0.1:443 — сам API-сервер с тремя бэкендами по числу control-plane-узлов. Ни одного правила iptables за этим не стоит: соответствие «фронтенд → набор бэкендов» лежит в eBPF-картах, и выбор бэкенда делается в ядре при обработке пакета.
Второе следствие такого датаплейна важнее производительности и будет вылезать во всех оставшихся разделах: Cilium принимает решение о доступе не по IP-адресу, а по security identity — числу, которое вычисляется из набора лейблов пода. Это меняет саму модель того, что означает «правило разрешает трафик», и разбирается ниже отдельно.
default-deny: запретить всё, разрешить точечно
NetworkPolicy в Kubernetes устроена как белый список с одной особенностью: пока на под не нацелена ни одна политика, он не изолирован и принимает/отправляет всё. Как только появляется хотя бы одна политика, выбирающая этот под, — разрешено ровно то, что политики называют явно, и ничего сверх.
Отсюда базовый паттерн: сначала перекрыть всё, потом открывать по одному правилу. Политика без единого правила и с пустым селектором подов:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: cookbook-net
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
podSelector: {} выбирает все поды namespace, policyTypes объявляет, что политика управляет обоими направлениями, а списков ingress/egress нет вовсе — значит, разрешений не выдано ни одного. С этого момента в namespace не проходит ничего, и первое, что об это спотыкается, — даже не обращение к сервису, а разрешение его имени:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 5 http://server:8080/a
curl: (28) Resolving timed out after 5001 milliseconds
Запрос не дошёл до сервера не потому, что сервер закрыт, а потому, что клиент не смог узнать его адрес: DNS — это такой же исходящий трафик, как любой другой, и под общий запрет он попал вместе со всем остальным. Это важная деталь для следующего раздела, где она превращается в самую частую ошибку при написании политик.
Отдельное практическое замечание про радиус действия. default-deny применяется к namespace целиком, и его действие не ограничивается тем приложением, ради которого его писали. Именно поэтому сетевой пример из digital-cookbook живёт в собственном namespace: примени такую политику в общем — и молча остановится всё, что там работает, без единой записи в логах приложений. Обратная ситуация не менее коварна: NetworkPolicy — объект, который применяет не Kubernetes, а CNI, и если установленный в кластере CNI её не энфорсит, объект создастся без ошибок, а трафик продолжит ходить как ни в чём не бывало — политика есть, эффекта нет.
Забытый DNS-egress
Теперь откроем доступ. Два обычных объекта NetworkPolicy: один разрешает клиенту с лейблом role: allowed исходящий трафик на поды app: server по TCP/8080, второй — входящий на server от того же role: allowed по тому же порту. Правила симметричны, порт правильный, селекторы правильные:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-client-to-server-egress
namespace: cookbook-net
spec:
podSelector:
matchLabels:
role: allowed
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: server
ports:
- protocol: TCP
port: 8080
И обращение по имени сервиса всё равно не работает:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 6 http://server:8080/a
curl: (6) Could not resolve host: server
Дальше начинается то, ради чего этот раздел вообще существует. То же самое обращение, но по адресу сервиса 10.104.186.225 — в обход разрешения имени:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 6 http://10.104.186.225:8080/a
ROUTE-A
Доступ есть. Полный, рабочий, ровно тот, что описан политикой. А сломан только резолв:
$ kubectl -n cookbook-net exec client-allowed -- nslookup server
;; connection timed out; no servers could be reached
Причина — в том, что NetworkPolicy не делает никаких исключений для служебного трафика. Запрос к CoreDNS — это обычный egress на UDP/TCP 53 в namespace kube-system, и если политика его не назвала, он идёт под общий запрет вместе со всем остальным. Правило доступа к серверу написано корректно и достаточно (иначе не прошёл бы и запрос по IP), но приложение всё равно не работает, потому что первый же его шаг — превратить имя в адрес — запрещён.
Лечится одним недостающим правилом: явный egress на поды CoreDNS по обоим протоколам. Селектор namespace — по стандартному лейблу kubernetes.io/metadata.name, селектор подов — по фактическому лейблу CoreDNS в кластере (его стоит сверить у себя, а не считать общеизвестным):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-client-to-dns
namespace: cookbook-net
spec:
podSelector:
matchLabels:
role: allowed
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
Сразу после применения обращение по имени начинает работать — правки политики доступа не потребовалось ни на строку:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 6 http://server:8080/a
ROUTE-A
При этом второй клиент, client-denied, остался там же, где был: разрешение DNS адресовано только role: allowed, и на него не распространяется.
$ kubectl -n cookbook-net exec client-denied -- curl -sS -m 6 http://server:8080/a
curl: (6) Could not resolve host: server
То есть лечение не открыло сеть всем подряд — политика по-прежнему избирательна.
Отсюда — практический приём, который стоит держать в голове при отладке. Если обращение по IP работает, а по имени нет — проблема в DNS, а не в доступе. Одна проверка отделяет «политика неправильная» от «политика правильная, но не хватает правила для DNS», и делается она за одну команду, до разбора манифестов построчно.
Небольшая деталь, чтобы не удивляться расхождению форм ошибки. Отказ резолва проявляется по-разному в зависимости от таймаута клиента и состояния кэша: в одном прогоне это curl: (6) Could not resolve host: server, в другом — curl: (28) Resolving timed out after 5000 milliseconds. Характер отказа один и тот же — имя не разрешается, — а конкретный код и текст зависят от клиента, а не от политики.
Identity вместо адреса
Интуиция «сетевая политика — это правило по IP» в Kubernetes ломается, и Cilium ломает её нагляднее прочих. Правила здесь привязаны не к адресу пода, а к security identity — числовому идентификатору, который датаплейн вычисляет из релевантного набора лейблов. Активные endpoints с одним и тем же набором identity-relevant лейблов делят одну текущую выделенную identity, независимо от того, какой под её носит и какой у него адрес. Селекторы в политиках матчат именно identity, а перевод identity в конкретные адреса происходит на лету.
Одна оговорка про само число, чтобы не принять его за контракт. Числовой ID — это динамически выделяемая деталь реализации: Cilium назначает его по набору identity-relevant лейблов. Число стабильно только в пределах жизни этого allocation — пока живы endpoints с такими лейблами, identity держится и переиспользуется; после того как все они исчезли и identity освобождена, собрана GC или мигрировала, allocator может выдать под то же сочетание лейблов другое число (на другом кластере — тем более). Контракт, на который опираются политики, — это identity, выведенная из лейблов пода, а не конкретный числовой идентификатор; полагаться на само число как на стабильный ключ нельзя — метки здесь контракт, число здесь деталь реализации.
В примере три пода получают три identity:
$ kubectl -n cookbook-net get ciliumendpoints \
-o custom-columns=NAME:.metadata.name,IDENTITY:.status.identity.id,IP:.status.networking.addressing[0].ipv4
NAME IDENTITY IP
client-allowed 13474 10.244.6.124
client-denied 48544 10.244.6.84
server 2715 10.244.3.127
Расшифровка показывает, из чего они складываются. Ниже — срез вывода: для каждой identity оставлены различающая строка и общий каркас; часть одинаковых лейблов, унаследованных от namespace и кластера, опущена. Наборы лейблов у двух клиентов отличаются ровно одной строкой:
$ kubectl -n kube-system exec ds/cilium -- cilium-dbg identity list \
| awk '/^[0-9]/ { p = ($1 == 13474 || $1 == 48544) } p'
13474 k8s:app=client
k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cookbook-net
…
k8s:io.cilium.k8s.policy.serviceaccount=default
k8s:io.kubernetes.pod.namespace=cookbook-net
k8s:role=allowed
48544 k8s:app=client
k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cookbook-net
…
k8s:io.cilium.k8s.policy.serviceaccount=default
k8s:io.kubernetes.pod.namespace=cookbook-net
k8s:role=denied
Namespace, приложение, service account — одинаковые; различие только в k8s:role. Этот один лейбл и есть переменная, от которой зависит доступ. Дальше — два эксперимента, которые разводят понятия «адрес» и «правило» окончательно.
Пересоздание пода: адрес новый, доступ на месте. Под удаляется и поднимается заново из того же манифеста — тот же набор лейблов, новый адрес:
$ kubectl -n cookbook-net get ciliumendpoints \
-o custom-columns=NAME:.metadata.name,IDENTITY:.status.identity.id,IP:.status.networking.addressing[0].ipv4
NAME IDENTITY IP
client-allowed 13474 10.244.6.239
client-denied 48544 10.244.6.84
server 2715 10.244.3.127
Адрес сменился с 10.244.6.124 на 10.244.6.239, identity осталась 13474. Обращение к серверу сразу после пересоздания:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 5 http://server:8080/a
ROUTE-A
Политики никто не трогал — доступ сохранился только потому, что не менялись лейблы.
Смена лейбла: адрес прежний, доступа нет. Теперь наоборот — у того же пода меняем role: allowed на role: denied, адрес не трогаем. Тут стоит знать про задержку: сам лейбл на объекте Pod применяется мгновенно, а пересчёт identity в агенте занимает время. Через 15 секунд после смены идентификатор ещё старый, и обращение всё ещё проходит — это гонка пересчёта, а не опровержение модели. Ещё через двадцать секунд картина устанавливается:
$ kubectl -n cookbook-net get ciliumendpoints client-allowed \
-o custom-columns=NAME:.metadata.name,IDENTITY:.status.identity.id,IP:.status.networking.addressing[0].ipv4
NAME IDENTITY IP
client-allowed 48544 10.244.6.239
Адрес тот же — 10.244.6.239, а identity стала 48544. Та же самая, что у client-denied: identity вычисляется из набора лейблов, поэтому два разных пода с одинаковым набором получают один идентификатор. К этому моменту доступа уже нет (сам момент потери здесь не замерялся — проверка снята после того, как картина устоялась):
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 5 http://server:8080/a
curl: (28) Resolving timed out after 5000 milliseconds
Отказ того же рода, что был у client-denied в предыдущем разделе (конкретный код и текст, как уже разбиралось, зависят от клиента), и по той же причине: у role: denied нет ни правила на сервер, ни правила на DNS. Возврат лейбла в allowed возвращает identity 13474 (тот же идентификатор, что был до экспериментов: identity под эти лейблы всё ещё была выделена, и возврат к тем же лейблам вывел её же — совпадение в пределах жизни этого allocation, а не гарантия навсегда) и вместе с ней доступ:
$ kubectl -n cookbook-net exec client-allowed -- curl -sS -m 5 http://server:8080/a
ROUTE-A
Сведём три шага. Адрес пода за всё это время изменился ровно один раз — при пересоздании — и дальше не менялся. А доступ переключался дважды, синхронно с лейблом, а не с адресом. Правило следует за identity, identity следует за лейблами. Практический вывод для тех, кто проектирует политики: правила по лейблам переживают пересоздание подов, масштабирование и переезд между узлами без единой правки, а правила по адресам мертвы с первого же рестарта — адрес эфемерен, лейбл это контракт. Обратная сторона той же медали — про неё стоит помнить при выдаче прав: смена лейбла у пода меняет его сетевые права, не трогая ни одной политики. Кто может править лейблы, тот косвенно управляет и доступом; смежная тема ограничения того, что вообще позволено нагрузке, разбирается в «Hardening контейнеров и подов: Pod Security, seccomp, capabilities»готовится, с 3 ноября.
L7-правила и союз разрешений
Обычная NetworkPolicy оперирует адресом, портом и протоколом. Она не может отличить GET /a от GET /b — просто потому, что не заглядывает внутрь TCP-потока. Cilium добавляет собственный тип объекта, CiliumNetworkPolicy, где к разрешению порта можно приписать правила уровня HTTP:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-server-http-get-a
namespace: cookbook-net
spec:
endpointSelector:
matchLabels:
app: server
ingress:
- fromEndpoints:
- matchLabels:
role: allowed
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: "GET"
path: "^/a$"
Разрешён ровно один метод по ровно одному пути. Работает это через L7-прокси (в этой конфигурации — Envoy), который поднимается на узле только при включённом enable-l7-proxy — то есть возможность не бесплатная и не подразумеваемая.
И вот тут — самый поучительный поворот. Первый прогон с этой политикой дал не то, что ожидалось: GET /a вернул 200 ROUTE-A, но и GET /b вернул 200 ROUTE-B, хотя правило разрешает только /a. Причина не в Cilium, а в общем свойстве самого API: политики складываются как объединение разрешений. Разрешающая NetworkPolicy уровня L3/L4 из предыдущего раздела продолжала действовать параллельно и пропускала любой TCP на server:8080 независимо от HTTP. L7-правило не «сужает» уже выданное разрешение — оно добавляет доступ там, где иного разрешения ещё нет.
Это свойство NetworkPolicy как API, а не особенность Cilium: результирующее разрешение — объединение всех применённых политик, и запрещающего правила, которое бы «победило» разрешающее, в самом API NetworkPolicy нет вовсе. Оговорка: отдельные deny-правила существуют — в расширениях CNI, включая CiliumNetworkPolicy с её ingressDeny/egressDeny, где запрет имеет приоритет над разрешением и модель складывания уже другая. Но это возможность конкретного CNI, а не стандартного API, и правила ниже — про стандартное. Вывод для проектирования простой и жёсткий: ограничение уровня HTTP работает только тогда, когда более широкое L3/L4-разрешение на тот же адрес и порт не выдано никем другим. Пока такое разрешение существует хоть в одной политике namespace, любое L7-ограничение поверх него — декорация.
Убираем широкое разрешение, оставляем CiliumNetworkPolicy единственным источником доступа на server, повторяем три пробы:
| Проба | Запрос | Результат |
|---|---|---|
| 1 | GET /a — разрешён правилом |
HTTP_CODE=200, тело ROUTE-A |
| 2 | GET /b — тот же адрес и порт, другой путь |
HTTP_CODE=403, тело Access denied |
| 3 | POST /a — тот же путь, другой метод |
HTTP_CODE=403, тело Access denied |
Обе пробы 2 и 3 идут на тот же server:8080, что и разрешённая первая. Различает их только содержимое HTTP-запроса — и для датаплейна этого достаточно.
Важная деталь про пробу 3, чтобы не спутать источники отказа. В первом прогоне — том, где широкое L3/L4-разрешение ещё действовало, — POST /a вернул 405 Not Allowed со страницей nginx/1.27.3. Это отказ самого веб-сервера, который не обслуживает POST для статики, а вовсе не решение политики. Когда единственным источником разрешения стало L7-правило, тот же запрос не дошёл до nginx вообще и получил 403 от прокси. Одинаковый на вид «отказ» в двух прогонах имел двух разных авторов, и различить их можно только по коду и телу ответа.
Теперь про контраст. Для client-denied, у которого разрешений нет вовсе, отказ выглядит принципиально иначе — обращение напрямую по адресу пода 10.244.3.127, в обход резолва:
$ kubectl -n cookbook-net exec client-denied -- curl -sS -m 10 http://10.244.3.127:8080/a
curl: (28) Connection timed out after 10001 milliseconds
HTTP_CODE=000 — кода нет, тела нет, ответа нет. Пакет SYN ушёл и не получил ничего: датаплейн отбросил его молча, и клиент просто не дождался ответа за десять секунд.
Отсюда — оговорка, без которой из этого раздела легко унести неверное обобщение. То, что L7-отказ приходит осмысленным HTTP-ответом с кодом 403 и телом, — свойство связки Cilium с Envoy в этой конфигурации: прокси терминирует HTTP, разбирает запрос и сам формирует ответ. Это не универсальное правило «любой L7-энфорсер отвечает HTTP-кодом» — другой инструмент с другой архитектурой может вместо ответа просто разорвать соединение. Обобщать наблюдение на произвольный L7-фильтр не стоит.
А вот сама мысль про два разных уровня, на которых запрос может не пройти, полезна при отладке всегда: пакет могут отбросить до установления соединения — и тогда у клиента не будет ничего, кроме таймаута и HTTP_CODE=000, — а могут пропустить, разобрать и отвергнуть уже разобранный запрос. В связке Cilium с Envoy эти два случая различаются по виду отказа: на L3/L4 отказ — это тишина, на L7 — ответ с кодом и телом. Если ваш энфорсер устроен так же, вид отказа сразу называет уровень, на котором сработало правило, ещё до того, как открывать манифесты. Если он вместо ответа рвёт соединение, такой признак не работает — уровень придётся выяснять по вердиктам в потоке, как в следующем разделе.
Hubble: вердикт вместо tcpdump
Обычный способ понять, почему не ходит трафик, — пойти на узлы и снять tcpdump, вручную сопоставляя адреса с подами, а порты с сервисами. На кластере с Talos Linux этот путь закрыт физически: SSH к узлам нет. Hubble решает ту же задачу иначе — он читает события eBPF-датаплейна и отдаёт их как именованный поток с уже готовым вердиктом.
Это тоже не поведение Kubernetes по умолчанию, а надстройка над Cilium, включаемая параметром enable-hubble (в конфигурации выше он true). Без CNI, который умеет разбирать собственные события, ничего подобного не появится.
Практическая деталь при получении потока: kubectl exec ds/cilium выбирает под DaemonSet недетерминированно и вполне может попасть на узел, где нужного трафика нет вовсе — тогда локальный hubble observe покажет пустой вывод, и это легко принять за «трафика не было». Надёжный путь — адресовать hubble-relay, кластерный агрегатор потоков со всех узлов, по его ClusterIP:
RELAY_IP=$(kubectl -n kube-system get svc hubble-relay -o jsonpath='{.spec.clusterIP}')
kubectl -n kube-system exec ds/cilium -- hubble observe --server ${RELAY_IP}:80 \
--namespace cookbook-net --last 40
Обращение к relay по DNS-имени изнутри пода cilium в этой конфигурации не сработало — помог только явный ClusterIP (10.102.168.139 на момент снятия примера; в другом кластере адрес будет свой). Правдоподобное объяснение — под агента работает в сети узла и берёт резолвер оттуда, но отдельно это не проверялось, поэтому пусть остаётся догадкой, а не фактом.
Ещё одно ограничение, о котором лучше знать заранее: поток конечен. Hubble держит кольцевой буфер, и он в рабочем кластере полон практически всегда:
$ kubectl -n kube-system exec ds/cilium -- hubble status | grep -E '^(Current/Max Flows|Flows/s)'
Current/Max Flows: 4,095/4,095 (100.00%)
Flows/s: 12.27
Число потоков в секунду здесь — характеристика текущей нагрузки кластера, а не константа; значение имеет сам факт: буфер ограничен и постоянно перезаписывается. Значит, порядок отладки — сначала воспроизвести ситуацию, потом сразу снимать срез, а не наоборот.
Теперь то, ради чего всё это. Три ситуации из предыдущих разделов дают три отчётливо разных рисунка в потоке.
Разрешённый запрос (GET /a) — полный TCP-хендшейк с вердиктом ALLOWED, затем отдельная пара HTTP-записей:
$ kubectl -n kube-system exec ds/cilium -- hubble observe --server ${RELAY_IP}:80 \
--namespace cookbook-net --protocol http --last 20 | grep 'server:8080/a' | tail -2
... http-request FORWARDED (HTTP/1.1 GET http://server:8080/a)
... http-response FORWARDED (HTTP/1.1 200 0ms (GET http://server:8080/a))
Виден весь путь: метод, путь, код ответа, время обработки.
L7-отклонение (GET /b) — тот же адрес и порт, поэтому TCP-хендшейк тоже получает ALLOWED, а вот сам HTTP-запрос получает свой, отдельный вердикт:
$ kubectl -n kube-system exec ds/cilium -- hubble observe --server ${RELAY_IP}:80 \
--namespace cookbook-net --protocol http --last 20 | grep 'server:8080/b' | tail -2
... http-request DROPPED (HTTP/1.1 GET http://server:8080/b)
... http-response FORWARDED (HTTP/1.1 403 0ms (GET http://server:8080/b))
Две записи на одну HTTP-транзакцию, и они означают разное. DROPPED на входящем запросе — это и есть решение политики: прокси разобрал запрос и отклонил именно его. FORWARDED на исходящем ответе — доставка уже принятого решения клиенту: сам ответ с кодом 403 сети не запрещён, он спокойно уходит обратно. Метод и путь видны текстом, поэтому понятно, что отклонён конкретный запрос, а не соединение: тот же клиент к тому же адресу секундой раньше получил 200.
L3/L4-отклонение (client-denied) — единственная пара записей на каждую попытку, и дальше SYN дело не идёт:
$ kubectl -n kube-system exec ds/cilium -- hubble observe --server ${RELAY_IP}:80 \
--namespace cookbook-net --verdict DROPPED --last 20 | grep client-denied | tail -2
... policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
... Policy denied DROPPED (TCP Flags: SYN)
Ни SYN-ACK, ни HTTP-записей — пакет отброшен до установления соединения. curl повторяет SYN примерно раз в секунду, и каждая попытка фиксируется отдельно с тем же вердиктом. Это единственная из трёх ситуаций, где причина названа буквально в самой строке — Policy denied.
Честное ограничение текстового вывода: для HTTP-записей отдельного поля с причиной в нём нет. У L3/L4-отказа причина — часть строки, а у L7-отказа её приходится читать косвенно, по вердикту DROPPED на запросе в паре с кодом 403 в ответе. Более точная причина — вплоть до имени сработавшего правила — доступна в JSON-выводе, но в текстовом потоке её не будет.
Итог по наблюдаемости: одной командой через API-сервер видно и вердикт, и уровень, на котором он вынесен, — без остановки трафика, без выхода за пределы kubectl и без ручного сопоставления адресов с подами.
Где заканчивается CNI и начинается service mesh
Всё показанное выше — возможности CNI. Никакого service mesh в примере не разворачивалось, и сравнивать «CNI против CNI плюс mesh» здесь не с чем: замеров такого сравнения нет и придумывать их незачем. Зато теперь понятно, где именно проходит граница.
Что CNI уже закрыл: сегментацию трафика по identity, а не по адресам; egress-контроль, включая служебный; ограничение по HTTP-методу и пути; сквозную наблюдаемость с вердиктом и уровнем отказа. Для большинства кластеров, где вопрос стоит как «ограничить, кто с кем разговаривает, и понять, почему не ходит», этого достаточно — и достаётся оно без единого дополнительного пода рядом с приложением.
Что добавляет service mesh поверх этого — четыре группы возможностей. Первая: mTLS между сервисами с автоматической выдачей и ротацией сертификатов — шифрование и взаимная аутентификация внутри кластера без правки приложений. Вторая: управление трафиком на уровне запросов — ретраи с бюджетом, таймауты, размыкатель цепи, отсечка перегруженных экземпляров. Третья: разделение трафика по долям между версиями сервиса — база для канареечных выкаток и того, что делает «GitOps с ArgoCD: progressive delivery, canary и blue-green»готовится, с 29 октября. Четвёртая: телеметрия уровня запросов на всех связях сразу — доли ошибок, перцентили задержки, трассировка между сервисами.
Подходов к тому, где это выполняется, сейчас два. Классический — sidecar: рядом с каждым контейнером приложения едет прокси, весь трафик пода идёт через него. Модель предсказуемая и зрелая, но цена линейна по числу подов: каждый прокси — это память, процессор, свой жизненный цикл и лишний сетевой прыжок в обе стороны. Второй подход — sidecarless: функции выносятся на уровень узла, в датаплейн CNI или в один прокси на узел, а из подов исчезают. Ресурсов уходит меньше, обновление прокси перестаёт быть перекаткой всех подов, но модель новее и в деталях менее обкатана. Устройство того и другого — Istio, Linkerd, голый Envoy, ambient и eBPF-варианты — разбирается отдельно в «Service mesh вглубь: Istio, Linkerd, Envoy и sidecarless»Скоро.
Теперь честный критерий. Mesh оправдан, когда есть конкретная потребность из списка выше, которую нечем закрыть иначе: требование шифровать трафик между сервисами внутри кластера и доказывать это аудиту; десятки взаимозависимых сервисов, где отказ одного лавиной валит остальные и нужны ретраи с размыканием цепи централизованно, а не в каждом приложении по-своему; регулярные канареечные выкатки с делением трафика по долям; несколько кластеров с общей моделью идентичности. Mesh избыточен, когда сервисов единицы, mTLS не требуется или решается на границе кластера, а «наблюдаемость» на деле означает «понять, почему не ходит трафик» — на этот вопрос, как видно выше, отвечает CNI.
Цена, которую стоит закладывать заранее: дополнительная задержка на каждом запросе (в sidecar-модели — на обоих концах связи), заметный расход памяти и процессора на прокси, второй control plane со своими обновлениями и совместимостью версий — и, главное, ещё один слой, который умеет ломаться сам и делает диагностику двухэтажной: теперь на каждый вопрос «почему не прошло» есть два кандидата вместо одного.
Отсюда порядок действий, который стоит держать по умолчанию. Сначала — default-deny и точечные разрешения, включая DNS. Потом — наблюдаемость на уровне CNI, чтобы видеть вердикты и уровень отказа. И только когда появилась названная потребность, которую этими средствами не закрыть, — mesh, под эту конкретную потребность, а не «потому что так делают в больших кластерах». Обратный порядок — сначала mesh, потом разбираться, — даёт кластер, в котором сложность выросла, а на вопрос «кто с кем имеет право разговаривать» по-прежнему нет ответа.
Дальше по серии сеть смыкается с соседними темами: CiliumNetworkPolicy — это ровно тот механизм расширения API через собственные типы объектов, который разбирается в «Операторы и CRD: расширяем Kubernetes под себя»готовится, с 27 октября; кто сводит применённые политики к фактическому состоянию датаплейна — в «Архитектура Kubernetes: control plane и reconciliation loop»; расход процессора и памяти на прокси, который приносит с собой mesh, считается по правилам из «Ресурсы в Kubernetes: requests, limits и QoS»; ограничение прав самой нагрузки — в «Hardening контейнеров и подов: Pod Security, seccomp, capabilities»готовится, с 3 ноября; а обращение к базе по имени сервиса, которое без правила для DNS ломается ровно так же, — в «StatefulSets и хранилище: stateful-нагрузки в Kubernetes». Входящий в кластер трафик — тема статьи про Gateway API; кластер, на котором всё это снято и к узлам которого нет SSH, собран в «Четыре отказа при bootstrap на Talos»; а если вопрос стоит шире — нужен ли вообще кластер под вашу задачу, — на него отвечает «Kubernetes для домашней лаборатории».
Источники
- Kubernetes Documentation — Network Policies, Cluster Networking, Service, DNS for Services and Pods
- Cilium — документация, Identity-Based Security, kube-proxy replacement, Layer 7 Policy, CiliumNetworkPolicy
- Hubble — обзор и компоненты, настройка и работа
- Gateway API · CNI specification
- Стенд:
kubernetes/networking(Kubernetes 1.36.2, Talos Linux, k8s-volga; Cilium v1.19.4, CoreDNS v1.14.2,nginx:1.27.3,curlimages/curl:8.11.1)
Комментарии