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.releaseist25) - 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ätzlichesnetcat-Paket brauchtdicom-router-ctlnicht: es spricht den Management-Port bevorzugt über Bashs eingebautes/dev/tcpan und nutztncnur als Rückfallweg (jede Variante, siehe management.md, Abschnitt 4).
Aus dem Quellcode bauen¶
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¶
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
│ └── dicom-router-haproxy-apply # Apply-Schritt auf dem HAProxy-Host (Modus staged, per ssh gestreamt)
├── 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
│ ├── haproxy-sync.env.example # Einstellungen für dicom-router-haproxy-sync (Modus, Ladepfad, sudo)
│ ├── 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.shliegt wie die übrigen Skripte im Quellbaum unterbin/; 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_studiesspeichert StudyInstanceUIDs, sobald eineCMoveTrackingStagemitstore: databaseläuft — unabhängig vonaudit.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 nachconfig/kopiert (config/audit-retention.sql) und dort bearbeitet: Die Fristen müssen bewusst gesetzt werden, undconfig/übersteht Updates, währendshare/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-Wrapperaudit-retention-run.sh: Ein Lauf löscht höchstensbatch_sizeZeilen 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 SchlusszeileAUDIT_RETENTION_DELETED_TOTAL 0meldet. Hintergrund indocs/database.mdim Repository (in der Distribution nicht enthalten).
Erstinstallation¶
- Entpacken:
tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect config/ausshare/*.examplebootstrappen 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 prüft das beim
Start und schreibt je Befund eine Warnung auf stderr; der Start läuft trotzdem
weiter, und wo kein stat bzw. id verfügbar ist, entfällt die jeweilige
Prüfung wortlos. Geprüft wird:
config/dicom-router.env— Rechte-Bits ausserhalb des Eigentümers (lesbar oder schreibbar für Gruppe/andere) und der Eigentümer: gehört die Datei weder dem Router-User noch root, wird gewarnt, denn systemd liestEnvironmentFile=als root, bevor es auf den Service-User wechselt — der Inhalt einer fremden Datei landet also trotzdem in der Umgebung des Routers. Als Router-User gilt der Benutzer, derbin/start.shausführt. Läuft das Skript als root (etwa ein manueller Start persudo), steht stattdessen der Eigentümer vonconfig/für den Router-User; gehörtconfig/selbst root, gelten nur root-eigene Dateien als in Ordnung.config/— gruppen- oder weltschreibbar. Über ein solches Verzeichnis lassen sich beide Dateien austauschen oder durch einen Symlink ersetzen, ohne dass sich ihre eigenen Rechte-Bits ändern. Aus demselben Grund wird gewarnt, wennconfig/weder dem Router-User noch root gehört (entfällt beim Start als root, dort ist der Eigentümer vonconfig/die Referenz).config/logback.xml(falls vorhanden) — schreibbar für Gruppe/andere, und der Eigentümer wie beim Env-File. Die Datei geht per-Dlogback.configurationFilean die JVM; eine Logback-Konfiguration lädt die Appender-Klassen, die sie nennt, Schreibrecht darauf ist damit ebenfalls Code-Ausführung. Lesbar darf sie für alle sein, sie enthält kein Geheimnis.
Nicht sichtbar sind für diese Prüfungen POSIX-ACLs, die anderen Lese- oder
Schreibzugriff geben. 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.
Beim Einlesen des Env-Files akzeptiert bin/start.sh ausschliesslich Zeilen der
Form KEY=VALUE (Kommentare und Leerzeilen ausgenommen). Jede andere Zeile —
export KEY=VALUE, KEY = VALUE, ein nackter Schlüssel, ein Schlüssel mit
führender Ziffer — wird mit einer Warnung übersprungen, die nur die Zeilennummer
nennt, nie den Inhalt; die Zeilen danach werden weiter gelesen. Die Warnung
beschreibt das Verhalten des Startskripts: systemd liest dasselbe Env-File mit
einem eigenen, toleranteren Parser (KEY = VALUE wird dort mit entfernten
Leerzeichen übernommen), eine hier gemeldete Zeile kann unter der Unit also
trotzdem wirken. Das Env-File deshalb strikt im Format KEY=VALUE halten, dann
verhalten sich Unit und manueller Start gleich.
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 10 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/haproxy-sync.env.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:
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 auchbin/start.shladen — ein manueller Start verhält sich wie der Dienst. Ein Unterschied: beistart.shgewinnen bereits in der Shell gesetzte Variablen gegenüber der Datei, sodass Ad-hoc-Overrides wieLOG_DIR=/tmp/x bin/start.shfunktionieren; die Werte werden geparst wie systemd es tut (keine Shell-Expansion), nie ge-sourced.Mit
DB_ENABLED=falsestartet 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, Modi und alle Einstellungen in
management.md. Die Einstellungen (Modus, Ladepfad, env-file,
Eskalation, Reload-Kommando) stehen in config/haproxy-sync.env (Vorlage:
share/haproxy-sync.env.example); ohne die Datei gilt der Modus direct. Auf
jedem HAProxy-Host braucht es passwortlosen (key-based) ssh-Zugang vom
Bearbeitungs-Host (wie bei dicom-router-sync); die weiteren Voraussetzungen
hängen vom Modus ab.
Modus direct (Default) — der ssh-Benutzer schreibt die Zieldatei selbst:
- Schreibrecht des ssh-Benutzers auf den Zielpfad (Default
/etc/haproxy/conf.d/dicom-return.cfg), - lesbare
/etc/haproxy/haproxy.cfgfür den Preflight (haproxy -c), - eine sudo-Regel für den Reload, z. B.:
Das Reload-Kommando ist per DICOM_ROUTER_HAPROXY_RELOAD_CMD überschreibbar
(Setups unterscheiden sich, conf.d vs. EXTRAOPTS--f). Der Preflight prüft
genau haproxy.cfg plus die Zieldatei — für eine Unit, die ein Verzeichnis oder
weitere -f-Dateien lädt, ist das nicht der echte Ladekontext; dafür gibt es
den Modus staged.
Modus staged — das Ladeverzeichnis bleibt root:root 0755, die Prüfung
läuft im echten Ladekontext der Unit (Verzeichnis-Load, env-file mit
$VAR-Tokens, Nachbardateien), auf dem HAProxy-Host ist nichts vorinstalliert:
- sudo für den ssh-Benutzer; kein Schreibrecht auf das Ladeverzeichnis nötig,
die Zieldatei wird als
root:root 0644installiert (und muss für den ssh-Benutzer lesbar bleiben — Drift-Check und Rollout-Hinweise lesen sie). Das Ladeverzeichnis muss root oder dem Benutzer gehören, der den Apply-Schritt ausführt, und darf nicht gruppen- oder weltschreibbar sein — sonst lehnt das Hilfsskript die Installation ab (Exit 3, Ziel unangetastet): wer das Verzeichnis beschreiben kann, könnte die Temp-Datei zwischen Erzeugen und Umbenennen gegen einen Symlink tauschen. - Umfang des sudo: im Default wird das Hilfsskript gestreamt (
sudo -n sh -s -- …), die sudoers-Regel dafür erlaubtshals root ohne Einschränkung — der ssh-Schlüssel des Router-Benutzers ist damit auf jedem HAProxy-Host root-äquivalent. Anders als im Modusdirect, wo sich sudo auf das Reload-Kommando begrenzen lässt, gibt es instagedkeine engere Regel; eine Regel auf das vorinstallierte Hilfsskript ändert daran nichts (siehe unten). - Das Eskalations-Präfix (Default
sudo -n) braucht entweder eine NOPASSWD-Regel oderDefaults noninteractive_authin den sudoers. Mit-nbricht sudo sonst ab, bevor PAM befragt wird — auch dann, wenn ein Modul ohne Benutzereingabe wiepam_ssh_agent_auth(auth sufficient pam_ssh_agent_auth.soin/etc/pam.d/sudo,Defaults env_keep += SSH_AUTH_SOCK) gegen den weitergereichten Agent authentisieren könnte.noninteractive_authlässt sudo die Authentisierung auch mit-nversuchen. - Ohne beides: das Hilfsskript vorinstallieren und
sudo -S -Hals Eskalation verwenden. Beim gestreamten Hilfsskript ist stdin durch den Skripttext belegt,sudo -Sist dort ausgeschlossen; mit dem vorinstallierten Skript läuft der Apply-Schritt mit `