Observability und Linux-Deployment — vom swift run zum systemd-Service
Das Gateway aus Artikel 5 authentifiziert, begrenzt und beantwortet Anfragen. Was noch fehlt, sind drei Dinge: Sichtbarkeit in den Betrieb (greift das Rate-Limit? Wie lange dauert ein Backend-Call?), ein portables Deployment-Artefakt das nicht von einer macOS-Toolchain-Version abhängt, und ein klar definierter Shutdown-Pfad der laufende Streams nicht einfach abreißt. In diesem Artikel lösen wir alle drei — und stellen dabei fest, dass Hummingbird Graceful Shutdown seit dem ersten Artikel bereits mitbringt, wir es nur noch nie gebraucht haben.
Metriken mit swift-metrics und swift-prometheus
Swift-Metrics ist eine Abstraktion: Library-Autoren erstellen Counter, Gauge, Histogram und Timer ohne zu wissen, wo die Daten landen. Der Anwendungscode entscheidet das zur Startzeit. Wir wählen swift-prometheus als Backend, weil Prometheus das Standardwerkzeug für Server-Metriken auf Linux ist und das Textformat von jedem Monitoring-Stack verstanden wird.
Beide Pakete kommen als Dependency in Package.swift:
.package(url: "https://github.com/apple/swift-metrics.git", from: "2.4.1"),
.package(url: "https://github.com/swift-server/swift-prometheus.git", from: "2.0.0"),
Das Backend booten wir genau einmal. Hummingbird’s Application.runService() läuft in einer asynchronen Service-Loop; MetricsSystem.bootstrap() darf aber nur einmal pro Prozess aufgerufen werden. Ein globales let mit einem Static-Initializer ist die einfachste Absicherung dagegen:
import Metrics
import Prometheus
private let metricsRegistry: PrometheusCollectorRegistry = {
let registry = PrometheusCollectorRegistry()
let factory = PrometheusMetricsFactory(registry: registry)
MetricsSystem.bootstrap(factory)
return registry
}()
func buildApplication(_ args: some AppArguments) async throws -> some ApplicationProtocol {
let registry = metricsRegistry // läuft die Initialisierung genau jetzt
...
let router = buildRouter(
mlxClient: mlxClient,
modelID: args.mlxModel,
keyPairs: keyPairs,
limiter: limiter,
metricsRegistry: registry
)
...
}
Die Registry reichen wir explizit an den Router weiter statt sie über ein globales Singleton zu lesen, weil die Abhängigkeit so im Typsystem sichtbar ist. Ein Router, der eine Registry als Parameter erwartet, lässt sich in Tests mit einer frischen Registry starten ohne den Prozesszustand zu berühren.
MetricsMiddleware und der /metrics-Endpoint
Hummingbird bringt MetricsMiddleware() als Built-in. Sie hängt sich in die Request-Verarbeitung, misst die Dauer und schreibt vier Metriken:
hb_requests(Counter) — jeder abgeschlossene Request, gelabelt mithttp_route,http_request_method,http_response_status_codehb_request_errors(Counter) — nur für Requests die mit einem Fehler-Status endenhttp_server_active_requests(Gauge) — gerade laufende Requestshttp_server_request_duration(Histogram) — Latenz in Sekunden, mit Standard-Buckets bis 10 Sekunden
Der entscheidende Punkt ist http_route: Hummingbird trägt das Route-Template ein, nicht den rohen Pfad. Ein Request gegen /v1/messages liefert http_route="/v1/messages", nicht /v1/messages?stream=true&model=qwen3. Das hält die Kardinalität bounded — Prometheus würde bei rohen Pfaden mit Query-Strings schnell in die Millionen von Label-Kombinationen laufen.
Wir fügen MetricsMiddleware() und TracingMiddleware() außerhalb der /v1-Gruppe ein, damit auch /healthz und /metrics selbst in den Zählen auftauchen:
router.addMiddleware {
LogRequestsMiddleware(.info)
MetricsMiddleware()
TracingMiddleware()
GatewayErrorMiddleware()
}
Den /metrics-Endpoint verdrahten wir direkt unter der Root, ohne Auth, ohne Rate-Limit — Prometheus scrapt über plain HTTP, und der Zugangsschutz gehört auf die Netzwerkebene:
router.get("metrics") { _, _ -> Response in
let body = metricsRegistry.emitToString()
return Response(
status: .ok,
headers: [.contentType: "text/plain; version=0.0.4; charset=utf-8"],
body: .init(byteBuffer: ByteBuffer(string: body))
)
}
Ein paar Requests später sieht ein Scrape so aus:
# TYPE hb_requests counter
hb_requests{http_route="/v1/models",http_request_method="GET",http_response_status_code="200"} 5
hb_requests{http_route="/v1/models",http_request_method="GET",http_response_status_code="401"} 1
# TYPE http_server_request_duration histogram
http_server_request_duration_bucket{http_route="/v1/models",http_request_method="GET",http_response_status_code="200",le="0.005"} 5
...
http_server_request_duration_sum{http_route="/v1/models",...} 0.001463
http_server_request_duration_count{http_route="/v1/models",...} 5
Mit einem scrape_config in prometheus.yml reicht das, um in Grafana rate(hb_requests[5m]) nach Route und Status-Code aufzubrechen oder histogram_quantile(0.95, rate(http_server_request_duration_bucket[5m])) als P95-Latenz zu sehen.
Tracing — Middleware ohne Backend
TracingMiddleware() öffnet pro Request einen Span gemäß swift-distributed-tracing. Solange kein Tracer via InstrumentationSystem.bootstrap() aktiviert ist, landet der Span bei einem NoOpTracer — er kostet nichts und geht nirgendwo hin. Die Middleware-Chain hat dadurch dieselbe Form wie in einer Production-Umgebung mit echtem OTLP-Export. Wer später swift-otel mit einem OpenTelemetry-Collector anbindet, ändert nur den Bootstrap-Aufruf in buildApplication; der Rest des Codes bleibt unverändert.
Graceful Shutdown
Application.runService() registriert die App bei ServiceLifecycle, das ist seit Artikel 1 so. Was das in der Praxis bedeutet: SIGINT oder SIGTERM lösen gracefulShutdown() auf jedem registrierten Service aus. Hummingbird hört dann auf, neue Connections anzunehmen, lässt alle laufenden Requests zu Ende laufen — inklusive der SSE-Streams aus Artikel 4 — und beendet den Prozess danach sauber.
Das erkennt man daran, dass ein curl -N auf einen laufenden Stream die Antwort vollständig erhält, auch wenn wir dem Prozess während der Generierung ein SIGTERM schicken: der Stream beendet sich erst wenn der letzte Token vom mlx_lm.server zurückkommt oder der Backend-Request abgebrochen wird, nicht sofort bei Signal-Eingang.
Kein eigener Code nötig. Die systemd-Unit weiter unten setzt KillSignal=SIGTERM und TimeoutStopSec=30s — das passt exakt zum Verhalten von ServiceLifecycle.
Release-Build auf macOS
Wer das Gateway auf demselben Mac betreibt wie mlx_lm.server — was der häufigste Entwicklungs- und Einzel-Maschinen-Fall ist — braucht kein Cross-Compile. Ein nativer Release-Build reicht:
swift build -c release
.build/release/gateway \
--host 0.0.0.0 \
--port 8080 \
--mlx-url http://localhost:8081 \
--api-keys "alice:sk-prod-key"
Für Auto-Start beim Login gibt es launchd. Ein minimales Plist-File unter ~/Library/LaunchAgents/de.rotecodefraktion.gateway.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>de.rotecodefraktion.gateway</string>
<key>ProgramArguments</key>
<array>
<string>/Users/alice/.build/release/gateway</string>
<string>--host</string><string>127.0.0.1</string>
<string>--port</string><string>8080</string>
<string>--mlx-url</string><string>http://localhost:8081</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GATEWAY_API_KEYS</key><string>alice:sk-prod-key</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/tmp/gateway.log</string>
<key>StandardErrorPath</key>
<string>/tmp/gateway.err</string>
</dict>
</plist>
Aktivieren:
launchctl load ~/Library/LaunchAgents/de.rotecodefraktion.gateway.plist
macOS- und Linux-Deployment unterscheiden sich nur in den Artefakten — das Binary selbst ist dasselbe Hummingbird-Programm. Der Unterschied liegt darin, wie der Prozess gestartet und überwacht wird: launchd auf macOS, systemd auf Linux.
Cross-Compile mit dem Swift Static Linux SDK
Das Swift Static Linux SDK baut statt mit glibc mit musl libc und linkt alles statisch. Das Ergebnis ist ein einzelnes Binary, das auf jedem x86_64-Linux läuft, ohne Glibc-Versionsanforderungen und ohne installierten Swift-Runtime. Das Binary selbst ist 30-50 MB groß — für Go-Entwickler vertraut, für Java- oder Python-Deployments eine kleine Überraschung.
Einmalige Installation des SDKs (muss zur Swift-Toolchain-Version passen, hier 6.3.2):
swift sdk install \
https://download.swift.org/swift-6.3.2-release/static-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz \
--checksum 3fd798bef6f4408f1ea5a6f94ce4d4052830c4326ab85ebc04f983f01b3da407
Der eigentliche Build:
swift build -c release --swift-sdk x86_64-swift-linux-musl
Das war es. swift build --show-bin-path zeigt den Pfad zum fertigen Binary. Das Binary lässt sich direkt auf einen Linux-Host kopieren und starten:
scp .build/x86_64-swift-linux-musl/release/gateway user@host:/usr/local/bin/gateway
ssh user@host '/usr/local/bin/gateway --host 0.0.0.0 --port 8080 --mlx-url http://mlx-host:8081'
Für ARM64-Hosts — Raspberry Pi 5, Ampere-Server, AWS Graviton — ändert sich nur das Triple:
TRIPLE=aarch64-swift-linux-musl swift build -c release --swift-sdk "$TRIPLE"
Caveat: Pakete die macOS-Frameworks nutzen (Network.framework, CoreFoundation-Spezifika, AppKit) kompilieren unter musl nicht. Alle unsere Dependencies — Hummingbird, swift-crypto, swift-prometheus, swift-metrics — sind plattformagnostisch und bauen ohne Änderungen. Das ist kein Zufall: server-seitige Swift-Pakete im SSWG-Ökosystem werden für Linux-Kompatibilität designed.
Betrieb via systemd
Auf einem Linux-Host ohne Container läuft der Dienst am einfachsten als systemd-Service. Das Unit-File in deploy/gateway.service:
[Unit]
Description=Swift MLX Gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=gateway
Group=gateway
ExecStart=/usr/local/bin/gateway \
--host 0.0.0.0 \
--port 8080 \
--mlx-url http://backend.internal:8081 \
--mlx-model qwen3-8b \
--api-keys "${GATEWAY_API_KEYS}" \
--rate-limit-per-minute 120 \
--rate-limit-burst 20
Restart=on-failure
RestartSec=2s
EnvironmentFile=-/etc/gateway/env
TimeoutStopSec=30s
KillSignal=SIGTERM
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
[Install]
WantedBy=multi-user.target
API-Keys kommen aus /etc/gateway/env (GATEWAY_API_KEYS=alice:sk-prod-key,bob:sk-other-key), nicht aus der Unit-Datei, weil env-Dateien leichter mit restriktiven Permissions versehen werden können. Das führende - beim EnvironmentFile bedeutet: fehlende Datei ist kein Fehler beim Starten ohne Konfiguration.
Einrichtung:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin gateway
sudo install -m 755 ./gateway /usr/local/bin/gateway
sudo install -m 644 deploy/gateway.service /etc/systemd/system/
sudo mkdir -p /etc/gateway
printf 'GATEWAY_API_KEYS=alice:sk-prod-key\n' | sudo tee /etc/gateway/env
sudo chmod 600 /etc/gateway/env
sudo systemctl daemon-reload
sudo systemctl enable --now gateway
systemctl status gateway zeigt dann Request-Logs und Rate-Limit-Warnings wie jeden anderen systemd-Dienst. journalctl -fu gateway streamt die Logs live.
Alternative: Multi-Stage-Dockerfile
Wenn Container das Deployment-Ziel sind, kommt ein Multi-Stage-Build in Frage. Die Build-Stage nutzt das offizielle Swift-Docker-Image, die Runtime-Stage nur Ubuntu Noble ohne Swift-Toolchain:
FROM swift:6.3-noble AS build
WORKDIR /build
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources Sources
RUN swift build -c release --static-swift-stdlib
WORKDIR /staging
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/gateway" ./
RUN cp /usr/libexec/swift/linux/swift-backtrace-static ./
FROM ubuntu:noble
RUN apt-get -q update \
&& apt-get -q install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --user-group --create-home --system --home-dir /app gateway
WORKDIR /app
COPY --from=build --chown=gateway:gateway /staging /app
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
USER gateway:gateway
EXPOSE 8080
ENTRYPOINT ["./gateway"]
CMD ["--host", "0.0.0.0", "--port", "8080"]
Package.swift und Package.resolved werden vor den Quellen kopiert, damit swift package resolve einen eigenen Layer bildet, der bei reinen Code-Änderungen wiederverwendet wird. Das spart beim Build-Loop des Schreibens und Testens mehrere Minuten pro Iteration.
--static-swift-stdlib vermeidet Swift-Runtime-.so-Abhängigkeiten in der Runtime-Stage — das Final-Image braucht keine Swift-Laufzeit-Libraries. Die Image-Größe landet bei etwa 140 MB; mit gcr.io/distroless/cc-debian12 statt Ubuntu Noble ließe sie sich auf ~60 MB drücken.
Container-Start:
docker build -t swift-mlx-gateway .
docker run -p 8080:8080 \
-e GATEWAY_API_KEYS="alice:sk-prod-key" \
swift-mlx-gateway
Static SDK vs. Docker: Beide Wege produzieren ein lauffähiges Binary auf Linux. Das Static-SDK-Binary ist kleiner (~40 MB vs. ~140 MB Image) und braucht kein Container-Runtime. Das Docker-Image ist portabler über verschiedene Deployment-Umgebungen (Kubernetes, Fly.io, ECS) und enthält die vertraute Abstraktion. Für einen einzelnen Linux-Host ohne Container-Setup ist das Static-SDK der einfachere Weg; für alles was auf einem Orchestrator läuft, ist der Docker-Build die natürliche Wahl.
Observability und Deployment bleiben unberührt
Alle Änderungen dieses Artikels sind additiv. Auth, Rate-Limit, Streaming, Anthropic- und OpenAI-Protokoll aus den vorigen Artikeln laufen unverändert durch die erweiterte Middleware-Chain. MetricsMiddleware und TracingMiddleware messen still mit, ohne irgendeinen Request-Pfad zu verändern. mlx_lm.server bleibt das macOS-Backend; der Gateway selbst läuft als Linux-Dienst und spricht über das Netzwerk mit dem Modell. Für Linux-Setups bleibt Ollama die Alternative — der Gateway braucht dafür nur --mlx-url http://localhost:11434.
Ausblick: Benchmarks und Hugo-Companion
In Artikel 7 werden wir Benchmarks machen — Hummingbird gegen FastAPI und Node mit demselben Gateway-Pattern — und schauen, was Swift auf dem Server wirklich bringt wenn es um rohen Durchsatz geht.