# Zentrales Logging mit dem ELK-Stack in Docker

URL: https://www.mikebild.dev/de/blog/docker-logging-mit-elk/

`docker logs <container>` skaliert bis genau eins. Bei einem Frontend, einer API, einer Datenbank und einem Cache sind es vier Aufrufe in vier Terminals, bei drei Hosts und ein paar Replikaten schnell fünfzehn – und jeder einzelne zeigt nur seinen Ausschnitt, und das nur so lange, wie der Container lebt.

**Redaktionelle Einordnung (Juli 2026):** Das Beispiel verwendet bewusst Docker Compose und ELK 6.4 aus dem Jahr 2018. Es ist kein aktuelles Produktions-Template. Unabhängig von der Version unterstützt der Docker-GELF-Treiber sowohl UDP als auch TCP; die frühere Aussage, er könne ausschließlich UDP, war bereits 2018 falsch.

Die Entscheidung dahinter ist weniger eine technische als eine strategische. Sie lautet nicht „welchen Logging-Driver konfiguriere ich?“, sondern „wie viel Sichtbarkeit in mein System will ich haben, und was zahle ich dafür an Speicher, Rechenzeit und Betriebsaufwand?“ Zentrales Logging ist kein Feature, das man anschaltet, sondern eine Investition mit laufenden Kosten. Wer das ELK-Setup als reine API-Übung angeht, baut sich zuverlässig ein System, das entweder zu wenig zeigt oder die Platte volllaufen lässt. Deshalb steht die Einordnung der Logs hier vor dem ersten umkonfigurierten Container.

Genau da setzt der ELK-Stack an. In diesem Artikel gehe ich den Weg durch, den ich auch in Projekten gehe: erst Logs in die Observability-Strategie einordnen, dann verstehen, wo Docker die Logs eigentlich hinschreibt und welche Logging-Driver es gibt, danach 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.

## Logs als eine von drei Säulen

Bevor ein einziger Driver umgestellt wird, gehört die Einordnung geklärt: Logs sind nur eine von drei Säulen, auf denen Sichtbarkeit in ein verteiltes System steht, und keine der drei ersetzt die andere. Metriken sind aggregierte Zahlen über die Zeit – Requests pro Sekunde, Fehlerrate, Speicherauslastung – und beantworten „ist gerade etwas kaputt, und seit wann?“. Logs sind einzelne Ereignisse mit Kontext und beantworten „was genau ist in diesem einen Fall passiert?“. Traces verfolgen einen Request quer über mehrere Services und beantworten „an welcher Stelle im Pfad hängt es?“. Der ELK-Stack deckt die mittlere Säule ab, und es hilft ungemein, das bewusst zu wissen: Wer mit Logs allein eine Fehlerrate über die Zeit beobachten will, zahlt dafür viel mehr, als eine simple Metrik gekostet hätte.

Und damit sind wir beim Kern der Sache, den Kosten. Logs sind die verführerischste und zugleich teuerste der drei Säulen, weil jede einzelne Zeile gespeichert und in Elasticsearch standardmäßig indiziert wird. Der Preistreiber heißt Kardinalität. Ein Feld mit wenigen möglichen Werten – etwa ein `log_level` mit `info`, `warn`, `error` – ist billig zu indizieren. Ein Feld mit fast unbegrenzt vielen unterschiedlichen Werten – eine `request_id`, eine `user_id`, ein voller Query-String – treibt die Größe des Index und damit den Speicher- und RAM-Bedarf nach oben. Bei Metriken wird eine solche hohe Kardinalität schnell existenzbedrohend, bei Logs ist sie teuer, aber beherrschbar, solange man sie bewusst steuert.

Diese Steuerung ist die eigentliche Entwurfsarbeit, und sie besteht aus wenigen Hebeln. Erstens: Was landet überhaupt im Log, und auf welchem Level? Ein `DEBUG`, das in Produktion mitläuft, ist reiner Kostentreiber ohne Gegenwert. Zweitens: Wie lange bewahre ich Logs auf? Der tagesbasierte Index, den ich weiter unten baue, ist genau dafür der Hebel – ganze Tage lassen sich in einem Rutsch löschen. Drittens: Muss wirklich jedes Ereignis vollständig indiziert werden, oder reicht bei hohem Volumen eine Stichprobe? Die Regel dahinter ist schlicht und in der Umsetzung trotzdem lästig: Geloggt wird nicht alles, was möglich ist, sondern das, was man im Fehlerfall wirklich lesen will – für den Rest zahlt man nicht. Der Rest dieses Artikels zeigt die Mechanik; dass sie mit einem Kostenbewusstsein aufgesetzt wird, entscheidet darüber, ob das Setup nach drei Wochen noch trägt.

