Files
pdf-ocr-hotfolder/docs/INSTALLATION.md
T
techadmin 465ff8873f fix: Ghostscript-Angebot log nicht mehr, fehlende Voraussetzungen dokumentiert (v0.7.1)
Befunde aus dem ersten echten Erstinstallations-Test auf frischen Debian-12-
und Debian-13-Containern. Der Weg selbst hat getragen (Basis-Install,
Instanz-Anlage, zweite Instanz, Update mit Rauchtest, Rollback) — diese
Stellen haben gelogen oder gefehlt:

- Das Ghostscript-Backports-Angebot auf Debian 12 war ein garantierter
  Leerlauf, der "aktualisiert ✓" meldete: bookworm-backports enthaelt gar
  kein ghostscript (am Paketindex verifiziert). Die Routine sucht jetzt den
  echten Kandidaten, vergleicht vorher/nachher und raeumt eine nur zur Probe
  angelegte Quelle wieder weg.
- Derselbe untaugliche Rat stand in der Preflight-Meldung, der
  pdfa_level-Warnung, config.example.toml und vier Doku-Dateien — ueberall
  ersetzt durch die echten Optionen.
- Mehrzeilige Log-Hinweise waren durch "echo -e" zerrissen und nicht
  kopierbar; log_* nutzt jetzt printf mit %s.
- pip-freeze.txt landete beim Rollback als /pip-freeze.txt im
  Wurzelverzeichnis, liegt jetzt unter opt/pdf-ocr-hotfolder/.
- git und sudo fehlen auf dem Proxmox-Debian-Template; "sudo ./install.sh"
  scheitert dort. Beide Wege dokumentiert, git als Voraussetzung ergaenzt,
  HTTPS-Clone als Normalfall.
- Rollback: systemctl start kann kein Glob. journald-Reparatur: Instanzen
  danach neu starten, sonst bleibt das Journal leer.

254 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 01:36:42 +02:00

