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.
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 dlaEgress. - 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ć klastra | Egzekwuje NetworkPolicy | Uwagi |
|---|---|---|
| Flannel samodzielnie | nie | API przyjmuje polityki, ale nic nie jest blokowane |
| k3s z domyślnym Flannelem | tak | wbudowany kontroler polityk, chyba że wyłączono go parametrem --disable-network-policy |
| Calico | tak | dodatkowo własne, rozszerzone polityki |
| Cilium | tak | dodatkowo 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
| Polityka | Wybiera pody | Pozwala |
|---|---|---|
default-deny | wszystkie | nic |
allow-dns | wszystkie | egress do CoreDNS, port 53 UDP i TCP |
shop-api | shop-api | ingress z Traefika na 8080, egress do shop-db na 5432 |
shop-db | shop-db | ingress 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
- 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ń. - Polityki z obserwacji. Każde potrzebne połączenie dostaje regułę; każde nieoczekiwane jest wyjaśniane, zanim dostanie regułę.
- Test. W Cilium tryb audytu; bez niego wdrożenie na kopii środowiska.
- 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.
- 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łę.