Dockerkonsolidierung

Im Laufe der Zeit haben sich in meinem Homelab ein paar Anwendungen, die auf Docker basieren, eingefunden und sie tummeln sich auf mehreren verschiedenen Servern herum. Teilweise werden dabei Container auch redundant betrieben. So habe bzw. hatte ich für verschiedene Paperless-NGX-Instanzen und für Docmost mehrere Redis, Tika und Postgresql-Container in Betrieb. Es ist also Zeit, aufzuräumen.



Vorbereitungen

Ich wähle einen virtuellen Server, der ausreichend Kapazitäten hat. Er bekommt von mir 8 GB RAM, 4 CPU-Kerne und 200 GB Plattenplatz. Der RAM ist hier noch am ehesten der limitierende Faktor. Ich installiere darauf Docker und Docker-Compose (s. hier: docker). Dann erstelle ich das Netzwerk mit


docker create network master1
            
Ich habe dem Netzwerk den überaus kreativen Namen master1 gegeben. Dieses Netzwerk wird unter /var/lib/docker in einer kleinen Datenbank-Datei gespeichert. Bei einem Neustart des Servers wird das Netzwerk wieder erstellt. Außerdem möchte ich künftig meine Docker-Anwendungen im Verzeichnis /srv ablegen. Dort sollen die Daten in einem Verzeichnis data und die Anwendungsdefinitionen im Verzeichnis apps liegen. Ich muss also zunächst die beiden Verzeichnisse anlegen. Damit ich sie ohne Rootrechte nutzen kann, genehmige ich mir selbst die Zugriffsrechte darauf.

sudo mkdir /srv/docker
sudo chown -R 1000:1000 /srv/docker
mkdir /srv/docker/apps
mkdir /srv/docker/data 
            



Umzug der Dienste Redis, Gotenberg und Tika

Für diese Dienste lege ich eine eigene Compose-Datei an. Ich erstelle zunächst einen Ordner in dem Verzeichnis /srv/docker/apps. Ich nenne das App-Verzeichnis dienste und wechsele in selbiges


mkdir /srv/docker/apps/dienste && cd /srv/docker/apps/dienste
            
Dort lege ich mit meinem Lieblingseditor nano eine Datei namens docker-compose.yml mit folgendem Inhalt an:

services:
  broker:
    container_name: broker
    image: docker.io/library/redis:latest
    restart: unless-stopped
    networks:
      - master1
    volumes:
      - redisdata:/data

  gotenberg:
    image: docker.io/gotenberg/gotenberg:latest
    container_name: gotenberg
    networks:
      - master1
    restart: unless-stopped

    # Beim Konvertieren von .eml-Files mit Gotenberg soll 
    # externer content wie z.B: tracking pixels oder javascript
    # nicht mit konvertiert werden
    command:
      - "gotenberg"
      - "--chromium-disable-javascript=true"
      - "--chromium-allow-list=file:///tmp/.*"

  tika:
    image: docker.io/apache/tika:latest
    container_name: tika
    networks:
      - master1
    restart: unless-stopped

volumes:
  redisdata:

networks:
    master1:
      external: true
            
Es werden also jeweils die aktuellsten Builds von Redis, Gotenberg und Tika gepullt und gestartet. Wichtig ist hier die Definition des genutzten Netzwerks für die jeweiligen Container und am Ende die Angabe "external: true" die besagt, dass das Netzwerk außerhalb dieser compose-Definitionen erstellt wurde. Mit dem Parameter "container_name" definiere ich, wie die Container heißen (wer hätte das gedacht?). Darüber werden sie dann von anderen Systemen im selben Docker-Netzwerk angesprochen.
Mit

docker compose up -d
starte ich diese drei Container.



Umzug der Docker-Anwendungen

Dockhand

Dockhand besteht nur aus einem Container und einem persistenten Volume. Ich erstelle zunächst wieder ein Verzeichnis im App-Ordner, das ich diesmal dockhand nenne und darin erstelle ich eine Datei namens docker-compose.yml mit folgendem Inhalt:


services:
  dockhand:
    image: fnsys/dockhand:latest
    container_name: dockhand
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /srv/docker/data/dockhand/dockhand_data:/app/data
    networks:
      - master1

volumes:
  dockhand_data:

networks:
  master1:
    external: true
