Auf Docker umgestellt

Ein Image (pfaedle + pmtiles + Python), zwei Container per compose:
web (Port 8080) und updater (stündlich Fahrplan, beim ersten Start
Kartenkacheln). install.sh installiert Docker und startet alles.
systemd-Dienste entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-21 19:24:44 +02:00
parent 88fd24c22b
commit 30d3127b21
12 changed files with 132 additions and 123 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv
.git
data
.env
**/__pycache__
+3
View File
@@ -0,0 +1,3 @@
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT").
# Leer lassen = Karte läuft nur nach Fahrplan, ohne Verspätungen.
TRAINPULSE_RT_KEY=
+2
View File
@@ -1,3 +1,5 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
data/
.env
+28
View File
@@ -0,0 +1,28 @@
# pfaedle: rechnet aus OpenStreetMap die echten Streckenverläufe
FROM debian:trixie AS pfaedle
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git cmake g++ make libzip-dev zlib1g-dev libbz2-dev \
&& git clone --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle /src \
&& cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release \
&& make -C /build -j"$(nproc)" pfaedle \
&& make -C /build install
# pmtiles: schneidet die Kartenkacheln für die Region aus
FROM golang:1.25-trixie AS pmtiles
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
FROM python:3.13-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
COPY --from=pmtiles /go/bin/go-pmtiles /usr/local/bin/pmtiles
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY trainpulse trainpulse
COPY web web
COPY tests tests
ENV TRAINPULSE_DATA=/data PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["uvicorn", "trainpulse.server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
+28 -18
View File
@@ -7,11 +7,11 @@ Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen K
## Wie es funktioniert ## Wie es funktioniert
``` ```
opentransportdata.swiss ──GTFS (Mo+Do)──▶ trainpulse-update (stündlich) opentransportdata.swiss ──GTFS (Mo+Do)──▶ updater-Container (stündlich)
├─ Filter: nur Bahn (route_type 100–199) ├─ Filter: nur Bahn (route_type 100–199)
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe
└─▶ /var/lib/trainpulse/feeds/<version>/ └─▶ data/feeds/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──▶ trainpulse (Webserver, Port 8080) opentransportdata.swiss ──GTFS-RT (35 s)──▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke ├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche └─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
``` ```
@@ -23,33 +23,43 @@ Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
sich die Echtzeitdaten gerade beziehen (`feed_version`), und schaltet automatisch mit um. sich die Echtzeitdaten gerade beziehen (`feed_version`), und schaltet automatisch mit um.
## Installation auf Proxmox ## Installation auf Proxmox (Docker)
TrainPulse läuft als zwei Docker-Container aus demselben Image:
`web` (Webserver, Port 8080) und `updater` (holt stündlich neue Fahrpläne und beim ersten Start
die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`.
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*): 1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **4 GB RAM** (pfaedle braucht beim Aufbereiten bis ~2 GB, der Server ~0,5 GB), 20 GB Disk, Netz per DHCP. Debian 12 oder 13 Template, unprivileged, 2 Kerne, **4 GB RAM** (pfaedle braucht beim
2. **Projekt in den Container kopieren**, z. B. `scp trainpulse.zip root@<ip>:` und dort Aufbereiten bis ~2 GB, der Server ~0,5 GB), **20 GB Disk**, Netz per DHCP.
`apt install -y unzip && unzip trainpulse.zip`. Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
3. **Installieren:** `bash trainpulse/deploy/install.sh` im LXC nicht.
Baut pfaedle, lädt die Kartenkacheln für die Schweiz, richtet die Dienste ein und startet 2. **Installieren** (im Container als root):
die erste Fahrplan-Aufbereitung im Hintergrund (`journalctl -fu trainpulse-update`). ```bash
4. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren, apt install -y git
git clone https://git.scarriffle.com/Scarriffle/Trainpulse.git
bash Trainpulse/deploy/install.sh
```
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
`/etc/trainpulse.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `systemctl restart trainpulse`. `Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach
hinter deinen bestehenden Reverse Proxy hängen. hinter deinen bestehenden Reverse Proxy hängen.
Ein Update des Programms: neue Version in den Container kopieren, `install.sh` nochmals ausführen. **Update:** `git pull && bash deploy/install.sh` – baut das Image neu, Daten bleiben.
## Nützliche Befehle ## Nützliche Befehle (im Ordner `Trainpulse`)
| Was | Befehl | | Was | Befehl |
|---|---| |---|---|
| Status | `curl localhost:8080/api/status` | | Status | `curl localhost:8080/api/status` |
| Logs Webserver | `journalctl -fu trainpulse` | | Logs Webserver | `docker compose logs -f web` |
| Logs Fahrplan-Update | `journalctl -fu trainpulse-update` | | Logs Fahrplan-Update | `docker compose logs -f updater` |
| Fahrplan sofort neu aufbereiten | `sudo -u trainpulse env $(cat /etc/trainpulse.env) /opt/trainpulse/.venv/bin/python -m trainpulse.prepare --force` | | Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --force` |
| Selbsttest | `/opt/trainpulse/.venv/bin/python tests/test_core.py` (im Projektordner) | | Selbsttest | `docker compose exec web python tests/test_core.py` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
## Datenquellen und Lizenzen ## Datenquellen und Lizenzen
+21
View File
@@ -0,0 +1,21 @@
services:
web:
build: .
image: trainpulse
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- ./data:/data
# holt stündlich neue Fahrpläne (und beim ersten Start die Kartenkacheln)
updater:
build: .
image: trainpulse
depends_on: [web]
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data
command: ["sh", "-c", "while true; do python -m trainpulse.prepare; sleep 3600; done"]
+22 -67
View File
@@ -1,87 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# TrainPulse – Installation in einem Debian-LXC (Proxmox). Im Container als root: # TrainPulse – Installation mit Docker (z. B. in einem Debian-LXC auf Proxmox). Als root:
# bash deploy/install.sh # bash deploy/install.sh
# Erneut ausführen = Update des Programmcodes (Daten, Karte und Einstellungen bleiben). # Erneut ausführen = Update (Image neu bauen, Daten und Einstellungen bleiben).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.."
APP=/opt/trainpulse
DATA=/var/lib/trainpulse
SRC=$(cd "$(dirname "$0")/.." && pwd)
BBOX=5.8,45.7,10.6,47.9 # Schweiz mit etwas Rand
[ "$(id -u)" = 0 ] || { echo "Bitte als root ausführen" >&2; exit 1; } [ "$(id -u)" = 0 ] || { echo "Bitte als root ausführen" >&2; exit 1; }
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; } command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
command -v systemctl >/dev/null || { echo "systemd fehlt – im Proxmox-LXC mit Debian-Template ist es dabei" >&2; exit 1; }
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
[ "$mem" -ge 3500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~4 GB empfohlen" >&2 [ "$mem" -ge 3500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~4 GB empfohlen" >&2
echo "==> Pakete (Python, Compiler und Bibliotheken für pfaedle, Werkzeuge)" if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive echo "==> Docker installieren (offizielles Skript von get.docker.com)"
apt-get update -qq apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip git cmake g++ make \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates >/dev/null
libzip-dev zlib1g-dev libbz2-dev curl ca-certificates tar >/dev/null curl -fsSL https://get.docker.com | sh
echo "==> Programm nach $APP"
id trainpulse &>/dev/null || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin trainpulse
mkdir -p "$APP" "$DATA/tiles"
rm -rf "$APP/trainpulse" "$APP/web"
cp -r "$SRC/trainpulse" "$SRC/web" "$SRC/requirements.txt" "$APP/"
[ -d "$APP/.venv" ] || python3 -m venv "$APP/.venv"
"$APP/.venv/bin/pip" install -q --upgrade -r "$APP/requirements.txt"
if ! command -v pfaedle >/dev/null; then
echo "==> pfaedle bauen (Streckenverläufe aus OpenStreetMap, dauert ein paar Minuten)"
tmp=$(mktemp -d)
git clone -q --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle "$tmp/src"
cmake -S "$tmp/src" -B "$tmp/build" -DCMAKE_BUILD_TYPE=Release >/dev/null
make -C "$tmp/build" -j"$(nproc)" >/dev/null
make -C "$tmp/build" install >/dev/null
rm -rf "$tmp"
fi fi
if ! docker run --rm hello-world >/dev/null 2>&1; then
if ! command -v pmtiles >/dev/null; then cat >&2 <<'EOF'
echo "==> pmtiles-Werkzeug" Docker ist installiert, kann aber keine Container starten.
url=$(curl -fsSL https://api.github.com/repos/protomaps/go-pmtiles/releases/latest | python3 -c \ In einem Proxmox-LXC: Container herunterfahren, unter Optionen → Features
'import json,sys; print(next(a["browser_download_url"] for a in json.load(sys.stdin)["assets"] if a["name"].endswith("Linux_x86_64.tar.gz")))') "Nesting" und "keyctl" aktivieren, Container starten, Skript nochmals ausführen.
curl -fsSL "$url" | tar xz -C /usr/local/bin pmtiles
fi
if [ ! -s "$DATA/tiles/region.pmtiles" ]; then
echo "==> Kartenkacheln (OpenStreetMap via Protomaps) für $BBOX"
for i in 1 2 3 4 5 6 7; do
d=$(date -d "-$i day" +%Y%m%d)
if pmtiles extract "https://build.protomaps.com/$d.pmtiles" "$DATA/tiles/region.tmp.pmtiles" --bbox=$BBOX --maxzoom=15; then
mv "$DATA/tiles/region.tmp.pmtiles" "$DATA/tiles/region.pmtiles"
break
fi
done
[ -s "$DATA/tiles/region.pmtiles" ] || { echo "Kartenkacheln konnten nicht geladen werden" >&2; exit 1; }
fi
if [ ! -f /etc/trainpulse.env ]; then
cat > /etc/trainpulse.env <<EOF
TRAINPULSE_DATA=$DATA
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT")
TRAINPULSE_RT_KEY=
EOF EOF
chmod 640 /etc/trainpulse.env exit 1
chgrp trainpulse /etc/trainpulse.env
fi fi
chown -R trainpulse:trainpulse "$DATA"
echo "==> Dienste" [ -f .env ] || cp .env.example .env
cp "$SRC"/deploy/trainpulse.service "$SRC"/deploy/trainpulse-update.service "$SRC"/deploy/trainpulse-update.timer /etc/systemd/system/ mkdir -p data
systemctl daemon-reload
systemctl enable --now trainpulse-update.timer echo "==> Image bauen und starten (das erste Mal dauert der Bau von pfaedle ein paar Minuten)"
systemctl enable trainpulse.service docker compose up -d --build
systemctl restart trainpulse.service
systemctl start --no-block trainpulse-update.service
ip=$(hostname -I | awk '{print $1}') ip=$(hostname -I | awk '{print $1}')
cat <<EOF cat <<EOF
Fertig. Die Karte läuft unter http://$ip:8080 Fertig. Die Karte läuft unter http://$ip:8080
Der erste Fahrplan wird jetzt im Hintergrund geladen und aufbereitet (ca. 10–15 min). Der Updater lädt jetzt Kartenkacheln (~1,2 GB) und den Fahrplan und berechnet die
Fortschritt: journalctl -fu trainpulse-update Streckenverläufe. Bis die ersten Züge erscheinen, dauert es ca. 15 Minuten.
Echtzeit: API-Key in /etc/trainpulse.env eintragen, dann systemctl restart trainpulse Fortschritt: docker compose logs -f updater
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
EOF EOF
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=TrainPulse: neuen Fahrplan holen und Streckenverläufe berechnen
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/python -m trainpulse.prepare
Nice=10
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=TrainPulse: stündlich auf neuen Fahrplan prüfen
[Timer]
OnBootSec=2min
OnCalendar=*-*-* *:20:00
Persistent=true
[Install]
WantedBy=timers.target
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=TrainPulse Webserver
After=network-online.target
Wants=network-online.target
[Service]
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080 --proxy-headers
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+2
View File
@@ -13,4 +13,6 @@ RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window)
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf") OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle") PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "5.8,45.7,10.6,47.9" # Schweiz mit etwas Rand
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)" USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+20
View File
@@ -201,6 +201,24 @@ def pack_shapes(d: Path) -> None:
src.unlink() src.unlink()
def ensure_tiles() -> None:
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen."""
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
if dest.exists() and dest.stat().st_size:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp")
for days in range(1, 8): # Protomaps baut täglich, ältere Builds werden gelöscht
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", "--maxzoom=15"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest)
return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(src: Path, meta: dict) -> None: def publish(src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta)) (src / "meta.json").write_text(json.dumps(meta))
dest = C.FEEDS_DIR / meta["feed_version"] dest = C.FEEDS_DIR / meta["feed_version"]
@@ -219,6 +237,8 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True) C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
if not args.zip:
ensure_tiles()
state_file = C.DATA_DIR / "state.json" state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {} state = json.loads(state_file.read_text()) if state_file.exists() else {}
work = C.DATA_DIR / "work" work = C.DATA_DIR / "work"