Zum Inhalt

Deployment & Betrieb

Wie man den DICOM-Router baut, paketiert und betreibt. Dies ist Referenz für den aktuellen Build; die massgebliche Schrittliste für Installation/Update wird mit der Distribution selbst in deploy/systemd/dicom-router.service ausgeliefert (deren Kopfzeile dokumentiert die exakten Kommandos).

Verwandte Doku: User-Guide · Configuration · Management & Drain


Voraussetzungen

  • Java 25 oder höher (maven.compiler.release ist 25)
  • Maven 3.9+
  • PostgreSQL — nur wenn datenbankgestütztes Connection-Tracking genutzt wird (DB_ENABLED=true). DICOM-Routing funktioniert auch ohne.
  • Bash auf den Betriebs-Hosts für die Skripte unter bin/. Ein zusätzliches netcat-Paket braucht dicom-router-ctl nicht: es spricht den Management-Port bevorzugt über Bashs eingebautes /dev/tcp an und nutzt nc nur als Rückfallweg (jede Variante, siehe management.md, Abschnitt 4).

Aus dem Quellcode bauen

mvn clean install

Der vollständige Build dauert ~2 Minuten. Die lauffähige Anwendung ist dicom-router-core/target/dicom-router-core-<version>.jar, mit ihren Abhängigkeiten unter dicom-router-core/target/lib/ und dem Beispiel-Plugins-JAR unter dicom-router-plugins/target/.

Direkt starten

java -jar dicom-router-core/target/dicom-router-core-*.jar

Die Anwendung sucht ihre Konfiguration unter ./config/dicom-router.yaml (relativ zum Arbeitsverzeichnis). Existiert die Datei nicht, wird beim ersten Start eine Standard-Vorlage aus dem JAR kopiert. Für die YAML-Struktur siehe configuration.md und für die Auflösung des Loggings management.md (Abschnitt Logging).


Distributions-tar.gz (Produktion)

Das Release-Archiv (von CI gebaut, .gitea/workflows/build.yml) trennt App-Code (bei jedem Update ersetzt) von betreiberverwalteter Config (über Updates erhalten). Das tar.gz enthält nur die App-Seite; config/ wird vom Betreiber bei der Erstinstallation angelegt und von tar nie angefasst.

/opt/imconnect/                       # aus tar.gz entpackt (App-Seite)
├── dicom-router-core-<version>.jar   # Hauptanwendung
├── lib/                              # Abhängigkeiten
├── plugins/                          # Beispiel-Plugins-JAR
├── bin/
│   ├── start.sh                      # lädt config/dicom-router.env, setzt -Dlogback.configurationFile, dann java -jar
│   ├── dicom-router-ctl              # Management-CLI
│   ├── dicom-router-sync             # Config auf alle Cluster-Hosts verteilen + reloaden
│   └── dicom-router-haproxy-sync     # HAProxy-Rückweg-Config verteilen + reloaden
├── share/                            # Vorlagen — bei Erstinstallation nach config/ kopiert
│   ├── dicom-router.yaml.example
│   ├── logback.xml.example
│   ├── dicom-router.env.example
│   ├── dicom-router.service.example
│   ├── cluster-hosts.example
│   ├── haproxy-hosts.example         # HAProxy-Hosts für dicom-router-haproxy-sync
│   ├── audit-retention.sql.example   # Retention-Beispiel für die Audit-Tabellen (psql)
│   ├── audit-timestamp-utc-fix.sql.example  # einmalige Zeitstempel-Korrektur für Bestände < 1.17.2
│   └── haproxy-example.cfg
├── docs/                             # Betreiber-Doku offline (User-Guide, Plugin-Referenz, Configuration, Management, Deployment)
├── README.md
├── CHANGELOG.md
├── LICENSE                           # proprietäre Nutzungsbedingungen
└── config/                           # betreiberverwaltet, übersteht Updates
    ├── dicom-router.yaml
    ├── logback.xml
    └── dicom-router.env              # chmod 600 — enthält DB-Zugangsdaten

bin/start.sh liegt wie die übrigen Skripte im Quellbaum unter bin/; der CI-Build kopiert es unverändert in die Distribution.

