In diesem Beitrag wird anhand einer beispielhaften Problemstellung gezeigt, wie man eine Shiny-App mit Anbindung an eine Datenbank mittels Docker-Container hostet.

Problemstellung

Unsere Shiny-App braucht Daten aus einer externen Quelle (1), in diesem Beispiel aus der Google BigQuery Cloud. Auf Basis dieser Daten (2) finden komplexe, zeitintensive Berechnungen statt (3). Je nach Anwendungsfall, beispielsweise eine Echtzeitanforderung oder schon alleine die Optimierung der Nutzerzufriedenheit, ist es notwendig, dass die Ergebnisse performant verarbeitet werden können und in kürzester Zeit zur Verfügung stehen. Das Laden der Daten vom externen Server und die Berechnungen in der Shiny-App können unter Umständen sehr lange dauern. Hier stellte sich nun die Frage: Wie lässt sich die Performanz neben einer Code-Optimierung weiterhin verbessern?

Projektstruktur ohne Docker

Projektstruktur mit Docker

Unsere Idee war es, die Daten vom externen Server mittels Docker-Containern lokal verfügbar zu machen, um die Datenabfrage wesentlich zu beschleunigen. Aufbauend auf dieser Idee ließen sich in unserem Anwendungsfall sogar die Berechnungen auslagern bzw. vorher durchführen, weshalb wir nur noch die Ergebnisse der Berechnungen gespeichert haben. Folgende Abbildung zeigt die Veränderung unserer Projektstruktur unter Einführung von drei Docker-Containern. Es gibt nun zwei voneinander unabhängig laufende Prozesse. Auf der einen Seite haben wir den von der Recheninstanz (Container 1) ausgeführten Berechnungs- und Speicherprozess. Dieser lädt periodisch die aktuellsten Daten aus der Google BigQuery Cloud (a, b) und führt auf deren Basis die zeitintensiven Berechnungen aus (c). Anschließend speichert er die Ergebnisse (d) in die lokale Datenbank (Container 2), in unserem Fall eine Postgres-Datenbank. Auf der anderen Seite ist weiterhin der Nutzer, der auf die Shiny-App zugreift. Diese holt sich jedoch nur noch direkt die Ergebnisse der Anfrage aus der lokalen Datenbank (1, 2), anstatt die Daten aus der Google BigQuery Cloud zu beziehen und die Berechnungen selbst durchzuführen. Dies beschleunigt die Darstellung der gewünschten Anfrage beim Nutzer enorm.

Projektstruktur mit Docker

Docker-Compose-File (docker-compose.yml)

Die Ordner und Dateien unseres Projekts haben wir wie folgt strukturiert:

project
    │   docker-compose.yml
    │
    ├───computation_con
    │   │   Dockerfile
    │   │
    │   └───data_share
    │           bigQuery_serviceToken.json
    │           computations.R
    │           functions.R
    │
    ├───postgres_con
    │   └───data_share
    └───shiny_con
        │   Dockerfile
        │
        ├───data_share
        ├───log
        └───shiny_app
                app.R

Basierend auf der gewählten Ordnerstruktur bilden wir das Compose-File, welches weiter unten zu sehen ist. Auf Container 1 und 3 müssen weitere Abhängigkeiten und R-Packages installiert werden, daher erzeugen wir diese anhand eigener Dockerfiles, welche relativ zum Verzeichnis des Compose-Files angegeben werden (build). Container 2 hält nur die Datenbank, dafür reicht das bereitgestellte Image “postgres” (image). Weiterhin sollen die Container im Falle eines Serverneustarts ebenfalls neugestartet werden (restart). Außerdem verbinden wir das Dateisystem des Hosts mit dem des Containers über ausgewählte Ordner (volumes). Dies ermöglicht uns beispielsweise einen vereinfachten Datenaustausch zwischen Container und Host (Ordner “data_share”) oder das Führen von Logs (Ordner “log”).

Desweiteren erweitern wir den Postgres-Container einerseits mit einer benannten Volume “pgdata” zur persistenten Datenspeicherung, die wir ebenfalls unter “volumes” einbinden, andererseits mit einem Nutzer, der die nötigen Berechtigungen zum späteren Lesen und Schreiben der Datenbank besitzt.

