Die App liest REDIS_PORT. Kubernetes hatte die Variable längst überschrieben.

kubernetes devops debugging django

Ich habe eine Django-App in meinem Homelab-Cluster deployt. Nichts Exotisches: die App, ein Postgres, ein Redis, jeweils hinter einem eigenen Service. Die Pods kamen grün hoch, die Fixtures wurden geladen, /health lieferte 200. Dann wollte ich mich einloggen und bekam ein nacktes HTTP 500 mit leerem Body.

Nicht nur beim Login. Der API-Schema-Endpoint lieferte ebenfalls 500. Der Health-Endpoint funktionierte weiter, statische Dateien auch. Es brauchte ein paar Requests, bis das Muster sichtbar wurde: Jede View, die den Cache anfasste, schlug fehl; alles andere lief.

Einen Traceback aus einem Production-Pod holen

Im Image war DEBUG fest auf False gesetzt, der Response-Body also leer, und die Logs zeigten den 500er ohne die Exception dahinter. Statt das Image mit Debug-Modus neu zu bauen, habe ich Djangos Test-Client direkt im Pod laufen lassen:

# kubectl exec -it <pod> -- python manage.py shell
from django.test import Client

c = Client(raise_request_exception=True)
c.post("/backend/token/", {"username": "admin", "password": "..."})

raise_request_exception=True ist der entscheidende Teil. Statt die 500er-Seite zu rendern, wirft der Test-Client die ursprüngliche Exception direkt in die Shell. Keine Code-Änderung, kein Redeploy, kein Debug-Modus im laufenden Cluster. Der Traceback kam sofort:

ValueError: Port could not be cast to integer value as 'tcp:'

Ein Port, der mit tcp: beginnt. Der Redis-Connection-String, den die App zusammengebaut hatte, sah so aus:

redis://redis:tcp://10.233.61.42:6379/1

Die App baut diese URL so, wie es die meisten Twelve-Factor-Apps tun:

REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
REDIS_PORT = os.environ.get("REDIS_PORT", "6379")
CACHE_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/1"

REDIS_PORT war in der Umgebung des Pods gesetzt. Nicht durch meine Manifeste, nicht durch das Chart, nicht durch eine ConfigMap. Der Wert war tcp://10.233.61.42:6379, und das ist kein Port. Das ist eine URL.

Wer setzt REDIS_PORT auf eine URL

Das Kubelet. Beim Start eines Pods injiziert Kubernetes einen Block von Umgebungsvariablen für jeden Service, der im Namespace existiert. Für einen Service namens redis auf Port 6379 bekommt jeder Pod im Namespace:

REDIS_SERVICE_HOST=10.233.61.42
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.233.61.42:6379
REDIS_PORT_6379_TCP=tcp://10.233.61.42:6379
REDIS_PORT_6379_TCP_PROTO=tcp
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_ADDR=10.233.61.42

Das ist eine Kompatibilitätsschicht für Docker Links, den Mechanismus, mit dem Docker vor DNS Container miteinander verdrahtet hat. Kubernetes hat das exakte Variablenformat in seinem ersten Release übernommen, damit Container, die für Docker Links geschrieben waren, weiterlaufen. Das war vor über einem Jahrzehnt. DNS-basierte Service Discovery hat gewonnen, niemand schreibt mehr Apps gegen *_PORT_6379_TCP_ADDR, und die Injektion ist trotzdem noch per Default aktiv. Das Pod-Feld dafür heißt enableServiceLinks und steht standardmäßig auf true.

Die Kollision ist aus einem bestimmten Grund fies: <SERVICE_NAME>_PORT ist genau der Variablenname, den eine Twelve-Factor-App von sich aus wählen würde. Nenne deinen Service redis und lies REDIS_PORT in deiner App, und du hast einen garantierten Konflikt, den Kubernetes gewinnt, weil die injizierte Umgebung schon da ist, bevor dein Code läuft. Der Wert ist nicht einmal eine Zahl, weil Docker Links ihn als protokoll-qualifizierte URL formatiert haben.

Mein Postgres ist derselben Falle durch Zufall entkommen. Kubernetes hat auch POSTGRES_PORT=tcp://... injiziert, aber die App liest DB_HOST und DB_PORT, also lagen die injizierten Variablen ungenutzt herum. Das ist der frustrierende Teil dieses Fehlerbilds: Ob es zuschlägt, hängt von einer unsichtbaren Namenskoinzidenz zwischen deinen Services und der Config-Konvention deiner App ab.

Der Fix

Drei Optionen, in der Reihenfolge, in der ich sie wählen würde.

Die Injektion am Pod-Spec abschalten:

spec:
  enableServiceLinks: false

Das ist der saubere Fix. Service Discovery über DNS bleibt unberührt; redis.namespace.svc löst weiterhin auf. Es verschwindet nur der Legacy-Variablenblock, und wenn deine App auf irgendeinem Cluster der letzten zehn Jahre läuft, hat ihn nichts gelesen.

Wenn du den Pod-Spec nicht anfassen kannst, setze die kollidierende Variable explizit:

env:
  - name: REDIS_PORT
    value: "6379"

Variablen im Container-Spec haben Vorrang vor den Service Links, ein expliziter Wert überdeckt also den injizierten. Das funktioniert, behebt aber immer nur eine Kollision und lässt den Mechanismus für den nächsten Service scharf, den jemand anlegt.

Die dritte Option ist Namensdisziplin: Services nie so benennen, dass <NAME>_PORT mit etwas kollidiert, das deine App liest. Der traue ich nicht. Sie setzt voraus, dass jeder künftige Kollege beim Benennen eines Service eine Injektionsregel aus der Docker-Ära kennt, und genau diese Art Wissen verdunstet.

Eine weitere Eigenschaft, die man kennen sollte: Die Variablen werden beim Pod-Start injiziert, für Services, die in diesem Moment existieren. Startet der Pod, bevor der Service angelegt wird, fehlt die Variable; wird derselbe Pod eine Woche später neu gestartet, ist sie plötzlich da. Ein Deploy, das funktioniert hat, kann beim nächsten Reschedule kaputtgehen, ohne dass sich ein Manifest geändert hat. Es ist dieselbe Fehlerklasse wie ein ConfigMap-Update, das den laufenden Pod nie erreicht: Die Deploy-Pipeline ist grün, und der Bruch existiert nur zur Laufzeit.

Was man daraus mitnimmt

Kubernetes liefert bis heute Kompatibilitätsverhalten aus, das älter ist als die meisten Cluster, auf denen es läuft, und es bleibt unsichtbar, bis es in deinen Umgebungsvariablen landet. Wie Finalizers ist enableServiceLinks ein Mechanismus, den die meisten an dem Tag kennenlernen, an dem er sie blockiert.

Zwei Gewohnheiten lohnen sich. Wenn sich eine App im Pod anders verhält als überall sonst, gib die tatsächliche Umgebung des Pods aus (kubectl exec <pod> -- env | sort), bevor du weiteren Code liest; die Antwort auf „wer hat das gesetzt?” ist manchmal die Plattform selbst. Und in Charts, die du kontrollierst, setze enableServiceLinks: false als Default für jede Workload, die Docker-Links-Variablen nicht ausdrücklich braucht, und das sind heute fast alle.