Kubernetes-Troubleshooting: Methodik und Werkzeuge

Es ist keine Offenbarung, wenn ich behaupte, dass immer mehr Anwendungen nicht mehr als Monolith, sondern containerisiert auf Kubernetes bereitgestellt werden. Noch öfter gibt es fertige Appliances, die einen hochverfügbaren K8S-Cluster beherbergen.

In diesem Beitrag werde ich Kubernetes aus der Perspektive eines Administrators betrachten, der die entstehenden technischen Herausforderungen (an dieser Stelle kann auch das Wort „Probleme“ verwendet werden) selbst bewältigen bzw. beseitigen muss. Bei dieser Art von Aufgaben geht es nicht darum, komplexe Deployment-Manifeste zu schreiben, sondern darum, die entstandene Fehlkonfiguration zu finden, den Fehler zu beheben und anschließend den Cluster wieder zum Laufen zu bringen, sodass alle Pods und darin laufende Container wie gewünscht funktionieren.

Das Thema Troubleshooting ist mit 30 % die höchstgewichtete Domäne der CKA-Prüfung (Certified Kubernetes Administrator).

Um die Struktur des Beitrags zu vereinfachen, orientiere ich mich, soweit es thematisch passt, an der Troubleshooting-Domäne des CKA-Exam-Outlines:

  • Troubleshoot clusters and nodes
  • Troubleshoot cluster components
  • Monitor cluster and application resource usage
  • Manage and evaluate container output streams
  • Troubleshoot services and networking

Zwischen Ist- und Soll-Zustand…

Kubernetes ist ein deklaratives System, bei dem der gewünschte Zustand in einem Manifest festgehalten wird (Soll-Zustand). Beim Kubernetes-Troubleshooting besteht unsere Aufgabe vereinfacht darin, die Diskrepanz zwischen dem tatsächlichen Zustand (Ist-Zustand) und dem Soll-Zustand zu beseitigen. Somit besteht unsere Aufgabe darin, systematisch herauszufinden, an welcher Stelle der Verantwortungskette die Angleichung scheitert und warum, z.B. beim Scheduler, kubelet, Container Runtime, Netzwerk, Storage oder in der Anwendung selbst.

Anwendung als Landkarte

Unabhängig von der Situation ist es immer sinnvoll, die betroffene Anwendung wie eine Landkarte zu betrachten. Gemeint ist damit eine einfache Skizze der beteiligten Komponenten und Verbindungen: wer spricht mit wem, über welchen Service, welchen Port und in welchem Namespace.  Somit lassen sich die möglichen Fehlerquellen anhand der Fehlerbeschreibung wesentlich schneller identifizieren.

Besonders wenn es um solche Konstrukte geht:  User > Ingress / NodePort / LoadBalancer > Web-Service > Web-Pod > DB-Service > DB-Pod.

Eine Meldung wie „Die Anwendung reagiert nicht“ sagt zunächst nur, dass irgendwo in dieser Kette ein Problem besteht.

Diagnose-Ablauf

Ich würde vorsichtig behaupten, dass die Störung in der realen Welt nicht mit einem kubectl Befehl beginnen wird, sondern mit einem Alarm auf irgendeinem Grafana-Dashboard und einer Nachricht in einem Messenger. Aus CKA-Sicht und aus Perspektive der Erfahrungssammlung wäre ein kubectl genau der richtige Einstieg.

Events vor Logs. Übrigens die Events sind oft eine effizientere Informationsquelle als Logs, wenn ein Objekt nicht erstellt, nicht geplant oder nicht gestartet werden kann. Die Events sind die Aussage des Clusters über sich selbst. Logs erklären dagegen, was die Anwendung tut, nachdem der Container gestartet wurde.

Schritt Phase / Fokus / Maßnahme
1. Symptom & Scope Was genau ist kaputt? Welcher Namespace, welcher Workload, seit wann?
2. Events lesen Was meldet die Control Plane eigenständig zu den betroffenen Ressourcen?
3. Status & Spec Soll- vs. Ist-Zustand
Abgleich der Deklaration im Manifest mit dem tatsächlichen Laufzeitstatus.
4. Logs  Anwendungsebene
Sollte die Infrastruktur verfügbar sein, wird der Applikations-Prozess selbst untersucht.
5. Knoten, Netzwerk und Control Plane  Infrastrukturebene
Untersuchung der unterliegenden Worker Nodes und Netzwerkkomponenten.
6. Fix verifizieren Erfolgskontrolle
Überprüfung, ob die Ursache behoben ist und die Symptome verschwunden sind

Diagnose-Werkzeug

Die Diagnose-Werkzeuge lassen sich in zwei Gruppen aufgliedern:

  • Beobachtungswerkzeuge: get, describe, logs
  • Aktives Zugreifen (Hineingehen): exec

