Containerisierung und die falsche Sicherheit
Warum „läuft im Container“ weder Isolation noch Reproduzierbarkeit noch geregelten Betrieb bedeutet – und welche Handgriffe die drei Lücken tatsächlich schließen.
„Es läuft im Container“ hat sich in Projektgesprächen zu einer Art Gütesiegel entwickelt. Der Satz soll gleich drei Dinge auf einmal belegen: Die Anwendung sei isoliert, der Build sei reproduzierbar, und der Betrieb sei geregelt. Keine dieser drei Aussagen folgt aus der Verpackung. Ein Container ist zunächst nur ein gewöhnlicher Prozess mit eingeschränkter Sicht auf das System – ob er Sicherheit und Wiederholbarkeit mitbringt, entscheidet sich im Dockerfile und in der Startkonfiguration, nicht im Wort „Container“.
Mir fällt diese Verwechslung seit einigen Monaten in Reviews auf. Ein Team zeigt ein sauberes Deployment auf Kubernetes 1.10, alles grün, und auf die Frage nach dem Patch-Stand des Basis-Images kommt ein Achselzucken. Ein anderes Team baut seit einem Jahr auf node:latest und wundert sich, dass der CI-Build sich anders verhält als der lokale. Die drei falschen Sicherheiten lohnen einen genaueren Blick, weil sie drei verschiedene Gegenmittel brauchen.
Erste falsche Sicherheit: Isolation
Ein Container ist keine kleine virtuelle Maschine. Eine VM bringt einen eigenen Kernel mit und spricht mit dem Host über einen Hypervisor. Ein Container teilt sich den Kernel mit dem Host und mit allen anderen Containern auf derselben Maschine. Was ihn abgrenzt, sind Kernel-Namespaces für Prozesse, Netzwerk und Dateisystem sowie cgroups für Ressourcen – nützliche Mechanismen, aber eine deutlich dünnere Wand als ein Hypervisor.
flowchart TB
subgraph VM["Virtuelle Maschine"]
A1[App] --> K1[Gast-Kernel]
K1 --> H1[Hypervisor]
H1 --> HW1[Host-Kernel<br/>+ Hardware]
end
subgraph CT["Container"]
A2[App als Prozess] --> NS[Namespaces<br/>+ cgroups]
NS --> HW2[gemeinsamer<br/>Host-Kernel]
end
Die praktische Konsequenz: Wer im Container als root läuft, läuft auf dem Host als root. UID 0 ist UID 0. User Namespaces, die Container-root auf einen unprivilegierten Host-Benutzer abbilden, gibt es in Docker seit einer Weile – aber sie sind opt-in über --userns-remap, und in den Umgebungen, die ich dieses Jahr gesehen habe, war das Feature genau null Mal eingeschaltet. Solange das so ist, trennt einen root-Prozess im Container vom Host nur noch die Frage, ob er einen Weg nach draußen findet.
Ein --privileged-Container findet diesen Weg garantiert. Das Flag schaltet sämtliche Capabilities frei und gibt Gerätezugriff – damit lässt sich vom Container aus das Host-Dateisystem einhängen. Ich habe das Flag schon in Produktions-Setups gefunden, gesetzt vor Monaten, „weil es sonst nicht lief“, und nie wieder hinterfragt. Ein privilegierter Container ist keine Isolationsschicht mit einer Schwäche, er ist eine offene Tür mit einem Vorhang davor.
Docker bringt seit Längerem ein seccomp-Standardprofil mit, das riskante Syscalls filtert, dazu AppArmor-Profile auf Ubuntu-Hosts. Das hilft, ändert aber nichts am Grundsatz: Die Grenze ist der Kernel, und der Kernel ist gemeinsam. Meltdown und Spectre haben im Januar zusätzlich gezeigt, dass selbst Prozessgrenzen auf CPU-Ebene wackeln können. Wer echte Mandantentrennung braucht, also fremden oder nicht vertrauenswürdigen Code fährt, nimmt weiterhin VMs – oder schaut sich gVisor an, das Google im Mai vorgestellt hat: ein Userspace-Kernel zwischen Container und Host. Spannender Ansatz, aber im Juli 2018 zu frisch, um eine Produktionsentscheidung darauf zu bauen.
Was heute schon geht, ist die Angriffsfläche beim Start zu verkleinern:
docker run -d \
--read-only \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
--memory 256m \
order-service:1.4.2
Alles daran ist seit Jahren verfügbar, nichts davon ist Standard. Genau das ist der Punkt: Isolation ist beim Container kein Zustand, sondern eine Konfiguration, die jemand aktiv vornehmen muss.
Zweite falsche Sicherheit: Reproduzierbarkeit
Die zweite Annahme lautet: Weil die Anwendung als Image ausgeliefert wird, ist der Build reproduzierbar. Das stimmt nur für das fertige Image – dieselbe Image-ID startet überall denselben Stand. Für den Weg dorthin stimmt es nicht. Ein Dockerfile wie dieses ist keine Beschreibung, sondern eine Momentaufnahme:
FROM node:latest
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y imagemagick
RUN npm install
ENV API_SECRET=changeme-in-prod
EXPOSE 3000
CMD ["node", "server.js"]
Hier ist fast jede Zeile ein Zeitzünder. node:latest ist keine Version, sondern eine Wette darauf, wann gebaut wird – letztes Jahr bekam man Node 8, seit ein paar Wochen Node 10, und niemand hat eine Entscheidung getroffen. apt-get install ohne Versionsangabe zieht, was der Spiegelserver heute hergibt. npm install ohne Lockfile würfelt die Abhängigkeitsversionen zum Build-Zeitpunkt aus; mit package-lock.json ist das seit npm 5 lösbar, aber nur, wenn die Datei auch eingecheckt und respektiert wird. Und das Secret in der ENV-Zeile liegt jetzt für immer in der Image-History – docker history zeigt es jedem, der das Image ziehen darf.
Dazu kommen Build-Args als stille Variable: Ein ARG, das auf dem CI-Server anders gesetzt ist als auf dem Entwicklerrechner, erzeugt zwei verschiedene Images aus identischem Quellstand – und weil das Argument nirgends im Repository steht, sieht man den Unterschied erst, wenn man beide Images nebeneinanderlegt. Zwei Builds desselben Dockerfiles im Abstand von sechs Wochen können sich also in Kernelnähe, Systempaketen und npm-Abhängigkeiten unterscheiden, ohne dass eine einzige Zeile im Repository geändert wurde. In einem Projekt im Frühjahr haben wir genau daran einen Nachmittag verloren: Der Hotfix-Build für einen sechs Wochen alten Stand verhielt sich anders als das laufende System, weil das Basis-Image unter demselben Tag inzwischen ein anderes war. Die ehrliche Formel ist: Ein Image ist ein Ergebnis. Reproduzierbar wird es erst, wenn alle Eingaben festgenagelt sind – Basis-Image per Tag oder besser per Digest (node:8.11-alpine@sha256:...), Systempakete mit Version, Anwendungsabhängigkeiten über das Lockfile.
Dritte falsche Sicherheit: Betrieb
Die dritte Annahme ist die leiseste: Der Container läuft, also ist der Betrieb erledigt. Dabei friert ein Image nicht nur die Anwendung ein, sondern auch jede Bibliothek darunter – inklusive ihrer Lücken. Auf einem klassisch administrierten Server spielt unattended-upgrades nachts Sicherheitsupdates ein. Im Container gibt es diesen Weg nicht. Die einzige Patch-Route ist: neues Basis-Image, neuer Build, neues Deployment. Wer diese Kette nicht als Routine eingerichtet hat, betreibt eine Flotte, die mit jedem Tag stiller veraltet, während das Dashboard grün bleibt.
Vorgelagert kommt noch eine Frage, die überraschend oft unbeantwortet bleibt: Welche Images laufen überhaupt, und aus welchem Stand wurden sie gebaut? Auf einem Cluster mit ein paar Dutzend Deployments ist das ohne Inventar reine Archäologie. Als im Januar die ersten Meltdown-Patches kamen, habe ich in einem Projekt erlebt, wie lange allein die Bestandsaufnahme dauert, wenn Tags wie latest und stable im Umlauf sind – man weiß dann buchstäblich nicht, was man betreibt, nur dass es läuft.
flowchart LR CVE[CVE im Basis-Image<br/>veröffentlicht] --> BASE[Basis-Image<br/>aktualisiert] BASE --> BUILD[Rebuild<br/>aller Services] BUILD --> SCAN[Image-Scan<br/>z. B. Clair] SCAN --> DEPLOY[Rollout] DEPLOY -. ohne Routine reißt<br/>die Kette hier ab .- BUILD
Dass Betrieb die eigentliche Flanke ist, hat dieses Jahr ein prominenter Fall gezeigt: Im Februar wurde bekannt, dass die Kubernetes-Verwaltungskonsole von Tesla ohne Passwortschutz aus dem Internet erreichbar war. Die Angreifer haben keine Kernel-Lücke gebraucht und keinen Container ausgebrochen – sie haben sich über die offene Konsole eingerichtet, Krypto-Mining-Pods deployt, die CPU-Last gedrosselt und den Mining-Pool hinter Cloudflare versteckt, um nicht aufzufallen. Dieselbe Kategorie: Docker-Daemons, deren Remote-API unauthentifiziert auf Port 2375 lauscht und die reihenweise von Mining-Skripten abgegrast werden. In beiden Fällen war die Container-Technik völlig unschuldig. Verwundbar war das Betriebsmodell drumherum – exponierte Verwaltungsschnittstellen, fehlende Zugangskontrolle, niemand, der hinschaut.
Für mich ist das die wichtigste Lektion aus dem Fall: Die Plattform verschiebt die Betriebsfragen, sie beantwortet sie nicht. Wer vorher wissen musste, welche Pakete auf dem Server liegen, muss jetzt wissen, welche Layer in den Images liegen. Wer vorher SSH abgesichert hat, muss jetzt Kubelet, API-Server und Dashboard absichern. Der Aufwand ist nicht verschwunden, er hat die Adresse gewechselt.
Was konkret hilft
Nichts davon ist ein Argument gegen Container – es ist ein Argument gegen das Gütesiegel. Die Gegenmittel sind unspektakulär und seit geraumer Zeit verfügbar. Am Dockerfile selbst sieht das so aus:
# build stage
FROM node:8.11-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build && npm prune --production
# runtime stage
FROM node:8.11-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]
Multi-Stage-Builds gibt es seit Docker 17.05, sie sind also seit über einem Jahr etabliert und kein Experiment mehr. Der Effekt: Compiler, Dev-Abhängigkeiten und Build-Werkzeuge bleiben in der ersten Stufe zurück, ausgeliefert wird nur, was zur Laufzeit gebraucht wird. Die USER-Direktive sorgt dafür, dass der Prozess gar nicht erst als root startet – eine Zeile, die einen Großteil des Isolationsproblems aus dem ersten Abschnitt entschärft, weil ein Ausbruch dann bei einem unprivilegierten Benutzer landet. Das gepinnte Basis-Image nimmt der Reproduzierbarkeitsfrage die Würfel aus der Hand. Ein angenehmer Nebeneffekt der kleinen Laufzeitstufe: Der Image-Scanner hat weniger zu melden, weil schlicht weniger Pakete an Bord sind – ein Alpine-Image mit einer Handvoll Bibliotheken erzeugt Befunde, die man tatsächlich liest, statt einer Liste mit dreihundert Einträgen, die nach der zweiten Woche niemand mehr öffnet.
Darüber hinaus haben sich bei mir ein paar Punkte als Checkliste für Reviews eingespielt:
- Basis-Images klein wählen –
alpinefür den Alltag, für kompilierte Sprachen die distroless-Images, die Google letztes Jahr veröffentlicht hat und die außer der Anwendung und ihren Laufzeitabhängigkeiten nichts enthalten, nicht einmal eine Shell. - Tags pinnen, für Produktions-Deployments zusätzlich den Digest festhalten –
latesthat in einem Deployment-Manifest nichts verloren. - Image-Scanning in die CI-Kette hängen: Clair oder Anchore gleichen die Layer gegen bekannte CVEs ab und machen aus „wir müssten mal patchen“ einen roten Build.
- Secrets niemals ins Image – nicht als
ENV, nicht alsARG, nicht als kopierte Datei. Beides landet in der History beziehungsweise im Build-Kontext. Secrets kommen zur Laufzeit von außen, etwa als Kubernetes Secret oder über den Orchestrator. - Capabilities beim Start beschneiden und
--privilegedwie einen offenen root-Zugang behandeln: Wer es setzen will, muss begründen, warum.
Keiner dieser Punkte ist schwer. Das eigentliche Hindernis ist, dass sie unsichtbar sind, solange alles läuft – und genau deshalb gehören sie ins Review und nicht in die Rubrik „machen wir, wenn Zeit ist“.
Unterm Strich
Container haben unsere Auslieferung spürbar besser gemacht: gleiche Artefakte in allen Umgebungen, schnelle Starts, saubere Schnittstelle zwischen Build und Betrieb. Was sie nicht liefern, ist das, was der Satz „läuft im Container“ so gern behauptet. Isolation ist eine Konfiguration, keine Eigenschaft – ohne USER, ohne beschnittene Capabilities und mit gemeinsamem Kernel ist die Wand dünner, als das Wort klingt. Reproduzierbarkeit ist eine Disziplin, keine Eigenschaft – ungepinnte Tags und Pakete machen aus dem Dockerfile ein Foto statt einer Bauanleitung. Und Betrieb ist eine Routine, keine Eigenschaft – ein Image, das niemand neu baut, ist ein Server, den niemand patcht, nur ohne die nächtlichen Updates.
Wenn mir heute jemand sagt, eine Anwendung sei sicher, weil sie containerisiert ist, frage ich drei Dinge: Als welcher Benutzer läuft der Prozess? Woher weiß der nächste Build, dass er dasselbe baut wie der letzte? Und wer baut neu, wenn morgen eine Lücke in der Basis veröffentlicht wird? An den Antworten erkennt man ziemlich zuverlässig, ob das Team ein Betriebsmodell hat – oder nur ein Gütesiegel.
Weiterführende Quellen
- Repository introduction-docker: https://github.com/MikeBild/introduction-docker
- Docker-Dokumentation zu Multi-Stage-Builds: https://docs.docker.com/build/building/multi-stage/
- Docker Engine Security Overview: https://docs.docker.com/engine/security/
- Clair, statische Analyse von Container-Images: https://github.com/quay/clair
- Distroless-Images von Google: https://github.com/GoogleContainerTools/distroless
- Bericht zum Tesla-Vorfall (CNBC, Februar 2018): https://www.cnbc.com/2018/02/21/hackers-hijack-teslas-cloud-system-to-mine-cryptocurrency-redlock.html
Kommentare