Ich nutze also das aktuellste Build der Anwendung. Dem Container gebe ich den Namen dockhand. Ich mounte den Docker-Socket, damit dockhand Docker-Container verwalten kann und ein persistentes Verzeichnis dockhand_data, das ich noch anlegen muss. Wenn ich keine neue Installation vornehme, sondern eine bestehende migriere, dann muss ich die Dateien aus diesem entsprechenden Verzeichnis von der alten Installation herüber kopieren. Natürlich muss ich hier auch wieder das Netzwerk master1 verwenden.
Mit

mkdir /srv/docker/data/dockhand
docker compose up -d
lege ich zunächst das Data-Verzeichnis an und dann starte ich diesen Container. Da ich keine Portdefinition in der Compose-Datei habe, lauscht der Container auch nicht außerhalb des Docker-Netzwerks auf Zugriffsversuche. Um die Weboberfläche zu erreichen, muss ich zunächst den Reverse-Proxy Caddy an den Start bringen.



Caddy

Damit ich die Dienste in den Containern aufrufen kann, nutze ich den Reverse-Proxy Caddy. Installation und Nutzung von Caddy habe ich hier beschrieben: Caddy. Ich erstelle wieder ein passendes Verzeichnis und wechsele in selbiges.


mkdir /srv/docker/apps/caddy
mkdir /srv/docker/data/caddy
cd /srv/docker/apps/caddy
Darin erstelle ich zunächst die Datei docker-compose.yml mit folgendem Inhalt:

services:
  caddy:
    build:
      context: .
      dockerfile_inline: |
        FROM caddy:builder AS builder
        # Caddy mit de Plugins bauen
        RUN xcaddy build \
           --with github.com/caddy-dns/all-inkl
        FROM caddy:latest
        COPY --from=builder /usr/bin/caddy /usr/bin/caddy

    container_name: caddy
    restart: unless-stopped
    dns:
      - 1.1.1.1
      - 8.8.8.8
    ports:
      - '80:80
      - '443:443'
      - '443:443/udp' # Wichtig für HTTP/3 Support
    volumes:
      - /srv/docker/data/caddy/Caddyfile:/etc/caddy/Caddyfile
      - /srv/docker/data/caddy/data:/data
      - /srv/docker/data/caddy//config:/config
    environment:
      - KAS_USERNAME=***********
      - KAS_PASSWORD=******************
    networks:
      - master1

networks:
    master1:
      external: true
Die Einzelheiten zur DNS-Challenge (hier für den Provider all-inkl) habe ich im Beitrag zu Caddy beschrieben. Dieser Container wird nun mit den Ports 80 und 443 nach außerhalb des Docker-Netzwerks (also in das lokale Netzwerk hinein) exponiert.

Dann erstelle ich das Caddyfile namens Caddyfile im Data-Ordner mit folgendem Inhalt:

{
        # Optionale globale Einstellungen
        email mail@sechzig-veedel.de
}

