Central Engine Services (swarm + compose)ยถ
Root-cause architecture for running stateful โengineโ dependencies (cache, queue,
search, vector, DNS resolver) the same way central databases already work, so that
application roles stay stateless, unpinned and NFS-shareable in swarm while the
engines are the only placement: manager (node-local) services.
Problem this solvesยถ
NFS placement is derived from placement (see
plugins/lookup/compose_volumes.py): an unpinned roleโs volumes are bound to the
shared NFS mount (survive a reschedule), a pinned role keeps its volumes node-local.
Engine on-disk state (redis/rabbitmq/elasticsearch/sqlite/postfix-queue) cannot
live on NFS (fsync + locking corruption), so a role that embeds such an engine
must be pinned. Pinning whole app stacks to the manager:
concentrates load on one node, and
for mailu (13 services, 4 overlay networks, a static-IP unbound resolver) triggers a swarm VIP-allocation deadlock (
could not find an available IP while allocating VIP+has pending allocations, even on empty /24 networks).
The fix is to externalise the engines into central, pinned svc-db-* / svc-dns-*
roles (exactly like svc-db-postgres / svc-db-mariadb) and have apps connect to
them. Apps then own only user-data volumes (NFS-safe) and need no pin.
Central engine rolesยถ
One central role per engine, mirroring svc-db-postgres:
role |
engine |
per-consumer isolation |
|---|---|---|
|
PostgreSQL |
a database |
|
MariaDB |
a database |
|
Redis |
ACL user + key-prefix |
|
Memcached |
key-prefix only (no native ACL) |
|
RabbitMQ |
a vhost + user |
|
Elasticsearch |
index/alias namespace |
|
Typesense |
API-key scoped collections |
|
Qdrant |
per-consumer collection prefix |
|
Unbound |
shared resolver (no per-consumer split) |
Each central role provides:
meta/services.yml:placement: manager,enabled,shared: true, image/version/ports/name.templates/compose.yml.j2: its own standalone stack.tasks/00_core.yml: deploy the central stack + wait healthy.tasks/01_init.yml: per-consumer provisioning (create the ACL user / vhost / namespace and its credentials), idempotent, mirroring postgres01_init.a connection lookup mirroring
database:lookup('<engine>', consumer_id, want)returning host/port/user/password/url/db for the consumer.
Cross-stack connectivityยถ
Central engines are shared-net providers (the existing mechanism in
utils/networks/render.py / compose_volumes.py); consumers attach to the engineโs
shared overlay and the <engine> lookup resolves the cross-stack address
(resolve-container-id in swarm, service name in compose), exactly as
lookup('database', ...) does today.
mailu root-cause fixยถ
svc-dns-unbound is a single-service, single-network, static-IP, pinned role. mailuโs
services drop their own unbound + static IP, attach to the svc-dns-unbound network,
and point dns: at its static IP. The static-IP allocation therefore lives in a
trivial 1-service stack (no deadlock) instead of mailuโs 13-service / 4-network stack.
mailu also stops embedding redis (uses central) -> fewer services -> less VIP pressure.
Placementยถ
Central
svc-db-*/svc-dns-*:placement: manager(single node).Application roles: no
placement-> distributed + NFS-shared.tests/lint/ansible/roles/meta/test_unpinned_no_engine_volume.pystays as the guard: an unpinned role must not declare an engine on-disk data volume.
Lifecycle + orderingยถ
Central engine is enabled on demand: deployed only when at least one inventory role consumes it
shared: true(same discovery the central DB uses).Deploy order: central engine -> consumer
02_initprovisioning -> consumer (run_after, like postgres).
Scopeยถ
Test-fresh: the goal is green compose and swarm deploys on fresh act clusters. No embedded->central data migration path (that is a separate prod-upgrade concern).