Ein roter Faden in diesem Abschnitt wäre: Je tiefer man in einen laufenden Pod eingreift, desto höher werden die erforderlichen Rechte und das Risiko. In gehärteten Umgebungen (Prod, Zero Trust, Least Privilege) sind die höheren Stufen oft bewusst gesperrt.

Beobachten: get, describe, events

Mit kubectl get erhält man einen schnellen Überblick. Mit -o wide werden zusätzlich der Knoten und die Pod-IP angezeigt. Mit -o yaml sieht man den vollständigen Ist-Zustand des Objekts, inklusive status.conditions. Das Feld status.conditions beschreibt, welche Bedingungen für ein Objekt erfüllt oder nicht erfüllt sind. z.B ob ein Pod geplant, gestartet oder bereit ist.

Auszug aus der Ausgabe:

status:
  phase: Pending
  conditions:
  - type: PodScheduled
    status: "False"
    reason: Unschedulable
    message: "0/3 nodes are available: 3 Insufficient cpu."
  - type: Initialized
    status: "True"
  - type: Ready
    status: "False"
  - type: ContainersReady
    status: "False"

In diesem Beispiel ist der Pod noch nicht auf einem Node geplant worden. Das erkennt man an PodScheduled: False. Der Grund steht im Feld reason: Unschedulable. Die eigentliche Ursache steht im Feld message:  Im Cluster ist nicht genug CPU verfügbar.

Seit Kubernetes 1.27 steht mit kubectl events ein eigenständiger Befehl zur Verfügung. Dieser ist flexibler als der Events-Block von describe, da er Events gezielt sortieren, filtern und namespace- oder clusterweit anzeigen kann.

kubectl get pods -o wide
kubectl get pod <pod> -o yaml | less
kubectl describe pod <pod>
kubectl events --for pod/<pod> --types=Warning
kubectl events -A --sort-by=.lastTimestamp | tail -30

Hineinhören: logs und –previous

Die Logs kann man als die Stimme der Anwendung bezeichnen.  Der mit Abstand wichtigste Schalter ist –previous (kurz -p), um die Logs des zuletzt abgestürzten Containers zu zeigen.

Wenn ein Container neu gestartet wird, existiert der alte Container-Prozess zwar nicht mehr. Normalerweise behalten Kubernetes bzw. die Container-Runtime aber die Logs des letzten beendeten Container-Laufs noch. Bei einem CrashLoopBackOff ist der Schalter –previous besonders wichtig, da der aktuelle Container ist möglicherweise gerade erst neu gestartet und hat noch kaum Logs geschrieben.

kubectl logs <pod>                      # Logs des aktuell laufenden Containers
kubectl logs <pod> --previous           # Logs des vorherigen, abgestürzten Laufs
kubectl logs <pod> -c <container>       # Logs eines bestimmten Containers im Pod
kubectl logs -f deploy/<name> --tail=50 # Logs verfolgen
kubectl logs <pod> --since=10m          # nur die letzten 10 Minuten

Wichtig zu merken: kubectl logs kann die Logs auslesen, nur wenn die Anwendung nach stdout oder stderr schreibt. Das sind die wichtigsten Standardkanäle:

  • stdin   = Standard Input  = Eingabe
  • stdout  = Standard Output = normale Ausgabe
  • stderr  = Standard Error  = Fehlerausgabe

Wenn eine Anwendung ihre Logs nur in eine Datei im Container selbst schreibt, kann Kubernetes diese nicht lesen oder anzeigen. In modernen Container-Umgebungen ist es gängige Praxis, die Anwendungslogs direkt nach stdout/stderr auszugeben.

Wenn es nicht möglich ist, die Anwendung so zu konfigurieren, dass sie ihre Logs direkt nach stdout oder stderr schreibt, kann ein sogenannter Logging-Sidecar helfen. Dabei schreibt der Hauptcontainer seine Logdatei in ein gemeinsames Volume. Ein zweiter Container im selben Pod liest diese Datei fortlaufend aus und gibt den Inhalt nach stdout aus. Dadurch werden die Logs wieder über kubectl logs oder über einen Log-Collector sichtbar.

Sidecar-Beispiel

apiVersion: v1
kind: Pod
metadata:
  name: log-sidecar-demo

spec:
  volumes:
    - name: log-volume
      emptyDir: {}

  containers:
    - name: main-container
      image: busybox
      command: ["sh", "-c"]
      args:
        - |
          while true; do
            echo "$(date) application log entry" >> /logs/app.log
            sleep 5
          done
      volumeMounts:
        - name: log-volume
          mountPath: /logs

    - name: log-sidecar
      image: busybox
      command: ["sh", "-c", "tail -f /logs/app.log"]
      volumeMounts:
        - name: log-volume
          mountPath: /logs

