Zentrales Logging mit dem ELK-Stack in Docker
Container-Logs verstreuen sich über viele Container und sind flüchtig. Dieser Artikel zeigt, wie ein ELK-Stack aus Elasticsearch, Logstash und Kibana im Compose-Setup Logs zentralisiert, indiziert und durchsuchbar macht.
In jedem Docker-Workshop kommt irgendwann der Moment, in dem jemand fragt: "Und wo sehe ich jetzt eigentlich, was mein Container gerade macht?" Die erste Antwort ist einfach: docker logs <container>. Das reicht, solange man einen einzigen Container vor sich hat. Sobald aber ein realistisches Setup auf dem Tisch liegt – ein Frontend, ein API-Service, eine Datenbank, ein Cache, vielleicht über mehrere Hosts verteilt – wird aus der harmlosen Frage schnell ein echtes Problem. Die Logs liegen nicht mehr an einer Stelle, sie liegen an vielen. Und sie sind flüchtig: Ist der Container weg, sind die Logs weg.
Genau hier setze ich in der Schulung mit dem ELK-Stack an. In diesem Artikel gehe ich den Weg durch, den ich auch im Training gehe: erst verstehen, wo Docker die Logs eigentlich hinschreibt und welche Logging-Driver es gibt, dann einen kompletten ELK-Stack per Docker Compose aufsetzen, die Logs eines App-Containers dorthin schicken und am Ende in Kibana durchsuchbar machen. Dazwischen sammle ich die Fallstricke ein, die mir in der Praxis am häufigsten begegnet sind.
Wo Docker-Logs standardmäßig landen
Docker hat ein Konzept, das viele im ersten Moment überrascht: Es kümmert sich nicht darum, was in eurer Anwendung passiert, sondern nur darum, was auf STDOUT und STDERR geschrieben wird. Alles, was der Prozess mit PID 1 im Container auf diese beiden Streams ausgibt, fängt Docker ab und reicht es an einen sogenannten Logging-Driver weiter.
Der Default-Driver heißt json-file. Er schreibt jede Logzeile als JSON-Objekt in eine Datei auf dem Host, typischerweise unter /var/lib/docker/containers/<id>/<id>-json.log. Genau aus dieser Datei liest docker logs. Das ist auch der Grund für eine Eigenheit, die in der Praxis für Verwirrung sorgt: docker logs funktioniert nur mit json-file (und journald). Stellt man den Driver auf gelf oder syslog um, weil man die Logs woanders hinschicken will, dann ist der lokale docker logs-Befehl für diesen Container leer. Die Logs sind nicht verschwunden, sie werden nur nicht mehr lokal vorgehalten.
Die verfügbaren Driver, Stand 2018, sind eine ganze Reihe:
json-fileundjournald– lokal, mitdocker logslesbar.syslog,gelf,fluentd,splunk– Weiterleitung an einen zentralen Empfänger.awslogs,gcplogs,etwlogs– plattformspezifische Ziele.none– Logging komplett aus.
Für die Zentralisierung sind vor allem gelf und syslog interessant. Sie schicken die Logzeilen über das Netzwerk an einen Endpunkt weiter – und dieser Endpunkt kann Logstash sein.
Ein Logging-Driver lässt sich pro Container beim docker run setzen:
docker run \
--log-driver=gelf \
--log-opt gelf-address=udp://logstash:12201 \
--log-opt tag="myapp" \
myorg/myapp:latest
In Compose sieht das gleiche pro Service so aus:
version: "3"
services:
app:
image: myorg/myapp:latest
logging:
driver: "gelf"
options:
gelf-address: "udp://logstash:12201"
tag: "myapp"
Ein Detail, das man kennen sollte: Der gelf-Driver spricht ausschließlich UDP. Das ist schnell und schlank, hat aber keinen Backpressure – geht eine Nachricht unter Last verloren, ist sie weg. Für Anwendungslogs ist das meist verkraftbar, für Audit-Trails oder Abrechnungsdaten wäre es das nicht.
Die drei Rollen im ELK-Stack
Bevor wir Compose schreiben, lohnt sich der Blick auf die drei Buchstaben. ELK steht für Elasticsearch, Logstash und Kibana, und jede Komponente hat genau eine klar umrissene Aufgabe. Elasticsearch speichert und indiziert: Es ist die Suchmaschine im Zentrum, die jede Logzeile durchsuchbar macht und die Daten vorhält. Logstash nimmt an, parst und transformiert – es ist die Eingangstür, die Logs aus verschiedenen Quellen entgegennimmt, sie in Struktur bringt und an Elasticsearch weiterreicht; seine Pipeline besteht aus input, filter und output. Und Kibana visualisiert und durchsucht: die Weboberfläche, in der man Logs filtert, Dashboards baut und Muster sichtbar macht.
Der Datenfluss ist eine Kette, und die zeichne ich in der Schulung immer als Erstes an die Wand:
flowchart LR
A[App Container] -->|stdout / gelf| B[Logstash]
B -->|index| C[Elasticsearch]
C -->|query| D[Kibana]
E[Browser] -->|http :5601| D
Die App schreibt nach STDOUT, der Docker-Driver leitet an Logstash weiter, Logstash indiziert in Elasticsearch, und Kibana macht das Ganze im Browser sichtbar. Wichtig ist: Die Anwendung selbst weiß von alldem nichts. Sie loggt nach STDOUT und STDERR – so, wie es das Twelve-Factor-Prinzip vorsieht – und der Transport ist Sache der Infrastruktur. Kein eigenes Logfile-Handling im Container, keine Rotationslogik in der App. Das hält die Anwendung sauber und macht den Log-Pfad austauschbar.
Der Compose-Stack
Jetzt bauen wir den Stack. Ich pinne die Images bewusst auf eine feste Version – in diesem Fall die 6.4er-Reihe – weil ein ungetaggtes elasticsearch:latest in einem Training die zuverlässigste Quelle für "bei mir sieht es aber anders aus" ist. Hier die elk-compose.yml:
version: "3"
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:6.4.0
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- es-data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
- "9300:9300"
logstash:
image: docker.elastic.co/logstash/logstash:6.4.0
volumes:
- ./pipeline:/usr/share/logstash/pipeline
ports:
- "12201:12201/udp"
depends_on:
- elasticsearch
kibana:
image: docker.elastic.co/kibana/kibana:6.4.0
environment:
- ELASTICSEARCH_URL=http://elasticsearch:9200
ports:
- "5601:5601"
depends_on:
- elasticsearch
volumes:
es-data:
Ein paar Punkte, die in der Konfiguration nicht zufällig so stehen. Elasticsearch bekommt discovery.type=single-node, damit es sich nicht vergeblich nach Cluster-Kollegen umschaut. Der Heap wird mit ES_JAVA_OPTS fix auf 512 Megabyte gesetzt – zum Ausprobieren reicht das, in Produktion würde man großzügiger dimensionieren, aber immer mit gleichem Xms und Xmx. Und die Daten liegen auf einem Named Volume es-data, damit sie einen Neustart des Containers überleben. Das ist kein Detail: Vergisst man das Volume, sind bei jedem docker-compose down alle indizierten Logs verloren – ausgerechnet beim Log-System besonders ärgerlich.
Kibana bekommt mit ELASTICSEARCH_URL die Adresse von Elasticsearch. Der Servicename elasticsearch funktioniert als Hostname, weil Compose alle Services in dasselbe Netzwerk hängt und Namensauflösung mitbringt.
Jetzt fehlt noch die Logstash-Pipeline. Die liegt als pipeline/logstash.conf neben der Compose-Datei und definiert, wie Logstash Nachrichten annimmt und wohin es sie schreibt:
input {
gelf {
port => 12201
}
}
filter {
# room for grok, mutate, date & co.
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "docker-logs-%{+YYYY.MM.dd}"
}
}
Der gelf-Input lauscht auf Port 12201 – genau der Port, an den unser App-Service seine Logs schickt. Der filter-Block ist hier leer, aber genau dort passiert in echten Setups die eigentliche Arbeit: Zeilen mit grok in Felder zerlegen, Zeitstempel mit date normalisieren, Felder mit mutate umbenennen. Der output-Block schreibt nach Elasticsearch, und zwar in einen tagesbasierten Index docker-logs-2018.09.18. Dieses Muster ist bewusst gewählt: Ein Index pro Tag macht das spätere Aufräumen alter Logs einfach, weil man ganze Indizes löschen kann, statt einzelne Dokumente zu suchen.
Die App anschließen
Fehlt noch die Anwendung, deren Logs wir sehen wollen. Ich hänge einen App-Service an denselben Stack und gebe ihm den gelf-Logging-Driver mit:
app:
image: myorg/myapp:latest
logging:
driver: "gelf"
options:
gelf-address: "udp://localhost:12201"
tag: "myapp"
depends_on:
- logstash
Beim gelf-Driver gibt es eine Stolperstelle, die in der Schulung fast garantiert auftaucht: Die gelf-address wird vom Docker-Daemon auf dem Host aufgelöst, nicht aus dem Container-Netzwerk heraus. Deshalb steht dort udp://localhost:12201 und der gemappte Host-Port, nicht der Servicename logstash. Der Daemon kennt das Compose-interne DNS nicht – er sieht nur den Port, den Logstash auf den Host veröffentlicht.
Startet man den Stack mit docker-compose -f elk-compose.yml up, fährt zuerst Elasticsearch hoch, dann Logstash und Kibana, dann die App. Ab jetzt fließt jede Logzeile der App als GELF-Nachricht an Logstash, wird indiziert und ist in Kibana sichtbar.
Ein Wort zur Reihenfolge: depends_on steuert nur, in welcher Reihenfolge Docker die Container startet – nicht, ob der jeweilige Dienst schon einsatzbereit ist. Elasticsearch braucht nach dem Start einige Sekunden, bis der Index grün ist, und in dieser Zeit meldet Kibana im Browser gerne "Kibana server is not ready yet". Das ist kein Fehler, das ist die Realität asynchroner Startvorgänge. Einmal durchatmen, Seite neu laden.
In Kibana durchsuchen
Kibana läuft auf Port 5601. Beim ersten Aufruf will es einen Index Pattern wissen – man gibt docker-logs-* ein, wählt das Zeitfeld aus, und schon zeigt der Discover-Tab die eintrudelnden Logzeilen. Von hier aus filtert man nach dem tag-Feld (myapp), sucht nach Volltext in den Nachrichten, grenzt auf Zeiträume ein und baut bei Bedarf Visualisierungen. Der Aha-Moment im Training kommt zuverlässig genau hier: Aus verstreuten docker logs-Aufrufen über mehrere Terminals wird eine einzige durchsuchbare Oberfläche.
Fallstricke aus der Praxis
Drei Dinge gehen bei diesem Setup am häufigsten schief, und alle drei habe ich mehr als einmal live erlebt.
Der Klassiker ist, dass Elasticsearch gar nicht erst startet und im Log max virtual memory areas vm.max_map_count [65530] is too low steht. Elasticsearch 6.x verlangt auf dem Host vm.max_map_count=262144. Das ist eine Kernel-Einstellung, kein Container-Parameter, und muss auf dem Docker-Host gesetzt werden:
sudo sysctl -w vm.max_map_count=262144
Auf einer Docker-Machine mit VirtualBox setzt man das entsprechend in der VM. Vergisst man es, hilft kein Neustart des Containers – Elasticsearch stirbt reproduzierbar beim Hochfahren.
Der zweite Fallstrick ist das Log-Volumen. Der Default-Driver json-file schreibt unbegrenzt auf die Platte. Ein gesprächiger Container kann so eine Entwicklungsmaschine über Nacht volllaufen lassen. Deshalb gehört bei json-file immer eine Begrenzung dazu:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Und beim UDP-Transport von gelf gilt, was ich oben schon angerissen habe: Unter Last können Nachrichten verloren gehen, ohne dass es irgendwo knallt. Für kritische Logs sollte man daher entweder auf einen TCP-fähigen Transport wie den syslog-Driver über tcp:// ausweichen oder das Log-Volumen bewusst im Blick behalten.
Der dritte Punkt ist die schon erwähnte Startkopplung. Logstash und Kibana brauchen ein erreichbares Elasticsearch. depends_on garantiert nur den Start, nicht die Readiness. In Produktion löst man das mit Health-Checks und Retry-Logik; im Training reicht meist das Wissen, dass "not ready" in den ersten Sekunden normal ist und kein Grund zur Panik.
Zwei Wege der Weiterleitung
Zum Abschluss noch eine Einordnung, die zeigt, dass der Logging-Driver nicht der einzige Weg ist. Grundsätzlich gibt es zwei Ansätze, Logs aus Containern zu Logstash zu bekommen. Der erste ist der, den wir hier gegangen sind: Der Docker-Logging-Driver am App-Service schickt direkt weiter. Der zweite ist ein Log-Collector als Sidecar – ein zusätzlicher Container wie logspout, der den Docker-Socket liest und die gesammelten Logs aller Container per syslog weiterleitet. Der Vorteil des Collector-Ansatzes ist, dass man an den App-Services nichts ändern muss und docker logs weiterhin funktioniert, weil die Container beim Default-Driver bleiben. Der Nachteil ist eine weitere bewegliche Komponente im Stack. Welcher Weg besser passt, hängt davon ab, wie viel Kontrolle man über die einzelnen Services hat – bei fremden Images ist der Collector oft die pragmatischere Wahl.
Wer den ELK-Stack in ein größeres Compose-Setup einbetten will, findet in Node.js und Redis mit Docker Compose die Grundlagen für mehrteilige Stacks, und wenn mehrere Projekte parallel laufen sollen, ist der Ansatz aus Docker-Workshops isolieren eine gute Ergänzung.
Fazit
Container-Logs sind per Default lokal und flüchtig – das ist bei einem Container kein Problem und bei zwanzig ein großes. Der ELK-Stack löst das mit einer klaren Arbeitsteilung: Logstash nimmt an und transformiert, Elasticsearch speichert und indiziert, Kibana macht durchsuchbar. Docker liefert mit seinen Logging-Drivern die Brücke, um die Logs eines Containers überhaupt erst dorthin zu bekommen, ohne die Anwendung anfassen zu müssen. Ein einziger Compose-Stack orchestriert alle Teile, und wer die drei großen Fallstricke kennt – vm.max_map_count, unbegrenztes Log-Volumen und die Readiness beim Start – hat den Weg von "wo sind meine Logs?" zu einer zentralen, durchsuchbaren Oberfläche in einem Nachmittag hinter sich. Der wichtigste Satz aus meiner Schulungspraxis bleibt dabei einfach: Die App loggt nach STDOUT und STDERR, alles andere ist Infrastruktur.
Weiterführende Quellen
- Docker: Configure logging drivers
- Docker: json-file logging driver
- Docker: gelf logging driver
- Docker: syslog logging driver
- Elastic: Getting started with the Elastic Stack and Docker Compose
- Elasticsearch 6.4: Install with Docker
- Kibana 6.4: Running Kibana on Docker
- Logstash 6.4: Running Logstash on Docker
- Referenz-Stack: deviantony/docker-elk
Kommentare