Nutzung von UNIX-Sockets über JDBC-Treiber in Docker-Containern
In diesem Artikel untersuchen wir die Nutzung von Unix-Sockets mit dem JDBC-Treiber (Java Database Connectivity) für den Zugriff von einer Java-Applikation auf eine Postgres-Datenbank. Es wird auf die notwendigen Konfigurations-Anpassungen eingegangen sowie das Für und Wider im Vergleich zur Nutzung des TCP/IP Stacks beleuchtet.
Die Standard Java-Applikation mit Datenbank
Wird mit einer Java-Applikation auf eine Datenbank zugegriffen, dann erfolgt dies über einen JDBC-Treiber. Die meisten Java-Applikationen konfigurieren den JDBC-Treiber für die Nutzung von TCP/IP. Damit ist es möglich, auf Datenbanken zuzugreifen, die auf anderen Servern oder über das Netzwerk bereitgestellt werden. Dies ist besonders nützlich in verteilten Systemen, wo die Datenbank auf einem separaten Host läuft.
Wird die Applikation mit docker compose betrieben, dann werden in der Konfigurations-Datei docker-compose.yml die Applikation und Datenbank jeweils als eigener Service definiert. Die Grundstruktur sieht wie nachfolgend beispielhaft dargestellt aus.
services:
application:
restart: unless-stopped
image: reg.exensio.de/simulator/measure:1.1.0
depends_on:
- postgres
...
networks:
- default
postgres:
image: postgres:13.15
restart: unless-stopped
...
networks:
- default
Für den Zugriff auf die Datenbank zeigt die JDBC-Treiber URL auf den internen Hostnamen der Datenbank, der dem Service-Namen entspricht, und hat folgenden Grundaufbau:
jdbc:postgresql://postgres:5432/appdb
Sind die genutzten Services in einer docker-compose Datei definiert, erfolgt der Betrieb auf dem gleichen Hostsystem, sodass die Frage berechtigt ist, ob nicht auch Unix-Sockets für die Verbindung genutzt werden können.
Warum Unix-Sockets?
Unix-Sockets sind nur für Prozesse auf demselben Host zugänglich. Somit sind sie sehr sicher, da das Risiko eliminiert ist, dass Datenverkehr über das Netzwerk abgefangen wird.
Des Weiteren haben Unix-Sockets eine geringere Latenz als TCP/IP, da sie keine Netzwerk-Protokolle wie IP und TCP verarbeiten müssen. Sie haben dadurch weniger Overhead und bieten eine schnellere Kommunikation zwischen Prozessen. Dies kann wiederum zu einer Leistungsverbesserung führen.
Für unseren Anwendungsfall sind potenzielle Performance-Verbesserungen der wesentliche Grund für den in Erwägung gezogenen Wechsel zu Unix-Sockets.
Konfiguration und Anpassungen
Für die Nutzung von Unix-Sockets wird die Bibliothek junixsocket benötigt, die als weitere Abhängigkeit in unsere Java-Applikation eingebunden wird. Diese Bibliothek wird vom Postgres JDBC-Treiber angesprochen. Die Zugriffs-URL für JDBC-Datenbanktreiber muss hierfür angepasst werden.
jdbc:postgresql://localhost:5432/appdb?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/socket-postgresql/.s.PGSQL.5432
Drei Konfigurations-Änderungen sind in der URL vorzunehmen:
- Als host wird localhost angegeben und nicht mehr der bisherige docker service Name postgres
- Der Parameter socketFactory referenziert die Initialisierungsklasse für den Unix-Socket aus der Bibliothek junixsocket
- Über den Parameter socketFactoryArg wird der Pfad zum Unix-Socket der Datenbank festgelegt
Jeder docker Container läuft in seinem eigenen Kosmos und kann standardmäßig nicht auf Dateien außerhalb seines Systems zugreifen. Außerdem kann auch nicht von außen auf Dateien innerhalb des Containers zugegriffen werden. Der Einsatz von Unix-Sockets erfordert jedoch, dass der JDBC-Treiber der Java-Applikation auf den Socket der Datenbank zugreifen kann. Wie wird hier vorgegangen?
Mit der Nutzung von docker Volumes wird der Socket des Datenbank-Containers auf dem Host-System bereitgestellt. Im Applikations-Container wird der gleiche Datei-Pfad des Host-Systems ebenfalls als lesendes Volume eingebunden, sodass ein Zugriff aus beiden Containern möglich ist.
Im aufgeführten Beispiel ist das Verzeichnis socket-postgresql auf dem Host-System als Austausch-Verzeichnis konfiguriert.
services:
application:
restart: unless-stopped
image: reg.exensio.de/simulator/measure:1.1.0
depends_on:
- postgres
volumes:
- ./socket-postgresql:/socket-postgresql
...
networks:
- default
postgres:
image: postgres:13.15
restart: unless-stopped
volumes:
- ./socket-postgresql:/var/run/postgresql:ro
...
networks:
- default
Wie änderen sich die Laufzeiten?
Es erfolgten verschiedene Messungen mit beiden Varianten, wobei jeweils drei Test-Durchläufe durchgeführt wurden:
- Einfache Abfrage für drei kleine Datenbanktabellen mit nur jeweils ca. 100 Einträgen. 150.000 Lookup-Queries mit sich ändernden Sortierbedingungen
- Einfache Abfragen auf Datenbanktabellen mit mehr als 1.000.000 Einträge. 5.000 Lookup-Queries mit sich ändernden Sortierbedingungen
- 50 komplexe select-Abfragen mit offset Verschiebungen, die generell Laufzeiten von über einer Sekunde haben
Die erste Variante zeigte Laufzeit-Verbesserungen im Bereich von ca. 20 %, die zweite Variante brachte noch ca. 10 % und die dritte Variante war für beide Varianten gleich schnell.
Dies zeigt, dass potenzielle Performance-Vorteile stark vom Applikations-Typ abhängig sind. Werden viele kleine Abfragen ausgeführt, um beispielsweise einzelne Datensätze auszulesen, kann durchaus ein nennenswerter Performance-Vorteil erwirtschaftet werden. Der Aufwand hierfür ist überschaubar, da nur Konfigurations-Änderungen vorzunehmen sind. Werden Performance-Optimierungen im Code vorgenommen, dann ist die Umsetzung wesentlich aufwändiger und damit teurer.
Werden hingegen in einer Anwendung viele Listen angezeigt, auf denen der Anwender über die Oberfläche filtern und sortieren kann, sind Abfragen tendenziell komplexer und werden nicht so oft ausgeführt. In solchen Anwendungs-Fällen sind also kaum Laufzeit-Verbesserungen zu erwarten.
Fazit
- Ist eine Nutzung mit der verwendeten Architektur überhaupt möglich?
- Schränke ich mich durch die Umstellung zukünftig ein?
- Kann ich dadurch später nicht skalieren, weil alles auf einer Maschine laufen muss?
- Werden unter Umständen Hürden bei einer Migration zu einem anderen Hoster oder nach Kubernetes aufgebaut?