In diesem Beispiel schreiben beide Container in dasselbe emptyDir-Volume. Der main-container schreibt seine Logeinträge in die Datei /logs/app.log. Der log-sidecar liest diese Datei mit tail -f fortlaufend aus und gibt den Inhalt nach stdout aus. Dadurch werden die Logs über kubectl logs sichtbar:

kubectl logs log-sidecar-demo -c log-sidecar

Hineingehen: exec, cp

Wenn die Logs nicht ausreichen, gibt es weitere Möglichkeiten, um direkt in einen laufenden Container zu schauen. Mit dem Befehl kubectl exec -it <pod> — sh wird eine Shell im Container geöffnet. Bei Pods mit mehreren Containern kann der gewünschte Container mit der Option -c <container> ausgewählt werden.

Das setzt allerdings voraus, dass der Container eine Shell enthält. Aus Sicherheitsgründen sind diese sowie der weitere Befehl kubectl cp mehr als problematisch und ein Anti-Pattern für den regulären Betrieb.

Auch kubectl cp kann für einmalige Diagnosen nützlich sein, etwa um eine Datei aus einem Container zu kopieren. Dies setzt jedoch meist das Archiv-Werkzeug tar sowie entsprechende RBAC-Rechte im Container voraus und umgeht somit den deklarativen Charakter von Kubernetes. In gehärteten Produktionsumgebungen sind deshalb häufig bewusst Zugriffe wie exec und „cp” gesperrt.

kubectl exec -it <pod> -c <container> -- sh # oder -- /bin/sh
kubectl exec <pod> -- cat /etc/nginx/nginx.conf  # einzelner Lesezugriff

# cp – nur für einmalige Diagnose, braucht tar im Container, sonst kommt die 
# Meldung: tar: not found

kubectl cp <ns>/<pod>:/pfad/im/pod ./lokal      # aus dem Pod
kubectl cp ./lokal <ns>/<pod>:/pfad/im/pod      # in den Pod

Zugriff auf laufende Anwendungen: port-forward und proxy

Um einen möglichen Fehler entlang der Kommunikationskette einzugrenzen, lohnt es sich, die Anwendung zunächst direkt anzusprechen und zu prüfen, ob der Pod selbst antwortet, bevor man Service und Ingress verdächtigt.

kubectl port-forward baut einen Tunnel von einem lokalen Port z.B. auf dem Admin-VM zum Pod oder Service im Cluster auf. Die Admin-VM muss dafür nicht direkt im Pod-Netz stehen, sondern nur den Kubernetes API-Server erreichen können. Die Weiterleitung läuft über die Kubernetes-API und das kubelet auf dem Zielknoten.

Aus Sicherheitsperspektive ist port-forward ebenfalls kritisch, da in diesem Fall eine Verbindung zu internen Anwendungen entstehen kann. Somit ist die Wahrscheinlichkeit, dass diese Diagnosemethode in einer Produktionsumgebung zur Verfügung steht, sehr gering.

kubectl port-forward <pod> 8080:<container-port>
kubectl port-forward service/<svc> 8080:<service-port>
# danach lokal:  curl http://localhost:8080

kubectl proxy funktioniert anders: Es wird keine Verbindung zu einer Anwendung im Cluster aufgebaut, sondern eine Verbindung zur Kubernetes-API. Auf der Admin-VM wird lokal ein authentifizierter Proxy zum API-Server gestartet. Über diesen lokalen Proxy kann man die Kubernetes-API mit curl abfragen, ohne Tokens oder Zertifikate manuell angeben zu müssen.

kubectl proxy --port=8001 &
curl http://localhost:8001/api/v1/nodes
curl http://localhost:8001/api/v1/namespaces/<ns>/pods

Ephemeral Containers und kubectl debug

Wie bereits erwähnt, werden die Container-Images so klein wie möglich gehalten, sodass die typischen Tools wie sh, ps, curl, dig usw. nicht enthalten sind. Dies ist aus Sicherheitsgründen sinnvoll, erschwert aber die Diagnose. So ist es beispielsweise nicht möglich, sich mit kubectl exec in den Container einzuloggen.

In solchen Fällen können Ephemeral Containers Abhilfe schaffen.  Dabei handelt es sich um einen zusätzlichen, temporären Container, der nachträglich in einen bestehenden Pod eingefügt wird. Der produktive Container wird dabei nicht verändert. Ein Ephemeral Container bringt eigene Diagnosewerkzeuge mit, mit denen sich ein laufender Pod untersuchen lässt.