## 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-file` und `journald` – lokal, mit `docker logs` lesbar.
- `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:

```bash
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:

```yaml
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 unterstützt UDP und TCP. In diesem Beispiel ist UDP gewählt. Das ist schnell und schlank, bestätigt den Empfang aber nicht – geht eine Nachricht unter Last verloren, ist sie weg. TCP arbeitet verbindungsorientiert, macht aus einem Log-Transport jedoch noch keinen revisionssicheren Audit-Trail.

## 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 kurze Kette:

```mermaid
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` die zuverlässigste Quelle für „bei mir sieht es aber anders aus“ ist. Hier die `elk-compose.yml`:

```yaml
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:

```ruby
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:

```yaml
  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 beim ersten Aufsetzen 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 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:

```bash
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:

```yaml
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
```

Und beim hier gewählten 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 kommen GELF über TCP oder ein gepufferter Collector infrage. Ob ein Log vollständig, unveränderbar und nachweisbar sein muss, bleibt darüber hinaus eine eigene fachliche Anforderung.

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; beim lokalen Ausprobieren 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](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/) die Grundlagen für mehrteilige Stacks. Mehrere parallele Projekte trennt Compose über eigene Projektnamen und die daraus abgeleiteten Netzwerke.

## 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.

Die Wirkung, auf die es mir dabei ankommt, ist eine Verschiebung im Arbeitsalltag. Der Weg von der Störung zur Ursache wird kürzer, weil man nicht mehr über mehrere Terminals hinweg `docker logs` zusammensucht, sondern in einer Oberfläche filtert. Die Reibung sinkt, weil die App sauber bleibt – sie loggt nach STDOUT und STDERR, der Transport bleibt Sache der Infrastruktur und damit austauschbar. Und die Qualität der Diagnosen steigt, weil ein durchsuchbarer Verlauf Muster sichtbar macht, die im flüchtigen Einzel-Log untergehen. Wer die drei großen Fallstricke kennt – `vm.max_map_count`, unbegrenztes Log-Volumen und die Readiness beim Start – hat diesen Weg in einem Nachmittag hinter sich.

Ebenso wichtig ist die Kenntnis der Grenzen. Logs sind eines von mehreren Telemetriesignalen, nicht die ganze Observability: Für Trends über die Zeit sind Metriken billiger, für den Weg eines Requests durch mehrere Services sind Traces das richtige Werkzeug. Die Kosten wachsen mit Volumen und Kardinalität, weshalb Aufbewahrung, Log-Level und die Frage, welche Felder indiziert werden, bewusste Entscheidungen bleiben müssen. Und das hier verwendete `gelf` über UDP eignet sich für unkritische Anwendungslogs, aber nicht für Audit-Trails oder Abrechnungsdaten, bei denen keine Nachricht verloren gehen darf. Innerhalb dieser Grenzen bleibt der Kern einfach: Die App loggt nach STDOUT und STDERR, alles andere ist Infrastruktur – und was diese Infrastruktur kostet, entscheidet man selbst.

## Weiterführende Quellen

- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-docker.html
- [Docker GELF logging driver – TCP- und UDP-Konfiguration](https://docs.docker.com/engine/logging/drivers/gelf/)
- [Docker 17.11 Release Notes – Einführung der GELF-TCP-Unterstützung](https://docs.docker.com/engine/release-notes/17.11/)
- [Docker: Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/)
- [Docker: json-file logging driver](https://docs.docker.com/config/containers/logging/json-file/)
- [Docker: gelf logging driver](https://docs.docker.com/config/containers/logging/gelf/)
- [Docker: syslog logging driver](https://docs.docker.com/config/containers/logging/syslog/)
- [Elastic: Getting started with the Elastic Stack and Docker Compose](https://www.elastic.co/blog/getting-started-with-the-elastic-stack-and-docker-compose)
- [Elasticsearch 6.4: Install with Docker](https://www.elastic.co/guide/en/elasticsearch/reference/6.4/docker.html)
- [Kibana 6.4: Running Kibana on Docker](https://www.elastic.co/guide/en/kibana/6.4/docker.html)
- [Logstash 6.4: Running Logstash on Docker](https://www.elastic.co/guide/en/logstash/6.4/docker.html)
- [Referenz-Stack: deviantony/docker-elk](https://github.com/deviantony/docker-elk)