Damit Veränderungen an der Shiny-App vom Host direkt auf den Container übertragen werden, verbinden wir außerdem das Shiny-Verzeichnis des Hosts (Ordner “shiny_app”) mit dem Container-Verzeichnis, aus dem die Shiny-App standardmäßig gestartet wird (Ordner “/srv/shiny-server”). Zuletzt machen wir die Shiny-App von außen zugänglich, indem wir den Port 3838 des Shiny-Containers freigeben.

Code
version: '3'

services:
    computation:                                # Container 1
        container_name: computation_container   # Container-Name	
        build: ./computation_con                # relativer Pfad zum "Dockerfile"
        depends_on:
            - db                                # Container 2 muss vorher gestartet sein
        restart: always                         # automatischer Container-Neustart
        volumes:                                # Host-Verzeichnis:Container-Verzeichnis
            - "./computation_con/data_share:/data_share"

    db:                                         # Container 2
        container_name: postgres_container      # Container-Name	
        image: postgres                         # Name des Images
        restart: always                         # automatischer Container-Neustart
        environment:                            # Postgres-Nutzer
            POSTGRES_USER: postgres
            POSTGRES_PASSWORD: test
        volumes:                                # Host-Verzeichnis:Container-Verzeichnis
            - "./postgres_con/data_share:/data_share"
            - "pgdata:/var/lib/postgresql/data" # Einbinden des benannten Volumes
            
    shiny:                                      # Container 3
        container_name: shiny_container         # Container-Name	
        build: ./shiny_con                      # relativer Pfad zum "Dockerfile"
        depends_on:
            - db                                # Container 2 muss vorher gestartet sein
        restart: always                         # automatischer Container-Neustart
        ports:                                  # Host-Port:Container-Port
            - "3838:3838"
        volumes:                                # Host-Verzeichnis:Container-Verzeichnis
            - "./shiny_con/shiny_app/:/srv/shiny-server"
            - "./shiny_con/log/:/var/log/shiny-server"
            - "./shiny_con/data_share/:/data_share"
        
volumes:                                        # Definition benannter Volumes
    pgdata:

Dockerfile des Computation-Containers

Das Dockerfile beginnt mit der Wahl des gewünschten Images. Hier reicht uns das R-base-Image. Außerdem war es nötig, die Zugriffsrechte des Library-Verzeichnisses anzupassen, um die R-Packages richtig installieren zu können. Anschließend wird das System aktualisiert und benötigte Pakete installiert. Zuletzt führen wir das R-Skript aus, welches die Daten periodisch aus der Google BigQuery Cloud lädt, verarbeitet und in unsere Postgres-Datenbank schreibt. Dies lässt sich je nach Anwendungsfall und Granularität der Periodenzeit beispielsweise mittels einer simplen While-Schleife im R-Skript oder einem Cronjob (feinste Granularität: Minuten), der das R-Skript dann im gewünschten Abstand periodisch aufruft, realisieren.

Code
# Auswahl des Images
FROM rocker/r-base	

# Installation diverser Systembibliotheken
RUN apt-get update && apt-get install --yes --no-install-recommends \
	libssl-dev \
	libxml2-dev \
	openjdk-8-jdk \
	libssh2-1-dev \
	libv8-dev \
	libgdal-dev libproj-dev \
	libudunits2-dev \
	libmpfr-dev \
    apt-transport-https \
    curl \
    gnupg \
    unixodbc-dev \
	&& curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \
	&& curl https://packages.microsoft.com/config/debian/9/prod.list > \
		/etc/apt/sources.list.d/mssql-release.list \
	&& apt-get update \
	&& ACCEPT_EULA=Y apt-get install --yes --no-install-recommends msodbcsql17
	
# Installation der benötigten R-Packages
RUN R -e "install.packages('bigrquery')"
RUN R -e "install.packages('data.table')"
RUN R -e "install.packages('RPostgreSQL')"
RUN R -e "install.packages('readr')"
RUN R -e "install.packages('lubridate')"

# Ausführen des Rscripts bei Containerstart
CMD Rscript data_share/computations.R

Dockerfile des Shiny-Containers

In diesem Fall wählen wir das Shiny-Image rocker/shiny in der Version 3.6.1. Auch hier müssen einige Zugriffsrechte angepasst und Abhängigkeiten installiert werden, damit R-Packages richtig installiert werden können. Außerdem kopieren wir die Shiny-App in den Ordner, in welchem die App standardmäßig gehostet wird. Dann geben wir noch den Port 3838 frei und starten die App.

Code
# Auswahl des Images
FROM rocker/shiny:3.6.1

# Installation diverser Systembibliotheken
RUN apt-get update && apt-get install --yes --no-install-recommends \
	libssl-dev \
	libxml2-dev \
	openjdk-8-jdk \
	libssh2-1-dev \
	libv8-dev \
	libgdal-dev libproj-dev \
	libudunits2-dev \
	libmpfr-dev \
    apt-transport-https \
    curl \
    gnupg \
    unixodbc-dev \
	&& curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \
	&& curl https://packages.microsoft.com/config/debian/9/prod.list > \
		/etc/apt/sources.list.d/mssql-release.list \
	&& apt-get update \
	&& ACCEPT_EULA=Y apt-get install --yes --no-install-recommends msodbcsql17

# Installation der benötigten R-Packages
RUN R -e "install.packages('pacman')"
RUN R -e "install.packages('bigrquery')"
RUN R -e "install.packages('shiny')"
RUN R -e "install.packages('shinydashboard')"
RUN R -e "install.packages('data.table')"
RUN R -e "install.packages('RPostgreSQL')"
RUN R -e "install.packages('DT')"

# Kopieren der Shiny-App ins Verzeichnis, aus dem die App standardmäßig gestartet wird
COPY /shiny_app /srv/shiny-server/
RUN chown -R "shiny:shiny" /srv/shiny-server/

# Wahl des Ports, unter dem von außen auf die App zugegriffen wird
EXPOSE 3838

# Ausführen der Shiny-App bei Containerstart
CMD ["/usr/bin/shiny-server.sh"]

Periodischer Lade- und Schreibprozess

So könnte die Implementierung des Prozesses aussehen, in welchem die Daten aus der Google BigQuery Cloud geladen, verarbeitet und schließlich in die Docker-Datenbank geschrieben werden. Da der Prozess ohne Nutzerinteraktion arbeiten soll, benutzen wir hier ein Service-Token zur Authentifikation. Das R-Skript “computations.R” wird ausgeführt, sobald die Container hochgefahren werden.

R-Skript computations.R

Code
source("data_share/functions.R")

library(bigrquery)
library(readr)
library(data.table)
library(RPostgreSQL)

# Authentifikation bei Google BigQuery mittels Service-Token
bq_auth(
	path = "data_share/bigQuery_serviceToken.json",
	scopes = "https://www.googleapis.com/auth/bigquery")

# Projektinformation 
projectId <- "projectId"
datasetId <- "datasetId"
tableId <- "tableId"

# Zeitbereich der Initialisierung
timestampFrom <- "2020-01-01"
timestampTo <- Sys.time()

# Initialisierung der Docker-Datenbank
initializeDatabase(projectId, datasetId, tableId, timestampFrom, timestampTo)

# Endlosschleife Datenbank-Updates
while(TRUE) {
    # Update-Intervall (exklusive Berechnung und Speicherung)
   	Sys.sleep(120)
    
    # aktuellster timestamp als RDS gespeichert (gegen Unterbrechungen)
	timestampFrom <- readRDS("lastMachineDataTime")
	timestampTo <- Sys.time()
	
    # Update der Docker-Datenbank
  	updateDatabase(projectId, datasetId, tableId, timestampFrom, timestampTo)
}

R-Skript functions.R

