fix(paperless-worker): celery command bypassed manage.py + missing shared scratch dir

Two config bugs found on the already-deployed split-host OCR worker
(module 3): (1) `command: celery ...` was routed through manage.py by
the image entrypoint because it didn't start with "/" — fixed with an
absolute gosu+celery path. (2) SCRATCH_DIR (/tmp/paperless) was not
shared over NFS like data/media/consume, so tasks picked up by
worker@SOLARIA instead of worker@PIHA failed with "File not found" —
fixed by adding a paperless_scratch NFS volume/bind mount on both
sides.

Verified live on PIHA + SOLARIA: test PDFs dropped into consume/ were
split across both workers, the SOLARIA-picked task completed OCR with
zero File not found errors, test documents cleaned up afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
oskar 2026-07-12 20:51:04 +02:00
parent 52e412dba3
commit 196a99ffef
4 changed files with 158 additions and 9 deletions

View file

@ -602,3 +602,44 @@ już nie zna → błąd → `set -e` przerywa deploy w połowie.
recreate (`docker compose down --remove-orphans` albo jawne usunięcie hash-prefixed recreate (`docker compose down --remove-orphans` albo jawne usunięcie hash-prefixed
kontenerów), ewentualnie jawny `-p` (COMPOSE_PROJECT_NAME) żeby nazwy były deterministyczne. kontenerów), ewentualnie jawny `-p` (COMPOSE_PROJECT_NAME) żeby nazwy były deterministyczne.
Deploy mózgu NIE MOŻE zostawiać control-plane w stanie zero-kontenerów. Deploy mózgu NIE MOŻE zostawiać control-plane w stanie zero-kontenerów.
## Fix: paperless-worker@SOLARIA — dwa bugi configu, naprawione i zweryfikowane na żywo (2026-07-12)
**Kontekst.** Moduł 3 (`services/paperless-worker/`, split-host OCR worker) był
zdeployowany i brał zadania z kolejki, ale miał dwa bugi w compose:
1. **`command: celery ...` nie odpalał celery.** Obraz paperless-ngx
(`/sbin/docker-entrypoint.sh`) routuje każdy argument NIE zaczynający się
od `/` do `manage.py` — więc `celery` lądował jako nieznana subkomenda
Django, nie jako program. Fix: `command: /usr/sbin/gosu paperless
/usr/local/bin/celery ...` (ścieżka absolutna wchodzi w gałąź `exec "$@"`
entrypointu; `gosu paperless` z przodu bo ta gałąź nie dostaje automatycznego
gosu, inaczej proces poszedłby jako root i zepsuł właściciela plików na NFS).
2. **Brak współdzielonego `SCRATCH_DIR`.** Paperless@PIHA staguje wgrywany
plik w `/tmp/paperless` (domyślny `SCRATCH_DIR`) i niesie tę ścieżkę w
payloadzie zadania celery jako ścieżkę absolutną. Worker@SOLARIA miał
własny, lokalny `/tmp/paperless` — gdy odbierał zadanie zamiast workera
PIHA, padał `Cannot consume ...: File not found`. Fix: NFS volume
`paperless_scratch` (ten sam wzorzec co `data/media/consume`) na
`/opt/homelab/data/paperless/scratch` (już istniał na PIHA, `chown 1000:1000`),
mount na `/tmp/paperless` po obu stronach.
Oba fixy + uzasadnienie: `services/paperless/docker-compose.yml`,
`services/paperless-worker/docker-compose.yml`, `services/paperless-worker/README.md`.
Zweryfikowane end-to-end na żywo (branch `task/paperless-worker-fix`, jeszcze
niezmergowany do master w momencie pisania tego wpisu): 3 dokumenty testowe
wrzucone do `consume/` na PIHA, jeden odebrany i dokończony przez worker@SOLARIA
(log: `ocrmypdf`/`tesseract` → `ConsumeTaskPlugin completed with: Success`),
zero `File not found`. Dokumenty testowe usunięte po teście (`document.delete()`
+ ręczny cleanup plików) — produkcyjne 6 dokumentów nietknięte.
**Otwarte (świadomie odłożone, nie blokuje działania):**
- `hosts/solaria/services.yaml` i `inventory/topology.yaml` nie mają wpisu
`paperless-worker` (SOLARIA ma tam tylko `node-agent`) — było zaplanowane w
cutover checkliście README jako krok "przy deployu", ale nigdy nie zrobione.
Bez tego wpisu supervisor/observer nie widzą tego serwisu w desired-state —
drift (np. worker padnie i nie wstanie) nie zostanie automatycznie wykryty
przez agent system, tylko przez brak przetwarzania kolejki.
- Test formalnego fallbacku (stop worker@SOLARIA → kolejka mieli na PIHA →
start → drenaż) nie był wykonany w tej sesji — mechanizm nie zmienił się
tym fixem (był już OK), ale warto zweryfikować przy okazji.

