
🌐 Auch auf: English · Français · Español
Deep Dive aus der Reihe rund um die Ansible-Tour. Niveau: fortgeschrittene Linux-Administration. Die Tour erklärt die Rollen; die Deep Dives erklären die Technik darunter – wie sie funktioniert, warum sie hier ist, was genau dieses Setup damit macht, wie man sie im Alltag betreibt und was es sonst noch gegeben hätte.
nftables hat iptables im Kernel schon vor Jahren abgelöst, aber die meisten Anleitungen übersetzen alte iptables-Regeln Zeile für Zeile und hören dann auf. Das geht gut, bis ein zweites Programm ebenfalls Regeln schreibt – und auf einem K3s-Host tun das mehrere. Dieser Beitrag erklärt das Datenmodell von nftables mit den Details, auf die es in der Praxis ankommt, wie die Rolle common_firewall eine Tabelle für Host, fail2ban, Portscan-Erkennung und SSH-Länderfilter baut, was K3s daneben legt, und vier Fehler der letzten Monate, die jeweils eines dieser Details auf die harte Tour beigebracht haben. Die Umstellung selbst beschreibt ein früherer Beitrag, den Länderfilter ein weiterer; hier geht es darum, wie alles zusammenpasst.
🧭 Wie nftables entscheidet
Tabellen, Chains, Hooks
Bei iptables waren die Tabellen (filter, nat, mangle) und ihre Chains fest vorgegeben. Bei nftables ist eine Tabelle nur ein Namensraum, den man selbst anlegt, und sie gehört zu einer Adressfamilie: ip, ip6, inet (beide), arp, bridge, netdev. Eine Chain sieht nur dann Pakete, wenn sie eine Base Chain ist – eine, die an einem Netfilter-Hook hängt (prerouting, input, forward, output, postrouting), mit einer Priorität und einer Policy. Normale Chains ohne Hook erreicht man nur per jump oder goto.
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
...
}
}Prioritäten: mehrere Tabellen an einem Hook
An demselben Hook können beliebig viele Base Chains aus beliebig vielen Tabellen hängen. Der Kernel führt sie in der Reihenfolge ihrer Prioritätszahl aus – die kleinere zuerst: raw ist −300, Connection Tracking läuft bei −200, mangle −150, dstnat −100, filter 0, srcnat 100. Daraus folgen zwei Tatsachen, die man leicht übersieht:
- Ein accept ist nur für die eigene Chain endgültig. Akzeptiert unsere
input-Chain ein Paket, sieht es die nächste Base Chain am selben Hook – etwa die von K3s – trotzdem und kann es verwerfen. Nur ein drop beendet den Weg des Pakets überall. - Es zählt, wo eine Chain relativ zu Conntrack sitzt. Eine Chain bei −300 läuft vor dem Connection Tracking und kann nicht wissen, ob ein Paket eine Antwort ist. Eine bei −150 läuft danach und kann
ct statenutzen. Einer der Fehler unten kam genau aus diesem Unterschied.
Sets und Maps
Sets sind typisierte Sammlungen (ipv4_addr, ipv6_addr, Portnummern, Verkettungen), die im Kernel liegen und in konstanter Zeit nachgeschlagen werden. Mit flags interval halten sie Bereiche, mit flags timeout läuft jedes Element von allein ab, und mit flags dynamic kann die Regel selbst aus dem Paketpfad Elemente hinzufügen (add @set, update @set). Genau das machte ipset überflüssig: Eine Sperrliste mit 24 Stunden Ablaufzeit sind drei Zeilen.
set banned4 {
type ipv4_addr
flags timeout
timeout 1d
}
...
ip saddr @banned4 dropAtomares Ersetzen – und warum „flush ruleset“ gefährlich ist
nft -f datei wendet die ganze Datei als eine Transaktion an: Entweder wird alles geladen oder nichts, und es gibt keinen Moment ohne Regeln. Viele Beispielkonfigurationen beginnen mit flush ruleset. Auf einem Host, auf dem nur ein Programm Regeln schreibt, ist das in Ordnung. Auf einem Host, auf dem kube-proxy, flannel und der Kubernetes-Network-Policy-Controller eigene Tabellen pflegen, löscht es deren Regeln mit – sie merken es erst bei ihrer nächsten Synchronisierung, Sekunden bis Minuten später.
iptables-nft und iptables-legacy
Die meisten Kubernetes-Komponenten sprechen noch iptables. Auf aktuellen Distributionen ist der Befehl iptables in Wahrheit iptables-nft: dieselbe Syntax, aber er schreibt in nftables-Tabellen namens ip filter, ip nat usw. und nutzt für alte Match-Module eine Kompatibilitätsschicht. Das andere Backend, iptables-legacy, schreibt in die alten x_tables-Strukturen des Kernels. Beide können gleichzeitig aktiv sein – und dann zeigen weder nft list ruleset noch iptables-legacy -L das ganze Bild.
🛡️ Warum es hier ist
- Eine lesbare Datei für IPv4 und IPv6 (
table inet), versioniert in Git, aus einem Template gerendert. - Default drop in beide Richtungen. Eingehend nur Web, SSH und was K3s braucht; ausgehend nur DNS, NTP, HTTP(S), Mail-Einlieferung und der Verkehr des Clusters selbst.
- Sperrlisten im Kernel mit Ablaufzeiten für fail2ban, die Portscan-Erkennung und die SSH-Länderliste – kein Daemon im Paketpfad.
🔧 Was dieses Repository macht
Eine eigene Tabelle
Die Rolle rendert fw.nft.j2 nach /etc/sysconfig/nftables.conf – die Datei, die die nftables-Unit auf AlmaLinux tatsächlich liest (nicht /etc/nftables.conf). Die Datei beginnt so:
table inet filter
delete table inet filter
table inet filter {
...
}Die erste Zeile legt die Tabelle an, falls es sie nicht gibt (damit das delete beim frischen Boot nicht scheitert), die zweite löscht sie mit allem darin, dann wird sie neu aufgebaut – alles in einer Transaktion. Nur unsere Tabelle wird ersetzt; die Tabellen von K3s bleiben unberührt. Ein systemd-Drop-in grenzt die Unit genauso ein: ExecReload lädt die Datei, ExecStop führt nft delete table inet filter aus statt des Standard-Flush der Distribution. Bevor die Datei die aktive ersetzt, prüft Ansible den gerenderten Kandidaten mit nft -c -f; ein Syntaxfehler erreicht die Firewall nie.
Die input-Chain, der Reihe nach
- Loopback, established/related, invalid verwerfen; XMAS- und NULL-Pakete verwerfen, SYN+ACK bei neuen Verbindungen mit Reset beantworten, RSTs begrenzen.
- Sperrlisten zuerst:
banned4/6(fail2ban, 24 h) undscanban4/6(Portscanner, 1 h) – vor jedem accept, damit eine gesperrte Adresse nichts erreicht. - K3s-Ausnahmen: Pods dürfen API-Server und Kubelet erreichen (6443, 10250), das eigene HTTP(S) des Hosts für den Selbsttest von cert-manager, und auf den Nextcloud-Hosts MariaDB und Redis auf dem Host.
- Private und reservierte Quellbereiche von außen verwerfen, das sinnvolle ICMP und die ICMPv6-Typen erlauben, ohne die IPv6 nicht leben kann.
- Web auf 80/443 (kein UDP 443 – der Ingress-Controller kann kein HTTP/3), SSH auf 10022 nur aus der Länderliste.
- Portscan-Erkennung: Was jetzt noch hier ist, trifft gleich auf das Policy-Drop – ein geschlossener Port. Eine Quelle, die mehr als 30 SYNs pro Minute an geschlossene Ports schickt, landet durch die Regel selbst in
scanban4. - Begrenzte Logzeile, Policy-Drop.
tcp flags & (fin|syn|rst|ack) == syn tcp dport != 22 \
update @portscan4 { ip saddr limit rate over 30/minute burst 30 packets } \
add @scanban4 { ip saddr } dropfail2ban kann das nicht: Es liest nur Logs, und das Drop-Log ist absichtlich auf zwei Zeilen pro Minute begrenzt. Port 22 ist ausgenommen, damit ein paarmal vergessenes -p 10022 den Admin nie aussperrt.
fail2ban schreibt in dieselben Sets
fail2ban bekommt keine eigene Tabelle. Seine Aktion besteht aus zwei Zeilen:
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; trueJeder Ban trägt seine eigene Ablaufzeit und läuft im Kernel ab, auch wenn fail2ban gar nicht läuft. Auf dem Blog enthielt banned4 beim Schreiben 22 Adressen, die SSH-Erlaubnisliste ssh_geo4 7.498 deutsche Adressbereiche (mit flags interval und auto-merge, wöchentlich aus RIPE-Daten gebaut). Seit dem 19. Oktober 2026 schützt dieselbe Liste auch den WordPress-Login: ssh-geo-update schreibt sie ein zweites Mal als nginx-geo-Datei, und nginx im Blog-Pod beantwortet /wp-login.php und /wp-admin/ für alles außerhalb mit 403. Seit dem 20. Oktober 2026 geht dieselbe Liste auf jedem Server auch an den Ingress-Controller, der /grafana außerhalb davon mit 403 beantwortet.
Was K3s daneben schreibt
Auf dem Blog zeigt nft list tables sieben Tabellen. Nur inet filter gehört uns (49 Regeln). Die anderen kommen von K3s über iptables-nft: ip filter mit etwa 190 Regeln – kube-proxys KUBE-SERVICES, KUBE-NODEPORTS, KUBE-PROXY-FIREWALL und die KUBE-ROUTER-*, KUBE-NWPLCY-* und eine KUBE-POD-FW-*-Chain pro Pod vom Network-Policy-Controller –, ip nat mit etwa 120 (Service-DNAT, flannels Masquerading) und kleine ip6-Tabellen. Unsere forward-Chain muss daher nur das Pod-Netz durchlassen; ob ein Pod mit einem anderen sprechen darf, entscheiden die Chains von K3s am selben Hook.
🐛 Vier Fehler, vier Details
1. „flush ruleset“ legte die Services lahm
Die erste Version des Templates begann mit flush ruleset. Jedes Neuladen der Firewall löschte auch die Regeln von kube-proxy und flannel; Nextcloud verlor 30 bis 90 Sekunden lang die Verbindung zu MariaDB und Redis über die ClusterIP, bis kube-proxy neu synchronisierte. Die Lösung ist das tabellenbezogene delete von oben.
2. Ein Neuladen entsperrte still alle
Wer unsere Tabelle ersetzt, ersetzt auch die Sperr-Sets – leer. fail2ban merkte nichts und führte die Adressen in seiner eigenen Datenbank weiter als „gesperrt“, während die Firewall sie wieder hereinließ. Gefunden am 30. September 2026: Nach einem Playbook-Lauf existierten auf dem einen Nextcloud-Server 15 von 17 Bans nur noch in fail2ban, auf dem anderen 17 von 24. Der Firewall-Handler startet fail2ban jetzt direkt nach jedem Neuladen neu (systemctl try-restart fail2ban), und fail2ban setzt jeden noch aktiven Ban aus seiner Datenbank wieder.
3. Die NodePort-Chain verwarf die Antworten auf unseren eigenen Verkehr
kube-proxy macht jeden NodePort (30000–32767) auf jeder lokalen Adresse erreichbar – auch auf der öffentlichen –, und dieser Verkehr wird per DNAT auf einen Pod umgeschrieben und läuft dann durch die forward-Chain, nicht durch input. Die Exporter waren aus dem Internet erreichbar. Die erste Lösung war eine Chain mit Priorität raw (−300), die alles an diesen Portbereich auf der öffentlichen Adresse verwarf. Das wirkte – und verwarf auch etwa 4 % der Antworten auf ausgehende Pod-Verbindungen: flannel maskiert Pod-Verkehr mit --random-fully über den ganzen Portbereich, also bekamen manche Verbindungen einen Quellport zwischen 30000 und 32767, und ihr SYN-ACK wurde verworfen, bevor Conntrack es als Antwort erkennen konnte. Das Symptom zwei Monate lang: sporadische Timeouts zu GitHub und App-Stores von jedem K3s-Host. Die Chain läuft jetzt bei −150, nach Conntrack, und verwirft nur ct state new.
chain prerouting {
type filter hook prerouting priority -150; policy accept;
ip daddr <public-ipv4> tcp dport 30000-32767 ct state new drop
ip6 daddr <public-ipv6> tcp dport 30000-32767 ct state new drop
}4. Die Policy-Engine, die nichts durchsetzte, und eine zu breite Modulsperre
Zwei Monate lang lief Calico neben K3s als vermeintliche NetworkPolicy-Engine. Ein Deny-all-Test am 19. Oktober 2026 zeigte, dass es nie etwas traf: Calicos Felix behandelt nur cali*-Interfaces – die seines eigenen CNI –, während Flannel in K3s veth* anlegt. Durchgesetzt hatte immer der eingebaute Policy-Controller von K3s (die KUBE-NWPLCY-*-Chains). Calico wurde entfernt; der Blog, der bis dahin iptables-legacy nutzte, wechselte auf reines nftables, und die Legacy-Tabellenmodule wurden mit install … /bin/false in modprobe.d gesperrt. Die erste Fassung dieser Sperre enthielt auch ip_tables und ip6_tables – nach dem nächsten Neustart geriet der Policy-Controller von K3s in Panik, denn iptables-nft nutzt den alten icmp-Match noch über die Kompatibilitätsschicht, und der braucht diese Module. Der Blog antwortete etwa fünf Minuten lang mit 502. Gesperrt sind jetzt nur die Tabellenmodule iptable_* und ip6table_*.
🧰 Betrieb im Alltag
# nur unsere Tabelle (das ganze Regelwerk enthält ~300 K3s-Regeln)
nft list table inet filter
# Sperrlisten: wer, und wie lange noch
nft list set inet filter banned4
nft list set inet filter scanban4
# steht eine Adresse in der SSH-Erlaubnisliste?
nft get element inet filter ssh_geo4 '{ 203.0.113.7 }'
# einen Ban aufheben (fail2bans eigener Eintrag: fail2ban-client unban <ip>)
nft delete element inet filter banned4 '{ 203.0.113.7 }'
# was K3s geschrieben hat
nft list tables
iptables-nft-save | grep -c '^-A'
# ein Paket durch alle Chains verfolgen (zuerst eine Trace-Regel setzen)
nft add rule inet filter prerouting ip saddr 203.0.113.7 meta nftrace set 1
nft monitor trace
nft flush chain inet filter prerouting # entfernt die Trace-Regel wieder - die Rolle stellt die Chain beim nächsten Lauf herHandarbeit und der nächste Ansible-Lauf
Von Hand zu banned4 hinzugefügte Elemente bleiben bis zu ihrem Ablauf – aber nur bis zum nächsten Neuladen, das die Tabelle neu aufbaut. Von Hand hinzugefügte Regeln sind nach dem nächsten Lauf weg, mit Absicht: Das Template ist die Wahrheit. Dauerhafte Ausnahmen gehören ins Template oder ins Inventar (etwa fw_ssh_geo_extra für eine Adresse außerhalb der Länderliste).
Das Drop-Log lesen
Verworfene Pakete werden mit den Präfixen nftables DROP IN:, DROP FWD: und DROP OUT: protokolliert, höchstens zwei pro Minute und Chain. Seit Oktober 2026 sind sie in Loki durchsuchbar: {job="journal", transport="kernel"} |= "nftables DROP". Interessant ist eine DROP-OUT-Zeile – sie bedeutet, dass etwas auf dem Server sich irgendwohin verbinden wollte, wo es nicht darf.
🔭 Was als Nächstes kommt
- Zähler in Prometheus. Benannte Counter an den Drop-Regeln und die Größe der Sets als Metriken würden Angriffsvolumen und Wachstum der Sperrlisten über die Zeit zeigen.
- Flowtables für den Forward-Pfad könnten etablierte Pod-Verbindungen auslagern – bei diesem Verkehr nicht nötig.
- Weniger K3s-Regeln: kube-proxy hat einen eigenen nftables-Modus; sobald K3s ihn als Standard ausliefert, kann die iptables-nft-Schicht weg.
⚖️ Alternativen – und warum reines nftables
| Option | Stärke | Warum nicht hier |
|---|---|---|
| iptables + ipset | Das, was die meisten Kubernetes-Komponenten noch sprechen | Zwei Werkzeuge, getrennte Regelwerke für IPv4/IPv6, kein atomares Ersetzen der ganzen Datei. |
| firewalld | Zonen, Laufzeit vs. dauerhaft, angenehme CLI | Besitzt das Regelwerk und schreibt es um; auf K3s-Hosts kommt es bekanntermaßen mit kube-proxy ins Gehege. Die Rolle entfernt es. |
| eBPF (Cilium, XDP) | Sehr schnell, tiefe Kubernetes-Integration | Ein anderes Betriebsmodell und ein anderes CNI – zu viel für drei Ein-Knoten-Cluster. |
| Reines nftables | Eine lesbare Datei, atomares Laden, Kernel-Sets, kein Daemon | Gewählt. |
Die eigentliche Lehre der letzten Monate ist kein Syntaxdetail. Sie lautet: Eine Firewall auf einem Kubernetes-Host ist nie allein. Jede Annahme darüber, wer welche Regel schreibt und in welcher Reihenfolge der Kernel sie ausführt, muss getestet werden – ein Deny-all-Test fand an einem Nachmittag mehr als zwei Monate Lesen von Konfigurationsdateien.