share/audit-retention.sql.example (Quelle: deploy/sql/audit-retention.sql.example) ist ein kommentiertes psql-Skript, das die Audit- und die Q/R-Tracking-Tabellen der Datenbank nach einer einstellbaren Frist aufräumt. Es ist damit nicht nur für Audit-Betreiber relevant: cmove_tracking_studies speichert StudyInstanceUIDs, sobald eine CMoveTrackingStage mit store: database läuft — unabhängig von audit.enabled. Wer die Q/R-Bridge mit Datenbank-Store betreibt, braucht das Skript also auch bei ausgeschaltetem Audit-Trail, sonst bleiben abgelaufene Tracking-Zeilen samt UIDs faktisch liegen (die Anwendung räumt nur beiläufig auf, beim zufälligen Wiederverwenden derselben Nummer). Der Router liest es nie und braucht es nicht zum Starten — er löscht selbst nichts, Retention ist bewusst Betreiber-Sache und zugleich eine Datenschutz-Massnahme. Wie die übrigen Vorlagen wird es trotzdem nach config/ kopiert (config/audit-retention.sql) und dort bearbeitet: Die Fristen müssen bewusst gesetzt werden, und config/ übersteht Updates, während share/ bei jedem Update ersetzt wird. Auf diesen Pfad beziehen sich die cron- und systemd-Timer-Beispiele im Skript. Sie rufen nicht das Skript direkt auf, sondern den ebenfalls dort ausgeschriebenen Loop-Wrapper audit-retention-run.sh: Ein Lauf löscht höchstens batch_size Zeilen pro Statement, ein einzelner Aufruf pro Tag hält also nur unterhalb dieses Volumens mit. Der Wrapper ruft das Skript in einer Schleife auf, bis dessen Schlusszeile AUDIT_RETENTION_DELETED_TOTAL 0 meldet. Hintergrund in docs/database.md im Repository (in der Distribution nicht enthalten).

