Management, Drain & Config-Reload¶
Diese Doku beschreibt den Management-Layer des DICOM-Routers: die beiden zusätzlichen TCP-Listener für Zero-Downtime-Deployment hinter HAProxy, die Operator-Kommandos, den Config-Hot-Reload und das CLI-Tool. Alle Aussagen sind gegen den aktuellen Code verifiziert.
Verwandte Doku: User-Guide · Configuration · Deployment
1. Überblick: Zero-Downtime hinter HAProxy¶
Für rollende Deployments betreibt der Router neben dem DICOM-Listener zwei weitere
TCP-Listener. Beide werden nur gestartet, wenn management.enabled: true gesetzt ist
(Wiring in App.java).
| Listener | Default-Port | Default-Bind | Zweck |
|---|---|---|---|
| Agent-Check-Port | 8404 |
0.0.0.0 |
HAProxy pollt den Server-Zustand (netzwerk-erreichbar) |
| Management-Port | 8405 |
127.0.0.1 |
Operator-Kommandos (localhost-only aus Sicherheitsgründen) |
Defaults stammen aus default-dicom-router.yaml:
management:
enabled: true
agent-check-port: 8404
agent-check-bind-address: "0.0.0.0"
management-port: 8405
management-bind-address: "127.0.0.1"
# allow-remote-management: false
# token-file: /opt/imconnect/config/management.token
Agent-Check-Port (HAProxy-Integration)¶
AgentCheckServer.java antwortet auf jede Verbindung mit einer einzeiligen ASCII-Antwort
und schliesst danach. Die Antwort leitet sich direkt aus der RouterState-State-Machine ab:
| Router-Zustand | Agent-Check-Antwort | Bedeutung für HAProxy |
|---|---|---|
READY |
ready up |
nimmt neue Verbindungen an |
DRAIN |
drain |
keine neuen Verbindungen, bestehende laufen weiter |
STOPPING |
down |
Instanz fährt herunter |
HAProxy-Konfiguration (Beispiel):
Zustandsautomat (RouterState.java)¶
Thread-sicher über AtomicReference<State>. Erlaubte Übergänge:
drain()wechselt nur vonREADYnachDRAIN(CAS).resume()wechselt nur vonDRAINzurück nachREADY(CAS).stop()wechselt aus jedem Zustand ausserSTOPPINGnachSTOPPING.
Der DICOM-Listener wertet RouterState beim Accept aus: ist der Zustand nicht READY,
wird die frisch angenommene Verbindung sofort geschlossen
(SocketHandler.java, accept-Schleife). Bestehende Verbindungen laufen unverändert weiter.
2. Management-Kommandos¶
Der ManagementServer.java liest pro Verbindung eine Zeile im Format
<kommando> [token], verarbeitet sie über ManagementCommandHandler.java und schliesst
nach Antwort. Kommandos werden case-insensitiv ausgewertet; das optionale Token (siehe
Sicherheits-Abschnitt unten) wird verbatim nach dem ersten Whitespace übernommen. Ohne
konfigurierte Token-Authentifizierung bleibt das Protokoll strikt: jeder Trailing-Text
ist ERROR unknown command (wie vor Stufe 2) — ein Client, der Tokens an einen Router
ohne Auth schickt, hat Konfigurations-Drift, und die soll sichtbar sein.
Die Kommandozeile ist auf 4096 Zeichen begrenzt. Wer mehr schickt, bekommt
ERROR command line too long und die Verbindung wird geschlossen, ohne dass das
Kommando verarbeitet wird. Das längste legitime Kommando — Kommandowort plus Token —
liegt weit darunter, die Grenze begrenzt also den Lesepuffer und nicht das Protokoll.
Sie ist nötig, weil der Socket-Timeout pro read() gilt und nicht für die ganze Zeile:
ein Client, der dauerhaft Bytes ohne Zeilenende schickt, löst ihn nie aus. Der
Token-Check läuft erst nach dem Lesen, die Grenze greift also vor jeder
Authentifizierung.
| Kommando | Wirkung (verifiziert gegen ManagementCommandHandler) |
|---|---|
status |
Liefert state, connections (Anzahl aktiver Verbindungen), uptime, die Kennung der geladenen Config (config-hash = SHA-256 der Datei-Bytes beim letzten erfolgreichen Load/Reload, config-loaded = Zeitpunkt), die Q/R-Zähler und den Audit-Pipeline-Zustand (siehe unten). |
connections |
Tabelle der aktiven DICOM-Assoziationen: UUID, Client, Calling-AET, Called-AET, Dauer. |
drain |
RouterState.drain() → keine neuen Verbindungen mehr; meldet verbleibende aktive Verbindungen. |
resume |
RouterState.resume() → nimmt wieder Verbindungen an (nur aus DRAIN). |
reload |
Triggert Config-Re-Read (siehe Abschnitt 3); meldet OK config reloaded oder die Validierungsfehler. |
shutdown |
RouterState.stop(), dann Graceful Shutdown in eigenem Virtual-Thread; meldet zu schliessende Verbindungen. |
Beispielantworten:
$ status
state: ready
connections: 3
uptime: 2d 4h 11m 8s
config-hash: 3f9a1c0e7b2d… (SHA-256 der zuletzt erfolgreich geladenen YAML)
config-loaded: 2026-07-12T16:41:03Z
qr-untracked-moves: 0
qr-lookup-errors: 0
audit: enabled
audit-backlog: 0 events / 118 bytes
audit-shipped: 4211
audit-skipped: 0
audit-dropped: 0
audit-undecodable: 0
events-dropped: 0
events-written: 137
audit-last-ship: 2026-07-05T14:02:11.482Z
$ drain
OK drain mode activated, 3 active connections remaining
$ reload
OK config reloaded
Unbekannte oder leere Kommandos liefern ERROR unknown command: ... (es wird nur das
Kommando-Wort zurück-echot, nie der Rest der Zeile — ein vertipptes Kommando darf das
Token nicht reflektieren). Abgelehnte state-changing Kommandos liefern
ERROR unauthorized.
Ein Kommando endet nie ohne Antwort: schlägt intern die Ermittlung der
Verbindungsliste fehl, antwortet connections mit einer ERROR-Zeile, und
drain/shutdown führen ihre Zustandsänderung trotzdem aus — die Verbindungszahl in
der OK-Zeile degradiert dann auf unknown. Der Shutdown-Thread startet bei
shutdown in jedem Fall; eine leere Antwort (Verbindungsabbruch ohne Zeile) ist damit
kein regulärer Ausgang mehr.
Sicherheit: Loopback-Zwang + Token für state-changing Kommandos¶
Zwei unabhängige Schutzstufen; der Agent-Check-Port ist von beiden ausgenommen — er ist by design netzwerk-erreichbar (HAProxy-Polling) und liefert nur einen Zustands-String zurück.
Stufe 1 — Loopback-Zwang (Remote-Bedrohung): ConfigValidator
(validateManagementBindNotRemote) erzwingt, dass management-bind-address eine
Loopback-Adresse ist (127.0.0.1, ::1); eine andere Adresse führt beim Start zu einem
Validierungsfehler. Zum bewussten Opt-out (z.B. Management aus einem separaten,
abgesicherten Netz-Segment) gibt es management.allow-remote-management: true
(Default false). Der Loopback-Zwang gilt unabhängig davon, ob ein Token konfiguriert ist.
Das Opt-out hebt die Loopback-Pflicht auf, nicht die Authentisierung: seit 1.17.0 verlangt
die Kombination aus Nicht-Loopback-Bind-Adresse und allow-remote-management: true
zusätzlich eine gesetzte management.token-file, sonst bricht der Start (und ein Reload)
mit einem Validierungsfehler ab. Ein netzwerk-erreichbarer Port, der shutdown/drain/
reload von jedem annimmt, der ihn erreicht, ist die eine Kombination ohne legitimen
Anwendungsfall — sie scheitert deshalb, statt sich auf ein Start-Banner zu verlassen.
Ist die Bind-Adresse nicht loopback, loggt App.java beim Start weiterhin eine WARN,
damit die Risikoentscheidung im Log sichtbar bleibt; sie unterscheidet die beiden Fälle:
ohne Token den mehrzeiligen Alarm-Block, mit Token eine Zeile, die den netzgebundenen Port
und die Token-Pflicht benennt.
Stufe 2 — Shared-Secret-Token (lokale Bedrohung): Auch loopback-only kann sonst
jeder unprivilegierte lokale Prozess den Router stoppen
(printf 'shutdown\n' | nc 127.0.0.1 8405). Mit management.token-file verlangen die
state-changing Kommandos drain/resume/reload/shutdown ein Token auf derselben
Zeile (<kommando> <token>); die read-only Kommandos status/connections antworten
weiterhin ohne Token — status reduziert dann aber die audit-last-error-Ausgabe auf
ihre Kategorie (siehe unten). Verhalten im Detail (TokenVerifier/FileTokenVerifier,
ManagementCommandHandler):
- Ohne
token-file(Default): heutiges Verhalten ohne Auth, aber einmaligerWARNbeim Start („management port accepts state-changing commands … from any local process"). Trailing-Text (auch ein Token) wird auf state-changing und unbekannten Kommandos nicht stillschweigend geschluckt, sondern alsERROR unknown commandabgelehnt — sichtbare Konfigurations-Drift statt überraschend ausgeführter Kommandos. Ausnahme sind die read-only Kommandosstatus/connections: dort wird ein mitgesendeter Token ignoriert (Antwort in der reduzierten Sicht) —dicom-router-ctl statusschickt den Token, sobald lokal eine Token-Datei liegt, und ein Fehler hier bräche Monitoring unddicom-router-syncgegen Router ohne Token-Auth. - Token-Semantik: Der Token ist die erste non-blank Zeile der Datei, getrimmt
(
FileTokenVerifier.readTokenFromFile— dieselbe Normalisierung nutzt auch derConfigValidator, eine Datei, die die Validierung besteht, authentifiziert also garantiert). Weitere Zeilen (z.B. Kommentare) werden ignoriert; Dateien über 64 KB werden als Fehlkonfiguration abgelehnt. - Mit
token-file: Beim Start validiertConfigValidator, dass die Datei existiert, lesbar, ≤ 64 KB und nicht leer (erste non-blank Zeile vorhanden) ist — sonst Startfehler. Der Token-Wert wird bei jedem Kommando frisch aus der Datei gelesen: Rotation = Datei neu schreiben, kein Restart/Reload nötig (der Datei-Pfad dagegen ist restart-pflichtig, siehe Abschnitt 3). Lesefehler zur Laufzeit (Datei gelöscht/unlesbar/leer) lehnen das Kommando ab und loggenERROR(fail-closed). Fehlendes/falsches Token →ERROR unauthorized+WARNmit Peer-Adresse. Der Vergleich läuft konstantzeit überMessageDigest.isEqual(kein Timing-Leak). Aufstatusist das Token optional, wird aber geprüft, wenn es mitgeschickt wird: mit gültigem Token rendertstatusdie volleaudit-last-error-Zeile, ohne Token nur deren Kategorie, ein falscher Token ergibtERROR unauthorizedwie bei den state-changing Kommandos. Das zugehörigeWARNist aufstatusauf eines pro Minute gedrosselt (RestDEBUG):statuswird gepollt, eine veraltete Token-Datei im Monitoring würde sonst pro Poll eine WARN-Zeile samtrouter_events-Eintrag erzeugen; auf den state-changing Kommandos bleibt jedesWARNerhalten. Ein Trailing-Token aufconnectionswird weiterhin ignoriert. - Datei-Hygiene: Ist die Token-Datei group/other-lesbar, loggt
FileTokenVerifierbeim Start einenWARN(kein Fehler — analog ssh-Key-Hygiene).
Setup:
# Token erzeugen, nur für den Router-User lesbar (install -m 600-Äquivalent):
umask 077; openssl rand -hex 32 > /opt/imconnect/config/management.token
chown imconnect:imconnect /opt/imconnect/config/management.token
# dicom-router.yaml:
# management:
# token-file: /opt/imconnect/config/management.token
# Rotation ohne Restart — einfach die Datei neu schreiben:
umask 077; openssl rand -hex 32 > /opt/imconnect/config/management.token.new
mv /opt/imconnect/config/management.token.new /opt/imconnect/config/management.token
bin/dicom-router-ctl findet die Datei unter dem Default-Pfad
/opt/imconnect/config/management.token automatisch (sonst DICOM_ROUTER_TOKEN_FILE
setzen, siehe Abschnitt 4) und hängt das Token an state-changing Kommandos an — damit
funktioniert auch systemctl stop (ExecStop = dicom-router-ctl drain-and-shutdown)
unverändert. Auch status schickt das Token mit, wenn die Datei lesbar ist (volle
audit-last-error-Zeile); ist sie nicht lesbar, läuft status tokenfrei weiter und
zeigt die Kategorie. Lehnt der Router einen mitgeschickten Token ab (veraltete oder
fremde Token-Datei), fasst status einmalig tokenfrei nach und zeigt die reduzierte
Sicht — mit einer Hinweiszeile auf stderr, damit stdout-Parser unberührt bleiben.
Die Warteschleife von drain-and-shutdown fragt den Status
bewusst tokenfrei ab — sie liest nur die connections:-Zeile, und eine mitten im
Drain rotierte Token-Datei darf das Warten nicht abbrechen.
Q/R-Zähler¶
Die beiden qr--Zeilen stehen immer da, auch auf einem Knoten ohne Q/R-Konfiguration (dann
dauerhaft 0). Sie zählen knotenweit seit Prozessstart und werden von einem Reload nicht
zurückgesetzt.
| Zeile | Bedeutung |
|---|---|
qr-untracked-moves |
C-MOVE-RQs, die diesen Knoten ohne Tracking-Eintrag verlassen haben: keine MoveDestination, kein passendes Rewrite konfiguriert, kein Store, oder die Zuteilung einer Router-MessageID ist gescheitert. Der Retrieve läuft dann an der Bridge vorbei — die Bilder gehen dorthin, wohin das Gerät sie adressiert hatte. Ein wachsender Wert heisst: Konfiguration prüfen, oder der Tracking-Raum ist voll (siehe Kapazität des Tracking-Raums). |
qr-lookup-errors |
Tracking-Abfragen auf dem Rückweg, die der Store nicht beantworten konnte — Datenbank weg, Pool erschöpft, oder gar kein Store auf der Verbindung. Gezählt wird pro fehlgeschlagener Abfrage, nicht pro verworfenem Objekt: nach dem ersten Fehler überspringt die Bridge den Store für den Rest der Association und beantwortet jede weitere Message mit C000. Jeder Zähler > 0 bedeutet also, dass Bilder eines Retrieves verworfen wurden. Sollte 0 sein. |
Audit-Zeilen im status¶
Bei aktiviertem Audit-Trail (siehe Configuration — audit) zeigt
status den Zustand des Spool-/Shipper-Pfads; die beiden events--Zeilen kommen nur bei
zusätzlich aktiviertem audit.events dazu und gehören zum davon unabhängigen Events-Writer:
| Zeile | Bedeutung |
|---|---|
audit |
Vier Zustände: enabled (Writer und Shipper laufen), disabled (audit.enabled: false oder DB_ENABLED=false), failed (eingeschaltet, aber der Spool liess sich nicht öffnen — es wird nichts aufgezeichnet, die Ursache steht in audit-last-error) und ended (lief in diesem Prozess und ist gestoppt, die Zähler sind Endstände). failed und ended haben früher wie disabled ausgesehen; ein voller Datenträger war damit von einem bewusst abgeschalteten Audit-Trail nicht zu unterscheiden. Bei failed erscheinen keine Audit-Zählerzeilen — sie wären alle 0 und würden „nichts verloren" behaupten; die events--Zeilen stehen weiterhin da, denn dieser Writer hängt nicht am Spool. ended ist naturgemäss nur zu sehen, solange der Prozess noch antwortet — praktisch im Drain-/Stop-Fenster; danach antwortet der Management-Port nicht mehr. |
audit-backlog |
Noch nicht in die DB versendete Events (Anzahl / Spool-Bytes). Wächst während eines DB-Ausfalls. |
audit-shipped |
Events, die der Shipper aus dem Spool genommen und an die Datenbank übergeben hat, ohne dass diese einen Fehler gemeldet hat. Nicht gleichbedeutend mit „gespeichert" — dafür siehe audit-skipped. |
audit-skipped |
Von den versendeten Events diejenigen, die die Datenbank nicht ablegen konnte, weil die Zeile fehlt, an der sie hängen (die Association zu einer DIMSE-Operation, der Listener zu einer Association). Das ist die Folgewirkung eines früheren Verlusts oder einer Retention-Lücke: das Event selbst hat den Spool unversehrt verlassen, es fehlt der Anker in der Datenbank. Bewusst nicht in audit-dropped eingerechnet — die beiden Ursachen liegen an verschiedenen Stellen. Sollte 0 sein. |
audit-dropped |
Endgültig verlorene Events. Fünf Wege führen hierher: das spool-max-mb-Cap; ein Schreibfehler des Spools auf der Platte; eine volle Übergabe-Queue (Writer hängt); ein Event, das sich nicht in den Spool schreiben liess, weil schon das Kodieren scheiterte; und ein Event, das der Shipper nach wiederholten Einzelversuchen als „poison" verwirft. Sollte 0 sein. |
audit-undecodable |
Spool-Zeilen, die der Shipper nicht dekodieren konnte und übersprungen hat. Kein Verlustpfad im Sinne von audit-dropped (die Zeilen zählen auch nicht mehr zum Backlog), aber der direkte Hinweis auf einen Binary-Rollback: eine ältere Version trifft auf Events, die eine neuere geschrieben hat. Sollte 0 sein. |
events-dropped |
Nur bei audit.events.enabled: Events, die der zentrale Events-Writer verloren hat — volle Übergabe-Queue, verworfener Batch, Emit nach Writer-Ende oder Fehler beim Erfassen im Appender (best-effort, es wird nie erneut versucht). Bewusst nicht mitgezählt: Zeilen, die der Re-Entrancy-Guard unterdrückt — das sind Meldungen des Persistenz-Stacks aus dem eigenen Schreibpfad (Schutzverhalten, kein Verlust von Router-Events). Dauerhaft wachsend heisst: DB-Sink hängt oder queue-size zu klein. Sollte 0 sein. |
events-written |
Nur bei audit.events.enabled: erfolgreich nach router_events geschriebene Events seit Prozessstart. |
audit-last-ship |
Zeitpunkt des letzten erfolgreichen DB-Batches (never vor dem ersten). |
audit-last-error |
Während eines Ausfallfensters der letzte Sink-Fehler (z.B. connection refused); bei audit: failed der Grund, aus dem sich der Spool nicht öffnen liess. Einzeilig und normalisiert: Exception-Klasse, SQLState und die erste Zeile der Meldung (gekürzt), gefolgt von der eigentlichen Ursache (| caused by …, gleiche Form). Die Folgezeilen eines PostgreSQL-Fehlers (Detail:/Where:/Hint:) bleiben weg — dort stehen die Spaltenwerte der betroffenen Zeile. Die volle Zeile ist token-pflichtig (status <token>): schon die erste Meldungszeile kann den DB-Benutzer und Host:Port tragen (Verbindungsfehler) oder den absoluten Spool-Pfad (Spool-Öffnungsfehler), und der Management-Port kann remote gebunden sein. Ohne Token (oder ohne konfiguriertes token-file) erscheint nur die Kategorie — Exception-Klasse(n) und SQLState, keine Meldungsteile; der volle normalisierte Text steht unverändert im Anwendungslog. dicom-router-ctl status schickt das Token automatisch mit, wenn die Token-Datei konfiguriert und lesbar ist. |
Ein wachsender Backlog mit audit-last-error bedeutet DB-Ausfall: Routing läuft
unverändert weiter, der Spool puffert, nach Wiederherstellung wird vollständig und ohne
Duplikate nachgesendet — auch über einen Router-Neustart hinweg.
3. Config-Reload im Detail¶
Der Reload ist als atomarer Config-Swap implementiert (siehe SocketHandler.java
und ConfigDiff.java). Ein Neustart ist nicht nötig, solange nur hot-reloadbare
Felder geändert wurden.
Ablauf (App.reloadConfig())¶
Serialisiert über einen reloadLock:
- Reload ist in
READYundDRAINerlaubt, im ZustandSTOPPINGabgelehnt. - YAML wird neu von Disk geladen (
configLoader.load()). Strukturell ungültige Configs oder I/O-Fehler werden als Validierungsfehler zurückgegeben — die alte Config bleibt aktiv. Fehlt die Config-Datei, wird der Reload ebenso abgelehnt: aus dem eingebauten Template wird nichts angelegt (das Bootstrap passiert nur beim ersten regulären Start), die Betreiber-Datei wird nie erzeugt oder überschrieben. ConfigDiff.validateReloadable(oldConfig, newConfig)prüft auf restart-pflichtige Änderungen; zusätzlich werden Plugin-Configs viaPluginFactory.validatePluginConfigsForReload(...)validiert.- Nur wenn keine Fehler vorliegen:
handler.setConfig(newConfig).
Jeder Fehler lässt die zuvor aktive Config unverändert. Dazu zählt seit 1.17.0 auch
jeder Schlüssel, der auf kein Feld passt — in einem Abschnitt oder im config:-Block
eines Plugins. Ein Reload mit einem Tippfehler wird also abgelehnt, statt die
betroffene Einstellung stillschweigend auf den Default zurückzusetzen; die alte Config
läuft weiter.
Atomarer Swap (SocketHandler.java)¶
Die aktive Config liegt in einer AtomicReference<ApplicationConfig> configRef.
setConfig(newConfig) ersetzt sie atomar. In handleClient(...) snapshottet jede
Verbindung beim Accept einmal configRef.get() in eine lokale Variable und arbeitet
durchgehend mit diesem Snapshot.
Folge: Neue Verbindungen nach dem Swap sehen die neue Config; in-flight-Verbindungen behalten ihren Snapshot, auch wenn die Config mitten in der Assoziation getauscht wird.
Hot-reloadbar vs. restart-required¶
Die massgebliche Feldliste steht in ConfigDiff.java. Nur die folgenden Felder werden
als restart-pflichtig markiert (sie werden beim Boot gebunden: Server-Socket-Binding,
Management-Server-Ports, AE-Title in der Listener-DB-Tabelle):
Restart-required (Reload wird mit requires restart abgelehnt):
router.listener.hostrouter.listener.portrouter.listener.aetmanagement.enabledmanagement.agent-check-portmanagement.agent-check-bind-addressmanagement.management-portmanagement.management-bind-addressmanagement.allow-remote-managementmanagement.token-file(der Pfad; der Token-Wert in der Datei wird pro Kommando frisch gelesen und rotiert ohne Restart)audit.enabledaudit.spool-pathaudit.spool-max-mbaudit.batch-sizeaudit.flush-intervalaudit.events.enabledaudit.events.levelaudit.events.queue-sizestore:einerCMoveTrackingStage-Action (memory↔database) — die beiden Tracking-Stores sind zwei getrennte Zustände; ein Wechsel im laufenden Betrieb liesse jedes bereits verfolgte C-MOVE ins Leere laufen. Gemeldet alsactions.<name>.store: requires restart; die Meldung nennt den betroffenen Eintrag über seinreturnDestination, weil eine Action mehrereCMoveTrackingStage-Einträge tragen kann. Geschlüsselt wird bewusst über diese identifizierende Eigenschaft statt über die Position in der Liste: ein Store-Wechsel zusammen mit einer Umsortierung entkommt so der Restart-Pflicht nicht, und umgekehrt löst weder ein davor eingefügter noch ein eingeschalteter Eintrag einen Fehlalarm für die übrigen aus. Ebenfalls restart-pflichtig, sobald ein Eintrag verschwindet (umbenannt oder entfernt): dann muss ein neu erschienener Eintrag mit demselben Store 1:1 seine Stelle einnehmen. Bleibt ein verschwundener Eintrag ungedeckt — oder kommt im selben Reload ein neuer Eintrag mit einem anderen Store dazu —, ist es restart-pflichtig, weil ein Umbenennen von einem Entfernen nicht zu unterscheiden ist und ein Geschwister-Eintrag mit demselben Store sonst für den Wechsel eines umbenannten aufkäme. Reine Ergänzungen (nichts verschwindet) bleiben hot: beide Stores existieren ab dem Start.store:einertype: qr-bridge-Destination — dieselbe Begründung von der Leseseite her: ein Wechsel liesse jeden Lookup für bereits verfolgte Retrieves ins Leere laufen. Gemeldet alsdestinations.<name>.store: requires restart. Ebenfalls restart-pflichtig, sobald eine Bridge verschwindet, ohne dass eine neu erschienene mit demselben Store 1:1 an ihre Stelle tritt — dieselbe Regel wie oben, samt der konservativen Auflösung, wenn im selben Reload eine Bridge verschwindet und eine mit anderem Store dazukommt. Alle übrigen Bridge-Felder (calling-aet,allowed-calling-aet,presentation-contexts,max-drain-bytes,timeouts.connect/.response/.release) sind hot-reloadbar — sie werden pro eingehender Association aus dem Snapshot gelesen.
Eine reine Ergänzung mit store: database ist dabei nur auf Knoten hot, die auch einen
datenbank-gestützten Tracking-Store haben: hat der Knoten keinen (Datenbank deaktiviert
oder das JDBC-Wiring beim Start fehlgeschlagen), wird der Reload abgelehnt statt still
auf den In-Memory-Store zu degradieren — die Store-Instanzen sind boot-gebunden, also
Eintrag auf store: memory stellen oder die Datenbank in Ordnung bringen und den Knoten
neu starten. Dieselbe Regel verhindert seit 1.16.0 bereits den Boot mit store: database
ohne Datenbank.
Zusätzlich: wird der gesamte management-Block hinzugefügt oder entfernt, meldet
ConfigDiff management.enabled: requires restart (section added or removed);
analog für den audit- und den audit.events-Block.
Hot-reloadbar (alles andere, nicht in ConfigDiff geprüft):
router.listener.socketTimeout— wird pro Verbindung aus dem Snapshot gelesen (clientSocket.setSoTimeout(...)) und greift damit automatisch für neue Verbindungen. Derselbe Wert begrenzt seit 1.17.0 auch einen einzelnen blockierenden Write in Richtung Client oder Ziel (SO_TIMEOUTdeckt nur Reads ab).router.listener.connectTimeout— das Zeitbudget für Namensauflösung plus TCP-Handshake beim Verbindungsaufbau zu einer Forward-Destination; wird pro Verbindung aus dem Snapshot gelesen (ConnectionHandler.connect).router.listener.tcplogrouter.memory-threshold/router.materialization-threshold/router.max-elements— die Streaming-Objektmodell-Schwellen; werden pro Verbindung aus dem Snapshot gelesen (DicomObjectPipeline/DicomStage) und greifen damit für neue Verbindungen.router.max-pending-assemblies/router.assembly-idle-timeout/router.max-object-bytes— die Limits für halb-offene P-DATA-Assemblies und der Deckel je assembliertem Data Set; werden ebenfalls pro Verbindung aus dem Snapshot gelesen (DicomStage).actions(Plugin-Instanzen und derenconfig:-Parameter) — mit einer Ausnahme: derstore:-Schalter derCMoveTrackingStageist restart-pflichtig (siehe oben)filter(Routing-/Filterregeln)destinations(forward-/reject-/qr-bridge-Ziele, inkl.return-port/return-vip) — mit einer Ausnahme: dasstore:einerqr-bridge-Destination ist restart-pflichtig (siehe oben)haproxy-return(globale Default-VIP undport-rangefür HAProxy-Rückwege, siehe configuration.md)proxyprotocol(Mappings)audit.capture-patient-name— wird pro Erfassung aus dem Verbindungs-Snapshot gelesen (die übrigenaudit-Felder binden Spool-Writer/Shipper beim Start und sind restart-pflichtig, siehe oben;audit.events.*bindet zusätzlich den Writer-Thread und den beim Start eingehängten Logback-Appender)
Hinweis:
socketTimeout,connectTimeoutundtcplogliegen unterrouter.listener, gehören aber bewusst nicht zur restart-pflichtigen Liste —ConfigDiffvergleicht unterrouter.listenerausschliesslichhost,portundaet.
4. CLI-Tool bin/dicom-router-ctl¶
Bash-Wrapper, der ein einzeiliges Kommando an den Management-Port schickt.
Für die Verbindung nutzt das Skript bevorzugt Bashs eingebautes /dev/tcp — das
braucht kein zusätzliches Paket auf dem Host. Ist die Netz-Umleitung im Bash-Build
abgeschaltet, fällt es auf nc zurück und ruft es nur mit -w auf, dem einzigen
Schalter, den alle Varianten kennen (netcat-openbsd, nmap-ncat, busybox). Ein
installiertes netcat ist damit nicht mehr Voraussetzung, und die Variante auf dem
Host spielt keine Rolle mehr.
Umgebungsvariablen:
| Variable | Default | Zweck |
|---|---|---|
DICOM_ROUTER_HOST |
localhost |
Management-Host |
DICOM_ROUTER_MGMT_PORT |
8405 |
Management-Port |
DICOM_ROUTER_TIMEOUT |
5 |
Sekunden, die auf Verbindung und Antwort gewartet wird |
DICOM_ROUTER_POLL_INTERVAL |
2 |
Poll-Intervall (nur drain-and-shutdown) |
DICOM_ROUTER_TOKEN_FILE |
/opt/imconnect/config/management.token (falls vorhanden) |
Token-Datei für state-changing Kommandos und die volle audit-last-error-Zeile von status (siehe Abschnitt 2) |
Befehle:
dicom-router-ctl status # Zustand, Verbindungszahl, Uptime
dicom-router-ctl connections # aktive DICOM-Verbindungen auflisten
dicom-router-ctl drain # keine neuen Verbindungen annehmen
dicom-router-ctl resume # Annahme wieder aufnehmen
dicom-router-ctl reload # Config neu einlesen (listener/management/audit → Restart nötig)
dicom-router-ctl shutdown # SOFORT stoppen — laufende Übertragungen werden getrennt
dicom-router-ctl drain-and-shutdown # verlustfrei stoppen: drain, auf 0 Verbindungen warten, dann shutdown
Exit-Codes der zustandsändernden Kommandos (drain, resume, reload,
shutdown): 0 = ausgeführt, 1 = Router nicht erreichbar oder Antwort
unbrauchbar, 3 = der Router hat geantwortet und abgelehnt — ein zurückgewiesener
Reload, ein fehlendes oder vom Router abgelehntes Token, ein shutdown während
bereits gestoppt wird. Eine konfigurierte, aber nicht lesbare Token-Datei endet
dagegen mit 1 — der Router wurde gar nicht erst gefragt. (Fehlt schlicht jede
Token-Datei, geht das Kommando ohne Token hinaus; ein token-geschützter Router
lehnt dann mit ERROR unauthorized ab → 3.) Bis 1.17.1 endeten die
3er-Fälle mit 0, eine Automatisierung las also Erfolg,
während die alte Konfiguration weiterlief (#343). Zwei Ausnahmen bleiben bei 0,
weil der gewünschte Zustand bereits erreicht ist: ein drain auf einen bereits
drainenden Router und ein resume auf einen bereits bereiten. Damit bricht ein
wiederholter Aufruf in einer Retry-Schleife nicht.
3 und nicht 1, damit „abgelehnt" von „nicht erreichbar" unterscheidbar bleibt:
dicom-router-sync braucht genau diese Unterscheidung, um eine rein
restart-pflichtige Antwort weiterhin als legitimen Ausgang zu behandeln.
shutdown vs. drain-and-shutdown: shutdown beendet den Router sofort —
bestehende DICOM-Associations (auch ein gerade laufender C-STORE-Transfer) werden
dabei geschlossen. drain-and-shutdown stoppt verlustfrei: erst keine neuen
Verbindungen mehr (der HAProxy-Agent-Check meldet drain, der Load Balancer nimmt
die Instanz aus der Rotation), dann warten bis alle laufenden Übertragungen fertig
sind, erst dann der Stopp. Für Wartung und Deployments ist drain-and-shutdown
der richtige Weg.
Zeitbudget des Teardowns. Was nach dem shutdown-Kommando passiert, hat ein festes
Budget: der Router schliesst alle noch offenen Verbindungen parallel und rechnet die
Wartezeiten gegen eine gemeinsame Deadline von 30 Sekunden — nicht je Verbindung. Danach
folgen die Tap-Shipper, der Drain der Audit-Queue in den Spool und der Event-Writer mit je
wenigen Sekunden. Der Teardown kostet also im schlechtesten Fall rund eine Minute,
unabhängig davon, wie viele Verbindungen hängen. Das TimeoutStopSec der systemd-Unit
(300 s) begrenzt demgegenüber den gesamten ExecStop — und der besteht überwiegend aus dem
Drain, der auf laufende Associations wartet. Wer Associations über fünf Minuten fährt,
erhöht dort; ein kleinerer Wert beschleunigt den Teardown nicht, er kürzt nur den Drain.
Restart: Es gibt bewusst kein restart-Kommando — das CLI spricht über den
Management-Port mit dem laufenden Prozess und kann keinen neuen starten (das ist
Sache des Prozess-Managers, und DICOM_ROUTER_HOST kann auf einen Remote-Host
zeigen). Unter systemd: systemctl restart dicom-router — die Unit drained dabei
automatisch zuerst (ExecStop=dicom-router-ctl drain-and-shutdown) und startet dann
den neuen Prozess. Ein bewusstes dicom-router-ctl shutdown bleibt dagegen unten
(Restart=on-failure greift nur bei Fehlern).
connections wird 1:1 an den Server durchgereicht und berührt die Token-Datei
nie; status hängt das Token an, wenn die Token-Datei konfiguriert bzw. am
Default-Pfad vorhanden und lesbar ist (volle audit-last-error-Zeile, siehe
Abschnitt 2) — ist sie nicht lesbar, läuft status tokenfrei weiter und zeigt die
Kategorie, ein Fehler ist das nicht (beide Kommandos funktionieren also weiterhin
für User ohne Leserecht auf die 0600-Datei); drain/resume/reload/shutdown
bekommen das Token angehängt, wenn eine Token-Datei konfiguriert bzw. am Default-Pfad
vorhanden ist (ohne Token-Datei wie bisher ohne Token — für Setups ohne Auth). Das
Token wird dabei pro Kommando frisch
aus der Datei gelesen (erste non-blank Zeile, getrimmt — wie der Server), sodass eine
Rotation während eines langen Drain-Waits den finalen shutdown nicht bricht. Eine
gesetzte, aber unlesbare DICOM_ROUTER_TOKEN_FILE bricht bei state-changing Kommandos
mit Fehler ab.
Ist der Router nicht erreichbar, meldet jedes Kommando das auf stderr
(„router not reachable at <host>:<port>") und endet mit Exit 1 — statt
kommentarlos nichts auszugeben. drain-and-shutdown ist clientseitig orchestriert:
- sendet
drain(toleriert „already draining"); antwortet der Router hier nicht, ist nichts gedrained worden → Fehlermeldung + Exit 1, - pollt in
POLL_INTERVAL-Sekunden-Schrittenstatus, bisconnections: 0. Ab hier gilt ein nicht mehr erreichbarer Router als erfolgreicher Shutdown; eine Antwort ohne verwertbareconnections:-Zeile dagegen als Fehler (Exit 1), damit ein abgeschnittener Status nicht in eine Endlosschleife bis zum systemd-Stop-Timeout läuft, - sendet
shutdownund prüft die Antwort:ERROR ...(z.B.unauthorized) → Fehlermeldung + Exit 1; nicht mehr erreichbar → Shutdown gilt als erfolgt.
Cluster-Verteilung: bin/dicom-router-sync¶
Für Multi-Host-Betrieb verteilt dicom-router-sync die auf dem Bearbeitungs-Host
gepflegte config/dicom-router.yaml an alle Hosts aus config/cluster-hosts und
reloaded sie — mit automatischer Return-Port-Vergabe (--allocate-return-ports,
ersetzt return-port: 0-Marker vor Snapshot und Verifikation; ohne Marker
No-Op, ohne greifbares Java/JAR bei vorhandenen Markern harter Abbruch), lokalem
Verlaufs-Snapshot (config/config-history/), Preflight-Validierung ohne Router
(--validate-config, best-effort wenn Java/JAR
greifbar), Canary-Reload auf dem lokalen Router (Validierungsfehler brechen ab,
bevor ein anderer Host angefasst wird), atomarer Kopie via ssh, Reload über den
ctl des jeweiligen Ziel-Hosts (Management-Port bleibt loopback-only) und
zweistufiger Verifikation: SHA-256 der Datei plus — wo der Router es meldet —
Abgleich gegen den config-hash aus status (beweist den geladenen Stand,
Tabelle zeigt dann MATCH (loaded)). Reihenfolge: allocate → Snapshot →
--validate-config → Canary → Verteilung — Verlauf und SHA-Verifikation erfassen
so immer den allozierten Stand. Modi: --check (nur Drift-Prüfung),
--no-reload (nur verteilen, Allokation läuft auch hier). Anwendersicht:
user-guide.md, Abschnitt 4.
Der Canary kennt vier Ausgänge: OK → verteilen und überall reloaden; ein
ERROR unauthorized → Abbruch mit eigenem Hinweis auf die Token-Datei (ein
Credentials-Problem, kein Config-Problem — nichts wird verteilt); eine Antwort,
deren Fehlerzeilen alle : requires restart lauten → verteilen, aber keinen Reload
auslösen (die Rolling-Restart-Anleitung wird ausgegeben); alles Übrige → Abbruch, bevor
ein anderer Host angefasst wird. Die Prüfung sieht jede Fehlerzeile einzeln an, weil
„restart-pflichtig" die Datei an alle Hosts verteilt: eine Ablehnung wegen eines
vertippten Plugin-Klassennamens nennt ebenfalls requires restart (im Fliesstext ihrer
Meldung), und eine gemischte Antwort — echte restart-pflichtige Änderung neben einem
kaputten Plugin-Eintrag — ist eine Ablehnung und keine restart-pflichtige Config.
Ein Grenzfall auf heterogenen Clustern (Knoten mit und ohne Datenbank): der
Canary-Reload prüft nur den lokalen Router. Eine Config mit store: database
kann dort gültig sein, während ein Zielknoten ohne Datenbank den Reload
ablehnt — die bereits verteilte Datei bleibt auf diesem Knoten liegen, und
weil der Start store: database ohne Datenbank genauso verweigert wie der
Reload, würde sie beim nächsten Restart den Boot verhindern. Configs mit
store: database deshalb nur auf Cluster verteilen, deren Knoten alle eine
Datenbank haben.
Validate-only: --validate-config¶
Die Konfiguration lässt sich ohne laufenden Router prüfen — mit exakt derselben Validator-Kette wie Start und Reload (YAML-Parse, Core-Validierung, Plugin-Config-Validierung), ohne Seiteneffekte (keine Listener, keine DB; eine fehlende Datei wird nie aus der Vorlage angelegt):
Aus dem Install-Root ausführen (bzw. in Automatisierung wie Ansible chdir
auf das Install-Verzeichnis setzen): externe Plugin-Klassen werden aus einem
plugins/-Verzeichnis geladen, das relativ zum Arbeitsverzeichnis aufgelöst
wird — derselbe Grund, aus dem start.sh ein cd macht und die systemd-Unit
WorkingDirectory= setzt. Aus einem anderen Verzeichnis liefert die Prüfung bei
Configs mit externen Plugins falsch-negative Ergebnisse (ClassNotFoundException).
Die Prüfung beurteilt die Datei, nicht den Rechner, auf dem sie läuft: Sie
braucht weder eine Datenbank noch die JDBC-Umgebung und ist damit auf jedem
Arbeitsplatz aussagekräftig — auch für eine Config, die auf einen anderen Knoten
verteilt wird. store: database ist deshalb hier immer gültig; ob dieser Knoten
tatsächlich eine Datenbank hat, entscheiden Start und Reload (beide verweigern
sonst, siehe Konfiguration).
Exit-Codes: 0 = gültig, 1 = ungültig (Fehlerliste auf stderr), 2 = Datei
nicht gefunden. dicom-router-sync nutzt das automatisch als Preflight (und
startet die JVM dafür selbst im Install-Root, egal von wo es aufgerufen wird).
HAProxy-Rückwege generieren: --render-haproxy¶
Der Router generiert die HAProxy-Konfiguration für die konfigurierten Rückwege
(haproxy-return + return-port an den Destinations) direkt
aus seiner eigenen YAML. Das Subkommando erzeugt nur die Datei — die Verteilung
auf die HAProxy-Hosts samt Reload übernimmt bin/dicom-router-haproxy-sync
(nächster Abschnitt) oder weiterhin der Betreiber selbst:
cd /opt/imconnect
java -jar dicom-router-core-*.jar --render-haproxy config/dicom-router.yaml [ausgabe-datei]
Ohne Ausgabe-Datei geht die Ausgabe nach stdout, und dort steht dann die
gerenderte Config. Zwei Quellen, die sich vorher davorschoben, sind abgeräumt:
für die Dauer dieses Laufs wird alles abgehängt, was das Logging nach stdout
schreiben würde (#346) — vorher landete die INFO-Zeile des Config-Ladens als
erste Zeile in der umgeleiteten Datei —, und Logbacks eigene Statusmeldungen
gehen nach stderr statt nach stdout (#363). Die Statusmeldungen erscheinen,
sobald an der Logging-Konfiguration selbst etwas schiefgeht: ruft ein Konto ohne
Schreibrecht auf LOG_DIR das Kommando direkt per java -jar auf, scheitert der
Datei-Appender — die Statusausgabe samt Stacktrace stand dann vor der ersten
Config-Zeile. HAProxy lehnte beides mit „unknown keyword" ab. Diagnose bleibt auf
stderr sichtbar — die Skip-Hinweise ohnehin, und ein Console-Appender, der
bereits auf stderr konfiguriert ist, wird nicht angefasst.
Für Automatisierung bleibt die Form mit Ausgabe-Datei die verlässlichere: sie
schreibt die Config direkt und hängt gar nicht an stdout — auch dann nicht, wenn
ein eigenes -Dlogback.statusListenerClass die Statusmeldungen bewusst wieder
auf stdout legt. dicom-router-haproxy-sync nutzt deshalb diese Form.
Die Config wird zuerst mit
derselben Validator-Kette wie bei --validate-config geladen und geprüft; der
Install-Root-Hinweis von oben gilt genauso (externe Plugins werden relativ zum
Arbeitsverzeichnis aufgelöst). Exit-Codes: 0 = generiert, 1 = Config ungültig
oder der Name einer gerenderten Destination ist kein gültiger
HAProxy-Proxy-Name (erlaubt: A-Za-z0-9-_.: — es wird nie stillschweigend
umbenannt), 2 = Datei nicht gefunden. Übersprungene Destinations (siehe unten)
sind kein Fehler.
Die Ausgabe ist deterministisch (nach Destination-Name sortiert, kein
Zeitstempel — identische Eingabe ergibt eine byte-identische Datei, diff-freundlich
für Config-Management), ein listen-Block pro gerenderter Destination, jeweils mit
explizitem mode tcp (die Datei ist für ein zusätzliches -f gedacht, die Defaults
des Ziel-HAProxy sind unbekannt) und ohne Health-Check (TCP-Probes erzeugen auf
DICOM-Geräten Log-Spam und Fehlalarme):
# generated by dicom-router --render-haproxy — do not edit
# source: dicom-router.yaml
listen dicom_return_ct_a
mode tcp
bind 10.0.0.10:5013
server ct_a 192.168.10.5:104
Was gerendert wird: die port-range als Grenze¶
Gerendert wird eine forward-Destination nur, wenn ihr return-port innerhalb
von haproxy-return.port-range liegt. Die Range trennt damit
die vom Router verwalteten Rückwege von denen, die der Betreiber selbst in seiner
HAProxy-Config führt: die generierte Datei wird bei jedem Sync-Lauf komplett
überschrieben, ein handgepflegter listen-Block mit demselben bind würde
kollidieren (cannot bind socket beim HAProxy-Start).
Ein return-port ausserhalb der Range bleibt gültige Konfiguration und wird zur
Laufzeit unverändert über vip:return-port angewählt — nur der listen-Block dazu
gehört in die HAProxy-Config des Betreibers. Übersprungene Destinations werden an
zwei Stellen sichtbar, mit unterschiedlichem Wortlaut:
In der generierten Datei stehen sie als Kommentarblock (Teil der Datei, wird also mitverteilt und geht in die Prüfsumme ein):
# skipped (return-port outside port-range 5000-5999 — manual HAProxy entry):
# ct_b bind 10.0.0.10:4711 -> 192.168.10.9:104
Auf stderr kommt zusätzlich eine einzeilige Zusammenfassung. Sie ist nicht Teil der
Datei — bei Ausgabe nach stdout bleibt sie dadurch sichtbar, und die Datei selbst bleibt
byte-identisch für den Drift-Check. dicom-router-haproxy-sync fängt stderr des
Render-Aufrufs ein und gibt die Zeile eingerückt aus:
NOTE: 1 destination(s) skipped (return-port outside port-range 5000-5999) — these return paths are manual HAProxy entries: ct_b
Die Prüfung ist rein portbasiert und VIP-unabhängig (die port-range ist global
definiert). Ist keine port-range konfiguriert, gibt es keine Grenze und jede
forward-Destination mit return-port wird gerendert. Für übersprungene Destinations
greift die Proxy-Namensprüfung nicht — sie werden nie zu einem HAProxy-Proxy-Namen;
ihr Name erscheint im Kommentar stattdessen konservativ bereinigt (alles ausserhalb
A-Za-z0-9-_.: wird zu ?). VIP und Host in derselben Zeile werden ebenso bereinigt,
dort sind zusätzlich [, ] und % erlaubt (IPv6-Klammern und Zone-IDs — derselbe
Zeichensatz, den die Validierung für Adressen ohnehin verlangt).
Ohne eine einzige gerenderte Destination entsteht nur der Header plus ein Kommentar,
der mit # no destinations with return-port beginnt (Exit 0) — mit dem Zusatz
inside port-range 5000-5999, wenn Destinations mit return-port existieren, aber
alle ausserhalb der Range liegen.
HAProxy-Rückwege verteilen: bin/dicom-router-haproxy-sync¶
Verteilt die generierte Rückweg-Config auf die HAProxy-Hosts und reloaded sie —
nach dem Muster von dicom-router-sync: verteilen, prüfen, reloaden,
verifizieren. Ablauf: lokal rendern (--render-haproxy, JVM im Install-Root;
ohne greifbares Java/JAR harter Abbruch — ohne Render gibt es nichts zu
verteilen; die deterministische Ausgabe macht den SHA-Drift-Check byte-genau) →
Drift-Check pro Host (SHA-256; unverändert → Kopie und Preflight entfallen, der
Reload läuft trotzdem: „Datei stimmt" heisst nicht „Config ist geladen" — ein
früherer Lauf kann am Reload gescheitert oder mit --no-reload gelaufen sein,
und der Seamless-Reload ist idempotent und billig; nur --no-reload überspringt
ihn auch hier) → Kopie
via ssh in eine Punkt-Temp-Datei neben dem Ziel (bei Directory-Loading
eines conf.d/ ignoriert HAProxy Dotfiles — eine halb geschriebene oder
ungültige Temp-Datei kann nie mitgeladen werden, das atomare Rename bleibt
möglich) → Preflight auf dem Ziel-Host gegen die Temp-Datei
(haproxy -c -f /etc/haproxy/haproxy.cfg -f <temp>); nur bei Erfolg wird sie
atomar über das Ziel umbenannt — bei einem Fehler wird sie entfernt und das
Ziel bleibt unangetastet auf dem alten Stand, auch ein späterer Reboot oder
manueller Reload kann nie eine ungeprüfte Datei laden. Voraussetzung des
Preflights: die HAProxy-Unit lädt genau haproxy.cfg plus die
Zieldatei/conf.d — weitere -f-Dateien der Unit sieht der Preflight nicht.
→ Reload (Default sudo -n systemctl reload haproxy, Seamless-Reload:
bestehende TCP-Verbindungen und laufende Associations überleben) →
SHA-Verifikation mit Ergebnis-Tabelle (Exit 0 nur, wenn jeder Host OK ist).
Modi: --check (nur Drift-Prüfung, read-only), --no-reload (verteilen +
Preflight, kein Reload).
Die Ziel-Hosts stehen in config/haproxy-hosts (Vorlage:
share/haproxy-hosts.example) — bewusst nicht in der Router-YAML: die YAML
ist load-strikt validierte Laufzeit-Config, wird byte-identisch auf alle
Router-Knoten gesynct und ist config-hash-überwacht; die
HAProxy-Deployment-Topologie ist Betriebs-Infrastruktur, die der Router zur
Laufzeit nie braucht. Format: eine Zeile pro Ziel host [zielpfad]
(user@host erlaubt, Kommentare/Leerzeilen ignoriert), Default-Zielpfad
/etc/haproxy/conf.d/dicom-return.cfg. Env-Overrides:
DICOM_ROUTER_HAPROXY_HOSTS (Hosts-Datei), DICOM_ROUTER_HAPROXY_TARGET
(Default-Zielpfad), DICOM_ROUTER_HAPROXY_RELOAD_CMD (Reload-Kommando —
Setups unterscheiden sich, conf.d vs. EXTRAOPTS--f, das Skript erzwingt
kein Layout).
Das Skript verwaltet ausschliesslich die eine generierte Datei — nie die
Haupt-haproxy.cfg. Die Rollout-Reihenfolge gibt es als Hinweis aus (nie
erzwungen): fehlen auf dem Ziel listen-Blöcke, die die YAML neu hat → vor
dicom-router-sync ausführen (ein neuer Block ist harmlos, solange ihn niemand
anwählt; umgekehrt wählte der Router einen Port an, den HAProxy noch nicht
kennt); hat das Ziel Blöcke, die die YAML nicht mehr kennt → nach
dicom-router-sync.
Einen dritten Fall unterscheidet der Hinweis davon: wandert ein return-port aus
der port-range heraus, verschwindet sein Block aus der generierten Datei, während
der Router die Destination weiter über vip:return-port anwählt. Auf dem Ziel-Host
sieht das identisch aus wie eine entfernte Destination — das Skript trennt beides
über den # skipped (…)-Block der frisch gerenderten Datei: steht der Blockname
dort, ist die Destination noch konfiguriert. Für diese Blöcke gibt es einen eigenen
Hinweis: der listen-Block gehört in die HAProxy-Config des Betreibers, und bis er
dort steht, ist der Rückweg nicht erreichbar. Verwaist ist ein solcher Block
ausdrücklich nicht — wird er entfernt, bleibt der Rückweg dauerhaft nicht
erreichbar. Die Hinweise schliessen einander nicht aus: sind in einem Lauf sowohl
eine entfernte als auch eine herausgewanderte Destination betroffen, erscheinen
beide nebeneinander, jeder bezogen auf seine eigenen Blöcke.
Der Zeitbezug hängt vom Modus ab. --check ändert nichts, der Hinweis nennt den
nächsten regulären Lauf als Zeitpunkt. Mit --no-reload ist die Zieldatei bereits
überschrieben, geladen ist sie noch nicht — hier ist der nächste Reload oder
Neustart der Zeitpunkt. Im regulären Lauf sind Überschreiben und Reload bereits
erfolgt, wenn die Zusammenfassung erscheint; sie sagt das entsprechend und nennt
den Weg zurück (Block in die eigene Config, reloaden).
Einen Restfall deckt der Abgleich nicht ab: Er läuft über den Blocknamen. Wird eine
Destination mit return-port ausserhalb der Range umbenannt, steht im
Skip-Block der neue Name, auf dem Ziel der alte — der alte Block wird als verwaist
gemeldet, obwohl sein Bind weiter angewählt wird. Der Hinweistext für verwaiste
Blöcke verweist deshalb ausdrücklich auf den Skip-Block, bevor von Hand etwas
entfernt wird.
Eine Render-Ausgabe ganz ohne listen-Blöcke wird erkannt und mit Hinweis trotzdem
verteilt (legitim, um Rückwege zu entfernen) — das gilt seit der port-range-Grenze
auch dann, wenn zwar Destinations mit return-port existieren, aber alle
ausserhalb der Range liegen; der Hinweistext benennt beide Fälle.
Voraussetzungen auf den Ziel-Hosts (ssh, Schreibrechte, sudo-Regel):
deployment.md; Anwendersicht: user-guide.md,
Abschnitt 4.
Return-Ports automatisch vergeben: --allocate-return-ports¶
Ersetzt jeden return-port: 0-Marker in der YAML durch den kleinsten freien
Port der haproxy-return.port-range — bezogen auf die
effektive VIP der Destination (return-vip-Override oder globale vip;
Eindeutigkeit pro (vip, port)-Paar). Bereits vergebene Ports werden nie neu
berechnet (sticky):
cd /opt/imconnect
java -jar dicom-router-core-*.jar --allocate-return-ports config/dicom-router.yaml
Die Config wird zuerst mit derselben Validator-Kette wie bei --validate-config
geladen und geprüft — toleriert werden dabei nur die Marker selbst, jeder
andere Fehler bricht ab (auf einer kaputten Config wird nichts alloziert); der
Install-Root-Hinweis von oben gilt genauso. Die YAML wird textuell und
kommentarerhaltend zurückgeschrieben: es ändern sich ausschliesslich die
betroffenen return-port: 0-Zeilen (atomar via Temp-Datei + Rename im selben
Verzeichnis). Die Zuordnung wird pro Destination ausgegeben:
Idempotent: ohne Marker ein No-Op mit Exit 0. Exit-Codes: 0 =
alloziert/nichts zu tun, 1 = Config ungültig, Range erschöpft oder Marker ohne
port-range, 2 = Datei nicht gefunden. Dies ist das einzige Subkommando
mit Seiteneffekt auf die Config-Datei — --validate-config und
--render-haproxy bleiben strikt lesend. dicom-router-sync führt die
Allokation automatisch vor Snapshot und Preflight aus (siehe oben).
5. Dependency-Hinweis¶
Das Paket management hat keine Imports aus dem core-Paket. ManagementCommandHandler
arbeitet ausschliesslich mit Callbacks (Supplier/Runnable) und den entkoppelten Records
(ConnectionInfo, ConfigValidationError). Das Wiring zwischen Management und Core findet
ausschliesslich in App.java statt:
toConnectionInfoList(handler)ist die einzige Stelle, an der Core-Typen (ConnectionHandler) auf Management-Typen (ConnectionInfo) gemappt werden.- Shutdown- und Reload-Callbacks (
shutdown(...),App::reloadConfig) werden dort als Lambdas in denManagementCommandHandlerinjiziert.
Relevante Klassen:
dicom-router-core/.../management/RouterState.java— Zustandsautomat (READY/DRAIN/STOPPING)dicom-router-core/.../management/AgentCheckServer.java— HAProxy-Agent-Check-Listenerdicom-router-core/.../management/ManagementServer.java— Kommando-Listenerdicom-router-core/.../management/ManagementCommandHandler.java— Kommando-Verarbeitung (ohne Core-Deps)dicom-router-core/.../management/TokenVerifier.java/FileTokenVerifier.java— Token-Auth für state-changing Kommandos; konstruiert aus der Config ausschliesslich inApp.javadicom-router-core/.../management/ConnectionInfo.java— immutabler Verbindungs-Snapshot (Record)dicom-router-core/.../config/ConfigDiff.java— Reload-Diff (restart-pflichtige Felder)dicom-router-core/.../App.java— Wiring &reloadConfig()dicom-router-core/.../core/SocketHandler.java— atomarer Config-Swap, Drain-Enforcement
6. Logging (Kurzfassung)¶
Zwei unabhängige, leicht zu verwechselnde Mechanismen:
| Mechanismus | Konfiguriert über | Was geschrieben wird |
|---|---|---|
| Application-Logs | logback.xml |
reguläre SLF4J-Ausgaben (INFO/WARN/ERROR/…) |
| DICOM-PDU-Dumps | YAML router.log-folder + filter logLevel 5/6/7 |
binäre PDU-Captures via PduLoggingStage |
Die beiden Mechanismen sind unabhängig — router.log-folder steuert nicht die
Application-Logs.
PDU-Logging nur temporär aktivieren. PDU-Dumps (
logLevel5/6/7) sind ein Diagnose-Werkzeug: sie enthalten die vollständigen Patientendaten (PHI) und werden nicht rotiert oder automatisch gelöscht — Aufbewahrung und Löschung sind Betreiber-Verantwortung. Nach der Analyse denlogLevelzurücksetzen und die Dumps inrouter.log-folderentfernen. Für dauerhafte Nachvollziehbarkeit ist der Datenbank-Audit-Trail (Konfigurationsabschnittaudit) da, nicht PDU-Dumps.
Logback-Discovery-Reihenfolge (erster Treffer gewinnt)¶
-Dlogback.configurationFile=<pfad>— vonbin/start.shaufconfig/logback.xmlgesetzt (operator-überschreibbar).logback-test.xmlim Classpath — wird in Testläufen automatisch genutzt.logback.xmlim Classpath — interner Default indicom-router-core/src/main/resources/logback.xml.
Interner logback.xml (verifiziert)¶
Liefert sinnvolle Defaults, damit auch ein direkter java -jar-Start File-Rotation erhält:
CONSOLE-Appender (ConsoleAppender) +FILE-Appender (RollingFileAppender).- Rolling-Policy:
maxFileSize100 MB,maxHistory30 Tage,totalSizeCap5 GB. - Datei:
${LOG_DIR}/dicom-router.log, rotiert nachdicom-router-%d{yyyy-MM-dd}.%i.log.gz. - Logger-Levels:
ch.immeditechINFO,org.hibernateWARN,org.jboss.weldWARN, Root INFO. org.hibernate.engine.jdbc.spi.SqlExceptionHelpersteht aufOFF: dieser Logger gibt bei SQL-Fehlern die rohe Treibermeldung als ERROR aus, und PostgreSQL rendert Constraint-Verletzungen mit der kompletten betroffenen Zeile (Detail: Failing row contains (…)— inklusive Patientenkennungen) wörtlich in diese Meldung. Audit-Trail, Q/R-Tracking und der zentrale Events-Writer schreiben für ihre Datenbankfehler eine eigene normalisierte, SQLState-tragende Zeile ins Log — dort geht keine Diagnose-Information verloren; die übrigen Datenbankpfade melden ihre Fehler weiterhin über die eigenen Fehlerbehandlungen.
Logback-Statusmeldungen¶
Logback protokolliert seine eigene Konfiguration in einer internen Statusliste und
gibt sie aus, sobald dabei eine Warnung oder ein Fehler auftrat — etwa ein
Datei-Appender, der sein Verzeichnis nicht beschreiben kann. Der Router lenkt diese
Ausgabe nach stderr (StderrStatusListener, gesetzt vor dem ersten Logger-Aufruf);
ohne diese Umlenkung schreibt Logback sie nach stdout und verschmutzt damit die
Ausgabe der Offline-Modi. Ausgegeben werden nur Meldungen ab WARN. Ein explizit
gesetztes -Dlogback.statusListenerClass=… hat Vorrang und wird nicht überschrieben.
LOG_DIR¶
Env-Variable, Default /var/log/imconnect (dasselbe Verzeichnis wie die übrigen
Deployment-Defaults — systemd ReadWritePaths, router.log-folder), überschreibt das
Log-Verzeichnis in interner und externer Config. Die mitgelieferte systemd-Unit
setzt sie explizit über das Env-File (EnvironmentFile=, siehe
dicom-router.env.example).