View file

@ -6,6 +6,15 @@ Ten sam obraz co `services/paperless/`, ale z nadpisanym poleceniem — działa
ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02 ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02
„serwis always-on + compute-worker on-demand z fallback". „serwis always-on + compute-worker on-demand z fallback".
**Status (2026-07-12): ZDEPLOYOWANY I DZIAŁA.** Worker bierze zadania z kolejki
razem z workerem PIHA, kończy OCR bez błędów, dokumenty trafiają do Paperless
niezależnie od tego, który worker odebrał zadanie. Zweryfikowane end-to-end:
kilka dokumentów testowych wrzuconych do `consume/` na PIHA, część odebrana
przez worker@SOLARIA (log: `Consuming ...``ocrmypdf`/`tesseract` →
`ConsumeTaskPlugin completed with: Success` → dokument widoczny w DB), zero
`File not found`. Dwa bugi znalezione i naprawione po drodze — patrz sekcje
„Gotcha: `command` i entrypoint obrazu" i „SCRATCH_DIR" niżej.
## Wynik badania: czy split-host worker jest wykonalny? ## Wynik badania: czy split-host worker jest wykonalny?
**TAK — z zastrzeżeniami.** Ustalenia (GH discussion #3900 + docs): **TAK — z zastrzeżeniami.** Ustalenia (GH discussion #3900 + docs):
@ -50,6 +59,55 @@ ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02
- **Wersje obrazów muszą być identyczne** (PIHA i SOLARIA) — wspólny schemat - **Wersje obrazów muszą być identyczne** (PIHA i SOLARIA) — wspólny schemat
DB i sygnatury zadań. Podbijać oba compose naraz. DB i sygnatury zadań. Podbijać oba compose naraz.
## Gotcha: `command` i entrypoint obrazu
Nadpisanie `command:` w compose na `celery --app paperless worker ...` (jak
sugeruje GH discussion #3900 i punkt 1 wyżej) **nie działa** — pada
`Unknown command: 'celery'`. Przyczyna w `/sbin/docker-entrypoint.sh` obrazu:
```bash
if [[ "$1" != "/"* ]]; then
exec gosu paperless python3 manage.py "$@" # argv[0] bez "/" -> manage.py
else
exec "$@" # argv[0] zaczyna się od "/" -> exec wprost
fi
```
Każdy argument NIE zaczynający się od `/` jest traktowany jako nazwa komendy
Django i lądowany w `manage.py <arg>` — stąd `celery` jest interpretowane jako
nieznana subkomenda `manage.py`, a nie program do uruchomienia. Fix: ścieżka
absolutna, żeby wejść w gałąź `exec "$@"`, plus jawne `gosu paperless` z
przodu (bo ten branch NIE dostaje automatycznego gosu jak branch manage.py) —
inaczej proces poszedłby jako root, co psuje właściciela plików na NFS
(musi być uid 1000, patrz "UID mapping" niżej):
```yaml
command: /usr/sbin/gosu paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4}
```
Ścieżki zweryfikowane w obrazie `ghcr.io/paperless-ngx/paperless-ngx:2.14`:
`gosu` = `/usr/sbin/gosu`, `celery` = `/usr/local/bin/celery`, user `paperless`
= uid/gid 1000.
## SCRATCH_DIR — musi być współdzielony tak samo jak data/media/consume
Paperless zapisuje wgrywany plik do katalogu roboczego przed konsumpcją —
`SCRATCH_DIR`, domyślnie `/tmp/paperless` (paperless-ngx:
`tempfile.gettempdir() / "paperless"` w `src/paperless/settings.py`, gdy
`PAPERLESS_SCRATCH_DIR` nie jest ustawione — nie trzeba go więc ustawiać
jawnie, domyślna wartość już pasuje do mount pointu poniżej). Payload zadania
celery niesie ścieżkę do tego pliku jako ścieżkę **absolutną**. Ponieważ
worker@PIHA i worker@SOLARIA dzielą jedną kolejkę, ten, który akurat odbierze
zadanie, musi widzieć dokładnie ten sam plik pod dokładnie tą samą ścieżką —
identyczna zasada jak dla `data/media/consume` (patrz komentarz w
`services/paperless/docker-compose.yml`). To zostało pominięte przy
pierwszym wdrożeniu modułu 3: dokumenty konsumowane na PIHA, ale odebrane do
OCR przez SOLARIĘ, padały z `Cannot consume ...: File not found`, bo SOLARIA
miała własny, lokalny, pusty `/tmp/paperless`. Fix: NFS-owy wolumen
`paperless_scratch` (ten sam wzorzec co `paperless_data/media/consume`) na
katalog `/opt/homelab/data/paperless/scratch` (już istnieje na PIHA,
`chown 1000:1000`), montowany na `/tmp/paperless` po obu stronach.
## NFS: export na PIHA, mount na SOLARIA ## NFS: export na PIHA, mount na SOLARIA
Transfer idzie po **LAN** (PIHA `192.168.31.5` ↔ SOLARIA `192.168.31.70`, Transfer idzie po **LAN** (PIHA `192.168.31.5` ↔ SOLARIA `192.168.31.70`,
@ -90,11 +148,18 @@ Weryfikacja po deployu: `./healthcheck.sh` robi test zapisu na mount.
## Cutover checklist (przy deployu modułu 3 — po działającym module 2) ## Cutover checklist (przy deployu modułu 3 — po działającym module 2)
1. `services/paperless/` działa na PIHA (healthcheck zielony). 1. ✅ `services/paperless/` działa na PIHA (healthcheck zielony).
2. Export NFS na PIHA (wyżej) + `showmount -e 192.168.31.5` z SOLARII. 2. ✅ Export NFS na PIHA (wyżej) + `showmount -e 192.168.31.5` z SOLARII.
3. `nfs-common` na SOLARII. 3. ✅ `nfs-common` na SOLARII.
4. `.env` z `env.example` — sekrety SKOPIOWANE z PIHA, nie nowe. 4. ✅ `.env` z `env.example` — sekrety SKOPIOWANE z PIHA, nie nowe.
5. `docker compose up -d` + `./healthcheck.sh`. 5. ✅ `docker compose up -d` + `./healthcheck.sh`.
6. Test: wrzucić PDF do consume na PIHA → obciążenie CPU na SOLARII, nie PIHA. 6. ✅ Test (2026-07-12): PDF-y wrzucone do consume na PIHA, część odebrana i
7. Test fallbacku: stop workera → zadanie czeka/mieli PIHA → start → drenaż. dokończona przez worker@SOLARIA (dowód w logach, zero File not found).
8. Wpis w `hosts/solaria/services.yaml` + topology (dopiero przy deployu). 7. ⬜ Test fallbacku: stop workera → zadanie czeka/mieli PIHA → start → drenaż
(jeszcze niewykonany formalnie, ale mechanizm nie zmienił się tym fixem —
fallback na PIHA działał już wcześniej, patrz sekcja "Fallback" wyżej).
8. ⬜ **OTWARTE**: wpis `paperless-worker` w `hosts/solaria/services.yaml` +
`inventory/topology.yaml` (obecnie SOLARIA ma tam tylko `node-agent`) —
bez tego supervisor/observer nie widzą tego serwisu w desired-state, więc
drift między `hosts/solaria/services.yaml` a rzeczywistością nie jest
wykrywany. Patrz `docs/backlog.md`.

View file

@ -21,7 +21,19 @@ services:
# install, then execs this instead of the full s6 service tree. # install, then execs this instead of the full s6 service tree.
# OCR (ocrmypdf/tesseract) is CPU-bound — the win here is SOLARIA's # OCR (ocrmypdf/tesseract) is CPU-bound — the win here is SOLARIA's
# 24 cores, not the GPU (stock paperless OCR does not use CUDA). # 24 cores, not the GPU (stock paperless OCR does not use CUDA).
command: celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4} #
# MUST be an absolute path. /sbin/docker-entrypoint.sh branches on
# argv[0]: anything NOT starting with "/" is treated as a Django
# management-command name and handed to `manage.py` (`exec gosu paperless
# python3 manage.py "$@"`) — so a bare `celery ...` command fails with
# "Unknown command: 'celery'". An absolute path takes the `else exec "$@"`
# branch instead, running celery directly. `gosu paperless` in front
# replaces the implicit gosu the manage.py branch would have applied, so
# the process still runs as uid 1000 (paperless), not root — required so
# files it writes on the NFS-shared storage keep the correct owner and
# match USERMAP_UID/GID below. Verified against the image: gosu is at
# /usr/sbin/gosu, celery at /usr/local/bin/celery.
command: /usr/sbin/gosu paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4}
env_file: env_file:
- .env - .env
environment: environment:
@ -58,6 +70,18 @@ services:
- paperless_data:/usr/src/paperless/data - paperless_data:/usr/src/paperless/data
- paperless_media:/usr/src/paperless/media - paperless_media:/usr/src/paperless/media
- paperless_consume:/usr/src/paperless/consume - paperless_consume:/usr/src/paperless/consume
# SCRATCH_DIR (default /tmp/paperless — paperless-ngx sets it to
# tempfile.gettempdir()/"paperless" when PAPERLESS_SCRATCH_DIR is unset,
# see src/paperless/settings.py) is where paperless@PIHA stages the
# uploaded file before consuming it. The celery task payload carries
# that path as an ABSOLUTE path, and this worker opens the same path on
# its own filesystem — without a shared mount here, any task picked up
# by SOLARIA (instead of the local PIHA worker) fails with "Cannot
# consume ...: File not found". Must be NFS-mounted at the identical
# container path, same reasoning as data/media/consume above. No need
# to set PAPERLESS_SCRATCH_DIR explicitly: the default already resolves
# to /tmp/paperless on both sides.
- paperless_scratch:/tmp/paperless
healthcheck: healthcheck:
# Pings THIS worker through the broker — proves broker connectivity and # Pings THIS worker through the broker — proves broker connectivity and
# a live celery process in one shot. # a live celery process in one shot.
@ -91,3 +115,9 @@ volumes:
type: nfs type: nfs
o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150
device: ":/opt/homelab/data/paperless/consume" device: ":/opt/homelab/data/paperless/consume"
paperless_scratch:
driver: local
driver_opts:
type: nfs
o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150
device: ":/opt/homelab/data/paperless/scratch"

View file

@ -76,6 +76,19 @@ services:
- /opt/homelab/data/paperless/media:/usr/src/paperless/media - /opt/homelab/data/paperless/media:/usr/src/paperless/media
- /opt/homelab/data/paperless/consume:/usr/src/paperless/consume - /opt/homelab/data/paperless/consume:/usr/src/paperless/consume
- /opt/homelab/data/paperless/export:/usr/src/paperless/export - /opt/homelab/data/paperless/export:/usr/src/paperless/export
# SCRATCH_DIR — where paperless stages an uploaded file before
# consuming it (default /tmp/paperless; see paperless-ngx
# src/paperless/settings.py, tempfile.gettempdir()/"paperless"). The
# celery task payload carries this staged file's path as an ABSOLUTE
# path. Since the built-in worker here and the worker@SOLARIA share one
# task queue, whichever one picks up the task must be able to open that
# exact path — so, same rule as data/media/consume above, this MUST be
# NFS-exported and mounted at the identical container path on both
# hosts. Was missed when module 3 was first wired up: documents
# consumed on PIHA but OCR'd by the SOLARIA worker failed with "Cannot
# consume ...: File not found" because SOLARIA had its own local,
# empty /tmp/paperless.
- /opt/homelab/data/paperless/scratch:/tmp/paperless
ports: ports:
# LAN-only bind; npm@PIHA proxies paper.kapala.org -> 192.168.31.5:8210. # LAN-only bind; npm@PIHA proxies paper.kapala.org -> 192.168.31.5:8210.
# TODO AT DEPLOY: port 8210 free per the 2026-06-30 inventory — # TODO AT DEPLOY: port 8210 free per the 2026-06-30 inventory —