6 min czytania

NetworkPolicy w Kubernetes: domyślnie zabroń, potem otwieraj tylko to, co potrzebne

Bez żadnej polityki sieciowej każdy pod w klastrze może połączyć się z każdym. Jak wprowadzić domyślną odmowę per przestrzeń nazw, dopuścić DNS, ruch z kontrolera ingress i do bazy, i nie wyłączyć przy tym aplikacji.

Rys. 1. Reguła domyślnej odmowy blokuje cały ruch w przestrzeni nazw; przechodzą tylko połączenia opisane jawnie: z kontrolera ingress do aplikacji, z aplikacji do bazy i do DNS.

W klastrze działa kilkanaście aplikacji w osobnych przestrzeniach nazw. Przestrzenie nazw porządkują zasoby i uprawnienia, ale nie ruch sieciowy: pod frontendu jednej aplikacji może bez przeszkód połączyć się z bazą danych innej, a pod z podatną biblioteką może wysyłać dane pod dowolny adres w internecie. Gdy jedna aplikacja zostanie przejęta, cała reszta klastra jest w zasięgu jednego połączenia TCP.

Zasada jest ta sama co na firewallu brzegowym: domyślnie odmowa, wyjątki jawne i opisane. Poniżej zestaw polityk dla typowej aplikacji (kontroler ingress, aplikacja, baza), specyfika Cilium i kolejność wdrażania, która nie kończy się awarią.

Jak działa NetworkPolicy

  • Pod, którego nie wybiera żadna polityka, nie jest izolowany: przyjmuje i wysyła cały ruch.
  • Gdy polityka z policyTypes: [Ingress] wybiera pod, ruch przychodzący do niego jest odrzucany, z wyjątkiem tego, na co pozwala suma reguł wszystkich polityk wybierających ten pod. Tak samo dla Egress.
  • Polityki tylko zezwalają. Standardowe API nie ma reguł „deny”, a polityki się sumują.
  • Połączenie między dwoma izolowanymi podami wymaga zgody po obu stronach: reguły egress na źródle i reguły ingress na celu.
  • Port w regule to port kontenera, a nie port Service.
  • Ruch od kubeleta z własnego węzła, w tym sondy liveness i readiness, nie jest blokowany.

Polityki egzekwuje sieć klastra (CNI), nie samo API Kubernetes:

Sieć klastraEgzekwuje NetworkPolicyUwagi
Flannel samodzielnienieAPI przyjmuje polityki, ale nic nie jest blokowane
k3s z domyślnym Flannelemtakwbudowany kontroler polityk, chyba że wyłączono go parametrem --disable-network-policy
Calicotakdodatkowo własne, rozszerzone polityki
Ciliumtakdodatkowo CiliumNetworkPolicy: reguły L7 i nazwy DNS

Krok 1: domyślna odmowa

Pusty podSelector wybiera wszystkie pody w przestrzeni nazw, a brak reguł oznacza, że nic nie jest dozwolone:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Krok 2: DNS

Po domyślnej odmowie ruchu wychodzącego żaden pod nie rozwiąże nazwy, a aplikacje zamiast błędu połączenia zwracają przekroczenie czasu. Wyjątek dla DNS dopuszcza wyłącznie pody CoreDNS w kube-system, na porcie 53 UDP i TCP:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: shop
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

Etykietę kubernetes.io/metadata.name Kubernetes nadaje każdej przestrzeni nazw automatycznie, więc nie trzeba niczego etykietować ręcznie. Jeśli klaster używa NodeLocal DNSCache, zapytania trafiają na adres lokalny węzła i reguła musi to uwzględniać.

Krok 3: kontroler ingress, aplikacja i baza

Aplikacja przyjmuje ruch tylko z kontrolera ingress i łączy się tylko z bazą. Przykład zakłada Traefika w przestrzeni nazw traefik; w k3s domyślnie jest on w kube-system.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: shop-api
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: shop-api
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: traefik
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: shop-db
      ports:
        - protocol: TCP
          port: 5432
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: shop-db
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: shop-db
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: shop-api
      ports:
        - protocol: TCP
          port: 5432
PolitykaWybiera podyPozwala
default-denywszystkienic
allow-dnswszystkieegress do CoreDNS, port 53 UDP i TCP
shop-apishop-apiingress z Traefika na 8080, egress do shop-db na 5432
shop-dbshop-dbingress z shop-api na 5432