Erstinstallation

  1. Entpacken: tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect
  2. config/ aus share/*.example bootstrappen und anpassen (dicom-router.yaml, logback.xml, dicom-router.env).

Die vollständige Schrittliste (dedizierter Benutzer, Ownership, Verzeichnisse) steht in der Kopfzeile von deploy/systemd/dicom-router.service — diese Datei ist die massgebliche Quelle.

Update

systemctl stop dicom-router
# Zuerst die App-Dateien des vorigen Releases entfernen: die Modul-JARs sind versionsbenannt,
# ein Entpacken über den alten Baum sammelt sie an und start.sh verweigert den Start
# (vor v1.6.1 startete stattdessen stillschweigend das ALTE Core-JAR).
# config/ und etwaige Drittanbieter-Plugin-JARs bleiben unangetastet.
rm -f  /opt/imconnect/dicom-router-core-*.jar
rm -f  /opt/imconnect/plugins/dicom-router-plugins-*.jar
rm -rf /opt/imconnect/lib /opt/imconnect/share
tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect
systemctl start dicom-router

Das Verzeichnis config/ bleibt über Updates erhalten.

systemctl stop stoppt verlustfrei: Die Unit drained zuerst (keine neuen Verbindungen) und wartet, bis alle laufenden Übertragungen abgeschlossen sind — der Stopp kann sich also hinziehen, solange z. B. ein grosser C-STORE läuft. Neue Verbindungen werden im Fenster zwischen stop und start abgelehnt; wer das vermeiden muss, betreibt mehrere Instanzen hinter HAProxy und updatet rollend (eine Instanz drainen, updaten, wieder aufnehmen — siehe management.md).


systemd

Die Quell-Vorlagen liegen in deploy/systemd/ (dicom-router.service, dicom-router.env.example); CI kopiert sie mit dem Suffix .example nach share/. Die Unit lädt alle Laufzeit-Einstellungen (LOG_DIR, DB-Zugangsdaten, JAVA_OPTS, …) aus dem Env-File (EnvironmentFile=). Als Basis für /etc/systemd/system/ verwenden.

Die Unit härtet mit ProtectSystem=strict ab und erlaubt Schreibzugriffe nur auf /var/imconnect und /var/log/imconnect (plus /tmp//var/tmp via PrivateTmp). /opt/imconnect — das App-Verzeichnis selbst — ist bewusst nicht beschreibbar: der Prozess liest nur daraus (JAR, lib/, config/) und schreibt nie, sodass ein read-only-Zustand einen kompromittierten Prozess daran hindert, seine eigenen Binaries oder seine Config zu manipulieren. Der Audit-Spool (audit.spool-path, Default /var/imconnect/audit-spool) muss innerhalb eines der beschreibbaren Pfade bleiben — ausserhalb kann der Audit-Trail seinen Spool nicht öffnen und deaktiviert sich selbst (lautstark, Routing unbeeinträchtigt). Laufen mehrere Router-Instanzen auf einem Host, gibt man jeder ihren eigenen spool-path (das Spool-Verzeichnis ist pro Instanz).

Dateirechte: router.storage-path hält transient unverschlüsselte Patientendaten (P-DATA-Spill-Dateien während der Verarbeitung), ebenso PDU-Dumps (router.log-folder), Tap-Captures/-Spools und der Audit-Spool (audit.spool-path: unverschlüsseltes JSONL mit Patienten-ID, Study-/SOP-Instance-UIDs und Accession Number, mit capture-patient-name zusätzlich dem Patientennamen — die Einträge liegen dort, bis der Shipper sie in die Datenbank verschoben hat); der Router legt alle diese Dateien und die selbst erstellten Verzeichnisse owner-only an (0600/0700). Das Löschen ist ein gewöhnliches unlink, kein Überschreiben der Blöcke — wer das Restrisiko auf dem Datenträger ausschliessen will, legt die Pfade auf ein verschlüsseltes Volume. Die Unit setzt zusätzlich UMask=0027, womit auch alles Übrige, was der Prozess schreibt (z. B. die Application-Logs), höchstens gruppenlesbar entsteht.

Das Env-File config/dicom-router.env gehört auf chmod 600. Es enthält JDBC_PASSWORD im Klartext, und aus JAVA_BIN/JAVA_OPTS baut bin/start.sh die Kommandozeile des JVM-Starts — Schreibrechte auf die Datei bedeuten damit Code-Ausführung als Router-User. bin/start.sh liest beim Start die Rechte-Bits der Datei und schreibt eine Warnung auf stderr, wenn davon etwas ausserhalb des Eigentümers gesetzt ist; der Start läuft trotzdem weiter, und wo kein stat verfügbar ist, entfällt die Prüfung wortlos. Sie sagt nur etwas über die Rechte-Bits dieser einen Datei aus. Eigentümer und Rechte des config/-Verzeichnisses prüft sie nicht, obwohl beide genauso zählen: systemd liest EnvironmentFile= als root, also auch eine Datei, die dem Router-User gar nicht gehört, und über ein gruppen- oder weltschreibbares config/ lässt sich die Datei austauschen, ohne dass sich ihre Rechte-Bits ändern. Beides bleibt in der Verantwortung des Betreibers. UMask=0027 greift hier nicht: die Einstellung gilt nur für Dateien, die der Router-Prozess selbst schreibt. Das Env-File entsteht beim Kopieren aus share/dicom-router.env.example und trägt die Rechte, die dabei gesetzt werden.


Docker

Das Image (docker/Dockerfile) ist kein Multi-Stage-Build — es erwartet die bereits gebauten Artefakte unter docker/app/, analog zum CI-Workflow. Lokal bauen:

# 1) Alles bauen
mvn clean install

# 2) Artefakte in den Docker-Build-Kontext stagen
VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)
mkdir -p docker/app/lib docker/app/bin docker/app/plugins docker/app/share docker/app/config
cp dicom-router-core/target/dicom-router-core-${VERSION}.jar docker/app/dicom-router.jar
cp dicom-router-core/target/lib/*.jar                        docker/app/lib/
cp bin/dicom-router-ctl                                      docker/app/bin/
cp dicom-router-core/src/main/resources/logback.xml         docker/app/config/logback.xml
cp dicom-router-plugins/target/dicom-router-plugins-${VERSION}.jar docker/app/plugins/
cp LICENSE                                                   docker/app/
# Referenz-Vorlagen in share/ eingebacken (das Dockerfile COPYt app/share/ → /app/share/).
# share/ ist 1:1 das Distributions-share/ — dieselben 8 Vorlagen wie im tar.gz:
cp dicom-router-core/src/main/resources/logback.xml                  docker/app/share/logback.xml.example
cp dicom-router-plugins/src/main/resources/example-dicom-router.yaml docker/app/share/dicom-router.yaml.example
cp deploy/systemd/dicom-router.service                               docker/app/share/dicom-router.service.example
cp deploy/systemd/dicom-router.env.example                           docker/app/share/
cp deploy/cluster-hosts.example                                      docker/app/share/
cp deploy/haproxy-hosts.example                                      docker/app/share/
cp deploy/sql/audit-retention.sql.example                            docker/app/share/
cp deploy/sql/audit-timestamp-utc-fix.sql.example                    docker/app/share/
cp deploy/haproxy/haproxy-example.cfg                                docker/app/share/

# 3) Bauen und starten (DICOM 11112, Agent-Check 8404, Management 8405)
docker build -t dicom-router docker/
docker run -p 11112:11112 -p 8404:8404 -p 8405:8405 -v ./config:/app/config dicom-router

Ein eigenes logback.xml einhängen, um den eingebackenen Default zu überschreiben:

docker run -v ./logback.xml:/app/config/logback.xml:ro ... dicom-router

Umgebungsvariablen

Variable Beschreibung Default
DB_ENABLED Datenbank-Konnektivität aktivieren/deaktivieren (true/false) true
JDBC_URL JDBC-Verbindungs-URL jdbc:postgresql://localhost:5432/dicomrouter
JDBC_USER Datenbank-Benutzer aus persistence.xml
JDBC_PASSWORD Datenbank-Passwort aus persistence.xml
HIBERNATE_SHOW_SQL SQL-Statements loggen false
HIBERNATE_FORMAT_SQL Geloggtes SQL formatieren false
HIKARI_MAX_POOL_SIZE Maximale Grösse des Connection-Pools (Minimum 3: eine Schema-Migration hält den Cluster-Lock auf einer eigenen Verbindung, während die Migration selbst auf einer zweiten läuft — Details in docs/database.md im Repository) 10
HIKARI_MIN_IDLE Minimale Idle-Grösse des Connection-Pools 2
LOG_DIR Verzeichnis für Anwendungslogs (von logback.xml genutzt, nicht von Java) /var/log/imconnect
DICOM_ROUTER_TOKEN_FILE Token-Datei für die state-changing Kommandos von dicom-router-ctl (von der CLI genutzt, inkl. systemd ExecStop; nur nötig, wenn management.token-file auf einen nicht-standardmässigen Ort zeigt, siehe management.md) /opt/imconnect/config/management.token (falls vorhanden)

Alle Variablen liegen in config/dicom-router.env, das sowohl die systemd-Unit (EnvironmentFile=) als auch bin/start.sh laden — ein manueller Start verhält sich wie der Dienst. Ein Unterschied: bei start.sh gewinnen bereits in der Shell gesetzte Variablen gegenüber der Datei, sodass Ad-hoc-Overrides wie LOG_DIR=/tmp/x bin/start.sh funktionieren; die Werte werden geparst wie systemd es tut (keine Shell-Expansion), nie ge-sourced.

Mit DB_ENABLED=false startet der Router ohne jede Datenbankverbindung. DICOM-Routing funktioniert vollständig, aber Connection-Tracking wird nicht persistiert — nützlich für Standalone-Deployments ohne PostgreSQL.


Hinter HAProxy

Für rollende Zero-Downtime-Deployments stellt der Router einen Agent-Check-Port und einen Management-Port bereit und unterstützt drain/reload über dicom-router-ctl. Ein vollständiges HAProxy-Beispiel liegt der Distribution als share/haproxy-example.cfg bei (Quelle im Repository: deploy/haproxy/haproxy-example.cfg). Dessen optionale Statistik-Seite bindet auf 127.0.0.1:8080, weil sie Backend-Adressen, Zustände und Verkehrszähler offenlegt. Wer sie von aussen erreichen will, öffnet einen SSH-Tunnel (ssh -L 8080:127.0.0.1:8080 <haproxy-host>) oder ändert die Bind-Adresse bewusst und stellt Authentifizierung davor. Die betrieblichen Details (Ports, Zustände, Kommandos, Config-Reload-Regeln) sind in management.md dokumentiert.

Das Beispiel führt die Strecke mit und die ohne PROXY-Protokoll bewusst auf zwei getrennte Router-Ports (11112 und 11113). Der Router entscheidet pro Verbindung anhand der proxyprotocol:-Einträge — Absender-Adresse plus Annahme-Port —, ob ein PROXY-Header erwartet wird; auf einem gemeinsamen Port sind beide Strecken nicht unterscheidbar, und eine von beiden bricht. Ein Router-Prozess bindet genau einen DICOM-Port (router.listener.port), der zweite Port ist also eine zweite Instanz.

Für Geräte, die anrufende IPs whitelisten, unterstützt der Router HAProxy-Rückwege (haproxy-return + return-port an den Destinations, siehe configuration.md): der Router wählt automatisch VIP:return-port an, und die passenden HAProxy-listen-Blöcke generiert er selbst aus der YAML — --render-haproxy, dokumentiert in management.md.

Die generierte Datei verteilt bin/dicom-router-haproxy-sync auf die HAProxy-Hosts aus config/haproxy-hosts (Vorlage: share/haproxy-hosts.example) und reloaded HAProxy unterbrechungsfrei — Ablauf und Modi in management.md. Voraussetzungen auf jedem HAProxy-Host:

  • passwortloser (key-based) ssh-Zugang vom Bearbeitungs-Host (wie bei dicom-router-sync),
  • Schreibrecht des ssh-Benutzers auf den Zielpfad (Default /etc/haproxy/conf.d/dicom-return.cfg),
  • lesbare /etc/haproxy/haproxy.cfg für den Preflight (haproxy -c),
  • eine sudo-Regel für den Reload, z. B.:
    imconnect ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy
    

Das Reload-Kommando ist per DICOM_ROUTER_HAPROXY_RELOAD_CMD überschreibbar (Setups unterscheiden sich, conf.d vs. EXTRAOPTS--f); die Haupt-haproxy.cfg verwaltet das Skript nie.