Ein Ephemeral Container wird nicht vorher im YAML des Pods definiert, sondern wird nachträglich zur Laufzeit in einen bestehenden Pod eingefügt. Wie z. B. so:

# Debug-Container in laufenden Pod einfügen und danach 
# mit ps die Prozesse des Zielcontainers prüfen:

kubectl debug -it <pod> --image=busybox --target=<container> 

# im Debug-Container:

ps

kubectl debug ist nicht nur für Ephemeral Containers gedacht. Ein weiteres typisches Szenario ist das Erstellen einer Debug-Kopie des Original-Pods. Das ist besonders hilfreich, wenn der Original-Pod im Status CrashLoopBackOff hängt, also startet und sich sofort wieder beendet. In diesem Fall kommt man mit kubectl exec oft nicht rechtzeitig in den Container hinein.

kubectl debug <pod> -it --copy-to=<pod>-debug --image=busybox -- sh

Netzwerk und Syscalls

Der Hersteller nennt es das „Schweizer Taschenmesser“ für tiefergehende Netzwerkdiagnosen: nicolaka/netshoot ist dafür gedacht, eine Ebene tiefer zu gehen. Er enthält viele Werkzeuge, die in normalen Anwendungscontainern oft fehlen, darunter curl, dig, ss, ip, tcpdump und weitere Netzwerktools. Auf der Website des Herstellers sind mehrere unterschiedliche Verwendungsbeispiele zu finden.

kubectl debug -it <pod> -c dbg --target=app --image=nicolaka/netshoot

--image=nicolaka/netshoot   # welches Werkzeug-Image wird verwendet?
-c dbg                      # wie heißt der neue Debug-Container?
--target=app                # welchen bestehenden Container wird untersucht?

Auf Nodeebene: crictl

Es kann vorkommen, dass das kubelet selbst Teil des Problems ist. Das kubelet ist der Agent auf jedem Kubernetes-Node, welcher die Pod-Spezifikation vom API-Server entgegen nimmt und dafür sorgt, dass die Container auf dem Node über die Container Runtime (meist mit containerd) gestartet werden.

In solchen Fällen reicht kubectl manchmal nicht aus. kubectl zeigt die Sicht des Kubernetes API-Servers. Wenn man aber wissen möchte, was direkt auf dem Node passiert, benötigt man ein Tool, das auf einer tieferen Ebene arbeitet. Hier hilft crictl. crictl spricht direkt mit der Container Runtime über die CRI-Schnittstelle, also das Container Runtime Interface. Damit sieht man Container, Images und Logs so, wie sie auf dem Node tatsächlich in der Runtime vorhanden sind.

  • Normaler Weg: kubectl > API-Server > kubelet > CRI > containerd
  • Mit crictl: crictl > CRI > containerd

Typische Befehle auf dem Node sind:

sudo crictl ps -a                 # alle Container der Runtime anzeigen
sudo crictl logs <container-id>   # Logs eines Containers anzeigen
sudo crictl images                # vorhandene Images anzeigen
sudo journalctl -u kubelet -f     # kubelet-Logs live verfolgen
sudo systemctl status kubelet     # Status des kubelet-Dienstes prüfen

Berechtigungen verifizieren: auth can-i

Nicht jeder Fehler ist ein technisches Problem im Pod/Node oder in der Anwendung. Es ist keine Seltenheit, dass die Ursache scheinbar unerklärlicher Fehler auf der RBAC-Ebene liegt. RBAC (Role-Based Access Control) regelt, welche Benutzer, Gruppen oder ServiceAccounts welche Aktionen im Cluster ausführen dürfen.

Mit kubectl auth can-i lässt sich prüfen, ob eine bestimmte Aktion für einen bestimmten Account erlaubt ist.

Ohne weitere Optionen prüft der Befehl die Berechtigungen des aktuell angemeldeten Kubernetes-Benutzers:

kubectl auth can-i create deployments -n prod 
# Darf ich im Namespace prod Deployments erstellen?

Mit –as kann man die Prüfung aus Sicht eines anderen Benutzers oder ServiceAccounts durchführen:

kubectl auth can-i create deployments -n prod --as=username.usernachname

So lässt sich überprüfen, ob der Service Account „webapp-sa“ im Namespace „prod“ Secrets auflisten darf:

kubectl auth can-i list secrets -n prod --as=system:serviceaccount:prod:webapp-sa

Weitere Beispiele:

sysop@akr-c01:~$ kubectl auth can-i create deployments
yes
sysop@akr-c01:~$ kubectl auth can-i delete pods
yes
sysop@akr-c01:~$ kubectl auth can-i get pods -n default
yes

Mehr zum „kubectl auth can-i“ auf der offiziellen Seite.


Die Fortsetzung folgt…

Kubernetes-Troubleshooting Tool öffnen…