# Block fuer die Wildcard-Domain
web-cooperation.de, *.web-cooperation.de {
        tls {
                dns allinkl {
                        kas_username {$KAS_USERNAME}
                        kas_password {$KAS_PASSWORD}
                }
                propagation_delay 120s
                propagation_timeout -1
        }

        @dockhand host dockhand.web-cooperation.de
        handle @dockhand {
                reverse_proxy dockhand:3000
        }

        @docmost host docmost.web-cooperation.de
        handle @docmost {
                reverse_proxy docmost:3000
        }

        @duplicati host duplicati.web-cooperation.de
        handle @duplicati {
                reverse_proxy duplicati:8200
        }

        @beszel host beszel.web-cooperation.de
        handle @beszel {
                reverse_proxy beszel:8090
        }
        @paperless host paperless.web-cooperation.de
        handle @paperless {
                reverse_proxy webserver:8000
        }

        @paper2 host paper2.web-cooperation.de
        handle @paper2 {
                reverse_proxy paper2:8000
        }

        @n8n host n8n.web-cooperation.de
        handle @n8n {
                reverse_proxy n8n:5678
        }
        @uptime host uptime.web-cooperation.de
        handle @uptime {
                reverse_proxy uptime-kuma:3001
        }

        @authentic host authentic.web-cooperation.de
        handle @authentic {
                reverse_proxy server:9000
        }

        @dms host dms.web-cooperation.de
        handle @dms {
                reverse_proxy dms:8000
        }

        # Fallback innerhalb dieses Blocks
        handle {
                abort
        }
Hier habe ich schon ein paar Proxy-Hosts für noch zu migrierende Dienste eingetragen. Der Anfang der Definitionen gehört noch zur DNS-Challenge (s.o.). Die jeweiligen Proxy Hosts werden einfach über ihren Namen angesprochen. Innerhalb des Docker-Netzwerks dient Docker als DHCP-Server. Ich kann hier auch mehrere Dienste mit dem gleichen Port nutzen, weil sie jeweils eine separate Docker-IP-Adresse bekommen.

Dann starte ich den Container mit

docker compose up -d
Das kann beim ersten Mal etwas dauern, insbesondere, wenn erst die Zertifikate von Let's Encrypt bezogen werden müssen. Wenn ich später Proxy Hosts ergänze, dann lasse ich Caddy erst einmal das Caddyfile hübsch machen und dann neu laden:

docker exec -w /etc/caddy caddy caddy fmt --overwrite
docker exec -w /etc/caddy caddy caddy reload
Wenn dabei keine Fehler auftreten, dann sind die im Caddyfile definierten Adressen im lokalen Netzwerk Dienste verfügbar, soweit ich bereits Dienste definiert und gestartet habe. Das ist bisher nur für Dockhand der Fall. Voraussetzung dafür ist aber natürlich, dass die interne Namensauflösung funktioniert. Hier kann man mit lokalen hosts-Dateien arbeiten oder besser mit einem lokalen DNS-Server. Das mache ich natürlich so. Ich nutze dazu dnsmasq.



Docmost

Für die Migration von Docmost muss ich ein persistentes Volume übertragen und vor allem die Migration von der Postgresql-DB, die bisher als eigener Docker-Container in der Compose-Datei definiert ist, auf einen abgesetzten Datenbankserver durchführen. Als erstes erstelle ich wieder ein entsprechendes Unterverzeichnis, welches ich diesmal docmost nenne.



Übertragung der Dateien

Dateien, wie z.B. Grafiken speichert Docmost intern im Verzeichnis /app/data/storage. Bei Einrichtung von Docmost mittels Docker Compose habe ich dafür ein persistentes Volume im Verzeichnis von Docmost angelegt (s. Docmost). Dieses Verzeichnis übertrage ich per rsync auf den neuen Server. Wenn der neue Server entsprechend meinen Standard abgesichert ist (s. Server absichern), dann muss ich zunächst den öffentlichen SSH-Schlüssel von dem Quellserver auf den Zielserver übertragen. Dann kann ich mit


rsync -avE -e ssh storage [username@ip-des-zielservers]:/home/[username]/docmost
die Datenübertragung anstoßen. Interessanterweise muss dazu auf beiden Servern rsync installiert sein. Zwischen den beiden Systemen muss die Kommunikation über Port 22 erlaubt sein. Ggf. muss ich zunächst die Firewallports dafür öffnen.



Datenbank migrieren

Zunächst muss ich einen Datenbankdump erzeugen. Dazu brauche ich ein persistentes Volume, in dem ich den Export dann außerhalb des Dockercontainers auch finde. Dafür nutze ich das Verzeichnis db_data, das in der Datei docker-compose.yml des laufenden Containers unter /var/lib/postgresql/data gemountet ist. Für die Erstellung des Dump nutze ich die Shell des laufenden alten Docmost-Containers. Das kann ich komfortabel mit Dockhand über die Web-GUI machen. Manuell geht das aber auch von der Konsole aus mit


docker exec -t [containername] pg_dump -U docmost docmost > /mnt/export/docmost.sql
Dann übertrage ich den Dump auf den Zielserver:

rsync -avE -e ssh db_data/docmost.sql [username@ip-des-zielservers]:/home/
Vom Zielserver aus erstelle ich zunächst den Datenbankuser und die Datenbank auf dem abgesetzten Datenbankserver. So sehe ich auch gleich, ob der Zugriff funktioniert. Ich melde mich zunächst auf der Postgresq-Konsole des DB-Servers an. Bei mir hat der den FQDN pg1.lan. Statt des FQDN könnte ich aber auch die IP-Adresse nutzen.

psql -h pg1.lan -U postgres
Dann lege ich den Benutzer und die Datenbank an und gewähre dem Nutzer alle Rechte an der Datenbank:

CREATE USER docmost WITH PASSWORD 'SEHR_SICHERES_PASSWORT';
CREATE DATABASE docmost OWNER docmost;
GRANT ALL PRIVILEGES ON DATABASE docmost TO docmost;
\q
Dann spiele ich den Dump ein:

pg_restore -h pg1.lan -U docmost -d docmost docmost.sql



Angepasste Compose-Datei

Die Datei docker-compose.yml auf dem Altsystem enthält die Definition des DB-Containers. Diese muss durch die Zugriffs-Parameter für die abgesetzte Datenbank ersetzt werden. Außerdem muss der Redis-Container gelöscht werden und der docmost-Container entsprechend angepasst werden. Die neue docker-compose.yml ist damit überschaubarer. Zunächst erstelle ich wieder die beiden Ordner unter /srv/docker


mkdir /srv/docker/apps/docmost
mkdir /srv/docker/data/docmost
und erstelle im apps-Ordner die folgende docker-compose.yml:

services:
  docmost:
    image: docmost/docmost:latest
    container_name: docmost
    environment:
      APP_URL: 'https://docmost.web-cooperation.de'
      APP_SECRET: '*****************************************************'
      DATABASE_URL: 'postgresql://docmost:[PASSWORT]@pg1.lan:5432/docmost?schema=public'
      REDIS_URL: 'redis://broker:6379'
      MAIL_DRIVER: 'smtp'
      SMTP_HOST: 'name.smtpserver.com'
      SMTP_PORT: '587'
      SMTP_USERNAME: 'name'
      SMTP_PASSWORD: '****'
      MAIL_FROM_ADDRESS: 'docmost@web-cooperation.de'
      MAIL_FROM_NAME: 'Docmost'
    restart: unless-stopped
    volumes:
      - /srv/docker/data/docmost/storage:/app/data/storage
    networks:
      - master1

volumes:
  docmost:

networks:
    master1:
      external: true
Die SMTP-Parameter sind optional. Sie werden gebraucht, wenn man z.B. eine Passwortrücksetzung haben möchte. Mit

docker compose up -d --remove-orphans
starte ich Docmost. Der Parameter "--remove-orphans" ist optional und kann dazu genutzt werden, Container, die in einer früheren Version des Compose-Files definiert waren und nun quasi verwaist sind, zu löschen. Wenn ich nichts falsch gemacht habe, kann ich nun Docmost über die im Dockerfile definierte Adresse aufrufen und sollte meine bisherige Umgebung vorfinden. Ggf. ist es erforderlich, zunächst den Browser-Cache zu löschen.



Paperless NGX

Die Migration von Paperless NGX ist nun kein Hexenwerk mahr. Die Verzeichnisse data und media werden zunächst per rsync übertragen. Dann erstelle ich eine angepasste Version der Datei docker-compose.env, bei der ich den URL und die Adresse beim Parameter PAPERLESS_ALLOWED_HOSTS anpasse. In der docker-Compose.yml muss ich die Container für Redis, Gotenberg und Tika entfernen und durch die Nennung der entsprechenden bereits laufenden Container ersetzen. Sie sieht bei mir so aus:

services:
  dms:
    container_name: dms
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    restart: unless-stopped
    volumes:
      - /src/docker/data/paperless-ngx/data:/usr/src/paperless/data
      - /srv/docker/data/paperless-ngx/media:/usr/src/paperless/media
      - /srv/docker/data/paperless-ngx/export:/usr/src/paperless/export
      - /srv/docker/data/paperless-nxg/consume:/usr/src/paperless/consume
    env_file: docker-compose.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_TIKA_ENABLED: 1
      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
      PAPERLESS_DBHOST: pg1.lan
      PAPERLESS_DBNAME: paperless
      PAPERLESS_DBUSER: paperless
      PAPERLESS_DBPASS: *********
      PAPERLESS_DBPORT: 5432

    networks:
      - master1

networks:
    master1:
      external: true
Nun kann ich den neuen Container mit

docker compose up -d --remove-orphans
starten. Das ist schon die ganze Magie. Auf diese Weise kann ich mehrere Paperless-NGX-Instanzen auf einem Server laufen lassen. Der Containername muss nur variieren und ich muss jeweils eine eigene Datenbank anlegen. Natürlich muss jede Instanz ein eigenes consume-Verzeichnis und ein eigenes media-Verzeichnis haben.



Fazit

Auf diese Weise ziehe ich alle meine Dockeranwendungen auf einem größeren Server zusammen. Aktuell sind das bei mir 15 Container, wobei ich unnötige Redundanzen vermeide. Die Auslastung des Servers liegt bei 50 % des RAM während sich die CPU-Kerne langweilen.