Code
initializeDatabase <- function(projectId, datasetId, tableId, timestampFrom, timestampTo) {
    # Laden der Daten aus Google BigQuery
    data <- getSQLQueryResult(projectId, datasetId, tableId, timestampFrom, timestampTo)

    # Berechnungen
    results <- compute(data)

    # Verbindung zum postgres_container
    pg <- dbDriver("PostgreSQL")
    connection <- dbConnect(
        drv = pg, 
        dbname = "postgres", 
        user = "postgres", 
        password = "test", 
        host = "postgres_container", 
        port = 5432)

    # Aufräumen und Neuschreiben der Docker-Tabelle (append=FALSE)
    if (dbExistsTable(connection, "tableNameDocker")) {
        dbRemoveTable(connection, "tableNameDocker")
	}
    dbWriteTable(connection, "tableNameDocker", results, row.names=FALSE, append=FALSE)

    # Trennung der Verbindung zum postgres_container
    dbDisconnect(connection)
	
    # Speichern des aktuellsten timestamp als RDS (gegen Unterbrechungen)
    saveRDS(tail(data$timestamp, 1), file = "lastMachineDataTime")
}

updateDatabase <- function(projectId, datasetId, tableId, timestampFrom, timestampTo) {
    # Laden der Daten aus Google BigQuery
    data <- getSQLQueryResult(projectId, datasetId, tableId, timestampFrom, timestampTo)
    
    # Berechnungen
    results <- compute(data)
    
    # Verbindung zum postgres_container
    pg <- dbDriver("PostgreSQL")
    connection <- dbConnect(
        drv = pg, 
        dbname = "postgres", 
        user = "postgres", 
        password = "test", 
        host = "postgres_container", 
        port = 5432)
    
    # Update der Tabelle bzw. Anhängen der neuen Daten (append=TRUE)
    dbWriteTable(connection, "tableNameDocker", results, row.names=FALSE, append=TRUE)
	
    # Update eines bestimmten Datensatzes
    # dbSendQuery(
    #     connection, 
    #     paste0('UPDATE ', "tableNameDocker", 
    #            ' SET "column1" = ', results$value1, ', "column2" = ', results$value2,
    #            'WHERE "id" =', results$id))
    
	# Trennen der Verbindung zum postgres_container
    dbDisconnect(connection)
    
    # Speichern des aktuellsten timestamp als RDS (gegen Unterbrechungen)
    saveRDS(tail(data$timestamp, 1), file = "lastMachineDataTime")
}

compute <- function(data) {
    # Berechnungen
    data <- as.data.table(data)
    return(data)
}

