Auf meinem Hypervisor befinden sich einige VMs mit K8s im Namen. Auf 3 von ihnen läuft dabei das Management-Tool Rancher. Zwar verursachen diese Server nicht allzu viel Arbeit, aber auch ein paar wenige Arbeitsschritte sollte man stets automatisieren. Nicht zuletzt, weil man seine Zeit im Homelab für Fortbildung und nicht für wiederkehrende administrative Aufgaben benötigt. Also gesagt und getan.
Ich habe mich einmal in Ruhe hingesetzt und geschaut, was ich hier alles an regelmäßig anfallenden Doings habe. Relativ viel Zeit in Anspruch nimmt dabei das Updaten von Linux selbst. Außerdem lege ich jedes Mal über die Weboberfläche von Proxmox Snapshots der 3 VMs an. Auch das muss dringend automatisiert werden. Und von den Rancher-Updates fange ich gar nicht erst an.
Der Plan stand also. Doch wie lässt sich so was in der Praxis eigentlich umsetzen und wo muss man dabei vielleicht Abstriche machen? Diese beiden Fragen lassen sich gar nicht so einfach beantworten und so möchte ich dir in diesem Artikel zeigen, wie ich das Ganze angegangen bin. Wie immer gilt hier, viele Wege führen nach Rom. Ich berichte daher nur von meiner Herangehensweise.
Was will ich automatisieren und wo sollte ich lieber selbst Hand anlegen?
Eine Sache habe ich in den letzten Jahren gelernt. Automatisieren kann man einfach alles. Doch sinnvoll ist das nicht immer. Deshalb habe ich mich gefragt, was relativ gefahrlos möglich ist und wo es durchaus brenzlig werden könnte. Kein Bedenken habe ich zum Beispiel, wenn Snapshots automatisch angelegt oder unkritische Aktualisierungen eingespielt werden.
Auf keinen Fall soll aber die installierte Kubernetes-Distribution RKE2 eine Aktualisierung erhalten. Das könnte nämlich ganz schnell zu Problemen bei meinem Rancher-Deployment führen. Meine Vorgehensweise ist in diesem Punkt also sehr konservativ. Ich werde in Zukunft daher weiterhin selbst prüfen, ob ein RKE-Update ansteht und ob Rancher vorher eine Aktualisierung braucht.
Festhalten lässt sich also, dass 2 Dinge automatisiert werden sollen:
- Erstellung von Snapshots der VMs meines Upstream-Clusters
- Update aller installierten Pakete, mit Ausnahme der RKE2-Software
Um das Ganze erfolgreich in die Tat umsetzen zu können, gibt es zahlreiche Möglichkeiten. Man denke da nur an Terraform, Chef, Cloud-Init, Shell-Scripting oder Ansible. Die beiden letztgenannten Möglichkeiten kenne ich definitiv am besten und möchte ich daher auch bei diesem Projekt einsetzen. Die folgenden Zeilen drehen sich also um Scripting und Ansible-Playbooks.
Wie kann man das Erstellen von Proxmox-Snapshots automatisieren?
Meine virtuellen Maschinen laufen alle auf einem einzigen Proxmox-Server. Ziel der Automatisierung muss es also sein, mit diesem Hypervisor in puncto Backups zu interagieren. Hierfür existieren zum Glück zahlreiche Möglichkeiten. So hat Proxmox zum Beispiel eine eigene REST-API. An diese lassen sich Daten im bekannten JSON-Format übermitteln.
Damit man allerdings etwas an Port 8006 über HTTPS schicken kann, braucht man noch einen Token. Dann kann es auch schon losgehen mit den Programmierarbeiten. Mir ist das allerdings etwas zu zeitaufwendig. Deshalb habe ich ein Bash-Skript geschrieben, welches das CLI-Tool von Proxmox benutzt. Und was soll ich sagen, nach 30 Minuten war ich auch schon fertig mit der Arbeit:
#!/bin/bash
# Array bitte mit den passenden VM-IDs befüllen:
snapshot_rke2_server=(200 201 202)
# Datum im deutschen Format generieren:
snapshot_date=$(date "+%d-%m-%Y")
# Einen einzigartigen Namen für den Snapshot erstellen:
snapshot_name="OS-Updates-${snapshot_date}"
# For-Schleife zum Abklappern des gesamten Arrays:
for ((u=0;u<${#snapshot_rke2_server[*]};u++))
do
# CLI-Befehl zur Snapshot-Erstellung:
qm snapshot ${snapshot_rke2_server[$u]} "$snapshot_name" \
--description "Linux-Updates without the RKE2-Packages"
# Prüfung, ob der letzte Befehl erfolgreich war:
if [[ $? -ne 0 ]];
then
exit 1;
fi
done
exit 0;
Das Skript ist wirklich simpel aufgebaut. Zuerst wird das Array snapshot_rke2_server mit den VM-IDs befüllt, damit das Kommandozeilenprogramm weiß, welche Server einen Snapshot bekommen sollen. Danach lasse ich mir das Datum im Format Tag.Monat.Jahr ausgeben und generiere noch die spätere Bezeichnung für den Snapshot.
Die ganze Magie steckt dann in einem einzigen Befehl. Die Rede ist natürlich von qm snapshot <VM-ID>. Damit auch alle Rancher-Server aus dem Array abgearbeitet werden, lasse ich den Befehl mittels einer For-Schleife ausführen. Die IF-Anweisung prüft dann noch bei jedem Schleifendurchlauf, ob es zu einem Fehler kam und bricht das Skript in diesem Fall mit dem Return Code 1 ab.
Letzteres ist sehr wichtig und wird leider häufig vergessen. Der Rückgabewert eines Skripts teilt Ansible oder genauer gesagt dem Shell-Modul mit, ob es bei der Ausführung zu Komplikationen kam. Im Allgemeinen lässt sich sagen, dass ein Wert größer 0 nichts Gutes bedeutet. Das gilt übrigens auch für viele Befehle, die man im täglichen Gebrauch in der Shell einsetzt.
Wie lassen sich Updates bei Linux mit Ansible automatisieren?
Wenn es um Thema Paketmanagement-Systeme geht, lässt Ansible keinen Wunsch übrig. Selbst ein generisches Modul für verschiedene Linux-Systeme gibt es. Zwar lassen sich hier nicht allzu viele Parameter übergeben, aber das Modul arbeitet zuverlässig. Man sollte sich aber im Klaren darüber sein, dass ein Debian oder Ubuntu mit einem Paketnamen wie httpd wenig anzufangen weiß.
- name: Installiere die aktuelle Version von Apache und MariaDB
ansible.builtin.package:
name:
- httpd
- mariadb-server
state: latest
when: ansible_os_family == 'RedHat'
In so einem Fall muss man dann das APT-Modul nutzen, die richtigen Paketnamen verwenden und die Ausführung auf Systeme einschränken, welche zum Beispiel auf Debian basieren. Das folgende Code-Muster soll dir das Vorgehen aufzeigen:
- name: Installiere Apache2 und MariaDB auf Debian-Systemen
apt:
name:
- apache2
- mariadb-server
when: ansible_os_family == 'Debian'
Im Prinzip könnte man mit so einem Modul den Bereich Update-Management abhaken. Allerdings vergisst man dabei ein paar Punkte. Was geschieht bei einem Kernel-Update? Soll dann ein Neustart ausgelöst werden? In meinem Homelab auf jeden Fall ja. Daher lasse ich im Playbook die yum-utils nachinstallieren, um den Befehl needs-restarting –reboothint einsetzen zu können.
Wird hier der Rückgabewert 0 generiert, ist ein Reboot nicht nötig. Die 1 hingegen empfiehlt den Neustart des Servers und ein Wert größer 1 deutet auf einen Fehler bei der Ausführung hin. Wie man das Ganze nun in einem Playbook verarbeitet, soll folgendes Code-Beispiel zeigen:
- name: "Check if a reboot is needed due to kernel updates"
shell:
executable: /bin/bash
cmd: needs-restarting --reboothint
register: reboot_required_command
failed_when: reboot_required_command.rc >= 2
changed_when: false
args:
warn: false
- name: "Reboot the servers if a new kernel is ready to use"
reboot:
msg: "Reboot initiated by Ansible for kernel updates"
connect_timeout: 10
reboot_timeout: 1000
pre_reboot_delay: 0
post_reboot_delay: 60
test_command: 'uptime'
when:
- reboot_required_command is defined
- reboot_required_command.rc == '1'
Hier sind sicherlich einige Punkte interessant. Aber fangen wir beim Wichtigsten an. Reboot verfügt über eine When-Condition. Darin wird festgelegt, dass das Modul nur bei einem Return Code von 1 ausgeführt wird. Die Ausgabe des Befehls needs-restarting –reboothint wird dafür in eine Variable eingelesen. Hinter rc verbirgt sich dann der Key mit dem gebrauchten Value.
Changed_when: false weist Ansible darauf hin, dass beim Playbook-Run ein OK und kein Changed ausgegeben werden soll. Schließlich wird hier ja nichts am System geändert. Der Parameter Failed_when legt fest, wann das Modul als gescheitert gilt. Mit dem Block args: warn: false sagt man Ansible, dass beim Playbook-Output kein Hinweis auf ein geeignetes Modul stattfinden soll.
Und zum Abschluss dieses Abschnitts kommen wir nun noch auf ein ganz wichtiges Modul in meinem Playbook zu sprechen. Die Rede ist von stat. Hiermit lassen sich, ähnlich wie beim Shell-Kommando stat, Fakten zu bestimmten Dateien erheben. Ich prüfe beispielsweise, ob die Datei yum.pid existiert:
- name: 'Check that yum lock file does not exist'
stat:
path: /var/run/yum.pid
register: yum_pid_file
failed_when: yum_pid_file.stat.exists == true
Sollte dies der Fall sein, läuft bereits eine Aktualisierung auf dem Cluster-Node und er wird daher vorsorglich vom restlichen Play ausgeschlossen. Ich schaue mir so einen Fall dann genau an und prüfe das weitere Vorgehen. In meinem Homelab gibt es zum Glück außer mir keinen weiteren Admin und so sollte das eher nicht vorkommen. Aber sicher ist sicher und so kontrolliere ich es lieber im Play.
Wie lässt sich das Ganze denn jetzt in der Praxis einsetzen?
Kommen wir nun endlich zum fertigen Code in seiner ganzen Pracht. Leider reicht es hier nicht ganz aus, nur Copy-and-paste zu benutzen. Es müssen nämlich noch ein paar nutzerspezifische Dinge angepasst werden. Die Ansible-Konfiguration ist dabei die erste Anlaufstelle. Ich gehe dabei immer möglichst simpel vor:
[defaults]
inventory = ./inventory.ini
Weiterhin muss Ansible natürlich wissen, gegen wen das Playbook am Ende laufen soll. Dafür musst du am unten befindlichen Inventory-File ein paar Anpassungen vornehmen. In der Gruppe Proxmox musst du einen deiner Hypervisor hinterlegen, damit das Shell-Skript darauf ausgeführt werden kann. Selbstredend gehören zur Gruppe rancher_server alle Nodes des dedizierten K8s-Clusters.
[proxmox]
proxmox-01 ansible_host=10.20.20.254
[proxmox:vars]
ansible_user=root
ansible_ssh_private_key_file=/root/.ssh/id_rsa
[rancher_server]
rancher-01 ansible_host=172.16.66.10
rancher-02 ansible_host=172.16.66.20
rancher-03 ansible_host=172.16.66.30
[rancher_server:vars]
ansible_user=root
ansible_ssh_private_key_file=/root/.ssh/id_rsa
Das fertige Playbook besteht aus 2 Plays. Damit auf dem Proxmox-Server das Skript erfolgreich ausgeführt werden kann, solltest du es einmal dort in den lokalen Binaries ablegen. Das kannst du natürlich auch via Ansible machen. Das Copy-Modul wäre hier die Qual der Wahl. Es kopiert standardmäßig Daten vom Control-Node zum Remote-Server.
Den Rest des Playbooks kannst du eigentlich unverändert lassen. Es kann allerdings sein, dass deine Cluster-Nodes anders heißen. Dann müsstest du noch ein paar Anpassungen vornehmen. Bei mir genügt es, den Hostnamen ohne Domain auslesen zu lassen. Prüfen kannst du die Namen deiner Cluster-Knoten zur Sicherheit noch mittels kubectl get nodes -o wide.
Insgesamt ist dieser Artikel recht lange geworden und auf jeden Aspekt bin ich gar nicht eingegangen. Daher wünsche ich dir viel Erfolg mit dem Playbook:
---
- name: "Make a snapshot of the virtual machines"
hosts: proxmox-01
remote_user: root
gather_facts: no
tasks:
- name: 'Create a snapshot of the virtual machines'
shell:
cmd: bash /usr/local/bin/rancher-vms-snapshot.sh
register: snapshot
failed_when: snapshot.rc >= 1
- name: "Update the Kubernetes nodes individually without RKE2 receiving an update"
hosts: rancher_server
serial: 1
remote_user: root
gather_facts: true
tasks:
- name: 'Update the operating system of the RKE2 nodes'
block:
- name: 'Check that yum lock file does not exist'
stat:
path: /var/run/yum.pid
register: yum_pid_file
failed_when: yum_pid_file.stat.exists == true
- name: "If not already installed, then install the yum-utils as dependency for the reboot check"
yum:
name: yum-utils
state: latest
- name: "The RKE2 node is drained before updates are installed"
shell:
cmd: kubectl drain --force $(hostname) --ignore-daemonsets --delete-emptydir-data
- name: "Update the nodes OS without touching the RKE2 packages"
dnf:
name: "*"
state: latest
exclude: "rke2*"
allowerasing: yes
update_cache: yes
- name: "Pause for 1 minute to finish all upgrades"
pause:
minutes: 1
- name: "Check if a reboot is needed due to kernel updates"
shell:
executable: /bin/bash
cmd: needs-restarting --reboothint
register: reboot_required_command
failed_when: reboot_required_command.rc >= 2
changed_when: false
args:
warn: false
- name: "Reboot the Rocky Linux servers if a new kernel is ready to use"
reboot:
msg: "Reboot initiated by Ansible for kernel updates"
connect_timeout: 10
reboot_timeout: 1000
pre_reboot_delay: 0
post_reboot_delay: 60
test_command: 'uptime'
when:
- reboot_required_command is defined
- reboot_required_command.rc == '1'
- name: "Wait a few minutes to allow RKE2 to boot up"
pause:
minutes: 3
when:
- reboot_required_command is defined
- reboot_required_command.rc == '1'
- name: "Uncordon the freshly updated RKE2 node"
shell:
cmd: kubectl uncordon $(hostname)
when: ansible_os_family == 'RedHat'