Cilium: nazwy DNS, Hubble i tryb audytu

Standardowa NetworkPolicy opisuje ruch wychodzący poza klaster tylko przez zakresy adresów (ipBlock), a adresy usług zewnętrznych się zmieniają. CiliumNetworkPolicy pozwala opisać cel nazwą. Warunkiem jest reguła DNS z sekcją rules.dns: Cilium śledzi wtedy odpowiedzi DNS i wie, jakie adresy kryją się za nazwą.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: shop-api-egress-fqdn
  namespace: shop
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: shop-api
  egress:
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
            k8s:k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: ANY
          rules:
            dns:
              - matchPattern: "*"
    - toFQDNs:
        - matchName: api.payments.example.com
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

Cilium egzekwuje polityki obu rodzajów jednocześnie i sumuje ich reguły. Odrzucone połączenia widać w Hubble:

hubble observe --namespace shop --verdict DROPPED --last 50

Najbezpieczniejszy sposób wdrożenia polityk w Cilium to tryb audytu: polityki są oceniane, ale nic nie jest blokowane, a połączenia, które zostałyby odrzucone, Hubble pokazuje z werdyktem AUDIT. Tryb włącza się w wartościach Helm dla całego klastra, co zmienia konfigurację agentów i wymaga ich restartu:

helm list -n kube-system   # kolumna CHART: zainstalowana wersja, np. cilium-1.17.4
# --version tej samej wersji, żeby przy okazji nie zaktualizować Cilium
helm upgrade cilium cilium/cilium -n kube-system --version 1.17.4 --reuse-values --set policyAuditMode=true
kubectl -n kube-system rollout restart daemonset/cilium
hubble observe --namespace shop --verdict AUDIT

Wdrażanie bez awarii

  1. Obserwacja. Przez kilka dni zbierasz rzeczywiste połączenia przestrzeni nazw: hubble observe --namespace shop. Bez Cilium źródłem są dokumentacja aplikacji i logi połączeń.
  2. Polityki z obserwacji. Każde potrzebne połączenie dostaje regułę; każde nieoczekiwane jest wyjaśniane, zanim dostanie regułę.
  3. Test. W Cilium tryb audytu; bez niego wdrożenie na kopii środowiska.
  4. Przestrzeń nazw po przestrzeni nazw. Zaczynasz od aplikacji najmniej krytycznych. Polityki zezwalające i domyślna odmowa trafiają do przestrzeni nazw razem, bo już sama polityka zezwalająca izoluje pody, które wybiera.
  5. Kontrola odrzuceń po każdym wdrożeniu i po każdej nowej wersji aplikacji.

Awaryjny powrót to usunięcie polityki default-deny z przestrzeni nazw (kubectl delete networkpolicy default-deny -n shop), co natychmiast przywraca ruch niewybrany przez pozostałe polityki. Polityki trzymaj w tym samym repozytorium co manifesty aplikacji: nowa usługa przychodzi wtedy razem ze swoimi regułami.

Sprawdź u siebie

Wszystkie polityki w klastrze:

kubectl get networkpolicy -A
kubectl get ciliumnetworkpolicy -A   # tylko w klastrach z Cilium
# Źle: pusty wynik albo polityki tylko w kilku przestrzeniach nazw.

Przestrzenie nazw bez żadnej polityki:

comm -23 \
  <(kubectl get ns -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | sort) \
  <({ kubectl get networkpolicy -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\n"}{end}'
      kubectl get ciliumnetworkpolicy -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\n"}{end}' 2>/dev/null
    } | sort -u)
# Każda wypisana przestrzeń nazw aplikacji to pody bez żadnej izolacji.

Czy w przestrzeniach nazw jest reguła domyślnej odmowy:

kubectl get networkpolicy -A -o json | jq -r '.items[]
  | select(.spec.podSelector == {})
  | "\(.metadata.namespace)\t\(.metadata.name)\t\(.spec.policyTypes | join(","))\tingress=\(.spec.ingress | length)\tegress=\(.spec.egress | length)"'
# Domyślna odmowa: typy Ingress,Egress i zero reguł obu rodzajów (ingress=0, egress=0).

Odrzucone połączenia po wdrożeniu polityk:

hubble observe --verdict DROPPED --last 100
# Dobrze: wyłącznie ruch, który miał zostać zablokowany. Odrzucenia DNS lub połączeń do bazy oznaczają brakującą regułę.