getSQLQueryResult <- function(projectId, datasetId, tableId, timestampFrom, timestampTo){
    sql <- paste0("SELECT a, b, c, timestamp
                FROM `", projectId, ".", datasetId, ".", tableId, "` 
                WHERE timestamp>=TIMESTAMP('", timestampFrom, "') 
				AND timestamp<=TIMESTAMP('", timestampTo, "')
                ORDER BY timestamp")
    data <- as.data.table(query_exec(
        query = sql, 
        project = projectId, 
        max_pages = Inf, 
        use_legacy_sql = FALSE))
    return(data)
}

Datenabfrage in der Shiny-App

In der Shiny-App werden nun nur noch an gewünschter Stelle die Daten aus der Docker-Datenbank abgefragt.

Code
# Verbindung zum postgres_container
pg <- dbDriver("PostgreSQL")
connection <- dbConnect(
	drv = pg, 
    dbname = "postgres", 
    user = "postgres", 
    password = "test", 
    host = "postgres_container", 
    port = 5432)

# Laden aus der Docker-Tabelle
data <- dbGetQuery(connection, 'SELECT * from "tableNameDocker"')

# Trennen der Verbindung zum postgres_container
dbDisconnect(connection)

Starten der Docker-Container

Nun öffnen wir eine Konsole und navigieren ins Verzeichnis des Docker-Compose-Files. Mittels “docker-compose up -d” starten wir unsere Container. Dies kann etwas länger dauern, vorallem wenn die Container erstmalig erzeugt und alle Abhängigkeiten installiert werden müssen. Sind die Container erfolgreich hochgefahren, erreichen wir unsere Shiny-App im Browser über die Adresse “localhost:3838”. Hier ist zu beachten, dass der Port 3838 in unserem Dockerfile der Shiny-App auch wirklich freigegeben wurde. Die Shiny-App lässt sich nun wie gewohnt bedienen. Sie greift nun jedoch nicht mehr auf die Google Big Query Cloud zu, sondern lädt die Daten aus der Datenbank des Datenbank-Containers, die im Hintergrund periodisch aktualisiert wird. In unserer Implementierung wird die Datenbank jedes mal neu erzeugt, wenn der Berechnungs-Container hochgefahren wird. Das soll mögliche Inkonsistenzen vermeiden, die beispielsweise durch einen Verbindungsabbruch oder einen Rechnerabsturz passieren können. Weiterhin sind die Daten stets im benannten Volume “pgdata” persistent gespeichert.

Einblick in die Daten des Datenbank-Containers

Möchte man die Daten des Datenbank-Containers einsehen, gibt man den Befehl “docker exec -it postgres_container bash” in die Konsole und öffnet damit die bash des Containers. Hier wechseln wir mittels “su postgres” zunächst auf den privilegierten Nutzer “postgres”, den wir im Docker-Compose-File definiert haben. Anschließend öffnen wir mit “psql” die interaktive PostgreSQL-Konsole. Hier können wir beispielsweise mit “\dt” alle Tabellen anzeigen lassen, mit geeigneter PostgreSQL-Syntax SELECT-Abfragen ausführen oder die Tabellen manipulieren.

Hilfe zur Problembehebung

Ab und an ist es passiert, dass ein Container nach einer Anpassung des Dockerfiles nicht mehr richtig gestartet oder in einer Dauerschleife neugestartet wurde (wegen der Option restart: always). Dies erkennt man entweder direkt an einer Fehlermeldung, oder durch das Überprüfen der laufenden Container mittels “docker ps”.

Wenn der Container nicht hochfährt und die Docker-Fehlermeldungen nicht aufschlussreich sind, kann man das jeweilige Dockerfile auf das mindeste bzw. auf eine funktionierende Version reduzieren. Ändert man ein Dockerfile, muss man die Veränderungen immer mittels eines “docker-compose build” bestätigen. Im erfolgreich gestarteten Container versucht man dann die gewünschten Anpassungen schrittweise manuell vorzunehmen und die Befehle anschließend in das Dockerfile zu übertragen.

Sollte ein Container in einer Dauerschleife feststecken, sollte man in die Log-Files des jeweiligen Containers schauen. Dafür geben wir den Befehl “docker logs <containername>“ in die Konsole und ersetzen <containername> mit dem Namen des zu untersuchenden Containers. Die hier zu findenden Informationen helfen in der Regel, den Fehler zu lokalisieren.

Übersicht über hilfreiche Befehle

docker-compose up -d

Startet die im docker-compose.yml definierten Container im “detached” Modus, also im Hintergrund.

docker-compose down

Fährt die im docker-compose.yml definierten Container herunter.

docker restart <containername>

Startet den laufenden, gewählten Container neu.

docker-compose build

Aktualisiert die Docker-Images bei Veränderungen an den Dockerfiles ausgehend von der ersten veränderten Zeile.

docker ps

Zeigt Informationen aller laufenden Container, wie zum Beispiel die Namen oder freigegebene Ports.

docker logs -f <containername>

Öffnet und folgt dem Log eines laufenden Containers. Mit der Option “-t” werden auch timestamps angezeigt.

docker exec -it <containername> bash

Öffnet die bash des gewählten Containers im “interaktiven” Modus.

Schlusswort

In diesem Beitrag wurde gezeigt, wie man eine Shiny-App mit Anbindung an eine Postgres-Datenbank mittels Docker-Compose hostet. Indem wir die benötigten Daten der Shiny-App lokal im Datenbank-Container verfügbar gemacht haben, haben wir ausschlaggebende Performanzgewinne erhalten können. Als Ausblick wäre nun noch die Einbindung von ShinyProxy interessant, gerade für den Fall, dass mehrere Nutzer die Shiny-App gleichzeitig benutzen und damit gleichzeitig auf die Datenbank zugreifen.


Möchten Sie mehr darüber erfahren, wie wir (nicht nur) mit R Lösungen für unsere Kunden entwickeln? Wir freuen uns auf Ihre Anfrage!