
🌐 Auch auf: English · Français · Español
Teil 2 der Ansible-Tour. Teil 1 hat erklärt, wie das Repository aufgebaut ist und was bei einem Lauf passiert; dieser Teil beginnt ganz unten im Stack. Wie die ganze Serie beschreibt er das Release v3.2.0, und jeder Link auf eine Datei zeigt dorthin.
Jeder Server in diesem Setup beginnt als schlichtes AlmaLinux-9-Cloud-Image: ein minimales Linux, ein root-Login, sonst nichts. Am Ende dieses Teils ist er ein gepatchtes, abgestimmtes Grundsystem, das Mails verschicken kann, sich nachts selbst aktualisiert, seine Logs rotiert – und einen Single-Node-Kubernetes-Cluster mit Helm, automatischen Let’s-Encrypt-Zertifikaten, einem Ingress-Controller und einer Policy-Engine betreibt. Das ist die Plattform: alles, was Blog und Nextcloud darunter brauchen, und fast alles davon gemeinsam für beide.
Der Teil behandelt acht Rollen. Bei jeder gehe ich Schritt für Schritt durch, was sie tut, welche Ansible-Features sie nutzt und warum, und was auf dem Weg zur heutigen Fassung schiefging – samt Handlern, den deklarierten Eingaben in meta/ und der Logik in den Templates. Wo man ein Linux- oder Kubernetes-Konzept braucht, um den Code zu verstehen, erkläre ich es dort, wo es zum ersten Mal auftaucht.
| Rolle | In einem Satz | Genutzt von |
|---|---|---|
blog_packages / next_packages | Hostname, Paketquellen, Systemupdate, Neustart bei Bedarf, Grundpakete | je Stack eine |
common_sysctl | Kernel-Parameter für Netzwerk und Härtung | beiden |
common_msmtp | Lässt den Server Mails über ein externes SMTP-Konto verschicken | beiden |
common_dnf_automatic | Jede Nacht Sicherheitsupdates, Neustart bei Bedarf | beiden |
common_logrotate | Rotationsregeln für Logs, die keine hatten | beiden |
common_k3s | K3s, Helm, cert-manager, der Ingress-Controller | beiden |
common_calico | Sorgt dafür, dass Kubernetes-NetworkPolicies wirklich wirken | beiden |
Wo diese Rollen im Playbook stehen, ist wichtig, deshalb kurz zur Erinnerung die Reihenfolge: Die Paket-Rolle läuft direkt nach dem Versionsbericht, dann SSH, die Firewall und common_sysctl (die drei Sicherheitsrollen stehen in Teil 5), dann Härtung und SELinux, dann common_k3s und common_calico, dann die Anwendung. common_msmtp, common_dnf_automatic und common_logrotate laufen später, nachdem die Anwendung ausgerollt ist.
🖥️ Bevor eine Rolle läuft: der Server selbst
Ansible braucht etwas, mit dem es sprechen kann: einen Linux-Server mit öffentlicher Adresse und SSH. Alle drei Server hier sind virtuelle private Server (VPS, auch vServer genannt) – virtuelle Maschinen auf der Hardware eines Anbieters, monatlich gemietet. Man bekommt einen festen Anteil an CPU-Kernen, Arbeitsspeicher und Platte, eine öffentliche IPv4- und IPv6-Adresse, root-Zugang und eine Web-Konsole für Notfälle. Man bekommt niemanden, der sich um das System kümmert: Updates, Sicherheit, Backups und Überwachung sind deine Sache. Genau das ist die Aufgabe dieses Repositorys.
Einen Anbieter auswählen
Der Markt ist groß und die Angebote sehen sich ähnlich, also lohnt sich ein Vergleich nach den Punkten, die für den Betrieb eines Dienstes zählen, nicht nur nach dem Preis:
| Punkt | Worauf achten |
|---|---|
| Ressourcen | Anzahl der vCPUs, RAM, Plattengröße und -typ (SSD/NVMe). Prüfe, ob die Kerne dediziert oder geteilt sind – geteilte Kerne sind günstiger und für einen Blog in Ordnung, können aber langsam werden, wenn ein Nachbar viel Last erzeugt. |
| Standort und Recht | Wo das Rechenzentrum steht und unter welchem Recht der Anbieter arbeitet. Für personenbezogene Daten von Nutzern in der EU sind ein Rechenzentrum in der EU und ein Auftragsverarbeitungsvertrag (DSGVO Art. 28) das Minimum. |
| Netzwerk | Eine feste IPv4- und eine IPv6-Adresse (oder ein Netz), inklusiver Traffic und ob der Anbieter Angriffe (DDoS) vorab filtert. |
| Konsolenzugang | Eine Web-Konsole, die auch funktioniert, wenn SSH es nicht tut. Mit gehärtetem SSH und Firewall ist das der Weg zurück nach einem Fehler – teste sie, bevor du sie brauchst. |
| Snapshots | Lässt sich vor einer riskanten Änderung ein Abbild der ganzen Platte anlegen? Das ist kein Backup (es liegt beim Anbieter), aber das schnellste Rückgängigmachen. |
| Images | Bietet der Anbieter ein sauberes Image der gewünschten Distribution an, am besten ein minimales? |
| DNS und Reverse DNS | Du brauchst DNS-Einträge für deine Domain (irgendwo), und ein passender Reverse-DNS-Eintrag für die Adresse des Servers hilft, wenn er Mails verschickt. |
| Vertrag | Monatliche Kündigung, wie Upgrades funktionieren (mehr RAM ohne Neuinstallation?) und welchen Support man in der Praxis bekommt. |
Die drei Server hier laufen bei zwei verschiedenen Anbietern, alle mit KVM-Virtualisierung. Die Verteilung ist ein kleines Sicherheitsnetz: Ein Ausfall oder ein Kontoproblem bei einem Anbieter legt nicht alles lahm.
Die Distribution auswählen
Die Rollen sind für AlmaLinux 9 geschrieben und für nichts anderes – Paketnamen, Pfade, SELinux und dnf werden überall vorausgesetzt. Wie ich von Debian zu AlmaLinux kam, ist eine eigene Geschichte; die Gründe, die für den Betrieb zählen, sind:
- Lange Unterstützung. AlmaLinux ist binärkompatibel zu Red Hat Enterprise Linux und folgt dessen Release-Zyklus: Jede Hauptversion bekommt etwa zehn Jahre lang Sicherheitsupdates. Ein heute eingerichteter Server braucht jahrelang kein großes Upgrade.
- Sicherheitsupdates sind gekennzeichnet. Jedes Update hat eine Meldung mit einem Typ. Genau das erlaubt
dnf-automatic(weiter unten), automatisch nur Sicherheitskorrekturen einzuspielen und alles andere einem geplanten Lauf zu überlassen. - SELinux standardmäßig im Modus „enforcing“, mit einer Container-Richtlinie, die K3s unterstützt. Teil 5 zeigt, warum das wichtig ist.
- Ein großes Ökosystem. Dokumentation, der CIS-Benchmark und die meisten Werkzeuge gibt es für diese Familie zuerst.
Ein Debian- oder Ubuntu-Server würde mit anderen Rollen genauso gut funktionieren – aber nicht mit diesen.
Wie groß?
Die Größe ist erst eine Schätzung und später eine Messung. Das sind die drei Server, mit dem, was sie an einem ruhigen Tag tatsächlich nutzen:
| Server | vCPU | RAM | Platte | RAM in Benutzung (Leerlauf) |
|---|---|---|---|---|
| Blog | 4 | 8 GB | 240 GB | etwa 2,5 GB |
| Kleine Nextcloud (wenige Nutzer) | 6 | 12 GB | 200 GB | etwa 3 GB |
| Große Nextcloud (etwa 150 Nutzer) | 8 | 24 GB | 600 GB | etwa 4 GB |
Wohin der Speicher geht, gemessen auf allen dreien: Die Plattform aus diesem Teil – K3s selbst, die Container-Laufzeit, die System-Pods des Clusters, cert-manager und der Ingress – braucht etwa 1,3 bis 1,5 GB. Prometheus und Grafana kommen mit etwa 0,5 GB dazu. Der Rest ist die Anwendung und der Dateicache des Betriebssystems, den Linux mit allem freien Speicher füllt (das ist gut, kein Problem).
Daraus eine grobe Faustregel:
- Ein Blog wie dieser braucht mindestens 2 vCPU und 4 GB RAM; 4 GB sind mit Monitoring knapp, 8 GB sind bequem. Platte: ein paar Gigabyte für WordPress, dazu die Uploads, dazu Platz für Datenbank-Backups.
- Nextcloud mit Online-Office braucht mehr: Jedes geöffnete Dokument in der Office-Suite kostet 150–200 MB, und Vorschaubilder, Datenbank und PHP wollen alle Speicher. 4 vCPU und 8 GB sind das Minimum für eine kleine Gruppe, mehr für viele gleichzeitige Nutzer. Die Platte hängt ganz an den Daten der Nutzer – plane Wachstum ein und behalte es im Blick (Teil 6 zeigt die Alarme bei 80 und 90 Prozent).
- Kein Swap. Keiner der Server hat Swap. Kubernetes erwartet traditionell, dass er aus ist, und ein Server, der zu swappen beginnt, ist schon zu klein – besser den Speicheralarm sehen und vergrößern.
- Vergrößern statt großzügig raten. Die meisten Anbieter erlauben später mehr Kerne und Speicher. Fang eine Nummer kleiner an, miss mit dem Monitoring aus Teil 6 und wachse, wenn die Zahlen es sagen.
Was der Server vor dem ersten Lauf braucht
Sehr wenig, und genau darum geht es: ein frisches minimales AlmaLinux-9-Image, root-Login mit SSH-Schlüssel auf Port 22, die Adressen und den Hostnamen des Servers im Inventory und DNS-Einträge, die die Domain auf den Server zeigen lassen (cert-manager kann nur für einen Namen ein Zertifikat holen, der auf ihn auflöst). Der erste Lauf verlegt dann SSH auf seinen neuen Port, schaltet die Firewall ein und startet einmal neu – Teil 5 erklärt, wie das funktioniert, ohne Ansible auszusperren.
📦 blog_packages und next_packages: vom Cloud-Image zum Grundsystem
Diese beiden Rollen sind das einzige Paar im Repository, das dieselbe Aufgabe in zwei Varianten erledigt. Der Blog betreibt alles in Containern, sein Host braucht also sehr wenig. Ein Nextcloud-Host betreibt zusätzlich MariaDB und Redis direkt auf dem Host (Teil 4 erklärt, warum) und braucht deshalb zwei Paketquellen und zwei Dienste mehr. Alles andere ist gleich, und ich beschreibe beide zusammen und weise auf die Unterschiede hin.
Schritt 1: ein echter Hostname
- name: Packages | Set system hostname to the public FQDN
ansible.builtin.hostname:
name: "{{ blog_hostname }}"
- name: Packages | Ensure /etc/hosts has a matching entry
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1'
line: "127.0.1.1 {{ blog_hostname }}"Ein frisches Cloud-Image heißt localhost. Das klingt harmlos, bis mehrere Server Mails verschicken: Jeder Bericht von rkhunter, ClamAV oder dem nächtlichen Update-Job setzt $(hostname -f) in seine Betreffzeile, und fünf Mails von „localhost“ sind nutzlos. Also setzt der allererste Task den öffentlichen Hostnamen aus dem Inventory. Das Modul hostname tut, was hostnamectl tut – es ändert den laufenden Namen und macht ihn dauerhaft.
Der zweite Task zeigt ein Modul, das dir oft begegnen wird: lineinfile. Es verwaltet eine Zeile in einer Datei. regexp sagt, für welche Zeile es zuständig ist (hier: die Zeile, die mit 127.0.1.1 beginnt), line sagt, wie sie aussehen soll. Gibt es eine passende Zeile und sie stimmt: ok. Gibt es sie, aber sie weicht ab: ersetzt. Gibt es keine: angehängt. Das ist von Grund auf idempotent und lässt den Rest der Datei in Ruhe – das richtige Werkzeug, wenn eine Datei dem System gehört und Ansible nur eine einzelne Zeile davon verwaltet.
Woher kommt blog_hostname? Aus der vars.yml des Hosts – und die Rolle sagt das in meta/argument_specs.yml:
argument_specs:
main:
short_description: Minimal host-level packages for the blog server
options:
blog_hostname:
description: Public hostname of the blog (e.g. www.example.de)
type: str
required: trueBevor der erste Task läuft, prüft Ansible jede dort aufgeführte Option. Ein Host ohne blog_hostname scheitert genau hier mit einem klaren „missing required arguments“ – nicht drei Tasks später mit einem Fehler über eine undefinierte Variable in einem Template. Die Nextcloud-Variante deklariert nextcloud_hostname auf dieselbe Weise.
Schritt 2: Paketquellen – erst fragen, dann handeln
Eine Paketquelle (Repository) ist eine Quelle, aus der dnf Pakete lädt. AlmaLinux kommt mit BaseOS und AppStream eingeschaltet. Viele Werkzeuge in diesem Setup (fail2ban, Monit, rkhunter, der Python-Client für Kubernetes) liegen in EPEL, dem Repository „Extra Packages for Enterprise Linux“, also installieren beide Rollen epel-release – ein kleines Paket, dessen einziger Inhalt die Definition der Paketquelle ist.
Die Nextcloud-Rolle braucht zwei weitere. CRB (CodeReady Builder) gehört zu AlmaLinux, ist aber abgeschaltet, und manche EPEL-Pakete hängen davon ab. Für „schalte diese Paketquelle ein“ gibt es keine Option in einem Ansible-Modul, also nutzt die Rolle einen Befehl – und macht ihn mit einer Wache idempotent:
- name: Packages | Read list of currently enabled repositories (to check if CRB is active)
ansible.builtin.command: dnf repolist --enabled
register: enabled_repos
changed_when: false
check_mode: false
- name: Packages | Enable CRB repository (only if not already active)
ansible.builtin.command: dnf -y config-manager --set-enabled crb
when: "'crb' not in enabled_repos.stdout"
changed_when: trueDieses Paar „erst fragen, dann handeln“ ist das häufigste Muster im ganzen Repository, es lohnt sich also, langsam zu lesen. Der erste Task liest nur: Er führt dnf repolist aus und speichert das Ergebnis mit register. changed_when: false sagt Ansible, dass Lesen nichts ändert (ein command-Task würde sonst immer „changed“ melden, weil Ansible nicht wissen kann, was ein Befehl tut). check_mode: false lässt ihn sogar im Probelauf laufen – er ist harmlos, und ohne seine Ausgabe könnte der zweite Task nichts entscheiden. Der zweite Task läuft nur, when CRB in dieser Ausgabe fehlt, und wenn er läuft, hat er etwas geändert (changed_when: true). Ergebnis: Der erste Lauf meldet eine Änderung, jeder spätere keine.
Die dritte Paketquelle ist MariaDB Community. AlmaLinux hat ein eigenes MariaDB, aber die Nextcloud-Hosts betreiben eine bestimmte Versionsreihe direkt von MariaDB, festgelegt in versions.yml. Drei Schritte:
rpm_keyimportiert den GPG-Schlüssel des Projekts. Jedes Paket ist signiert; mit installiertem Schlüssel verweigertdnfPakete, die unterwegs verändert wurden.- Ein
copy-Task schreibt/etc/yum.repos.d/mariadb.repomit der festgelegten Versionsreihe in der URL – die Variable steht sogar im Task-Namen, sodass die Ausgabe des Laufs zeigt, welche Version eingerichtet wurde. - Das AppStream-„Modul“ für MariaDB wird abgeschaltet. AppStream-Module sind alternative Paketströme, und ein eingeschaltetes MariaDB-Modul würde die Community-Pakete verdecken. Wieder „erst fragen, dann handeln“: zuerst
dnf module list --disabled mariadb,dnf module disablenur, wenn es noch nicht abgeschaltet ist.
Noch ein Detail: EPEL bekommt module_hotfixes=1 in seine Repository-Datei, über das Modul community.general.ini_file. Eine .repo-Datei ist eine INI-Datei, und ini_file setzt genau eine Option in einem Abschnitt – das INI-Gegenstück zu lineinfile.
Schritt 3: das Systemupdate – und warum der Kernel draußen bleibt
- name: Packages | Upgrade all system packages # noqa: package-latest
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
exclude: kernel*name: "*" mit state: latest ist ein vollständiges dnf upgrade. ansible-lint warnt normalerweise vor state: latest (es macht Läufe nicht reproduzierbar), und der Kommentar # noqa sagt ihm, dass hier ein vollständiges Update genau der Zweck ist.
Interessant ist exclude: kernel*, und um das zu verstehen, braucht man ein Stück der SSH-Härtung aus Teil 5. Der SSH-Server läuft mit einer systemd-Einstellung namens ProtectKernelModules. systemd startet jede SSH-Sitzung in einem eigenen Mount-Namespace – einer privaten Sicht auf das Dateisystem –, und in dieser Sicht ist das Verzeichnis der Kernel-Module /usr/lib/modules leer und schreibgeschützt. Niemand, der sich per SSH anmeldet, kann ein Kernel-Modul laden, genau darum geht es. Aber auch Ansible meldet sich per SSH an, und einen neuen Kernel zu installieren heißt, seine Module genau in dieses Verzeichnis zu schreiben. Innerhalb der Sitzung scheitert das immer.
Also bleiben Kernel hier außen vor, und sie kommen trotzdem an: dnf-automatic (weiter unten in diesem Beitrag) läuft als systemd-Timer, außerhalb jeder SSH-Sitzung, und installiert jede Nacht Sicherheitsupdates – Kernel eingeschlossen.
Schritt 4: nur neu starten, wenn es wirklich nötig ist
Zwei Situationen verlangen einen Neustart, bevor der Rest des Plays funktionieren kann. Die erste ist heimtückisch: Ein Cloud-Image hat manchmal schon einen neueren Kernel installiert, wenn Ansible sich zum ersten Mal verbindet, während noch der alte läuft. Das Modulverzeichnis des alten Kernels ist weg, und der erste Task, der ein Modul braucht – common_sysctl, das ein paar Rollen später tcp_bbr lädt –, scheitert mit einem verwirrenden „No such file or directory“. Besser, man bemerkt es hier:
- name: Packages | Check whether the running kernel's module directory still exists
ansible.builtin.command: >-
systemd-run --wait --pipe --quiet --
test -d /lib/modules/{{ ansible_facts['kernel'] }}
register: running_kernel_modules
changed_when: false
check_mode: false
failed_when: false
- name: Packages | Reboot – running kernel's modules are gone
ansible.builtin.reboot:
reboot_timeout: 300
when: running_kernel_modules.rc != 0Warum nicht einfach das Verzeichnis mit stat prüfen? Wegen derselben SSH-Sandbox: Innerhalb der Sitzung sieht /lib/modules immer leer aus, und eine einfache Prüfung würde bei jedem Lauf einen fehlenden Kernel „finden“ und umsonst neu starten. systemd-run ist der Ausweg. Es bittet systemd (PID 1), einen kurzlebigen Dienst zu starten – eine transiente Unit –, der außerhalb des Namespaces der SSH-Sitzung läuft und das echte Dateisystem sieht. --wait wartet, bis er fertig ist, --pipe gibt seine Ausgabe und seinen Exit-Code zurück. Die Härtung bleibt vollständig bestehen; nur dieser eine Befehl läuft dort, wo er hinsehen darf. systemd-run begegnet dir in common_sysctl wieder, aus demselben Grund.
ansible_facts['kernel'] ist einer der wenigen Facts, die dieses Repository nutzt – die Version des laufenden Kernels, zu Beginn des Plays gesammelt. failed_when: false macht aus „test hat 1 zurückgegeben“ ein Ergebnis statt eines Fehlers, und das Modul reboot erledigt den Rest: Es startet neu, wartet bis zu fünf Minuten, bis SSH wieder antwortet, und das Play macht einfach mit dem nächsten Task weiter.
Die Blog-Rolle hat eine zweite Prüfung, die die Nextcloud-Rolle nicht hat: needs-restarting -r (aus yum-utils) meldet, ob aktualisierte Systembibliotheken wie glibc, systemd oder OpenSSL auf einen Neustart warten. Es endet mit 1, wenn ja, und der Blog startet sofort neu. Die Nextcloud-Rolle hat diese zweite Prüfung nicht; dort passieren solche Neustarts nachts über den Job, der weiter unten beschrieben ist.
Schritt 5: Pakete und Dienste
Dann die eigentlichen Paketlisten. Auf dem Blog: nftables, die SELinux-Werkzeuge, container-selinux (die SELinux-Richtlinie, mit der Container laufen dürfen), ein paar Hilfs- und Diagnosewerkzeuge. Auf Nextcloud zusätzlich MariaDB-server, redis, rsync und rdiff-backup für die Backups und python3-PyMySQL – die Python-Bibliothek, die die MySQL-Module von Ansible auf dem Host brauchen. Die Nextcloud-Rolle schaltet danach MariaDB und Redis ein und startet sie.
Der letzte Block schaltet ab, was dieser Server nicht nutzt. firewalld wird gestoppt, deaktiviert und auf dem Blog zusätzlich maskiert – masked: true verlinkt die Unit auf /dev/null, sodass sie gar nicht mehr gestartet werden kann, nicht einmal als Abhängigkeit eines anderen Dienstes (die Firewall-Rolle in Teil 5 maskiert sie auf allen Hosts). Die NFS/RPC- und LDAP-Helfer (rpcbind, sssd) gehen denselben Weg, und gssproxy wird maskiert, weil es sonst über Socket-Aktivierung zurückkommt, selbst wenn es deaktiviert ist. Alle diese Tasks haben failed_when: false: Auf einem Host, auf dem der Dienst nicht existiert, darf „stoppe X“ den Lauf nicht abbrechen.
| Baustein | Wo | Was man mitnimmt |
|---|---|---|
hostname, lineinfile | Schritt 1 | Eine Sache ändern (einen Namen, eine Zeile), ohne die ganze Datei zu verwalten |
argument_specs | meta/ | Eingaben werden vor dem ersten Task geprüft |
register + when + changed_when | Schritt 2 | „Erst fragen, dann handeln“ für Befehle ohne Modul |
rpm_key, ini_file | Schritt 2 | Einer Paketquelle vertrauen, eine INI-Option ändern |
# noqa | Schritt 3 | Eine Lint-Regel stummschalten, wo ihr Bruch der Zweck ist |
systemd-run, reboot | Schritt 4 | An der SSH-Sandbox vorbeischauen; neu starten und weitermachen |
masked: true, failed_when: false | Schritt 5 | Einen Dienst unstartbar machen; fehlende ignorieren |
Keine Handler, keine Defaults: Die Paketlisten stehen direkt in den Tasks, und jeder Dienst wird dort gestartet, wo er installiert wird.
🧮 common_sysctl: Kernel-Parameter
Der Linux-Kernel stellt unter /proc/sys Hunderte Einstellungen bereit – wie sich TCP verhält, wie groß Warteschlangen sind, ob das System bestimmte Pakete annimmt. Das Werkzeug, um sie zu setzen, heißt sysctl, und dauerhaft geht es über eine Datei in /etc/sysctl.d/: Jede Datei dort wird beim Booten gelesen. Diese Rolle schreibt zwei solche Dateien und lädt ein Kernel-Modul.
Zwei Templates, zwei Zwecke
99-webserver.conf dreht sich um Geschwindigkeit:
- TCP BBR statt des Standards CUBIC als Algorithmus zur Staukontrolle – der Teil von TCP, der entscheidet, wie schnell gesendet wird. BBR misst Bandbreite und Laufzeit, statt auf Paketverlust zu warten, was auf langen oder verlustbehafteten Verbindungen hilft. Es braucht den Paket-Scheduler
fq(fair queueing), der in der Zeile davor gesetzt wird. - TCP Fast Open und kein langsamer Neustart nach Leerlauf: Bei einer Website mit unregelmäßigen Besuchern soll eine Verbindung, die kurz ruhig war, nicht wieder langsam anfangen.
- Kürzere Zeitlimits für schließende und ruhende Verbindungen und größere Warteschlangen für eingehende Verbindungen, damit ein Schwall von Besuchern nicht an der Tür abgewiesen wird.
98-cis-hardening.conf dreht sich um Sicherheit – die Netzwerkpunkte des CIS-Benchmarks (eine Geschichte für sich): keine ICMP-Redirects (ein Router, der dem Host sagt „schick es lieber so“, ist der Weg, über den ein Angreifer auf der Strecke Verkehr umleitet), kein Source-Routing, Pakete mit unmöglichen Absenderadressen protokollieren, SYN-Cookies gegen SYN-Fluten, volle Zufallsverteilung des Adressraums, eingeschränktes ptrace und keine Core-Dumps von privilegierten Prozessen.
Am lehrreichsten an dieser Datei ist, was sie weglässt. Ein übliches CIS-Profil schaltet IP-Forwarding ab und strenge Rückwegfilterung (Reverse-Path-Filtering) ein. Auf einem Kubernetes-Node würde beides den Cluster kaputtmachen: Pods werden über Routing auf dem Host erreicht, Forwarding muss also an bleiben, und strenge Rückwegfilterung ist eine bekannte Ursache für verworfenen Pod-Verkehr. Das Template sagt das in einem Kommentar, mit dem Datum der Prüfung – damit es später niemand „repariert“, indem er dem Benchmark blind folgt.
BBR laden – wieder durch die Sandbox
- name: Sysctl | Load tcp_bbr kernel module
ansible.builtin.command: systemd-run --wait --pipe --quiet -- modprobe tcp_bbr
changed_when: false
- name: Sysctl | Persist tcp_bbr kernel module across reboots
ansible.builtin.copy:
dest: /etc/modules-load.d/tcp_bbr.conf
content: |
tcp_bbrBBR ist ein Kernel-Modul, und Module zu laden ist genau das, was die SSH-Sandbox verbietet. Also wird das Modul über systemd-run geladen, und eine Datei in /etc/modules-load.d/ lässt systemd es bei jedem Boot laden. Es gäbe dafür ein Ansible-Modul, community.general.modprobe, aber das liest zuerst das Modulverzeichnis, um den Zustand zu prüfen – und innerhalb der Sitzung ist dieses Verzeichnis leer, also scheitert es mit einer irreführenden Meldung. Zwei kleine Tasks, die dasselbe tun, sind ehrlicher als ein Modul, das nichts sehen kann.
Die Werte anwenden: ein Handler und ein Task
- name: Sysctl | Deploy web server kernel tuning
ansible.builtin.template:
src: 99-webserver.conf.j2
dest: /etc/sysctl.d/99-webserver.conf
notify: apply sysctl
- name: Sysctl | Apply settings immediately
ansible.builtin.command: systemd-run --wait --pipe --quiet -- sysctl --system
changed_when: falseEine Datei in /etc/sysctl.d/ zu schreiben, wirkt erst beim nächsten Boot; sysctl --system wendet alle Dateien sofort an. Die Rolle tut das absichtlich zweimal. Der Handler apply sysctl läuft am Ende des Plays, wenn sich eine der Dateien geändert hat – die normale Handler-Logik aus Teil 1. Der Task ohne Bedingung oben läuft bei jedem Lauf, und er fängt etwas, das keine Dateiänderung bemerken würde: dass jemand zur Laufzeit einen Wert mit sysctl -w geändert hat. Die Datei stimmt dann noch, also feuert kein Handler, aber der laufende Kernel stimmt nicht. Dieser Task stellt ihn wieder her. Er kann nicht erkennen, ob er etwas geändert hat, also sagt er changed_when: false. Beide gehen über systemd-run: Die SSH-Sandbox macht auch /proc/sys schreibgeschützt (ProtectKernelTunables).
Transparent Huge Pages
Eine Einstellung ist gar kein sysctl: Transparent Huge Pages, eine Speicherfunktion, die Speicher in große Seiten bündelt. Auf „always“ verursacht sie Latenzspitzen in Datenbanken und Redis, also kommt sie auf madvise – nur Programme, die danach fragen, bekommen sie. Der Wert liegt in /sys/kernel/mm/, und es gibt keine Konfigurationsdatei dafür. Die Rolle schreibt deshalb einen winzigen systemd-Dienst, der ihn beim Booten setzt (Type=oneshot mit RemainAfterExit=yes: einmal laufen, dann als „aktiv“ gelten), schaltet ihn ein und benachrichtigt einen zweiten Handler, der den Wert sofort setzt – wieder über systemd-run.
| Baustein | Was man mitnimmt |
|---|---|
template nach /etc/sysctl.d/ | Dauerhafte Kernel-Einstellungen sind einfach Dateien |
notify + ein Task ohne Bedingung | Handler für Dateiänderungen, Task für Abweichungen zur Laufzeit |
systemd-run | Von außerhalb der SSH-Sandbox in den Kernel schreiben |
| oneshot-Unit | Für Einstellungen ohne Konfigurationsdatei |
✉️ common_msmtp: ein Server, der Mails verschicken kann
Die Hälfte der Schutzmechanismen in diesem Setup meldet sich per Mail: die nächtlichen Scanner, der Update-Job, der Wachhund, der SSH-Login-Alarm. Ein vollständiger Mailserver wäre übertrieben und noch etwas, das abgesichert werden müsste. msmtp ist das Gegenteil: kein Dienst, keine Warteschlange, kein offener Port. Ein Programm, das eine Mail verschicken will, ruft sendmail auf (das msmtp-Paket stellt diesen Befehl bereit), msmtp verbindet sich mit einem externen SMTP-Konto, übergibt die Mail und beendet sich.
Die Rolle besteht aus drei Tasks – Paket, Konfigurationsdatei, Logdatei –, aber jeder zeigt etwas:
- Defaults als Übersetzungsschicht.
defaults/main.ymlenthält keine echten Werte, nur Zuordnungen wiemsmtp_host: "{{ mail_smtphost }}". Das Inventory verwendet dieselbenmail_*-Namen, die Nextcloud für seine eigenen Mail-Einstellungen nutzt, und die Rolle übersetzt sie in ihre eigenen Namen. Ein Satz Werte, mehrere Nutzer – Nextcloud, der Monit-Wachhund und msmtp. - Secrets als Secrets deklariert. Die
argument_specsder Rolle markieren SMTP-Login und Passwort mitno_log: true, sodass selbst die Eingabeprüfung sie nie ausgibt. no_logam Template-Task. Die gerenderte/etc/msmtprcenthält das Passwort im Klartext (Rechte 0600, nur root). Ein Probelauf mit--diffwürde den Dateiinhalt ausgeben – also versteckt der Task seine Ausgabe ganz. Der Preis: Ein Drift-Check sagt dann nur „würde sich ändern“, ohne Diff. Für eine Datei mit Passwort ist das der richtige Tausch.- Eine Logdatei ohne falsche Änderungen.
file: state=touchlegt das Log an, aktualisiert aber bei jedem Lauf die Zeitstempel, und das zählt als Änderung.modification_time: preserveundaccess_time: preservebehalten die Zeitstempel, sodass der Task nur dann „changed“ ist, wenn die Datei angelegt werden musste.
🌙 common_dnf_automatic: Updates, während ich schlafe
dnf-automatic gehört zu AlmaLinux: ein systemd-Timer (die systemd-Variante eines Cronjobs), der unbeaufsichtigt dnf upgrade ausführt. Die Rolle installiert ihn, beschränkt ihn auf Sicherheitsupdates und – das ist der interessante Teil – fügt ihn in einen Plan für die ganze Nacht ein.
Zwei Zeilen in einer Konfigurationsdatei
Zwei lineinfile-Tasks setzen upgrade_type = security (nur Pakete, die als Sicherheitskorrektur markiert sind) und apply_updates = yes (wirklich installieren, nicht nur herunterladen). Alles andere bleibt ein bewusster Schritt von Hand mit dem Playbook.
Die Nacht hat einen Plan
Nachts laufen mehrere Jobs, und ihre Reihenfolge ist wichtig. Das ist der Plan, so wie er in der Rolle steht:
| Uhrzeit | Was | Rolle |
|---|---|---|
| 00:00 | AIDE prüft Systemdateien gegen seine Referenz | common_cis_hardening (Teil 5) |
| 01:00–02:00 | Sicherheitsupdates werden installiert | common_dnf_automatic |
| 03:30 | Neustart, falls ein Update einen braucht | common_dnf_automatic |
| 04:00 | Die AIDE-Referenz wird erneuert | common_cis_hardening (Teil 5) |
| 04:30 | ClamAV scannt die Nutzerdaten (Nextcloud) | common_clamav (Teil 5) |
| 06:00 | Warn-Mail, wenn noch ein Neustart aussteht | common_dnf_automatic |
Der mitgelieferte Timer feuert um 06:00 – nach dem Neustart-Fenster und nach der AIDE-Erneuerung. Die Folge zeigte sich auf die harte Tour: Jedes Sicherheitsupdate tauchte in der nächsten Mitternacht als AIDE-Alarm auf, und ein neuer Kernel wartete fast einen Tag auf den nächsten Neustart um 03:30. Also verlegt die Rolle den Timer mit einem Drop-in:
- name: dnf-automatic | Move the install timer into the 01:00-02:00 window
ansible.builtin.copy:
dest: /etc/systemd/system/dnf-automatic-install.timer.d/schedule.conf
content: |
[Timer]
OnCalendar=
OnCalendar=*-*-* 01:00
RandomizedDelaySec=60m
register: dnf_automatic_timer_dropin
- name: dnf-automatic | Enable and start install timer
ansible.builtin.systemd:
name: dnf-automatic-install.timer
enabled: true
state: "{{ 'restarted' if dnf_automatic_timer_dropin is changed else 'started' }}"
daemon_reload: trueEin Drop-in ist eine Datei in <unit>.d/, die Teile einer Unit überschreibt, ohne die mitgelieferte Datei anzufassen – Paket-Updates können das Original ersetzen, der Drop-in bleibt. Beachte die leere Zeile OnCalendar=: Bei listenartigen Einstellungen ergänzt systemd jede Zuweisung. Ohne die leere Zeile würde der Timer um 06:00 und um 01:00 feuern. Eine leere Zuweisung leert die Liste zuerst.
Der zweite Task berechnet seinen state selbst, mit einem Jinja-if/else in der Zeile. Ein laufender Timer behält seinen alten Zeitplan, bis er neu gestartet wird, also muss er neu gestartet werden, wenn – und nur wenn – sich der Drop-in geändert hat. register am copy-Task und der Test is changed drücken genau das aus, ohne Handler. (Ein Handler ginge auch, liefe aber erst ganz am Ende des Plays; so stimmt der Timer sofort.)
Neustart um 03:30, Kontrolle um 06:00
- name: dnf-automatic | Configure nightly automatic reboot if required
ansible.builtin.cron:
name: "automatic reboot after security update"
hour: "3"
minute: "30"
job: >-
OUT=$(/usr/bin/needs-restarting -r 2>&1);
[ $? -ne 0 ] && { echo "$OUT" | /usr/bin/mail -s "... Rebooting now ..." {{ dnf_automatic_reboot_mail }};
/usr/sbin/shutdown -r now; }Das Modul cron verwaltet einen Crontab-Eintrag, erkennbar an seinem name – ändert man den Job, wird derselbe Eintrag aktualisiert, statt dass ein zweiter auftaucht. Der Job fragt needs-restarting -r, ob ein Neustart aussteht, und schickt nur dann den Grund per Mail und startet neu. >- ist YAML für „falte diese Zeilen zu einer“ – ein Crontab-Eintrag muss eine einzige Zeile sein. Ein zweiter Eintrag um 06:00 führt dieselbe Prüfung noch einmal aus: Steht ein Neustart immer noch aus, ist der automatische gescheitert, und eine Warnung geht raus. Der Empfänger kommt aus defaults/, wo dnf_automatic_reboot_mail einfach auf die Admin-Adresse des Hosts zeigt und pro Host überschrieben werden kann.
Noch ein Detail, das den Plan zusammenhält: Die Nextcloud-Hosts laufen auf deutscher Zeit, der Blog auf UTC. Weil Timer und Cronjobs beide die Uhr des Hosts verwenden, ist die Reihenfolge überall gleich, auch wenn die Uhrzeiten auf der Wanduhr abweichen.
🗂️ common_logrotate: Logs, die nicht endlos wachsen
logrotate läuft täglich über einen Timer, der mit dem Betriebssystem kommt. Er liest jede Datei in /etc/logrotate.d/ und rotiert die dort beschriebenen Logs: das aktuelle Log umbenennen, ein neues beginnen, die alten komprimieren und irgendwann löschen. Grafana und Prometheus kommen ohne solche Datei; fail2ban und rkhunter mit unvollständigen. Die Rolle besteht aus vier copy-Tasks mit content: – der Dateitext steht direkt im Task – und keinem Handler: logrotate liest die Dateien bei seinem nächsten Lauf.
Die eine Direktive, die eine Erklärung braucht, ist postrotate. Ein laufendes Programm hält seine Logdatei offen. Benennt logrotate die Datei um, schreibt das Programm in die umbenannte weiter – das alte Log wächst, das neue bleibt leer. postrotate sagt dem Programm, dass es sein Log neu öffnen soll: Grafana bekommt ein SIGHUP, fail2ban ein fail2ban-client flushlogs. rkhunter braucht nichts, weil es nur während seines nächtlichen Scans schreibt und die Datei nie offen hält. || true am Ende verhindert, dass logrotate einen Fehler meldet, wenn der Dienst gerade nicht läuft.
☸️ common_k3s: ein Kubernetes-Cluster auf einem Server
Jetzt die große Rolle. Zuerst, was sie baut. Kubernetes betreibt Anwendungen in Containern und hält sie in dem Zustand, den man beschreibt: „Betreibe einen WordPress-Pod mit diesen Einstellungen, erreichbar unter diesem Namen.“ Diese Beschreibung schreibt man als Objekte (YAML), übergibt sie dem API-Server, und Kubernetes bringt die Wirklichkeit in Übereinstimmung. K3s ist eine kompakte Kubernetes-Distribution: ein Binary, das API-Server, Scheduler, den Node-Agenten (kubelet), eine Container-Laufzeit und ein einfaches Pod-Netz (Flannel) enthält. Hier läuft es als Single-Node-Cluster – ein Server ist der ganze Cluster. Wie ich dort gelandet bin, ist eine ältere Geschichte.
Warum überhaupt Kubernetes auf einem einzelnen Server? Ein Blog und eine Nextcloud liefen auch als normale Pakete auf dem Host. Der Cluster zahlt sich auf ein paar Arten aus, die für den Betrieb zählen: Jede Anwendung ist als Satz von Objekten im Repository beschrieben und lässt sich auf einem neuen Server identisch wieder aufbauen; Updates heißen, einen Image-Tag zu ändern, und zurück geht es, indem man ihn zurück ändert; jede Anwendung lebt in ihrem eigenen Namespace mit eigenen Netzwerkregeln und Ressourcenlimits; und beide Stacks werden auf dieselbe Weise ausgerollt, sodass das, was man an einem lernt, auch für den anderen gilt. Der Preis sind Komplexität und etwa 1,5 GB Speicher für die Plattform – deshalb verbringt dieser Teil so viel Zeit damit.
Auf K3s installiert die Rolle drei Dinge: Helm, einen Paketmanager für Kubernetes; cert-manager, der Let’s-Encrypt-Zertifikate holt und erneuert; und den F5 NGINX Ingress Controller, den Webserver am Rand, der jede HTTPS-Anfrage empfängt und an die richtige Anwendung weiterreicht. Die Rolle ist auf fünf Dateien verteilt, die in fester Reihenfolge eingebunden werden:
- name: common_k3s | K3s install/upgrade
ansible.builtin.import_tasks: install_k3s.yml
- name: common_k3s | Helm install/upgrade
ansible.builtin.import_tasks: install_helm.yml
- name: common_k3s | cert-manager + Let's Encrypt ClusterIssuer
ansible.builtin.import_tasks: cert_manager.yml
- name: common_k3s | F5 nginx-ingress controller
ansible.builtin.import_tasks: nginx_ingress.yml
- name: common_k3s | Default ServiceAccount hardening (CIS 5.1.5)
ansible.builtin.import_tasks: default_sa_hardening.ymlimport_tasks ist statisch: Ansible liest alle fünf Dateien beim Start des Playbooks, als wären sie eine lange Datei. Damit sehen Tags, --list-tasks und Syntaxprüfungen jeden Task. Der dynamische Verwandte, include_tasks, liest eine Datei erst, wenn das Play sie erreicht – nützlich, wenn der Dateiname oder die Entscheidung, sie einzubinden, von etwas abhängt, das erst im Lauf berechnet wird. Teil 5 zeigt die eine Stelle, an der das zählt. Hier ist alles vorher bekannt, also ist die statische Form die richtige.
install_k3s.yml, Teil 1: Konfiguration vor der Installation
Die erste Datei schreibt drei Konfigurationsdateien, bevor K3s installiert oder aktualisiert wird – damit die Einstellungen schon beim allerersten Start und bei jedem weiteren Lauf gelten:
- Eine Audit-Richtlinie für den API-Server: wer im Cluster was getan hat. Secrets und ConfigMaps werden nur mit Metadaten protokolliert – wer darauf zugegriffen hat, nie ihr Inhalt. Befehle in Pods (
pods/exec) werden mit der Anfrage protokolliert, weil das das Mächtigste ist, was man mit einer laufenden Anwendung tun kann. Änderungen an Berechtigungen (RBAC) werden vollständig protokolliert. Der laute, rein lesende Strom von „Events“ wird verworfen. - Eine Admission-Konfiguration, die begrenzt, wie viele Events pro Sekunde der API-Server annimmt, damit ein abstürzender Pod ihn nicht fluten kann.
config.yaml, die eigenen Einstellungen von K3s: welche eingebauten Komponenten abgeschaltet werden, die Parameter des API-Servers für das Audit-Log und die Admission-Plugins und ein Limit an Prozessen pro Pod.
- name: K3s | Deploy /etc/rancher/k3s/config.yaml
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
mode: "0600"
content: |
disable:
- traefik
- servicelb
- local-storage
flannel-backend: vxlan
write-kubeconfig-mode: "0600"
kube-apiserver-arg:
- "audit-log-path=/var/lib/rancher/k3s/server/logs/audit.log"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
- "enable-admission-plugins=NodeRestriction,EventRateLimit"
- "admission-control-config-file=/etc/rancher/k3s/admission-control-config.yaml"
kubelet-arg:
- "pod-max-pids=4096"
notify: Restart k3s serviceDrei Komponenten sind abgeschaltet. Traefik ist der Standard-Ingress von K3s; dieses Setup nutzt stattdessen NGINX. ServiceLB ist ein kleiner Load Balancer; der Ingress bindet sich direkt an die Ports des Hosts, also wird er nicht gebraucht. local-storage legt Speichervolumes auf Anforderung an – hier fordert keines an, alle Daten liegen in festen Verzeichnissen auf dem Host, also lief es nur als ein weiterer privilegierter Prozess mit. Was nicht läuft, kann nicht angegriffen werden.
Die Zeile mit den Admission-Plugins trägt eine der besseren Lektionen des Repositorys, dokumentiert in der Datei: In normalem Kubernetes ergänzt enable-admission-plugins die Standardliste. In K3s ersetzt es sie. Nur EventRateLimit zu setzen hat still NodeRestriction entfernt – ein Plugin, das einen kompromittierten Node-Agenten daran hindert, etwas anderes als seine eigenen Objekte zu ändern. Ein erneuter Lauf des CIS-Prüfers (kube-bench) hat es gefunden. Seitdem steht NodeRestriction ausdrücklich in der Liste, mit einem Kommentar, warum. Die Datei erklärt auch eine CIS-Empfehlung, der sie bewusst nicht folgt (bei jedem Pod-Start das Image neu aus der Registry ziehen): In einem Setup ohne getrennte Mandanten bringt das kaum Sicherheit, macht aber aus jedem kurzen Registry-Ausfall scheiternde Pods.
Alle drei Tasks benachrichtigen denselben Handler, Restart k3s service – ein Neustart, egal wie viele Dateien sich geändert haben. Und ein kleiner Task gleich danach legt das Verzeichnis für das Audit-Log an, weil der API-Server es nicht selbst anlegt und ohne es nicht startet. Es muss vor diesem Neustart existieren.
install_k3s.yml, Teil 2: installieren oder aktualisieren, festgelegt
Jetzt das Binary. Die Rolle vergleicht die installierte Version mit der Festlegung:
- name: K3s | Check installed k3s version
ansible.builtin.shell: |
set -o pipefail
k3s --version 2>/dev/null | awk '{print $3}'
register: k3s_installed_version
changed_when: false
check_mode: false
failed_when: false
- name: K3s | Download k3s binary (install or upgrade to {{ k3s_version }})
ansible.builtin.get_url:
url: "https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s"
dest: /usr/local/bin/k3s
mode: "0755"
when: not k3s_binary.stat.exists or (k3s_installed_version.stdout | trim) != k3s_version
notify: Restart k3s service
timeout: 600Das ist das Muster des festgelegten Binarys, und es kehrt bei Helm, dem helm-diff-Plugin und allen Monitoring-Programmen wieder: installierte Version lesen, nur bei Abweichung herunterladen, Neustart anstoßen. Auf einem frischen Server ist die Version ein leerer String, der nie der Festlegung entspricht – also deckt derselbe Task Installation und Upgrade ab. Ein Upgrade ist bei K3s wörtlich „Binary ersetzen, Dienst neu starten“; die Daten des Clusters bleiben erhalten. set -o pipefail lässt die Pipe scheitern, wenn k3s --version scheitert, nicht nur, wenn awk es tut. timeout: 600 ist ein Schlüsselwort des Tasks, keine Option des Moduls: Hängt der Download, gibt Ansible nach zehn Minuten auf, statt ewig zu warten.
Das Installationsskript, das den systemd-Dienst anlegt, kommt aus dem K3s-Repository mit demselben Tag wie das Binary, nicht vom üblichen get.k3s.io. Diese Adresse liefert immer das neueste Skript, und sie hat bei der ersten Installation eines neuen Servers eine Weile mit HTTP 500 geantwortet, während GitHub in Ordnung war. Festgelegtes Binary plus festgelegtes Skript heißt: jedes Mal dieselbe Installation. Der Tag enthält ein +, das in einer URL als %2B geschrieben werden muss – das erledigt der Filter urlencode.
- name: K3s | Run K3s installer (skip binary download, flags from config.yaml)
ansible.builtin.command: sh /tmp/k3s-install.sh
environment:
INSTALL_K3S_SKIP_DOWNLOAD: "true"
args:
creates: /etc/systemd/system/k3s.service
when: not k3s_unit.stat.exists
- name: K3s | Apply pending handlers (restart on config/binary change)
ansible.builtin.meta: flush_handlersDrei Details in zwei Tasks. environment setzt eine Variable nur für diesen einen Befehl: Das Installationsskript soll das Binary nicht noch einmal herunterladen. creates lässt den Befehl nur laufen, wenn diese Datei noch nicht existiert – der älteste Idempotenz-Trick in Ansible. Und das Skript wird als sh /tmp/k3s-install.sh gestartet, statt direkt ausgeführt zu werden, weil die CIS-Härtung /tmp mit noexec einhängt: Dateien dort können nicht ausgeführt, aber von einer Shell gelesen werden.
Auch die Bedingung when hat eine Geschichte. Ursprünglich prüfte sie, ob das Binary existiert. Dann wurde eine erste Installation nach dem Download des Binarys, aber vor dem Installationsskript unterbrochen – und jeder folgende Lauf sah das Binary, übersprang das Installationsskript und scheiterte weiter unten, weil der Dienst nicht existierte. Jetzt entscheidet die systemd-Unit, und eine unterbrochene Installation heilt sich beim nächsten Lauf selbst.
Dann meta: flush_handlers. Handler laufen normalerweise am Ende des Plays – aber jede Rolle nach dieser spricht mit Kubernetes. Wurde K3s gerade aktualisiert oder neu konfiguriert, muss der Neustart jetzt passieren, nicht erst, nachdem die Anwendung gegen die alte Version ausgerollt wurde. flush_handlers führt an dieser Stelle alle anstehenden Handler aus. Die Handler-Datei selbst ist ein einziger Eintrag:
- name: Restart k3s service
ansible.builtin.service:
name: k3s
state: restartedDanach wartet die Rolle, bis der API-Server antwortet – nicht mit einem festen sleep, sondern indem sie fragt:
- name: K3s | Wait for the API server to become available
ansible.builtin.command: k3s kubectl get nodes
register: k3s_nodes
until: k3s_nodes.rc == 0
retries: 30
delay: 5
changed_when: falseuntil wiederholt den Task, bis die Bedingung wahr ist, hier höchstens 30-mal mit 5 Sekunden Abstand. Bei einem schnellen Neustart geht das Play nach ein paar Sekunden weiter; bei einem langsamen wartet es bis zu zweieinhalb Minuten; kommt K3s nie hoch, scheitert der Lauf mit einer klaren Meldung statt mit einem verwirrenden Fehler in der nächsten Rolle.
Zwei kleine Dinge schließen die Datei ab. K3s schreibt seine Zertifikatsdateien für alle lesbar und erzeugt manche davon beim Neustart neu. find sammelt alle *.crt-Dateien, und ein file-Task mit loop setzt jede auf 0600 – bei jedem Lauf, weil die Dateien zurückkommen. (loop_control.label gibt pro Element nur den Pfad aus statt des ganzen Dateieintrags.) Zuletzt wird die Admin-Konfiguration des Clusters nach /root/.kube/config kopiert, damit kubectl und helm den Cluster in einer root-Shell finden. remote_src: true heißt: von einem Ort auf dem Server an einen anderen kopieren, nicht von meinem Rechner.
install_helm.yml: Helm und das Plugin helm-diff
Helm installiert Charts – Pakete von Kubernetes-Objekten mit Einstellungen („Values“) – und führt über jede Installation als Release Buch. Die Rolle installiert es mit demselben Festlegungs-Muster: Versionsprüfung, Download des Archivs nur bei Abweichung, entpacken, nach /usr/local/bin kopieren. Zwei Zeilen in den Tasks zum Entpacken und Installieren sind einen Blick wert:
when:
- helm_download.changed | default(false)
- not ansible_check_modeEine Liste unter when heißt „alle davon“. Die erste Bedingung: nur, wenn etwas heruntergeladen wurde. Die zweite betrifft Probeläufe. Im Check-Modus tut der Download-Task nur so – er meldet „würde sich ändern“, aber es gibt keine Datei. Eine Datei zu entpacken, die nicht existiert, würde scheitern, also überspringt die Rolle diesen Schritt im Check-Modus. So meldet ein Probelauf auf einem Server mit veraltetem Helm das Upgrade korrekt, statt abzustürzen. Dieselben zwei Zeilen schützen jedes heruntergeladene Archiv im Repository.
Die zweite Hälfte installiert helm-diff, ein Plugin, mit dem das Helm-Modul einen echten Unterschied zwischen dem laufenden und dem angeforderten Release berechnen kann. Die installierte Version wird aus helm plugin list gelesen, das eine kleine Tabelle ausgibt, ungefähr so:
NAME VERSION TYPE APIVERSION PROVENANCE SOURCE
diff 3.x.x cli/v1 legacy ...…und ein einziger Jinja-Ausdruck holt die Version heraus:
when: >-
(helm_plugins.stdout_lines | default([]) | map('split')
| selectattr('0', 'equalto', 'diff') | map(attribute='1') | first | default('')) != helm_diff_versionLies ihn als Pipeline von links nach rechts: die Ausgabezeilen nehmen → jede Zeile in Wörter teilen (jetzt ist jede Zeile eine Liste) → nur die Zeilen behalten, deren erstes Wort (Index 0) diff ist → von jeder das zweite Wort (Index 1) nehmen → davon das erste, oder einen leeren String, wenn es keins gibt. Ist das nicht die festgelegte Version, läuft der block darunter. Ein block gruppiert Tasks, sodass ein when für alle gilt.
Im Block trägt der Download eine Prüfsumme: checksum: "sha256:{{ helm_diff_sha256 }}". get_url prüft die Datei nach dem Herunterladen und scheitert, wenn sie nicht passt. Das Plugin ist zusammen mit seiner Prüfsumme in versions.yml festgelegt, und der Bot, der Versions-Updates vorschlägt, ist so eingestellt, dass er mich daran erinnert, dass beides zusammen geändert werden muss.
cert_manager.yml: Zertifikate ohne certbot
cert-manager beobachtet den Cluster nach Objekten, die sagen „dieser Hostname braucht ein Zertifikat“, holt eines von Let’s Encrypt, legt es als Kubernetes-Secret ab und erneuert es, bevor es abläuft. Let’s Encrypt prüft mit einer HTTP-01-Challenge, dass man eine Domain kontrolliert: Es gibt cert-manager ein Token und fragt dann http://your-domain/.well-known/acme-challenge/<token> ab. Kommt das Token zurück, gehört die Domain dir.
- name: cert-manager | Install/upgrade cert-manager via Helm
kubernetes.core.helm:
kubeconfig: "{{ k3s_kubeconfig }}"
name: cert-manager
chart_ref: jetstack/cert-manager
chart_version: "{{ cert_manager_chart_version }}"
release_namespace: cert-manager
create_namespace: true
update_repo_cache: true
values:
crds:
enabled: true
wait: true
timeout: 300sFrüher war das ein Shell-Task mit helm upgrade --install. Es funktionierte, meldete aber bei jedem Lauf „changed“ und legte jedes Mal eine neue Helm-Revision an – das Playbook konnte also nie „nichts zu tun“ sagen. Das Modul kubernetes.core.helm vergleicht angeforderte Chart-Version und Values mit dem laufenden Release und aktualisiert nur bei einem echten Unterschied. wait: true wartet, bis die Pods des Releases bereit sind.
Dann kommt ein Schritt, der viele beim ersten Mal verwirrt. cert-manager bringt neue Objekttypen mit – zum Beispiel ClusterIssuer, „wo und wie man Zertifikate bekommt“. Kubernetes kennt einen solchen Typ erst, wenn seine CustomResourceDefinition (CRD) registriert ist. Einen ClusterIssuer einen Moment zu früh anzuwenden, scheitert mit „no matches for kind“. Also wartet die Rolle, bis der Webhook verfügbar und die CRD established ist, und wendet den Issuer dann mit dem K8s-Apply-Muster an (vollständig erklärt im Calico-Abschnitt weiter unten).
Das Issuer-Template enthält die am härtesten erarbeitete Einstellung dieser Datei. Die Challenge-Anfrage muss den temporären Solver von cert-manager erreichen, aber für denselben Hostnamen gibt es schon einen Ingress – den der Anwendung. Der Controller von F5 ignoriert einen zweiten Ingress für denselben Host stillschweigend. Die Lösung steckt in zwei Annotationen: Der Ingress des Solvers wird als Minion angelegt, den der Controller in den bestehenden Master-Ingress dieses Hosts einbaut, und er ist von der Weiterleitung von HTTP auf HTTPS ausgenommen, weil Let’s Encrypt über einfaches HTTP prüft.
nginx_ingress.yml: die Eingangstür
Der Ingress-Controller ist das Einzige, was auf den Ports 80 und 443 antwortet. Er läuft als DaemonSet (ein Pod pro Node – hier: genau einer) mit hostNetwork, bindet sich also direkt an die Ports des Hosts wie ein normaler Webserver. Die Datei hat vier Schritte.
Ein Ersatzzertifikat. Bekommt der Controller eine Anfrage für einen Hostnamen, für den er kein Zertifikat hat – falsches DNS, ein noch nicht ausgestelltes Zertifikat –, zeigt der Controller von F5 kein selbstsigniertes, sondern schließt einfach die Verbindung. Im Browser ist das nicht von einem ausgefallenen Server zu unterscheiden. Das fiel bei einem DNS-Fehler auf, als „falsches DNS“ und „Server down“ genau gleich aussahen. Die Rolle erzeugt deshalb einmal ein selbstsigniertes Zertifikat (openssl mit creates:) und meldet es als Standard an. Echte Zertifikate sind davon nicht betroffen: nginx nimmt das, das zum Hostnamen passt.
- name: ingress-nginx | Read default TLS certificate and key
ansible.builtin.slurp:
src: "/etc/ingress-nginx-defaults/{{ item }}"
loop: [tls.crt, tls.key]
register: ingress_default_tls
no_log: true
- name: ingress-nginx | Apply default TLS certificate as a K8s Secret
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: Secret
type: kubernetes.io/tls
metadata: { name: ingress-default-tls, namespace: ingress-nginx }
data:
tls.crt: "{{ ingress_default_tls.results[0].content }}"
tls.key: "{{ ingress_default_tls.results[1].content }}"
no_log: trueslurp liest eine Datei vom Server und gibt sie Base64-kodiert zurück – was zufällig genau das Format ist, das Kubernetes im data eines Secrets erwartet. Weil der Task eine loop hat, hat die registrierte Variable eine Liste results, ein Eintrag pro Element, in der Reihenfolge der Schleife: results[0] ist das Zertifikat, results[1] der Schlüssel. Beide Tasks verstecken ihre Ausgabe mit no_log: Einer dieser Werte ist ein privater Schlüssel.
Eine einmalige Migration. Früher lief das Community-Projekt kubernetes/ingress-nginx, das eingestellt wurde. Ein Shell-Task prüft, ob dieser Chart noch installiert ist, und entfernt ihn vor der Installation des F5-Charts, damit Helm nie versucht, zwischen zwei unverträglichen Charts zu aktualisieren. Er gibt „uninstalled“ oder „skipped“ aus, und changed_when sucht nach dem Wort – eine einfache Art, einem Skript einen ehrlichen Status zu geben.
Das Release selbst. Die Helm-Values kommen aus einem Template, im Speicher gerendert und als YAML eingelesen: values: "{{ lookup('ansible.builtin.template', 'ingress-nginx-values.yml.j2') | from_yaml }}". Keine temporäre Datei auf dem Server. In der Values-Datei wird der Rand konfiguriert: nur TLS 1.2 und 1.3, eine feste Liste von Verschlüsselungsverfahren, HTTP/2, Wiederverwendung von Verbindungen zu den Anwendungs-Pods, gzip, keine Versionsnummer im Server-Header und die Zonen für die Ratenbegrenzung, die die Login-Seiten von Blog und Nextcloud nutzen (Teile 3 und 4). Sie dokumentiert auch, was bewusst fehlt: Brotli-Kompression (das Controller-Image enthält das Modul nicht, die Einstellungen waren wirkungslos) und OCSP-Stapling (Let’s Encrypt betreibt kein OCSP mehr). Der TLS-Beitrag hat das ganze Bild.
Warten. Zuletzt blockiert kubectl wait, bis der Controller-Pod bereit ist, damit die Anwendungsrollen nicht in einen Cluster ohne Eingangstür ausrollen.
default_sa_hardening.yml: standardmäßig keine Tokens
Jeder Pod in Kubernetes läuft unter einem ServiceAccount und bekommt standardmäßig ein Token eingehängt, mit dem er mit dem API-Server sprechen kann. Eine Webanwendung braucht das nicht – und eine kompromittierte sollte es nicht haben. CIS empfiehlt, es für das Konto default jedes Namespaces abzuschalten:
- name: K3s | Find non-kube-system default ServiceAccounts (CIS 5.1.5)
kubernetes.core.k8s_info:
kind: ServiceAccount
field_selectors:
- metadata.name=default
register: k3s_default_sas
- name: K3s | Disable SA token automount on every non-kube-system default ServiceAccount
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: "{{ item }}"
automountServiceAccountToken: false
loop: >-
{{ k3s_default_sas.resources | map(attribute='metadata.namespace')
| reject('equalto', 'kube-system') | list }}k8s_info ist das nur lesende Gegenstück zu k8s – wie kubectl get. field_selectors filtert auf dem Server: nur Konten namens default. Die Schleife baut ihre Liste mit einer Filterkette: aus allen gefundenen Konten den Namespace nehmen, kube-system weglassen (die eigenen Komponenten des Clusters dort brauchen ihre Tokens) und daraus eine Liste machen. Der k8s-Task wendet dann eine teilweise Definition an – nur das eine Feld, das sich ändern soll; alles andere am Konto bleibt, wie es ist. Die Namespaces der Anwendungen bekommen denselben Patch in ihren eigenen Rollen, weil sie an dieser Stelle einer frischen Installation noch nicht existieren.
| Baustein | Wo | Was man mitnimmt |
|---|---|---|
import_tasks | main.yml | Eine große Rolle statisch in Dateien aufteilen |
| Muster des festgelegten Binarys | K3s, Helm, helm-diff | Versionsprüfung → Download nur bei Abweichung → Neustart |
notify + meta: flush_handlers | install_k3s | Ein Neustart für viele Änderungen, und zwar jetzt statt am Ende |
until / retries / delay | install_k3s | Auf eine Bedingung warten, nicht auf eine feste Zeit |
creates, environment | install_k3s, Ingress | Einmal ausführen; eine Variable für einen Befehl setzen |
find + loop + loop_control.label | install_k3s | Auf eine Liste von Dateien wirken, die erst im Lauf gefunden wird |
not ansible_check_mode | install_helm | Schritte überspringen, die von etwas abhängen, das ein Probelauf nicht getan hat |
map / selectattr / first | install_helm | Befehlsausgaben mit Jinja-Filtern auswerten |
get_url mit checksum | install_helm | Prüfen, was man herunterlädt |
kubernetes.core.helm | cert-manager, Ingress | Idempotente Helm-Releases |
slurp + results[n] + no_log | Ingress | Eine Datei in ein Secret lesen, ohne sie auszugeben |
k8s_info, teilweise Definitionen, reject | default_sa_hardening | Objekte lesen, ein Feld ändern |
Die Rolle deklariert eine Eingabe in meta/argument_specs.yml: email, die Adresse, an die Let’s Encrypt Ablaufwarnungen schickt – markiert mit no_log wie jeder persönliche Wert.
🛡️ common_calico: damit NetworkPolicies echt werden
Eine NetworkPolicy ist ein Kubernetes-Objekt, das sagt, welche Pods mit welchen sprechen dürfen – zum Beispiel: „Der Datenbank-Pod nimmt Verbindungen nur vom WordPress-Pod an.“ Blog und Nextcloud verlassen sich beide auf solche Regeln. Es gibt einen Haken, den man leicht übersieht: Kubernetes selbst setzt sie nicht durch. Das tut das Netzwerk-Plugin – und Flannel, das in K3s eingebaute Plugin, tut es nicht. Eine Regel, die alles verbietet, wird ohne Murren angenommen und hat überhaupt keine Wirkung. Das wurde auf dem Testserver nachgeprüft: Eine Alles-verbieten-Regel änderte nichts, bis Calico installiert war, und wirkte danach sofort.
Calico kann auch selbst das Netzwerk übernehmen, läuft hier aber im Modus policy-only: Flannel transportiert weiter die Pakete, und der Agent von Calico (Felix) liest nur die Regeln aus der Kubernetes-API und macht daraus Paketfilterregeln auf dem Host. Das Manifest ist vom offiziellen „Canal“-Manifest von Calico abgeleitet (Calico für Regeln, Flannel für das Netz), ohne alles, was K3s schon mitbringt: die Installation des Netzwerk-Plugins, den Flannel-Container, die Netzwerkkonfiguration. Übrig bleiben 34 Objekte – 23 CRDs, die Berechtigungen, ein DaemonSet für den Agenten und ein Deployment für den Controller.
Das K8s-Apply-Muster, vollständig
Die ganze Rolle ist ein Task plus zwei Wartevorgänge, und der Task ist das Muster, mit dem 26 Tasks im Repository Kubernetes-Objekte in den Cluster bringen. Hier ist es, von innen nach außen zu lesen:
- name: Calico | Apply policy-only manifest
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ k8s_objects if ansible_diff_mode else [k8s_objects] }}"
loop_control:
label: "{{ (item.kind ~ '/' ~ item.metadata.name) if item is mapping
else ((item | length) ~ ' objects') }}"
vars:
k8s_objects: "{{ lookup('ansible.builtin.template', 'calico-policy-only.yml.j2')
| ansible.builtin.from_yaml_all | select | list }}"
register: calico_applylookup('ansible.builtin.template', ...)rendert das Jinja-Template auf meinem Rechner zu einem langen String. Nichts wird auf den Server kopiert. (Lookups laufen immer auf dem Controller.)from_yaml_allzerlegt diesen String – viele YAML-Dokumente, getrennt durch---– in eine Liste von Objekten.selectohne Argument entfernt leere Einträge, zum Beispiel aus einem---am Ende.vars:an einem Task definiertk8s_objectsnur für diesen Task.kubernetes.core.k8smitstate: presentundapply: trueverhält sich wiekubectl apply: Fehlendes anlegen, Abweichendes aktualisieren, den Rest lassen – und „changed“ nur melden, wenn sich ein laufendes Objekt wirklich geändert hat. Es spricht direkt mit der API über den Python-Client, dencommon_k3sinstalliert hat; keinkubectl, keine temporäre Datei.- Die Schleife ist der pfiffige Teil. In einem normalen Lauf hat die Liste
[k8s_objects]ein Element – die ganze Liste der 34 Objekte –, also läuft das Modul einmal. Mit--diff(ansible_diff_modeist wahr) läuft die Schleife über die Objekte selbst, ein Modulaufruf pro Objekt, weil nur dann der Diff die Änderung pro Objekt zeigt. Schnell, wenn es egal ist, ausführlich, wenn ich es sehen will. loop_control.labelhält die Ausgabe lesbar: „DaemonSet/calico-node“ pro Objekt oder „34 objects“.
Die beiden Tasks danach warten mit kubectl rollout status auf den Agenten und den Controller – aber nur when: calico_apply.changed. Ein unveränderter Lauf wartet überhaupt nicht.
Noch eine Entwurfsnotiz aus dem Kopfkommentar der Rolle, weil sie eine gute allgemeine Lektion ist: Die Berechtigungen für den Calico-Agenten sind vollständig von upstream übernommen, nicht auf das zurechtgestutzt, was der policy-only-Modus „eigentlich“ brauchen sollte. Mehrere interne Schleifen im Agenten laufen unabhängig vom Modus, und ohne ihre Berechtigungen überspringen sie sich nicht elegant – der Pod wird einfach nie bereit. Berechtigungen zu stutzen ist gute Praxis, aber nicht unter das, was die Software tatsächlich braucht.
🛠️ Im Betrieb: die Plattform im Alltag
Das meiste aus diesem Teil läuft von allein – nächtliche Updates, Zertifikatserneuerungen, Logrotation. Was dem Administrator bleibt, ist zu wissen, wo man hinschaut und wie man eine geplante Änderung macht.
Nachsehen
# The nightly plan: when do the timers fire next?
systemctl list-timers dnf-automatic-install.timer logrotate.timer
# Is a reboot pending? (exit code 1 = yes)
needs-restarting -r
# Does the server send mail? (the address from the inventory)
echo "test from $(hostname -f)" | mail -s "mail test" you@example.org
tail /var/log/msmtp.log
# Kubernetes: the node, all pods, and the Helm releases
k3s kubectl get nodes
k3s kubectl get pods -A
helm list -A
# All certificates: valid, and when they are renewed
k3s kubectl get certificate -A \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,RENEWAL:.status.renewalTimeMails aus diesem Teil
| Betreff enthält | Bedeutung | Was tun |
|---|---|---|
| „Rebooting now after security update“ | Nachts wurde ein Kernel oder eine Kernbibliothek aktualisiert; der Server ist um 03:30 neu gestartet | Nichts – später prüfen, ob alles wieder da ist (der Wachhund aus Teil 6 meldet sich, wenn nicht) |
| „WARNING: reboot still required“ | Der automatische Neustart hat nicht stattgefunden | Herausfinden, warum (journalctl -b -1), von Hand neu starten |
Geplante Änderungen
- Host-Pakete, die keine Sicherheitskorrekturen sind (ein neues Grafana zum Beispiel), kommen nur mit der Paket-Rolle:
ansible-playbook blog.yml --limit <host> --tags blog_packages(bzw.next_packagesmit dem Nextcloud-Playbook). - K3s, Helm, die Charts: die Festlegung in
versions.ymländern (oder den Pull Request des Bots mergen),scripts/check-drift.sh <playbook> <host> --tags common_k3sausführen, um zu sehen, was passieren würde, dann der echte Lauf mit--tags common_k3s. K3s wird immer um eine Minor-Version auf einmal aktualisiert – lies die Release Notes und leg vorher einen Snapshot des Servers an. - Calico: dasselbe mit
--tags common_calico. Danach mussk3s kubectl -n kube-system get ds calico-nodeden Pod wieder als bereit zeigen. - Kernel-Parameter: das Template ändern,
--tags common_sysctlausführen. „Repariere“ nie IP-Forwarding oder Rückwegfilterung nach Lehrbuch – der Cluster braucht sie so, wie sie sind.
🧭 Wie es weitergeht
An dieser Stelle ist die Plattform komplett: ein gepatchtes Grundsystem mit vernünftigen Kernel-Einstellungen, Mail, nächtlichen Updates und rotierten Logs, und ein Kubernetes-Cluster mit Helm, Zertifikaten, einer Eingangstür und wirksamen Netzwerkregeln. Teil 3 setzt den Blog darauf – WordPress, MariaDB und Redis als Pods und alles, was blog_wordpress in der laufenden Anwendung tut. Teil 4 macht dasselbe für Nextcloud. Die Rollen, die hier übersprungen wurden – SSH, die Firewall, Härtung und SELinux –, stehen alle in Teil 5, dem Sicherheitsteil.