994 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Installation
Erstinstallation und Anlage von Hotfolder-Instanzen mit `install.sh`.
Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Major-Upgrade](OS-UPGRADE.md)
---
## Voraussetzungen
| Punkt | Anforderung |
|-------|-------------|
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) |
| Rechte | `root` — als root direkt `./install.sh`, sonst `sudo ./install.sh` (s. [root oder sudo](#root-oder-sudo)) |
| `git` | zum Klonen des Repos — **nicht** vorinstalliert, s. [Pakete, die fehlen können](#pakete-die-auf-einem-frischen-system-fehlen-können) |
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
### Pakete, die auf einem frischen System fehlen können
Auf dem **Proxmox-Debian-Standard-Template** (12 **und** 13) fehlen zwei Dinge,
die jede Anleitung stillschweigend voraussetzt — am frisch angelegten Container
verifiziert:
| Fehlt | Folge | Abhilfe |
|-------|-------|---------|
| **`git`** | `git clone …` schlägt mit `git: command not found` fehl — und ohne Clone gibt es kein Repo, aus dem `install.sh` läuft | `apt install git` |
| **`sudo`** | der überall dokumentierte Aufruf `sudo ./install.sh` schlägt mit `sudo: command not found` fehl | entweder `apt install sudo`, oder **einfach als `root` ohne `sudo` arbeiten** |
```bash
apt update
apt install -y git # zwingend
apt install -y sudo # nur, wenn nicht als root gearbeitet wird
```
### root oder sudo
Installer und Updater brauchen **root-Rechte** — *wie* man dahin kommt, ist
ihnen gleich. Beide Wege sind gleichwertig:
```bash
# (a) man ist bereits root — im frischen Container der Normalfall
./install.sh
# (b) man arbeitet als normaler Benutzer und hat sudo
sudo ./install.sh
```
In dieser Doku steht durchgehend die Variante mit `sudo`, weil die meisten
Systeme es haben. **Wer als `root` arbeitet, lässt das `sudo` bei jedem Befehl
einfach weg** — das gilt für alle Kommandos in diesem und den übrigen
Dokumenten. Ein fehlendes `sudo` ist kein Grund, es nachzuinstallieren.
Die System-Pakete installiert der Installer selbst. Die Liste steht als
einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen
den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh`
sourct die Datei, und `update.sh` schneidet den Block zusätzlich noch einmal
aus der **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und
nicht die eventuell ältere Kopie unter `/opt/pdf-ocr-hotfolder/lib/`:
```
python3 python3-venv python3-pip
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
ghostscript qpdf unpaper pngquant icc-profiles-free
ca-certificates curl
```
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
nach (siehe [OCR-Sprachen](#ocr-sprachen)).
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt).
---
## Systemanforderungen
CPU und Platte sind unkritisch — **der Arbeitsspeicher ist es nicht.** OCR
rastert jede Seite in voller Auflösung ins RAM; der Spitzenbedarf hängt an der
Seitengröße, nicht an der Dateigröße der PDF.
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
`max_workers > 2` entsprechend mehr.
### Dateisystem: ext4, xfs oder zfs
Der Dienst wird **ausschließlich auf ext4, xfs oder zfs** betrieben. Andere
Dateisysteme sind nicht vorgesehen und werden nicht getestet.
**`incoming/` gehört auf ein lokales, inotify-fähiges Dateisystem — kein
CIFS/NFS-Mount.** Der Hotfolder hängt vollständig an inotify (`watchdog`
meldet `created`, `moved`, `closed`). inotify ist ein Mechanismus des *lokalen*
Kernels: er sieht nur Änderungen, die dieser Kernel selbst ausführt. Schreibt
ein anderer Rechner über SMB oder NFS in ein gemountetes Verzeichnis, geht das
am lokalen VFS vorbei und **es entsteht gar kein Event** — nicht verzögert,
nicht unzuverlässig, sondern grundsätzlich keines.
Die Folge ist heimtückisch, weil nichts kaputt aussieht: Der Dienst startet,
meldet `active (running)`, arbeitet den Bestand beim Start-Scan sauber ab — und
bemerkt danach **keine einzige neue Datei mehr**. Es gibt keinen Fehler, keine
Meldung, keine Mail. Erst wenn jemand die Instanz neu startet, wird der
inzwischen angesammelte Stapel auf einmal verarbeitet.
Richtiger Aufbau: der Scanner schreibt über SMB/NFS auf **den Rechner, auf dem
der Dienst läuft**, und `incoming/` liegt dort auf der lokalen Platte. Der
Netz-Export zeigt auf dieses lokale Verzeichnis, nicht umgekehrt. Für die
**Ausgabe** gilt die Einschränkung nicht — `outgoing/` und
`[upload.folder].target` dürfen auf einem Netz-Share liegen, dorthin wird nur
geschrieben.
### Was 2 GB tatsächlich tragen
Gemessen auf einem LXC-Container mit **2 GB RAM**, Debian 13 — eine
**A4-Seite in 300 dpi** mit `deskew = true`, `oversample = 300`, `jobs = 4`:
| Messwert | Ergebnis |
|----------|----------|
| Laufzeit | **19 s** |
| `MemoryPeak` des Dienstes (`systemctl show -p MemoryPeak`) | **380 MB** |
| `memory.peak` des ganzen Containers | **503 MB** |
| `oom_kill` in `/sys/fs/cgroup/memory.events` | **0** |
**Dieselbe Seite auf derselben Maschine mit 512 MB war genau der OOM-Kill
unten.** Der Spitzenbedarf liegt also bei rund einem halben Gigabyte für
*eine* Seite bei *einem* Worker — 2 GB lassen damit Luft für den
Default `max_workers = 2`, für das Betriebssystem und für einen zweiten
Hotfolder, sind aber keine üppige Reserve.
### Warum 512 MB nachweislich nicht reichen
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
Ghostscript 10.05.1:
| Vorgang | Ergebnis |
|---------|----------|
| eine einzelne **A4-Seite in 300 dpi**, `deskew = true` | cgroup-Limit gerissen, Dienst vom **OOM-Killer** beendet |
| dieselbe Verarbeitung mit einer kleineren Seite (850 × 1100 px) | läuft sauber durch, ca. 19 s |
Beleg aus dem `dmesg` des LXC-Hosts:
```
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201, task=python
total-vm:1168764kB, anon-rss:489676kB
```
Eine Seite, ein Worker — und schon knapp 500 MB anonymer Speicher. 512 MB sind
damit für 300-dpi-Scans keine knappe, sondern eine unzureichende Dimensionierung.
### RAM ↔ `max_workers` × `jobs` × Auflösung
Der Spitzenbedarf multipliziert sich über drei Config-Werte aus
[`[ocr]`](#ocr):
| Key | Default | Wirkung auf den Speicher |
|-----|---------|--------------------------|
| `max_workers` | `2` | **so viele PDFs gleichzeitig** — jede mit eigenem Seitenpuffer. Der direkte Multiplikator |
| `jobs` | `4` | Threads **innerhalb** einer PDF; mehrere Seiten gleichzeitig im Speicher |
| `oversample` | `300` | Auflösung gerasterter Seiten — der Bedarf wächst quadratisch mit der dpi |
Der Default `max_workers = 2` erlaubt also, dass **zwei** solcher Seiten
parallel verarbeitet werden. Wer knapp dimensioniert, zieht zuerst
`max_workers` auf `1` herunter, danach `jobs`. `oversample` unter 300 zu
drücken, spart zwar Speicher, kostet aber Erkennungsqualität — das ist der
letzte Hebel, nicht der erste.
### Wie sich ein OOM-Kill äußert
Von außen sieht ein OOM-Kill wie ein Anwendungsfehler aus — er ist keiner:
- Der Dienst ist **weg** bzw. wurde von systemd neu gestartet
(`Restart=on-failure`); `systemctl show -p NRestarts` steigt.
- Im journal bricht die Verarbeitung **mitten in der Datei** ab, ohne
Python-Traceback und ohne `ERROR`-Zeile aus dem Tool.
- Die betroffene PDF bleibt in `working/` liegen.
### Wie man ihn nachweist
**Im Container:**
```bash
cat /sys/fs/cgroup/memory.events
# oom_kill 1 <- alles über 0 ist ein Treffer
```
**Auf dem LXC-Host** (im Container zeigt `dmesg` diese Zeilen nicht):
```bash
dmesg -T | grep -i oom-kill
```
Diese Prüfung gehört an den **Anfang** der Fehlersuche, wenn Dateien
unerklärlich in `working/` liegen bleiben: ohne sie sucht man den Fehler in
ocrmypdf, Tesseract oder der Config, wo keiner ist.
### Datenverlust ist abgefangen, der OOM bleibt
Seit **v0.6.0** greift der Dienst beim nächsten Start auf, was in `working/`
liegen geblieben ist (siehe [UPDATE.md](UPDATE.md#wiederaufnahme-aus-working)).
Eine vom OOM-Killer unterbrochene Datei geht also nicht verloren. Behoben ist
damit aber nur die Folge: bei unveränderter Dimensionierung läuft dieselbe Datei
nach dem Neustart erneut in denselben OOM — bis `max_workers` sinkt oder das
System mehr RAM bekommt.
---
## Installation
```bash
apt update && apt install -y git # fehlt im Proxmox-Standard-Template
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
sudo ./install.sh # als root: ./install.sh
```
#### Welche Clone-URL?
| Variante | URL | Wann |
|----------|-----|------|
| **HTTPS** (Normalfall) | `https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git` | braucht **keine** Credentials und funktioniert auf einem frischen Server sofort — der Weg, der im Installations-Test benutzt wurde |
| **SSH** | `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git` | nur sinnvoll, wenn auf dem Zielsystem ein **Deploy-Key** hinterlegt ist (z.B. weil auch gepusht werden soll) |
> ⚠️ **Der SSH-User bei `gitea.sonith.de` heißt `gitea`, nicht `git`.**
> `git@gitea.sonith.de:…` ist der Default anderer Git-Hoster und hier **falsch**
> — der Clone scheitert dann mit `Permission denied (publickey)`.
`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent —
jeder weitere Aufruf überspringt, was schon steht.
### So sieht ein Erstlauf aus
Verifiziert auf frischen Debian-12- und Debian-13-Containern. Die Reihenfolge
der Abfragen:
1. **LXC-Drop-in** — nur im Container: „systemd-Hardening-Drop-in für LXC
installieren?" → **ja**, sonst scheitert der Start mit `226/NAMESPACE`
([warum](#lxccontainer-error-226namespace)).
2. **Ghostscript-Hinweis** — nur auf Debian 12 mit betroffener Version. Kein
Abbruch: mit dem Default `pdfa_level = ""` ist die Installation
unproblematisch ([Hintergrund](#ghostscript-bug-auf-debian-12)).
3. **journald-Warnung** — nur, wenn `systemd-journald` nicht läuft. Typisch für
Debian 13 in LXC auf Proxmox. Hier **abbrechen**, journald reparieren und neu
anfangen — sonst hat der Dienst kein Log
([Abhilfe](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)).
4. Dann die **fünf Fragen pro Instanz**: Instanz-Name → Basis-Pfad →
Service-User → OCR-Sprachen → Original-Behandlung (Archiv/löschen/liegen
lassen). Im Detail: [Die Abfragen pro Instanz](#die-abfragen-pro-instanz).
5. Zum Schluss `Weitere Instanz anlegen? [j/N]:` — hier entsteht die zweite
Instanz, ohne dass irgendetwas am Basis-Install noch einmal angefasst wird.
Danach läuft `pdf-ocr-hotfolder@<instanz>.service`. Gegenprobe: eine PDF nach
`incoming/` kopieren und `journalctl -u pdf-ocr-hotfolder@<instanz> -f`
mitlesen.
### Basis-Install vs. Instanz-Anlage
Der Installer unterscheidet zwei Ebenen:
| Ebene | Wann | Was passiert |
|-------|------|--------------|
| **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung (inkl. [journald-Prüfung](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)), Default-User `pdfocr`, Code **und `lib/`** nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit |
| **Instanz-Anlage** | bei jedem Lauf, beliebig oft | Abfragen pro Instanz, Datenverzeichnisse, `<instanz>.toml`, optionales User-Drop-in, `enable --now` |
Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum
System-Python (typisch nach einem Distributions-Upgrade), läuft der Basis-Install
**zur Reparatur erneut**: die alte venv wird nach `venv.old-<timestamp>`
weggesichert und neu gebaut. Für den geplanten Weg über ein Debian-Major-Upgrade
ist aber `update.sh --rebuild-venv` gedacht, siehe [OS-UPGRADE.md](OS-UPGRADE.md).
Beim Erstlauf ist mindestens **eine** Instanz Pflicht. Danach fragt der
Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`.
---
## Die Abfragen pro Instanz
`create_instance()` stellt fünf Fragen, in der Reihenfolge der folgenden
Abschnitte: Instanz-Name, Basis-Pfad, Service-User, OCR-Sprachen,
Original-Behandlung. Alle Antworten gelten **nur für diese Instanz** — nichts
davon ist global.
### Instanz-Name
```
Instanz-Name (nur a-z, 0-9, -):
```
Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix
(`pdf-ocr-hotfolder@<name>.service`) und zum Config-Dateinamen
(`/etc/pdf-ocr-hotfolder/<name>.toml`). Existiert die Config schon, bricht die
Anlage ab — ein versehentliches Überschreiben gibt es nicht.
### Basis-Pfad für die Daten
```
Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
```
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
den Service-User gechownt.
### Service-User
```
Service-User [pdfocr]:
```
- **Existiert der User** (lokal oder als **AD-User via SSSD/Winbind**), wird er
übernommen; die primäre Gruppe ermittelt der Installer per `id -gn`.
- **Existiert er nicht**, bietet der Installer an, ihn lokal als System-User
anzulegen. Wird das abgelehnt, bricht die Instanz-Anlage ab — der User muss
dann erst über AD/SSSD bereitstehen.
- Ist der gewählte User **nicht** `pdfocr`, legt der Installer das Drop-in
`/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` mit
`User=`/`Group=` an.
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
gesetzt — das läuft transparent.
### OCR-Sprachen
```
Tesseract-Sprachen [deu+eng]:
```
Die Sprachen werden bewusst **je Instanz** abgefragt: ein Hotfolder
`buchhaltung` sieht nur deutsche Belege, ein Hotfolder `export`
internationale Korrespondenz.
```toml
# /etc/pdf-ocr-hotfolder/buchhaltung.toml
languages = "deu"
# /etc/pdf-ocr-hotfolder/export.toml
languages = "deu+eng+fra"
```
**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet
Laufzeit *und* Erkennungsqualität: Tesseract wägt mehr Modelle gegeneinander ab
und verwechselt dabei Wörter, die in einer Sprache eindeutig wären.
`deu+eng+fra` auf reinen Deutsch-Scans ist kein Sicherheitsnetz, sondern ein
Rückschritt.
Was der Installer damit macht:
1. **Format prüfen** — Sprachcodes mit `+` verbunden
(`^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`), also `deu`, `deu+eng`, `chi_sim+eng`.
Bei Unsinn wird erneut gefragt, nicht abgebrochen.
2. **Jeden Code gegen `tesseract --list-langs` prüfen.** Fehlt eine
Sprachdatei, bietet er das passende apt-Paket an: `tesseract-ocr-<code>`,
Unterstrich wird zum Bindestrich (`chi_sim` → `tesseract-ocr-chi-sim`).
3. **Lehnt man ab oder scheitert die Installation**, warnt er, dass OCR mit
dieser Sprache **bei jeder Datei** scheitern würde, und fragt die Sprachen
erneut ab — die fehlende Sprache kann man dann einfach weglassen.
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
Eingabe unverändert übernommen.
### Original archivieren?
```
Original nach erfolgreichem OCR archivieren? [j/N]:
```
- **Nein** (Default) → `original_on_success = "delete"`, das Original wird nach
erfolgreichem OCR gelöscht.
- **Ja** → `original_on_success = "archive"`, danach:
```
Archiv-Verzeichnis [<basis>/archive]:
```
Der Pfad muss **absolut** sein und darf **nicht** `incoming/`, `outgoing/`,
`working/` oder `error/` der Instanz sein — im Eingang würde das Original sonst
endlos neu aufgegriffen, in den übrigen kollidiert es mit der Verarbeitung. Das
Verzeichnis wird angelegt und auf den Service-User gechownt; innerhalb des
Basis-Pfads erledigt das bestehende `chown -R` das mit, ein Archiv **außerhalb**
bekommt ein eigenes.
### Was danach passiert
Die Instanz-Config entsteht per `sed` aus `config.example.toml`. Substituiert
werden die vier `[paths]`-Zeilen sowie `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir`. Anschließend liest
der Installer diese drei Keys aus der erzeugten Datei zurück und vergleicht sie
mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und
Archiv-Verzeichnis. Sonst gibt es eine Warnung mit der Bitte, die Config von
Hand nachzuziehen.
Die Config bekommt `chmod 640` und `chown root:<service-gruppe>`, das
Verzeichnis `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr` — in den
Instanz-Configs stehen **Klartext-Passwörter** für SMTP, Nextcloud und SFTP.
Zum Schluss: `daemon-reload` und `systemctl enable --now
pdf-ocr-hotfolder@<instanz>.service`.
### Test
```bash
cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -f
```
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/` der Instanz.
---
## Multi-Instanz-Betrieb
Das Tool arbeitet komplett instanzbasiert über die systemd-Template-Unit
`pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat eigene Config, eigene
Datenverzeichnisse, eigene Unit, optional eigenen Service-User — und eigene
OCR-Sprachen und Original-Behandlung.
```bash
sudo ./install.sh # legt z.B. kunde-a, kunde-b, buchhaltung an
systemctl status 'pdf-ocr-hotfolder@*'
journalctl -u pdf-ocr-hotfolder@kunde-a -f
```
Der Code unter `/opt/pdf-ocr-hotfolder/` (inkl. venv) ist für **alle** Instanzen
gemeinsam. Ein Update trifft damit immer alle Instanzen auf einmal — siehe
[UPDATE.md](UPDATE.md).
Das vollständige Verzeichnis-Layout steht im
[README](../README.md#verzeichnisse).
---
## LXC/Container: Error 226/NAMESPACE
In LXC-Containern schlagen die systemd-Hardening-Optionen der Unit
(`PrivateTmp`, `ProtectSystem`, `ProtectKernelTunables`, …) fehl; systemd
quittiert das mit `Error 226/NAMESPACE`.
Der Installer erkennt Container über `systemd-detect-virt --container` und
bietet das Drop-in automatisch an. Manuell:
```bash
sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \
/etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo systemctl daemon-reload
sudo systemctl restart 'pdf-ocr-hotfolder@*'
```
Das Drop-in setzt alle betroffenen Hardening-Schalter auf `false`. Es liegt auf
Template-Ebene (`pdf-ocr-hotfolder@.service.d/`) und gilt damit für alle
Instanzen. Ist es installiert, zieht `update.sh` es bei jedem Update aus dem
Repo nach, damit ein neu ergänzter Hardening-Schalter nicht alle
Container-Instanzen reißt.
---
## Ghostscript-Bug auf Debian 12
Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** —
enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf
verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern.
### Wann genau ocrmypdf abbricht
Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py`
(`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der
ocrmypdf-Version:
| ocrmypdf | Die Prüfung greift bei | Heißt für uns |
|----------|------------------------|---------------|
| **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default |
| **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch |
> ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur
> zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf
> `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` —
> bei grünem `systemctl status` und „Preflight ok".
> `requirements.txt` pinnt deshalb 17.x.
### Was das Tool dagegen tut
- `pdfa_level = ""` ist der Default (kein PDF/A-Output).
- `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede
Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der
gepinnten Pakete deshalb ausdrücklich aus.
- Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die
Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`,
`pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch
führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei
einzeln scheitern zu lassen.
- `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt
ocrmypdf- und Ghostscript-Version an (siehe
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
- Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update
eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt.
### Abhilfe
> 🚫 **Es gibt auf Debian 12 kein neueres Ghostscript.** `bookworm-backports`
> führt **kein** Ghostscript-Paket — am Paketindex geprüft
> (`bookworm-backports/main/binary-amd64`: 2606 Pakete, `ghostscript` nicht
> darunter, `systemd`/`golang-go`/`linux-image-amd64` schon). Ältere Fassungen
> dieser Anleitung und frühere Versionen des Installers haben genau das
> empfohlen — der Rat konnte nie funktionieren.
>
> **Planungsaussage:** Wer auf Debian 12 **PDF/A in der schnellen Betriebsart**
> (`skip_text = true`) braucht, hat dort **keinen Weg** — weder über Backports
> noch sonst. Es bleibt: PDF/A aufgeben, `skip_text` aufgeben (Weg 2, kostet
> Laufzeit) oder Debian 13. **Das gehört vor die Installation**, nicht in den
> Moment, in dem der Preflight abbricht.
**Weg 1 — `pdfa_level = ""` lassen** (Default, empfohlen). Ohne PDF/A-Ausgabe
fasst ocrmypdf ≥ 17 Ghostscript gar nicht an; die betroffene Version ist dann
völlig unproblematisch. Der Preis ist PDF/A — das Ergebnis ist eine normale
durchsuchbare PDF. Für die übliche Anwendung (Scan wird durchsuchbar) reicht
das.
**Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt
statt übersprungen, und die Bedingung greift nicht mehr — PDF/A ist damit auch
auf Debian 12 möglich. Das kostet Laufzeit bei PDFs, die bereits eine Textebene
haben, und OCRt sie ein zweites Mal.
**Weg 3 — Distribution mit neuerem Ghostscript.** **Debian 13 liefert
Ghostscript 10.05.1** und ist vom Bug nicht betroffen; dort ist PDF/A zusammen
mit `skip_text = true` ohne Einschränkung nutzbar. Wer PDF/A verbindlich
braucht, installiert von vornherein auf Debian 13.
| Ich brauche … | Debian 12 | Debian 13 |
|---------------|-----------|-----------|
| durchsuchbare PDF, kein PDF/A | ✅ Default (`pdfa_level = ""`) | ✅ |
| PDF/A **und** `skip_text = true` | ❌ kein Weg | ✅ |
| PDF/A mit `skip_text = false` | ✅ (langsamer) | ✅ |
---
## Instanz manuell löschen
Der Installer legt Instanzen an, löscht aber keine. Von Hand:
```bash
sudo systemctl disable --now pdf-ocr-hotfolder@<name>
sudo rm /etc/pdf-ocr-hotfolder/<name>.toml
sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
sudo systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
```
Das Datenverzeichnis bleibt **bewusst** liegen: dort können noch unverarbeitete
PDFs in `incoming/`, Fehlerfälle in `error/` oder Originale im Archiv liegen.
Erst hineinsehen, dann löschen.
Solange die Config unter `/etc/pdf-ocr-hotfolder/` liegt, zählt `update.sh` die
Instanz weiter mit — auch wenn sie gestoppt ist.
---
## Konfigurationsreferenz
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](../config.example.toml).
Jede Instanz hat ihre eigene Kopie unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
Unbekannte Keys werden beim Laden ignoriert, aber **gemeldet** — beim
Dienststart im Log und von `--check-config`. Ein Tippfehler wie
`[ocr].langauges` fällt damit auf.
### `[paths]` — Pflicht
| Key | Bedeutung |
|-----|-----------|
| `incoming` | Eingang, hier schreibt der Scanner hinein |
| `outgoing` | Ausgang, fertige OCR-PDFs |
| `working` | Arbeitsverzeichnis während der Verarbeitung |
| `error` | fehlgeschlagene PDFs |
Fehlt die Sektion oder einer der vier Einträge, gibt es eine deutsche
`ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst
startet nicht.
**Alle vier Pfade müssen absolut sein.** Ein relativer Pfad wird gegen das
Arbeitsverzeichnis des *Prozesses* aufgelöst, bei der Unit also gegen
`WorkingDirectory=/opt/pdf-ocr-hotfolder` — **nicht** gegen das Verzeichnis, in
dem die Config liegt. `incoming = "in"` legte damit still
`/opt/pdf-ocr-hotfolder/in` an: der Scanner schreibt woanders hin als der
Dienst schaut, und niemand sieht einen Fehler. Seit 0.7.0 ist das ein
Config-Fehler mit **Exit 2**. Dieselbe Regel gilt für
[`[output].archive_dir`](#output) und
[`[upload.folder].target`](#uploadfolder--uploadnextcloud--uploadsftp);
`install.sh` erzeugt ohnehin nur absolute Pfade.
`incoming` muss außerdem auf einem **lokalen** Dateisystem liegen — siehe
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
### `[ocr]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `languages` | `"deu+eng"` | Tesseract-Sprachen; der Installer fragt sie pro Instanz ab |
| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt |
| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen |
| `oversample` | `300` | Auflösung für gerasterte Seiten |
| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 |
| `deskew` | `true` | schiefe Scans begradigen |
| `clean` | `false` | Hintergrund säubern (unpaper) |
| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden |
| `timeout` | `300` | max. Sekunden, die Tesseract **pro Seite** laufen darf; `0` = kein eigenes Limit |
**`timeout` ist ein Seiten-Timeout, kein Gesamt-Timeout.** Der Wert geht als
`tesseract_timeout` an ocrmypdf; ein Dokument-Timeout kennt ocrmypdf nicht. Wer
noch den alten Default `1800` aus einer Config vor 0.4.0 stehen hat, gibt
Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Läuft eine Seite in den
Timeout, landet sie ohne Textebene im Ergebnis, die übrigen Seiten laufen
weiter. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu
überspringen**, deshalb wird `0` (oder negativ) gar nicht erst übergeben und der
ocrmypdf-Default greift. Siehe auch
[Config-Drift](UPDATE.md#config-drift-nach-einem-update).
### `[output]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert |
| `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` |
| `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab |
| `archive_dir` | `""` | absoluter Pfad (relativ wird abgelehnt), **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix |
Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum
Abbruch mit **Exit 2**, nicht erst bei der ersten Datei.
**Kollisionen überschreiben nichts.** Liegt im Ziel bereits eine Datei
desselben Namens, wird die neue mit Zeitstempel danebengelegt
(`scan.pdf` → `scan_20260923-081500.pdf`; bei zwei Dateien innerhalb derselben
Sekunde zusätzlich mit Zähler), und es gibt eine Warnung im Journal. Das gilt
seit 0.7.0 einheitlich für **`outgoing/`, das Archiv, `error/` und den
Ordner-Upload** — vorher ersetzte der zweite Durchlauf das Ergebnis des ersten
kommentarlos. Die E-Mail-Benachrichtigung und die Upload-Ziele nennen den
tatsächlich geschriebenen Namen.
**Scheitert das Entsorgen des Originals** (Platte voll, Verzeichnis
read-only), gilt der Durchlauf trotzdem als Erfolg: das fertige PDF liegt
bereits in `outgoing/` und wird normal ausgeliefert. Es gibt aber eine
`ERROR`-Zeile im Journal, und die Mail geht als **„OK mit Warnung"** raus —
auch bei `[notify.email].on = "errors"`. Das Original bleibt dann in
`working/` liegen und wird beim nächsten Start **erneut** durch das OCR
geschickt; es gehört von Hand aufgeräumt und die Ursache behoben.
### `[verapdf]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI |
| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary, oder ein nackter Name, der im `PATH` gesucht wird |
| `flavour` | `"1b"` | PDF/A-Flavour |
veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die
Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach
`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also
erhalten).
**Mit `enabled = true` wird `binary` im Preflight geprüft** (seit 0.7.0). Zeigt
der Pfad nicht auf ein vorhandenes, ausführbares Programm, startet der Dienst
gar nicht erst (**Exit 2**), und `--check-config` meldet denselben Fehler.
`--check-config` zeigt Binary und Flavour außerdem in der Übersicht an.
> ⚠️ **Warum das eine harte Sperre ist.** Bis 0.6.3 war ein Tippfehler in
> `binary` der gefährlichste Fehler des ganzen Dienstes: `run_verapdf()` fand
> das Programm für **jede** Datei nicht, wertete das als „nicht konform",
> schob das OCR-Ergebnis nach `error/` — und entsorgte das Original laut
> `original_on_success`, beim Default `delete` also die Vorlage. Scan für Scan
> verschwanden so die Originale, während die Unit als `active (running)`
> dastand.
**Störung ist kein FAIL.** Lässt sich veraPDF im laufenden Betrieb nicht mehr
befragen — Programm verschwunden, JVM startet nicht, Timeout (300 s pro Datei),
oder die Ausgabe enthält weder `PASS` noch `FAIL` —, ist das **kein Urteil über
die PDF**. In diesem Fall wandern **Original und OCR-Ergebnis** nach `error/`,
und das Original wird **weder gelöscht noch archiviert**, unabhängig von
`original_on_success`. Im Journal steht die Ursache samt Hinweis auf
`--check-config`.
### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]`
Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige
PDF einfach in `outgoing/` liegen.
| Sektion | Keys |
|---------|------|
| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op; sonst **absoluter** Pfad (relativ wird abgelehnt, Exit 2) |
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
Schlägt mindestens ein Ziel fehl, zählt das als Fehler und die Mail geht als
FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR
selbst war ja erfolgreich.
Liegt im `target` von `[upload.folder]` schon eine gleichnamige Datei, wird sie
seit 0.7.0 **nicht mehr ersetzt**, sondern die Kopie mit Zeitstempel
danebengelegt (samt Warnung) — dieselbe Regel wie in [`[output]`](#output).
### `[notify.email]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `enabled` | `false` | E-Mail-Benachrichtigung an/aus |
| `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `use_starttls` | — | SMTP-Zugang |
| `from_addr` | — | Absender |
| `to_addrs` | `[]` | Empfängerliste |
| `on` | `"errors"` | `always` \| `errors` \| `never` |
### `[logging]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `level` | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` |
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal.
---
## Exit-Codes
Der Dienst und die CLI benutzen vier Codes. `systemctl status` und
`journalctl` zeigen sie als `status=<n>`.
| Exit | Bedeutung | Startet systemd neu? | Was zu tun ist |
|------|-----------|----------------------|----------------|
| **0** | regulärer Stopp (SIGTERM/SIGINT); bei `--once`: alles verarbeitet, auch „nichts da"; bei `--check-config`: Config sauber | — | nichts |
| **1** | nur im Einmal-Betrieb: mindestens eine PDF ist fehlgeschlagen. Bei `--check-config`: Config nutzbar, aber mit **Warnungen** | — | `error/` ansehen bzw. Warnungen nachziehen |
| **2** | **Config- oder Preflight-Fehler** — kaputtes/unlesbares TOML, fehlender Pflicht-Key, relativer Pfad, ungültige `[output]`-Werte, fehlendes `tesseract`/`gs`, betroffene Ghostscript-Version, nicht aufrufbares veraPDF | **nein** — `RestartPreventExitStatus=2` in der Unit | Config korrigieren, mit `--check-config` gegenprüfen, dann `systemctl start` |
| **3** | **Der Verzeichnis-Watch ist gestorben** — es würden keine neuen Dateien mehr erkannt | **ja**, und genau darum geht es | meist nichts; häuft es sich, `fs.inotify.max_user_watches` und den Mount von `incoming/` prüfen |
**Zu Exit 2:** Ein Neustart heilt einen Config-Fehler nicht. Ohne
`RestartPreventExitStatus=2` startete `Restart=on-failure` die Instanz endlos
im 5-Sekunden-Takt neu (das Start-Rate-Limit greift bei `RestartSec=5` nie).
Seit 0.7.0 bleibt sie stattdessen sichtbar `failed` stehen — das ist gewollt
und soll beim Nachsehen auffallen.
**Zu Exit 3:** Stirbt der watchdog-Observer im Betrieb (erschöpftes
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr —
für einen Hotfolder der schlechteste denkbare Zustand. Der Dienst prüft den
Observer jetzt sekündlich mit, loggt eine `ERROR`-Zeile mit den möglichen
Ursachen und beendet sich mit 3, damit systemd ihn neu startet und der Watch
neu aufgesetzt wird. Ein einzelnes Vorkommnis ist damit selbstheilend; ein
steigendes `systemctl show -p NRestarts` ist der Hinweis, dass man nachsehen
sollte.
---
## Troubleshooting
### Tesseract findet die Sprache nicht
```bash
sudo apt install tesseract-ocr-deu tesseract-ocr-eng
```
Danach `[ocr].languages` prüfen. Der Installer nimmt einem das beim Anlegen
einer Instanz ab, ein nachträglich in die Config geschriebener Sprachcode wird
aber nicht geprüft — `--check-config` zeigt die eingestellten Sprachen an.
### "PriorOcrFoundError"
ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config
setzen (Default).
### Berechtigungsprobleme bei AD-User
Der Service-User braucht **rw** auf alle vier Verzeichnisse der Instanz (und auf
das Archiv, falls konfiguriert):
```bash
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/<instanz>
```
### veraPDF: Dienst startet nicht / Dateien landen in `error/`
Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit
**Exit 2** nicht mehr, nachdem vorher „alles lief", ist das die gute Nachricht:
der Pfad war schon vorher falsch, nur hat es bisher niemand gemerkt. Vorher
wurde jede PDF als ungültig gewertet und das Original laut
`original_on_success` entsorgt.
```bash
ls -l /opt/verapdf/verapdf # vorhanden? ausführbar (chmod +x)?
```
Korrigieren — oder, wenn die Validierung nicht zwingend gebraucht wird,
`[verapdf].enabled = false` setzen. Landen **Original und OCR-Ergebnis
gemeinsam** in `error/`, war veraPDF im laufenden Betrieb nicht mehr
ansprechbar; das Original ist dann unangetastet, siehe
[`[verapdf]`](#verapdf).
### Dienst startet nicht (Exit 2)
Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes).
Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet.
Die Ursache steht im journal und ausführlicher in:
```bash
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
Typische Fälle: kaputtes TOML (die Meldung nennt Zeile und Spalte, sofern der
Interpreter sie liefert), ein relativer Pfad in `[paths]`,
`[output].archive_dir` oder `[upload.folder].target`, ein leeres `archive_dir`
bei `original_on_success = "archive"`, ein nicht aufrufbares veraPDF oder eine
betroffene Ghostscript-Version.
### Dienst läuft, verarbeitet aber nichts mehr
`systemctl status` sagt `active (running)`, in `incoming/` stapeln sich die
PDFs, im Journal passiert nichts. Drei Ursachen, in dieser Reihenfolge prüfen:
1. **`incoming/` liegt auf einem CIFS/NFS-Mount.** Dann liefert inotify
grundsätzlich keine Events — der Dienst verarbeitet nur noch beim Start.
`findmnt -T /var/lib/pdf-ocr-hotfolder/<instanz>/incoming` zeigt den Typ;
Hintergrund und richtiger Aufbau unter
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
2. **Der Verzeichnis-Watch ist gestorben.** Seit 0.7.0 fällt das auf: der
Dienst beendet sich mit [Exit 3](#exit-codes) und systemd startet ihn neu.
Im Journal steht die `ERROR`-Zeile, `systemctl show -p NRestarts` steigt.
Häuft sich das, ist meist das inotify-Limit erschöpft:
```bash
cat /proc/sys/fs/inotify/max_user_watches
```
3. **Es sind gar keine PDFs.** Dateien ohne `.pdf`-Endung werden ignoriert.
Beim Start-Scan meldet der Dienst sie seit 0.7.0 als Sammelzeile mit Anzahl
und bis zu drei Beispielnamen:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> | grep 'ohne .pdf-Endung'
```
### Dienst startet nicht (203/EXEC)
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
### Keine Logs: "No journal files were found"
**Symptom:** `journalctl -u pdf-ocr-hotfolder@<instanz>` bleibt leer oder meldet
`No journal files were found.` — auch dann, wenn der Dienst nachweislich läuft
und Dateien verarbeitet.
**Erste Prüfung:**
```bash
systemctl status systemd-journald
```
Ist `systemd-journald.service` selbst `failed` (beobachtet mit
`status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben
werden könnte.
Läuft journald dagegen `active (running)` und das Journal der Instanz ist
trotzdem leer (`-- No entries --`), ist meist journald **erst nach dem Dienst**
wieder hochgekommen: die Startmeldungen hatten in der Zwischenzeit kein Ziel und
sind weg. `sudo systemctl restart 'pdf-ocr-hotfolder@*'` schreibt sie neu —
siehe die Warnung im
[nächsten Abschnitt](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials).
**Einordnung.** Der Dienst loggt seit v0.4.1 **ausschließlich** nach journald —
es gibt bewusst kein eigenes Logfile und kein Logverzeichnis. Ein kaputtes
journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der
Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern
systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten
Abschnitt. Auf Debian 12 tritt sie nicht auf.
`install.sh` warnt beim Erstinstall in einem Container von sich aus, wenn
`systemd-journald` nicht läuft, nennt den Drop-in-Befehl und fragt, ob
fortgefahren werden soll.
**Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
journal vorbei.
```bash
sudo systemctl stop pdf-ocr-hotfolder@<instanz>
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
--config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
`/opt/pdf-ocr-hotfolder` kopiert und nur über das Arbeitsverzeichnis gefunden
(ohne `cd` gibt es `No module named pdf_ocr_hotfolder`, v0.6.2). Beenden mit
`Strg+C`, danach `sudo systemctl start pdf-ocr-hotfolder@<instanz>`. Wer nur den
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
[Manueller Lauf](#manueller-lauf-one-shot)).
### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS)
Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht
nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern
alles, was auf dem Container-journal aufsetzt.
**Symptom im Container:**
```bash
systemctl status systemd-journald
# ● systemd-journald.service - Journal Service
# Active: failed
# Process: ... (code=exited, status=243/CREDENTIALS)
# Main PID exited, status=243/CREDENTIALS
journalctl -u pdf-ocr-hotfolder@<instanz>
# No journal files were found.
```
**Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die
journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials
startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür.
Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des
**Hosts** steht dazu:
```
apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>" name="/dev/" comm="(sd-mkdcreds)"
```
Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und
ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser,
nicht der Container an sich.
**Dieselbe Ursache legt weitere Units lahm** — beobachtet bei
`systemd-logind`, `systemd-networkd`, `console-getty` und
`systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor
sich; ein Blick auf `systemctl --failed` lohnt sich.
**Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird
per Drop-in auf leer gesetzt und damit abgeschaltet:
```bash
sudo mkdir -p /etc/systemd/system/systemd-journald.service.d
printf '[Service]\nImportCredential=\n' | \
sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf
sudo systemctl daemon-reload
sudo systemctl restart systemd-journald
sudo journalctl --flush
```
Danach `systemctl status systemd-journald` gegenprüfen — muss `active
(running)` sein. Für die anderen betroffenen Units gilt dasselbe Muster mit
deren Unit-Namen.
> ⚠️ **Jetzt die Instanzen einmal neu starten — sonst steht man vor einem
> leeren Journal.** Wurde der Dienst gestartet, **während** journald tot war,
> sind seine Startmeldungen unwiederbringlich weg: sie hatten kein Ziel. Nach
> der Reparatur meldet `journalctl -u pdf-ocr-hotfolder@<instanz>` dann
> `-- No entries --` — und das sieht exakt so aus wie ein gescheiterter
> Reparaturversuch, obwohl journald längst wieder läuft. Der Neustart schreibt
> die Startmeldungen neu ins frische Journal:
>
> ```bash
> sudo systemctl restart 'pdf-ocr-hotfolder@*'
> journalctl -u pdf-ocr-hotfolder@<instanz> -n 20
> ```
>
> Erst wenn hier die Startmeldungen stehen, ist die Reparatur belegt. (`restart`
> kann das Glob, weil die Units geladen sind — `start` nicht, siehe
> [UPDATE.md](UPDATE.md#rollback).)
> **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im
> Container. Richtig behoben wird es auf dem Proxmox-Host: Update von
> `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln —
> oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der
> Container-Config, was die AppArmor-Isolation dieses Containers allerdings
> komplett aufgibt.
### Dienst bricht mitten in der Verarbeitung weg
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
immer der OOM-Killer, kein Anwendungsfehler. Nachweis und Dimensionierung unter
[Systemanforderungen](#wie-sich-ein-oom-kill-äußert).
---
## Manueller Lauf (One-Shot)
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch
Dateien auf, die in `working/` liegen geblieben sind:
```bash
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
```
Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine
Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. Exit 3 gibt es hier
nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes).