mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(deploy): one worker container runs every background process
The stack was nine long-running containers: the web server, the database, the Mercure hub, and six of plMail's own image, one per background process. It is three now: php, worker and database, with secrets-init still running once and exiting. The worker runs `app:work`, a small supervisor keeping seven processes alive the way Docker's restart policy kept seven containers alive: the IMAP supervisor, one consumer per queue, the scheduler and the hub. A clean exit (the hourly recycle, the restart signal) comes straight back; a crash backs off from one second to thirty. Output passes through a whole line at a time, so two processes' JSON records never interleave, and SIGTERM reaches every child at once with 25s to finish. Each queue keeps a process of its own, which is the part the split was for: a consumer inside a long handler cannot take a send. And each process keeps its container's old name as APP_CONTAINER_NAME, so heartbeats, /healthz and the log filters are unchanged. The hub is the official Mercure 1.x binary, copied into the image and run as one of those processes. Not FrankenPHP's built-in hub, which is 0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31. The worker answers to `mercure` on the network, so the Caddyfile proxy and MERCURE_URL reach it where they reached the hub container, and older compose files keep working with this image unchanged. It is configured from the application's own settings, including MERCURE_COOKIE_NAME, so a plain-HTTP install names the cookie once for both halves. A 5s shutdown grace stops it waiting on open subscriptions, which made every stop take the whole 25s. The dev override loses its per-worker blocks, one of which (worker-bulk) had been missing, so that queue ran the published image's code in development. The demo overlay runs app:work --without=imap-supervisor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57db16b6f4
commit
a8364e34d2
31 files changed
+1018
-620
No files matched your search
+6
-4
@@ -59,8 +59,9 @@ That first boot takes a few minutes. The dev stack bind-mounts the source tree o
|
||||
`composer install` once to fill it, under a lock so the five services sharing the mount cannot race.
|
||||
Later boots skip it.
|
||||
|
||||
Migrations run automatically via the entrypoint. The `imap-supervisor`, `messenger-worker` and
|
||||
`scheduler` services start with the stack and restart on failure.
|
||||
Migrations run automatically via the entrypoint. The worker container starts with the stack and
|
||||
runs everything that is not the web server (`app:work`): the IMAP supervisor, one consumer per
|
||||
queue, the scheduler and the Mercure hub, each its own process, each restarted when it stops.
|
||||
|
||||
`.github/workflows/docker.yml` builds the published image for both `linux/amd64` and `linux/arm64`,
|
||||
each on a native runner, then merges the two into one manifest. A change to the `Dockerfile` must
|
||||
@@ -811,6 +812,7 @@ php bin/mirror-wiki.php --check
|
||||
| `app:reset --full [--rotate-secrets]` | Back to first-run state: every table, every user, the stored files. `--rotate-secrets` also discards the generated secrets and requires restarting the whole stack — see "Secrets and the encryption key" |
|
||||
| `app:secrets:init` | Generate the per-install secrets that need PHP, and verify the encryption key against stored credentials |
|
||||
| `app:upgrade:run [--status]` | Run the one-time repairs this installation has not run yet, and record that it did. Scheduled every ten minutes, so normally nobody types it — what it guards is the gap between an update landing and a repair happening, and asking costs one indexed lookup per task. Run it by hand after restoring an old backup, or with `--status` to read the ledger when a task has used up its three attempts and you want to see what it says |
|
||||
| `app:work [--only=…] [--without=…]` | Run every background process in one container, one process each: the Mercure hub, the IMAP supervisor, the four queue consumers and the scheduler. It is the worker container's command. `--only` and `--without` take process names, comma-separated, for an installation that gives a busy queue a container of its own, or the demo, which runs without the IMAP supervisor |
|
||||
| `app:updates:check` | Ask the update channel (Admin → Updates) whether a newer build is published, and tell the administrators the first time it is. Scheduled hourly at :37, so normally nobody types it; run it to check now. Exits 0 when the registry cannot be reached, because an installation behind a firewall is not a fault: the page records why and keeps the last answer |
|
||||
| `app:db:migrate` | Run pending migrations under a lock, so several containers booting together cannot collide. This is what the entrypoint calls; run it by hand only when a boot was interrupted |
|
||||
| `app:ai:embed-mailbox --email=… \| --all` | Queue a pass that embeds an existing mailbox for semantic search. Needed once after switching the feature on, and again after changing the search model. Runs on the maintenance worker a chunk at a time, takes hours on a large mailbox, and is safe to interrupt and safe to repeat — it skips whatever is already embedded under the current model, so changing the model is how a re-embed is asked for. This is the whole-mailbox job: it claims a state row, resumes where it stopped and can be paused from Admin → AI |
|
||||
@@ -823,8 +825,8 @@ php bin/mirror-wiki.php --check
|
||||
|
||||
These run on a schedule already — see `App\Infrastructure\Scheduler\MaintenanceSchedule`
|
||||
for the cadences (polling sync every 15 min, push renewal and monitoring pruning
|
||||
nightly, the blob sweep weekly). They are dispatched by the `scheduler` service in
|
||||
compose, which consumes the `scheduler_default` transport; without that container
|
||||
nightly, the blob sweep weekly). They are dispatched by the `scheduler` process in
|
||||
the worker container, which consumes the `scheduler_default` transport; without it
|
||||
running, none of them fire. `php bin/console debug:scheduler` shows the next run of
|
||||
each.
|
||||
|
||||
|
||||
+21
@@ -3,6 +3,23 @@
|
||||
# Versions
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
|
||||
# The Mercure hub, taken as a binary rather than run as an image. plMail runs it
|
||||
# as one of the background processes in the worker container (app:work), which
|
||||
# answers to the name `mercure` on the stack's network: the web container
|
||||
# proxies /.well-known/mercure there and the application publishes there, as
|
||||
# they always did to a container of that name.
|
||||
#
|
||||
# Not the hub FrankenPHP has built in. That one is a release behind (0.24 in
|
||||
# FrankenPHP 1.12), and plMail speaks the 1.0 protocol, whose RFC 9068 tokens a
|
||||
# 0.x hub refuses. That exact mismatch, the other way round, stopped live
|
||||
# updates in v0.2.31.
|
||||
#
|
||||
# `v1`, not an exact version: the hub keeps itself current within the protocol
|
||||
# plMail speaks, the way the floating `dunglas/mercure` tag was meant to, but it
|
||||
# now moves with plMail's own releases rather than whenever a host last pulled.
|
||||
# A static Go binary, so an Alpine build runs on this Debian image as it is.
|
||||
FROM docker.io/dunglas/mercure:v1 AS mercure_upstream
|
||||
|
||||
# The different stages of this Dockerfile are meant to be built into separate images
|
||||
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||
# https://docs.docker.com/compose/compose-file/#target
|
||||
@@ -78,6 +95,10 @@ COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-en
|
||||
# service runs it on its own, before Postgres and Mercure start.
|
||||
COPY --link --chmod=755 frankenphp/generate-secrets.sh /usr/local/bin/generate-secrets
|
||||
COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile
|
||||
# The hub and its own Caddyfile, verbatim: the image's default config (the one
|
||||
# that requires a subscriber JWT), not the dev one. See mercure_upstream above.
|
||||
COPY --link --from=mercure_upstream /usr/bin/caddy /usr/local/bin/mercure
|
||||
COPY --link --from=mercure_upstream /etc/caddy/Caddyfile /etc/mercure/Caddyfile
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint"]
|
||||
|
||||
|
||||
+22
-40
@@ -55,45 +55,27 @@ services:
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
# Delivers the scripted mail the Receive button asks for, and the auto-reply
|
||||
# to anything a visitor sends. Without this worker the button appears to do
|
||||
# nothing: the job is queued and never handled.
|
||||
worker-ingest:
|
||||
# Every background process but one, and each is needed:
|
||||
#
|
||||
# - worker-ingest delivers the scripted mail the Receive button asks for,
|
||||
# and the auto-reply to anything a visitor sends. Without it the button
|
||||
# appears to do nothing: the job is queued and never handled.
|
||||
# - worker-export: nothing outbound leaves a demo (DemoMailSender swallows
|
||||
# it), but a send still travels through this queue, so without it Send
|
||||
# sits pending.
|
||||
# - worker-bulk: a demo's mailboxes are small, so a bulk action is quick,
|
||||
# but without the consumer its indicator would sit at nothing for ever.
|
||||
# - the scheduler runs app:demo:reap every ten minutes. Without it expired
|
||||
# visitors are never deleted, and the database grows by a user, an
|
||||
# account, ten threads and ten contacts for everybody who followed the
|
||||
# link, with nothing anywhere saying so.
|
||||
# - the Mercure hub is what refreshes a visitor's inbox when mail arrives.
|
||||
#
|
||||
# The IMAP supervisor is the one left out. It holds an IDLE connection per
|
||||
# mailbox, and a demo has no mailbox to hold one to: its accounts point at
|
||||
# documentation domains that resolve nowhere, so it would spend the whole
|
||||
# time failing to connect and backing off.
|
||||
worker:
|
||||
command: php bin/console app:work --without=imap-supervisor
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
# Nothing outbound actually leaves on a demo — DemoMailSender swallows it —
|
||||
# but the queue is still the one a send travels through, so the consumer has
|
||||
# to be here for Send to complete rather than sit pending.
|
||||
worker-export:
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
worker-maintenance:
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
# The demo's mailboxes are small, so a bulk action there is quick — but it
|
||||
# still travels through this queue, and without the consumer the indicator
|
||||
# would sit at nothing for ever.
|
||||
worker-bulk:
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
# Runs app:demo:reap every ten minutes. Drop this service and expired
|
||||
# visitors are never deleted — the database then grows by one user, one
|
||||
# account, ten threads and ten contacts for every person who followed the
|
||||
# link, with nothing anywhere saying so.
|
||||
scheduler:
|
||||
environment:
|
||||
<<: *demo-env
|
||||
|
||||
# Holds an IMAP IDLE connection per mailbox, and a demo has no mailbox to
|
||||
# hold one to: its accounts point at documentation domains that resolve
|
||||
# nowhere, so this would spend the whole time failing to connect and backing
|
||||
# off. Parked behind a profile nothing activates, which is how the compose
|
||||
# spec says "not in this configuration" — there is no way to delete a service
|
||||
# an earlier file defined.
|
||||
imap-supervisor:
|
||||
profiles:
|
||||
- imap-not-used-on-a-demo
|
||||
+14
-56
@@ -1,5 +1,5 @@
|
||||
# Every app service builds the same dev stage, so they share one image — one
|
||||
# build instead of six.
|
||||
# build, whichever of them starts it.
|
||||
#
|
||||
# The `image` line is the load-bearing part. compose.yaml names these services
|
||||
# `ghcr.io/karatektus/pl_mail:latest`, and compose tags what it builds with the
|
||||
@@ -46,14 +46,20 @@ services:
|
||||
- host.docker.internal:host-gateway
|
||||
tty: true
|
||||
|
||||
imap-supervisor:
|
||||
# Everything that is not the web server (app:work), from the dev image with
|
||||
# the source mounted. A service the base file defines and this one does not
|
||||
# still starts, from the published image and without the source: not a
|
||||
# missing container but one running yesterday's code. There used to be one
|
||||
# block per worker here, and worker-bulk's had been forgotten; one block has
|
||||
# nothing left to forget.
|
||||
worker:
|
||||
<<: *dev-build
|
||||
volumes:
|
||||
- ./:/app
|
||||
# Durable mail data must be visible to BOTH the web container (which
|
||||
# serves downloads) and the workers (which write during sync). php
|
||||
# overlays /app/var with the private app_var volume to keep cache/logs
|
||||
# off the bind mount, which otherwise hides these two directories from
|
||||
# off the bind mount, which otherwise hides these directories from
|
||||
# everything else.
|
||||
- app_attachments:/app/var/attachments
|
||||
- app_raw:/app/var/raw
|
||||
@@ -61,52 +67,6 @@ services:
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
|
||||
# One block per worker in compose.yaml. A service the base file defines but
|
||||
# this one does not still starts — from the published image, without the
|
||||
# source bind-mounted — so a missing block here is not a missing container
|
||||
# but a container running yesterday's code.
|
||||
worker-export:
|
||||
<<: *dev-build
|
||||
volumes:
|
||||
- ./:/app
|
||||
# Durable mail data must be visible to BOTH the web container (which
|
||||
# serves downloads) and the workers (which write during sync). php
|
||||
# overlays /app/var with the private app_var volume to keep cache/logs
|
||||
# off the bind mount, which otherwise hides these two directories from
|
||||
# everything else.
|
||||
- app_attachments:/app/var/attachments
|
||||
- app_raw:/app/var/raw
|
||||
- app_uploads:/app/var/uploads
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
|
||||
worker-ingest:
|
||||
<<: *dev-build
|
||||
volumes:
|
||||
- ./:/app
|
||||
- app_attachments:/app/var/attachments
|
||||
- app_raw:/app/var/raw
|
||||
- app_uploads:/app/var/uploads
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
|
||||
worker-maintenance:
|
||||
<<: *dev-build
|
||||
volumes:
|
||||
- ./:/app
|
||||
- app_attachments:/app/var/attachments
|
||||
- app_raw:/app/var/raw
|
||||
- app_uploads:/app/var/uploads
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
|
||||
scheduler:
|
||||
<<: *dev-build
|
||||
volumes:
|
||||
- ./:/app
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
|
||||
database:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
@@ -132,18 +92,16 @@ services:
|
||||
# The image healthcheck probes the FrankenPHP admin endpoint on :2019, but
|
||||
# this service replaces the server with the Tailwind watcher, so nothing
|
||||
# ever listens there and the container is reported unhealthy while working
|
||||
# fine. Same reason imap-supervisor, messenger-worker and scheduler disable
|
||||
# it in compose.yaml.
|
||||
# fine. Same reason the worker disables it in compose.yaml.
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
- php
|
||||
|
||||
###> symfony/mercure-bundle ###
|
||||
mercure:
|
||||
ports:
|
||||
- "80"
|
||||
###< symfony/mercure-bundle ###
|
||||
# No `mercure` block: the hub is a process in the worker now, reachable
|
||||
# through php at /.well-known/mercure like everything else. The Mercure
|
||||
# bundle's recipe put one here to publish the hub's port; brought back, it
|
||||
# would define a service with no image and compose would refuse the file.
|
||||
volumes:
|
||||
app_var:
|
||||
app_attachments:
|
||||
|
||||
+42
-190
@@ -39,6 +39,10 @@ x-app-env: &app-env
|
||||
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0}
|
||||
MERCURE_URL: ${MERCURE_URL:-http://mercure/.well-known/mercure}
|
||||
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://localhost/.well-known/mercure}
|
||||
# Blank on HTTPS, which is every real install. Serving plain HTTP, set it to a
|
||||
# name without the `__Secure-` prefix (a browser drops such a cookie over
|
||||
# HTTP). The app sets the cookie and the hub reads it, and both read this.
|
||||
MERCURE_COOKIE_NAME: ${MERCURE_COOKIE_NAME:-}
|
||||
|
||||
# The state that must outlive a container and be shared between them.
|
||||
#
|
||||
@@ -64,8 +68,8 @@ x-app-storage: &app-storage
|
||||
services:
|
||||
# Mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and
|
||||
# MERCURE_JWT_SECRET into app_secrets, then exits. Runs before everything
|
||||
# else because Postgres and Mercure read their secrets when their containers
|
||||
# are created — they cannot wait for the app to hand them one.
|
||||
# else because Postgres reads its password when its container is created: it
|
||||
# cannot wait for the app to hand it one.
|
||||
#
|
||||
# Idempotent: on every later start it finds the file and does nothing.
|
||||
secrets-init:
|
||||
@@ -114,122 +118,47 @@ services:
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
imap-supervisor:
|
||||
# ── Everything that is not the web server ─────────────────────────────────
|
||||
# One container, seven processes (app:work): the IMAP supervisor, one
|
||||
# consumer per queue, the scheduler, and the Mercure hub. They used to be
|
||||
# seven containers of this same image and the hub's own; see
|
||||
# App\Service\System\Work\BackgroundProcesses for what each is for.
|
||||
#
|
||||
# Still one PROCESS per queue, which is the part that matters: a consumer
|
||||
# already inside a long handler cannot pick up anything else, so pressing
|
||||
# Send must never queue behind a Gmail batch. Only the containers merged.
|
||||
#
|
||||
# Answers to `mercure` on the network, because it runs the hub: the web
|
||||
# container proxies /.well-known/mercure there and MERCURE_URL publishes
|
||||
# there, exactly as they did to a hub container of that name.
|
||||
worker:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console app:imap:supervise
|
||||
command: php bin/console app:work
|
||||
# The IMAP supervisor's children are this container's grandchildren; if
|
||||
# the supervisor dies hard, something has to reap them. Docker's own init.
|
||||
init: true
|
||||
# Longer than app:work's own 25s: it signals every process at once and
|
||||
# gives them that long to finish the message in hand.
|
||||
stop_grace_period: 30s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: imap-supervisor
|
||||
volumes: *app-storage
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# ── Workers, one per queue ────────────────────────────────────────────────
|
||||
# Three processes rather than three transports on one, because a worker
|
||||
# already inside a long handler cannot pick up anything else however the
|
||||
# queues are prioritised. That was the whole problem: pressing Send behind a
|
||||
# Gmail batch waited for the batch.
|
||||
#
|
||||
# Anything leaving plMail, and the only queue somebody is watching. Kept on
|
||||
# its own process so a send is never behind a sync.
|
||||
worker-export:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-export
|
||||
volumes: *app-storage
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
|
||||
# Mail arriving and the work that immediately follows it.
|
||||
worker-ingest:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-ingest
|
||||
volumes: *app-storage
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
|
||||
# Bulk and deliberate: backfills, rule runs over existing mail, admin sweeps.
|
||||
#
|
||||
# Also drains `async`, the pre-split queue, so envelopes written before the
|
||||
# upgrade still have a consumer. Nothing routes there any more — drop it from
|
||||
# this command and from messenger.yaml once a release has passed.
|
||||
worker-maintenance:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-maintenance
|
||||
volumes: *app-storage
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# A whole-view mark-read or archive. On its own process for the reason the
|
||||
# send queue is: somebody pressed a button and is watching an indicator, and
|
||||
# this must not wait behind a backfill on `maintenance`. Drop this service and
|
||||
# the indicator fills to nothing — the job is queued and never handled.
|
||||
worker-bulk:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-bulk
|
||||
volumes: *app-storage
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# Fires the recurring jobs in App\Infrastructure\Scheduler\MaintenanceSchedule.
|
||||
# Separate from messenger-worker so a long prune cannot stall the async queue.
|
||||
scheduler:
|
||||
image: *app-image
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: scheduler
|
||||
volumes: *app-storage
|
||||
# Each process overrides this with its own name; this one is the
|
||||
# supervisor's.
|
||||
APP_CONTAINER_NAME: worker
|
||||
networks:
|
||||
default:
|
||||
aliases: [mercure]
|
||||
volumes:
|
||||
# The hub's state, in the volumes the hub container used, so an upgrade
|
||||
# keeps its event history.
|
||||
- mercure_data:/data
|
||||
- mercure_config:/config
|
||||
# The x-app-storage list, spelled out for the reason php spells it out.
|
||||
- app_secrets:/app/var/secrets
|
||||
- app_attachments:/app/var/attachments
|
||||
- app_raw:/app/var/raw
|
||||
- app_uploads:/app/var/uploads
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
@@ -265,83 +194,6 @@ services:
|
||||
- "-c"
|
||||
- "pg_stat_statements.track=top"
|
||||
|
||||
mercure:
|
||||
image: dunglas/mercure
|
||||
# PULLED ON EVERY `up`, which is the only thing that makes "unpinned"
|
||||
# actually mean "keeps itself current".
|
||||
#
|
||||
# Without this the tag floats and the image does not: compose reuses
|
||||
# whatever is already on the box, so a hub can sit years behind the tag it
|
||||
# claims to be while `docker compose up -d` reports success. v0.2.31 is what
|
||||
# that costs — plMail moved to the Mercure 1.0 protocol, every install whose
|
||||
# hub had not been pulled was left speaking 1.0 to a 0.x hub, and live
|
||||
# updates stopped with nothing in the application's own logs to say why.
|
||||
#
|
||||
# The cost of the check is one registry round trip per `up` that answers
|
||||
# "same digest" almost every time. The cost of not doing it is the paragraph
|
||||
# above.
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SERVER_NAME: ':80'
|
||||
# Left empty on purpose: the entrypoint below fills them from the
|
||||
# generated file. The hub image has no _FILE support, so sourcing it in a
|
||||
# shell before exec'ing Caddy is the way in. Setting MERCURE_JWT_SECRET
|
||||
# yourself still wins — the generator never overwrites it, so both sides
|
||||
# read the same value.
|
||||
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
|
||||
# The two halves of RFC 9068: the issuer this hub accepts, and the
|
||||
# audience it demands. Both are the literal from services.yaml —
|
||||
# `mercure.identifier` — which is where the reasoning lives. In short: a
|
||||
# hub is configured at container start, and an installation's own URL is
|
||||
# not always known by then, so these cannot be derived from it.
|
||||
#
|
||||
# resource_identifier has to be PINNED whatever value is chosen. Left
|
||||
# alone the hub works the audience out from each request, and plMail
|
||||
# arrives at it by two different roads — the browser subscribes at the
|
||||
# public URL, the application publishes to the internal one — so a hub
|
||||
# deriving its own answer would refuse every publish.
|
||||
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
|
||||
MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /run/plmail/generated.env ]; then
|
||||
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /run/plmail/generated.env)"
|
||||
fi
|
||||
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
|
||||
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
|
||||
exec "$$@"
|
||||
- --
|
||||
# The image's default Caddyfile, NOT dev.Caddyfile. The dev one turns on
|
||||
# `anonymous`, which accepts any subscriber without a JWT — and since
|
||||
# frankenphp/Caddyfile proxies /.well-known/mercure* straight through, that
|
||||
# let anyone reaching this instance subscribe to mail/user/<any id> and
|
||||
# watch that account's sync activity. It also enables `demo`, `subscriptions`
|
||||
# and `cors_origins *`. The default config requires the subscriber JWT the
|
||||
# app now mints, and needs no CORS because the browser reaches the hub
|
||||
# same-origin through our own Caddy.
|
||||
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
# NO `test:` HERE ON PURPOSE — the image's own HEALTHCHECK is used.
|
||||
#
|
||||
# We used to spell one out, aimed at "/healthz", and the hub moved its
|
||||
# readiness probe out from under it: "/healthz" answers 404 now, `curl
|
||||
# -f` reads a 404 as failure, and this container reported itself unhealthy
|
||||
# for ever while serving every subscription put to it. The image is
|
||||
# deliberately on a floating tag so it keeps up to date by itself, which
|
||||
# means anything we hard-code about its internals is a thing waiting to
|
||||
# rot. Its own check — wget against 127.0.0.1:2019/mercure/health/ready,
|
||||
# 60s grace, 5 retries — moves when the image moves, which is the whole
|
||||
# point of not pinning it.
|
||||
volumes:
|
||||
- mercure_data:/data
|
||||
- mercure_config:/config
|
||||
- app_secrets:/run/plmail:ro
|
||||
|
||||
# Push for Android, without Google.
|
||||
#
|
||||
# Optional and off by default: `docker compose --profile push up -d`. Nobody
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/configuration.md sha1:5ed07d4ee0630df517792929aa3da26f85c1953f -->
|
||||
<!-- translated-from: install/configuration.md sha1:960f0f7ec8f30fc6aba96e312303042e5f0dcc32 -->
|
||||
# Konfigurationsreferenz
|
||||
|
||||
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
|
||||
@@ -62,6 +62,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
|
||||
| `MERCURE_URL` | Die Hub-Adresse, an die die **Anwendung** innerhalb des Docker-Netzes veröffentlicht. | `http://mercure/.well-known/mercure` | Ja | Falsch, und nichts wird veröffentlicht: keine Live-Aktualisierungen, kein sichtbarer Fehler auf der Seite. |
|
||||
| `MERCURE_PUBLIC_URL` | Die Hub-Adresse, die der **Browser** abonniert. | `https://localhost/.well-known/mercure`, sowohl in `.env` als auch in `compose.yaml` | Ja | Falsch, und der Browser öffnet einen Stream dorthin, wo er nicht hinkommt — Maillisten hören auf, sich selbst zu aktualisieren, während der Rest der Anwendung funktioniert. Wird nur dann aus `APP_PUBLIC_URL` abgeleitet, wenn sie nicht gesetzt oder leer ist, was die Standard-`compose.yaml` verhindert. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
|
||||
| `MERCURE_JWT_SECRET` | Signiert die Publisher- und Subscriber-JWTs. | leer — 32 zufällige Bytes, hexadezimal, beim ersten Start erzeugt | Ja, aber erzeugt | Anwendung und Hub müssen denselben Wert halten. Weichen sie voneinander ab, weist der Hub jeden Subscriber ab — aus Sicht des Browsers lautlos. |
|
||||
| `MERCURE_COOKIE_NAME` | Der Name des Cookies, mit dem der Browser abonnieren darf. Die Anwendung setzt es und der Hub liest es, beide aus dieser einen Variablen. | leer — `__Secure-mercure_access_token` | Nur ohne TLS | Eine Installation, die schlichtes HTTP ausliefert und nirgends TLS vorgeschaltet hat, braucht einen Namen ohne das Präfix `__Secure-`, etwa `mercure_access_token`: Ein Browser verwirft ein `__Secure-`-Cookie, das nicht über HTTPS kam, und Live-Aktualisierungen starten nie. |
|
||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google-OAuth-Client. | leer | Für Gmail | Ohne sie kann "Mit Google anmelden" nicht starten. Ein unter **Administration → Integrationen** gespeicherter Wert gewinnt gegen diesen. Siehe [Google](../providers/google.md). |
|
||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Client-Secret des Google-OAuth-Clients. | leer | Für Gmail | Wie oben. |
|
||||
| `GMAIL_PUBSUB_TOPIC` | Vollständiger Topic-Name, in den Gmail Watch-Benachrichtigungen veröffentlicht. | `projects/your-project-id/topics/gmail-push` — ein Platzhalter, kein funktionierender Wert | Für sofortige Gmail-Zustellung | Der Platzhalter ist kein Topic, das dir gehört, also scheitert jeder `watch`-Aufruf. Die Projekt-ID ist kleingeschrieben und weicht oft vom Anzeigenamen ab. |
|
||||
|
||||
+49
-22
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/docker.md sha1:89c280cbbe9bb45d384b184c1dd64fb5d1dd053c -->
|
||||
<!-- translated-from: install/docker.md sha1:73ae0d9a9b349c20c36dcfbc031c792cf1631649 -->
|
||||
# Installation mit Docker Compose
|
||||
|
||||
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was `docker compose up` tatsächlich tut,
|
||||
@@ -21,7 +21,9 @@ einige Details — siehe [Plattform-Hinweise](platforms.md).
|
||||
Über die Dimensionierung lohnt es sich nachzudenken, eine feste Regel gibt es nicht. PHPs
|
||||
`memory_limit` steht im Image auf `2G`, jeder der vier Messenger-Worker ist durch
|
||||
`--memory-limit=256M` begrenzt und startet sich bei `--time-limit=3600` neu, und Postgres, der
|
||||
Mercure-Hub und der IMAP-Supervisor wollen jeweils ihren eigenen Anteil obendrauf. Der erste
|
||||
Mercure-Hub und der IMAP-Supervisor wollen jeweils ihren eigenen Anteil obendrauf. Die Worker, der
|
||||
Hub und der IMAP-Supervisor teilen sich einen Container, ein Speicherlimit darauf gilt also für alle
|
||||
zusammen. Der erste
|
||||
Abgleich eines großen Postfachs ist die anstrengendste Phase überhaupt.
|
||||
|
||||
Das genügt für IMAP-Postfächer. Gmail und Outlook brauchen zusätzlich OAuth-Zugangsdaten — siehe
|
||||
@@ -68,16 +70,16 @@ die du kennen solltest:
|
||||
1. **`secrets-init` läuft und beendet sich.** Es erzeugt `APP_SECRET`, `APP_ENCRYPTION_KEY`,
|
||||
`POSTGRES_PASSWORD` und `MERCURE_JWT_SECRET` in `var/secrets/generated.env` auf dem gemeinsamen
|
||||
Volume `app_secrets`, dazu eine nackte Datei `postgres_password`. Es läuft vor allem anderen,
|
||||
weil Postgres und Mercure ihre Geheimnisse beim Anlegen des Containers lesen und nicht darauf
|
||||
warten können, dass die Anwendung sie ihnen reicht. Bei jedem späteren Start findet es die Datei
|
||||
weil Postgres sein Passwort beim Anlegen des Containers liest und nicht darauf warten kann, dass
|
||||
die Anwendung es ihm reicht. Bei jedem späteren Start findet es die Datei
|
||||
vor und tut nichts.
|
||||
2. **`database` startet** und liest sein Passwort über `POSTGRES_PASSWORD_FILE`. Jeder
|
||||
Anwendungsdienst wartet auf dessen Healthcheck, der eine Startphase von 60 Sekunden zugesteht.
|
||||
3. **Jeder Anwendungscontainer führt denselben Entrypoint aus.** Er lädt die erzeugten Geheimnisse,
|
||||
setzt `DATABASE_URL` aus `POSTGRES_PASSWORD` zusammen — es sei denn, du hast eine DSN mit einem
|
||||
eigenen Passwort mitgegeben —, wartet bis zu 60 Versuche lang auf die Datenbank und führt dann
|
||||
`app:db:migrate` aus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die sechs
|
||||
gemeinsam startenden Container nicht kollidieren.
|
||||
`app:db:migrate` aus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die gemeinsam
|
||||
startenden Container nicht kollidieren.
|
||||
4. **`app:secrets:init` läuft**, nach den Migrationen. Es prüft, ob der geltende
|
||||
Verschlüsselungsschlüssel die bereits gespeicherten Zugangsdaten entschlüsseln kann, und erzeugt
|
||||
anschließend ein VAPID-Schlüsselpaar sowie das JMAP-JWT-Schlüsselpaar, falls diese fehlen.
|
||||
@@ -131,28 +133,53 @@ rufe die Einrichtungsseite über die Adresse auf, die du tatsächlich verwenden
|
||||
|
||||
| Dienst | Was darin läuft | Warum er ein eigener Container ist |
|
||||
|---|---|---|
|
||||
| `secrets-init` | `generate-secrets`, dann Ende | Postgres und Mercure brauchen ihre Geheimnisse beim Anlegen des Containers, bevor die Anwendung existiert |
|
||||
| `php` | FrankenPHP, das die Anwendung ausliefert | Der einzige Anwendungsdienst mit HTTP-Server und damit der einzige, dessen Image-Healthcheck aktiv bleibt — die übrigen schalten ihn ab und melden Lebendigkeit stattdessen über Heartbeats |
|
||||
| `secrets-init` | `generate-secrets`, dann Ende | Postgres braucht sein Passwort beim Anlegen des Containers, bevor die Anwendung existiert |
|
||||
| `php` | FrankenPHP, das die Anwendung ausliefert | Der einzige Anwendungsdienst mit HTTP-Server und damit der einzige, dessen Image-Healthcheck aktiv bleibt — der Worker schaltet ihn ab und meldet Lebendigkeit stattdessen über Heartbeats |
|
||||
| `worker` | `app:work`: jeder Hintergrundprozess, jeweils als eigener Prozess | Alles, was nicht der Webserver ist. Im Netz antwortet er auf den Namen `mercure`, weil er den Hub betreibt |
|
||||
| `database` | `postgres:18-alpine` mit vorgeladenem `pg_stat_statements` | — |
|
||||
| `mercure` | Der Mercure-Hub | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
|
||||
| `ntfy` | ntfy, unter dem Profil `push` | Optional. Android-Push ohne Google — starte ihn mit `docker compose --profile push up -d` |
|
||||
|
||||
Die Prozesse des Workers. Jeder wird wieder gestartet, wenn er endet: sofort, wenn er sauber
|
||||
endete (ein Consumer, der an seinem Zeit- oder Speicherlimit neu anfängt), nach einer Wartezeit von
|
||||
bis zu dreißig Sekunden, wenn er abgestürzt ist.
|
||||
|
||||
| Prozess | Was darin läuft | Wofür er da ist |
|
||||
|---|---|---|
|
||||
| `mercure` | Der Mercure-Hub, 1.x | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
|
||||
| `imap-supervisor` | `app:imap:supervise` | Startet und überwacht je einen `app:imap:idle`-Prozess pro IDLE-fähigem Postfach, damit gewöhnliche IMAP-Mail in dem Moment ankommt, in dem sie eintrifft |
|
||||
| `worker-export` | `messenger:consume export` | Alles, was plMail verlässt, und die einzige Warteschlange, auf die jemand wartet. In einem eigenen Prozess, damit ein Versand nie hinter einem Abgleich steht |
|
||||
| `worker-ingest` | `messenger:consume ingest` | Eingehende Mail und die Arbeit, die unmittelbar darauf folgt |
|
||||
| `worker-maintenance` | `messenger:consume maintenance async` | Nachträgliche Verarbeitungen, Regelläufe über vorhandene Mail, administrative Durchläufe. Leert außerdem die stillgelegte Warteschlange `async` |
|
||||
| `worker-bulk` | `messenger:consume bulk` | Aktionen über eine ganze Ansicht: alles Ungelesene als gelesen markieren, alles archivieren. Eigener Prozess, weil jemand dabei auf eine Fortschrittsanzeige schaut und das nicht hinter einer nachträglichen Verarbeitung warten darf |
|
||||
| `scheduler` | `messenger:consume scheduler_default` | Löst alles Wiederkehrende aus. **Ohne diesen Container plant sich nichts von selbst** |
|
||||
| `ntfy` | ntfy, unter dem Profil `push` | Optional. Android-Push ohne Google — starte ihn mit `docker compose --profile push up -d` |
|
||||
| `scheduler` | `messenger:consume scheduler_default` | Löst alles Wiederkehrende aus. **Ohne diesen Prozess plant sich nichts von selbst** |
|
||||
|
||||
Vier Prozesse statt vier Transports in einem Worker, weil ein Worker, der bereits in einem langen
|
||||
Handler steckt, nichts anderes mehr annehmen kann, wie auch immer die Warteschlangen priorisiert
|
||||
sind. Genau das war das ursprüngliche Problem: Ein Klick auf Senden wartete hinter einem
|
||||
Gmail-Batch.
|
||||
Vier Warteschlangen-Prozesse statt vier Transports in einem, weil ein Worker, der bereits in einem
|
||||
langen Handler steckt, nichts anderes mehr annehmen kann, wie auch immer die Warteschlangen
|
||||
priorisiert sind. Genau das war das ursprüngliche Problem: Ein Klick auf Senden wartete hinter
|
||||
einem Gmail-Batch. Früher waren sie je ein Container, jetzt sind sie je ein Prozess, und das war
|
||||
der Teil, auf den es ankam. Jeder behält seinen alten Namen als `APP_CONTAINER_NAME`, nach dem sich
|
||||
die Heartbeats, `/healthz` und die Protokollansicht im Administrationsbereich richten, sie lesen
|
||||
sich also genau wie vorher.
|
||||
|
||||
**Der typische Fehlerfall ist, den Dienst `scheduler` wegzulassen.** Ohne ihn wird überhaupt nichts
|
||||
Wiederkehrendes ausgelöst — kein Abgleich per Polling, kein Aufwachen zurückgestellter
|
||||
Konversationen, kein Kalenderabgleich, keine Erinnerungen, kein Aufräumen — und nirgends erscheint
|
||||
ein Fehler, weil nichts fehlgeschlagen ist. `php bin/console debug:scheduler` listet auf, was laufen
|
||||
sollte.
|
||||
Der Hub ist das offizielle Mercure-1.x-Programm, ins plMail-Image kopiert statt aus einem eigenen
|
||||
Image betrieben. Es ist nicht der in FrankenPHP eingebaute Hub, denn der ist eine 0.x-Version, und
|
||||
plMail spricht das 1.0-Protokoll.
|
||||
|
||||
**Wieder aufteilen** geht mit `app:work --only=…` und `--without=…`. Ein Container mit
|
||||
`app:work --only=worker-bulk` neben einem mit `app:work --without=worker-bulk` gibt der
|
||||
Bulk-Warteschlange einen eigenen Container und ein eigenes Speicherlimit. Nur ein Container darf
|
||||
`mercure` betreiben, und das ist der, der den Netzwerk-Alias `mercure` braucht.
|
||||
|
||||
**Umsteigen von einem Container pro Prozess** muss nicht sofort sein: Das Image betreibt jeden
|
||||
dieser Dienste weiterhin wie bisher, und der separate Hub aus `dunglas/mercure` funktioniert auch
|
||||
weiterhin. Ersetze die Compose-Datei durch die aktuelle, wenn es dir passt. Der Hub behält seinen
|
||||
Verlauf, weil der Worker dasselbe Volume `mercure_data` einhängt.
|
||||
|
||||
**Der typische Fehlerfall ist, den Prozess `scheduler` wegzulassen**, mit `--only` oder
|
||||
`--without`. Ohne ihn wird überhaupt nichts Wiederkehrendes ausgelöst — kein Abgleich per Polling,
|
||||
kein Aufwachen zurückgestellter Konversationen, kein Kalenderabgleich, keine Erinnerungen, kein
|
||||
Aufräumen — und nirgends erscheint ein Fehler, weil nichts fehlgeschlagen ist.
|
||||
`php bin/console debug:scheduler` listet auf, was laufen sollte.
|
||||
|
||||
## Speicher, und was die Standarddatei nicht dauerhaft ablegt
|
||||
|
||||
@@ -160,11 +187,11 @@ Die Compose-Datei deklariert zehn benannte Volumes:
|
||||
|
||||
| Volume | Enthält |
|
||||
|---|---|
|
||||
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** App-Dienst eingehängt, in `database` und `mercure` nur lesend |
|
||||
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** App-Dienst eingehängt, in `database` nur lesend |
|
||||
| `app_attachments`, `app_raw`, `app_uploads` | Anhänge, Rohnachrichten und zwischengelagerte JMAP-Uploads. Von jedem App-Dienst eingehängt, denn die Worker schreiben sie und der Web-Container liefert sie aus |
|
||||
| `database_data` | Der PostgreSQL-Cluster |
|
||||
| `caddy_data`, `caddy_config` | Caddys TLS-Material und Zustand |
|
||||
| `mercure_data`, `mercure_config` | Zustand des Hubs |
|
||||
| `mercure_data`, `mercure_config` | Zustand des Hubs, vom Worker eingehängt |
|
||||
| `ntfy_data` | Zustand der Benachrichtigungs-Topics |
|
||||
|
||||
Die drei Blob-Volumes fehlten bis vor Kurzem, und der Fehler war auf lehrreiche Weise lautlos. Das
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/reverse-proxy.md sha1:97f1ea6185294b50988ab2e0a9dc93ff18ee8b74 -->
|
||||
<!-- translated-from: install/reverse-proxy.md sha1:0c7b41ef1eee378b74f5810cd9189eab728c4a1a -->
|
||||
# Hinter einem Reverse-Proxy
|
||||
|
||||
Alles, was von außerhalb des eigenen Netzes erreichbar ist, will ein echtes Zertifikat davor, und
|
||||
@@ -109,8 +109,8 @@ Redirect-URI-Abweichung nennt die URI, und das `http://` darin ist der ganze Hin
|
||||
## Mercures öffentliche URL
|
||||
|
||||
Der Hub wird auf der Origin der Anwendung selbst durchgereicht: `frankenphp/Caddyfile` leitet
|
||||
`/.well-known/mercure*` an den `mercure`-Container, sodass der Browser den Hub auf derselben Origin
|
||||
erreicht, das Subscriber-Cookie ein First-Party-Cookie ist und es nirgends eine CORS-Konfiguration
|
||||
`/.well-known/mercure*` an `mercure` weiter, den Worker-Container, der den Hub betreibt und auf
|
||||
diesen Namen antwortet. Der Browser erreicht den Hub so auf derselben Origin, das Subscriber-Cookie ein First-Party-Cookie ist und es nirgends eine CORS-Konfiguration
|
||||
gibt.
|
||||
|
||||
Zwei Variablen, und sie zeigen in entgegengesetzte Richtungen:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/troubleshooting.md sha1:376ffd5aba03bcdd113d715cfcca4ce64526fa1f -->
|
||||
<!-- translated-from: install/troubleshooting.md sha1:978f8c024df0873bd3f40023340d6b2667177efa -->
|
||||
# Fehlersuche
|
||||
|
||||
Was `/healthz` bedeutet, wie du eine hängende von einer leeren Warteschlange unterscheidest, wo die
|
||||
@@ -54,9 +54,10 @@ HTTP-Status.
|
||||
|
||||
## Die Warteschlange
|
||||
|
||||
Vier Transports auf dem Doctrine-Transport, jeder mit einem eigenen Worker-Prozess:
|
||||
Vier Transports auf dem Doctrine-Transport, jeder mit einem eigenen Consumer-Prozess im
|
||||
Worker-Container:
|
||||
|
||||
| Warteschlange | Container | Was darauf liegt |
|
||||
| Warteschlange | Prozess | Was darauf liegt |
|
||||
|---|---|---|
|
||||
| `export` | `worker-export` | Alles, was plMail verlässt — Versand, Flag-Pushes, Gmail-Label-Änderungen, Mail aus dem Notifier. Die einzige Warteschlange, auf die jemand wartet |
|
||||
| `ingest` | `worker-ingest` | Eingehende Mail, Gmail- und Graph-Nachrichten-Batches, Kalenderabgleiche, Terminerkennung |
|
||||
@@ -163,10 +164,13 @@ Dashboard mit toten Workern füllt, die nie wirklich tot waren.
|
||||
|
||||
```bash
|
||||
docker compose logs -f php
|
||||
docker compose logs -f worker-ingest
|
||||
docker compose logs -f scheduler
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
Alles, was nicht der Webserver ist, protokolliert über den Worker: die Warteschlangen-Consumer, der
|
||||
Scheduler, der IMAP-Supervisor und der Hub. Jeder JSON-Eintrag nennt den Prozess, von dem er stammt,
|
||||
ein Filter auf `worker-ingest` oder `scheduler` findet also genau einen davon.
|
||||
|
||||
Der Haupt-Handler ist `fingers_crossed` auf `error` mit einem Puffer von 50 Nachrichten, das heißt,
|
||||
gewöhnliche Info-Zeilen werden verworfen — *bis* etwas fehlschlägt, und dann werden die 50
|
||||
vorangegangenen Nachrichten mit ausgegeben. Deshalb kommt ein Fehler im Log meist mit seinem eigenen
|
||||
@@ -250,6 +254,6 @@ Installation, deren unlesbare Daten wirklich entbehrlich sind, neu anfangen kann
|
||||
des Verschlüsselungsschlüssels lässt es bewusst zu — siehe
|
||||
[CONTRIBUTING](../../CONTRIBUTING.md#when-the-keys-disagree).
|
||||
|
||||
**Logs aus `docker compose logs php` sind nur die des Web-Containers.** Sechs Container laufen mit
|
||||
demselben Image, und die interessante Zeile steht meist in dem Worker, dem die Warteschlange gehört,
|
||||
auf der die Arbeit lag.
|
||||
**Logs aus `docker compose logs php` sind nur die des Web-Containers.** Die interessante Zeile steht
|
||||
meist in dem Worker-Prozess, dem die Warteschlange gehört, auf der die Arbeit lag, und die
|
||||
protokollieren alle über `docker compose logs worker`.
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/upgrading.md sha1:5ff93f95aaf246cd1cfa30883c1381c2722131ea -->
|
||||
<!-- translated-from: install/upgrading.md sha1:cd56ca54927898f35075d3948a2deb9257fc0a4a -->
|
||||
# Aktualisieren
|
||||
|
||||
plMail migriert seine eigene Datenbank beim Start. Diese eine Entscheidung prägt alles auf dieser
|
||||
@@ -10,16 +10,17 @@ Tag zu ziehen.
|
||||
## Migrationen laufen automatisch, bei jedem Start
|
||||
|
||||
`frankenphp/docker-entrypoint.sh` wartet auf die Datenbank und führt dann in jedem
|
||||
Anwendungscontainer `app:db:migrate` aus — im Webserver, im IMAP-Supervisor, in allen vier Workern.
|
||||
Anwendungscontainer `app:db:migrate` aus — im Webserver und im Worker, und in jedem Container eines
|
||||
älteren Aufbaus, der einen Prozess pro Container betrieb.
|
||||
Dieser Befehl ist `doctrine:migrations:migrate --all-or-nothing --no-interaction` mit einer
|
||||
Ergänzung: Er hält für den gesamten Lauf einen Postgres-Advisory-Lock.
|
||||
|
||||
Der Lock ist keine Zierde. Sechs Container starten im Abstand von Millisekunden gegen eine
|
||||
Datenbank, alle sechs lesen das Migrationsregister, bevor einer von ihnen hineingeschrieben hat, und
|
||||
alle sechs entscheiden, dass dieselbe Migration aussteht. Ohne den Lock gewinnt einer, die übrigen
|
||||
blockieren an seiner Tabellensperre, werden bei seinem Commit freigegeben und sterben an einem
|
||||
Schema, das sich bereits bewegt hat — `SQLSTATE[42701]: Duplicate column`. Unter `set -e` sind das
|
||||
fünf Dienste, die nie starten. Mit dem Lock warten die Verlierer, lesen ein bereits aktuelles
|
||||
Der Lock ist keine Zierde. Die Anwendungscontainer starten im Abstand von Millisekunden gegen eine
|
||||
Datenbank, alle lesen das Migrationsregister, bevor einer von ihnen hineingeschrieben hat, und alle
|
||||
entscheiden, dass dieselbe Migration aussteht. Ohne den Lock gewinnt einer, die übrigen blockieren
|
||||
an seiner Tabellensperre, werden bei seinem Commit freigegeben und sterben an einem Schema, das
|
||||
sich bereits bewegt hat — `SQLSTATE[42701]: Duplicate column`. Unter `set -e` sind das Dienste, die
|
||||
nie starten. Es waren fünf, solange jeder Prozess seinen eigenen Container hatte. Mit dem Lock warten die Verlierer, lesen ein bereits aktuelles
|
||||
Register und beenden sich, ohne etwas gefunden zu haben.
|
||||
|
||||
Daraus folgen drei Dinge, und sie sind der Grund für diese Seite:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: internals/architecture.md sha1:d211816996a11ea7432dcd0b9925abb7c42dfa45 -->
|
||||
<!-- translated-from: internals/architecture.md sha1:ad354438d897072ff5d4a852b98d222881661e2b -->
|
||||
# Architektur
|
||||
|
||||
Die Schichten, was wo liegt, und die Regeln, die das so halten. Diese Seite beschreibt die
|
||||
@@ -178,7 +178,7 @@ Workers, damit ein verzögerter Retry nie auf einen Neustart warten muss.
|
||||
Getrennte Transports allein genügen nicht. Ein Worker, der bereits in einem langen Handler
|
||||
steckt, kann eine Sendung nicht aufnehmen, wie hoch sie auch priorisiert ist — also hat jeder
|
||||
Transport seinen eigenen Prozess: `worker-export`, `worker-ingest`, `worker-maintenance` und
|
||||
`worker-bulk` in `compose.yaml`. Ein fünfter Transport, `async`, wird ohne Routing
|
||||
`worker-bulk`, die `app:work` nebeneinander im Worker-Container betreibt. Ein fünfter Transport, `async`, wird ohne Routing
|
||||
weitergeführt, damit Envelopes, die vor der Aufteilung eingereiht wurden, noch irgendwo landen
|
||||
können; der Maintenance-Worker leert ihn.
|
||||
|
||||
@@ -221,7 +221,7 @@ Referenz auf einen Manager, den es nicht mehr gibt.
|
||||
|
||||
`App\Infrastructure\Scheduler\MaintenanceSchedule` ist die eine Stelle, an der wiederkehrende
|
||||
Arbeit deklariert wird. Konsumiert wird sie von `messenger:consume scheduler_default` — dem
|
||||
Dienst `scheduler` in `compose.yaml` — und **sonst läuft davon nichts**, was genau der Zustand
|
||||
Prozess `scheduler` im Worker-Container — und **sonst läuft davon nichts**, was genau der Zustand
|
||||
war, in dem das Projekt vorher steckte: Logs und verwaiste Blobs wuchsen ohne Grenze.
|
||||
|
||||
| Cron | Befehl | Warum diese Taktung |
|
||||
@@ -282,8 +282,8 @@ einem HTTP-Request herauszuholen, bleibt damit stillschweigend darin.
|
||||
`CalendarSubscriberTest` prüft das Routing; eine neue Message bekommt weder das eine noch das
|
||||
andere geschenkt.
|
||||
|
||||
**Ein neuer Befehl in `MaintenanceSchedule` tut nichts, solange der Dienst `scheduler` nicht
|
||||
läuft.** Das ist ein eigener Container, der `scheduler_default` konsumiert. `php bin/console
|
||||
**Ein neuer Befehl in `MaintenanceSchedule` tut nichts, solange der Prozess `scheduler` nicht
|
||||
läuft.** Er ist einer der Prozesse des Worker-Containers und konsumiert `scheduler_default`. `php bin/console
|
||||
debug:scheduler` zeigt den nächsten Lauf jedes Eintrags und ist der schnellste Weg
|
||||
herauszufinden, dass die Antwort „nie" lautet.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: providers/imap-smtp.md sha1:9c6e34825ac15a5d17b1754a85fb81a4335d4c27 -->
|
||||
<!-- translated-from: providers/imap-smtp.md sha1:a63a352201b6840dd517647e669c8fd3ac19fec8 -->
|
||||
|
||||
# IMAP und SMTP
|
||||
|
||||
@@ -56,8 +56,8 @@ Protokolls selbst zu sagen „melde dich, wenn sich etwas ändert", statt alle p
|
||||
nachzufragen. Eine Nachricht, die auf dem Server eintrifft, erreicht plMail in Sekunden.
|
||||
|
||||
Es gibt nichts einzuschalten. Wenn plMail die Ordner eines Kontos entdeckt, markiert es die, die der
|
||||
Server als Posteingang und als Spam-Ordner kennzeichnet, als IDLE-fähig, und der Dienst
|
||||
`imap-supervisor` betreibt eine Verbindung pro solchem Ordner und startet abgebrochene mit einem
|
||||
Server als Posteingang und als Spam-Ordner kennzeichnet, als IDLE-fähig, und der IMAP-Supervisor
|
||||
(einer der Prozesse des Worker-Containers) betreibt eine Verbindung pro solchem Ordner und startet abgebrochene mit einem
|
||||
kurzen Backoff neu. Andere Ordner werden synchronisiert, aber nicht beobachtet, und das ist eine
|
||||
bewusste Sparsamkeit: Eine IDLE-Verbindung ist ein gehaltener TCP-Socket, und dreißig davon pro Konto
|
||||
zu halten, um von Mail zu erfahren, die in einen Archivordner wandert, kostet mehr, als es wert ist.
|
||||
@@ -67,8 +67,9 @@ dafür, dass Mail sofort eintrifft; der Durchlauf sorgt dafür, dass sie überha
|
||||
Verbindung von einer Firewall gekappt wurde oder ein Anbieter beschlossen hat, IDLE eine Weile nicht
|
||||
mehr zu beantworten.
|
||||
|
||||
Wenn Mail nicht von selbst eintrifft, ist das Erste zu prüfen, ob der Container `imap-supervisor`
|
||||
läuft. Ohne ihn hält nichts eine IDLE-Verbindung, und Mail trifft nur im Viertelstundentakt ein.
|
||||
Wenn Mail nicht von selbst eintrifft, ist das Erste zu prüfen, ob der Worker-Container läuft und
|
||||
ob das Feld „Prozesse“ im Administrationsbereich den IMAP-Supervisor schlagen zeigt. Ohne ihn hält
|
||||
nichts eine IDLE-Verbindung, und Mail trifft nur im Viertelstundentakt ein.
|
||||
|
||||
## App-Passwörter bei den großen Anbietern
|
||||
|
||||
@@ -151,7 +152,7 @@ Viertelstunden-Durchlauf ein. Eine Nachricht, die vier Minuten nachdem eine Rege
|
||||
in einen Unterordner einsortiert hat in plMail auftaucht, ist genau das und funktioniert wie
|
||||
vorgesehen.
|
||||
|
||||
**Ohne den Dienst `imap-supervisor` hält nichts eine IDLE-Verbindung.** Mail trifft weiterhin ein,
|
||||
**Ohne den IMAP-Supervisor hält nichts eine IDLE-Verbindung.** Mail trifft weiterhin ein,
|
||||
nach Zeitplan, was das zu einem sehr leisen Fehlschlag macht — das Symptom ist „Mail ist immer ein
|
||||
paar Minuten spät" und nichts, was kaputt aussieht.
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ These are the variables in `.env`, in the order they appear there.
|
||||
| `MERCURE_URL` | The hub address the **app** publishes to, inside the Docker network. | `http://mercure/.well-known/mercure` | Yes | Wrong and nothing publishes: no live updates, no visible error on the page. |
|
||||
| `MERCURE_PUBLIC_URL` | The hub address the **browser** subscribes to. | `https://localhost/.well-known/mercure` in both `.env` and `compose.yaml` | Yes | Wrong and the browser opens a stream to somewhere it cannot reach — mail lists stop updating by themselves while the rest of the app works. Derived from `APP_PUBLIC_URL` only when this is unset or empty, which the stock `compose.yaml` prevents. See [Behind a reverse proxy](reverse-proxy.md). |
|
||||
| `MERCURE_JWT_SECRET` | Signs the publisher and subscriber JWTs. | blank — 32 random bytes, hex, generated on first start | Yes, but generated | The app and the hub must hold the same value. They disagree and the hub rejects every subscriber, silently, from the browser's point of view. |
|
||||
| `MERCURE_COOKIE_NAME` | The name of the cookie that lets the browser subscribe. The app sets it and the hub reads it, both from this one variable. | blank — `__Secure-mercure_access_token` | Only without TLS | An install serving plain HTTP, with no TLS anywhere in front, needs a name without the `__Secure-` prefix, such as `mercure_access_token`: a browser drops a `__Secure-` cookie that did not arrive over HTTPS, and live updates never start. |
|
||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client. | blank | For Gmail | Without it, "Sign in with Google" cannot start. A value stored under **Admin → Integrations** wins over this one. See [Google](../providers/google.md). |
|
||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret. | blank | For Gmail | As above. |
|
||||
| `GMAIL_PUBSUB_TOPIC` | Full topic name Gmail publishes watch notifications to. | `projects/your-project-id/topics/gmail-push` — a placeholder, not a working value | For instant Gmail | The placeholder is not a topic you own, so every `watch` call fails. The project id is lowercase and often differs from the display name. |
|
||||
|
||||
@@ -78,8 +78,8 @@ works; writing `${APP_DEMO_MODE:-1}` into a compose file does not, and fails in
|
||||
stack comes up healthy and simply is not a demo. So the overlay hardcodes it, and
|
||||
`ComposeEnvironmentTest` keeps it that way.
|
||||
|
||||
It also parks `imap-supervisor`, which would otherwise spend the whole time failing to connect to
|
||||
mailboxes a demo does not have.
|
||||
It also leaves the IMAP supervisor out of the worker (`app:work --without=imap-supervisor`), which
|
||||
would otherwise spend the whole time failing to connect to mailboxes a demo does not have.
|
||||
|
||||
The install still needs a first user before anything is reachable — `/install` or `app:setup`, as
|
||||
on any other deployment. That user is yours, not a visitor's: the reaper only ever deletes
|
||||
|
||||
+43
-17
@@ -18,7 +18,8 @@ emulation. Which machine you are on changes a few details — see [Platform note
|
||||
|
||||
Sizing is worth a thought rather than a rule. PHP's `memory_limit` is `2G` in the image, each of the
|
||||
four Messenger workers is capped by `--memory-limit=256M` and recycles on `--time-limit=3600`, and
|
||||
Postgres, the Mercure hub and the IMAP supervisor each want their own share on top. A first sync of
|
||||
Postgres, the Mercure hub and the IMAP supervisor each want their own share on top. The workers, the
|
||||
hub and the IMAP supervisor share one container, so a memory limit on it covers all of them. A first sync of
|
||||
a large mailbox is the busiest this ever gets.
|
||||
|
||||
That is enough for IMAP mailboxes. Gmail and Outlook additionally need OAuth credentials — see
|
||||
@@ -63,14 +64,14 @@ knowing about:
|
||||
1. **`secrets-init` runs and exits.** It mints `APP_SECRET`, `APP_ENCRYPTION_KEY`,
|
||||
`POSTGRES_PASSWORD` and `MERCURE_JWT_SECRET` into `var/secrets/generated.env` on the shared
|
||||
`app_secrets` volume, plus a bare `postgres_password` file. It runs before everything else
|
||||
because Postgres and Mercure read their secrets when their containers are created and cannot
|
||||
wait for the app to hand them one. On every later start it finds the file and does nothing.
|
||||
because Postgres reads its password when its container is created and cannot wait for the app
|
||||
to hand it one. On every later start it finds the file and does nothing.
|
||||
2. **`database` starts** and reads its password through `POSTGRES_PASSWORD_FILE`. Every app service
|
||||
waits on its healthcheck, which allows a 60-second start period.
|
||||
3. **Each app container runs the same entrypoint.** It loads the generated secrets, assembles
|
||||
`DATABASE_URL` from `POSTGRES_PASSWORD` unless you supplied a DSN with a password of your own,
|
||||
waits up to 60 attempts for the database, then runs `app:db:migrate` — Doctrine's migrate under a
|
||||
Postgres advisory lock, so the six containers booting together cannot collide.
|
||||
Postgres advisory lock, so the containers booting together cannot collide.
|
||||
4. **`app:secrets:init` runs**, after migrations. It verifies that the encryption key in force can
|
||||
decrypt the credentials already stored, then generates a VAPID keypair and the JMAP JWT keypair
|
||||
if they are missing.
|
||||
@@ -117,26 +118,51 @@ from the environment, or reach the setup page through the address you actually i
|
||||
|
||||
| Service | What it runs | Why it is its own container |
|
||||
|---|---|---|
|
||||
| `secrets-init` | `generate-secrets`, then exits | Postgres and Mercure need their secrets at container-create time, before the app exists |
|
||||
| `php` | FrankenPHP serving the app | The only app service with an HTTP server, and therefore the only one whose image healthcheck is left enabled — the others disable it and report liveness through heartbeats instead |
|
||||
| `secrets-init` | `generate-secrets`, then exits | Postgres needs its password at container-create time, before the app exists |
|
||||
| `php` | FrankenPHP serving the app | The only app service with an HTTP server, and therefore the only one whose image healthcheck is left enabled — the worker disables it and reports liveness through heartbeats instead |
|
||||
| `worker` | `app:work`: every background process, one process each | Everything that is not the web server. It answers to `mercure` on the network, because it runs the hub |
|
||||
| `database` | `postgres:18-alpine` with `pg_stat_statements` preloaded | — |
|
||||
| `mercure` | The Mercure hub | Live updates — the mail list refreshing by itself |
|
||||
| `ntfy` | ntfy, under the `push` profile | Optional. Android push without Google — start it with `docker compose --profile push up -d` |
|
||||
|
||||
The worker's processes. Each is started again when it stops: at once when it stopped cleanly (a
|
||||
consumer recycling at its time or memory limit), after a backoff of up to thirty seconds when it
|
||||
crashed.
|
||||
|
||||
| Process | What it runs | What it is for |
|
||||
|---|---|---|
|
||||
| `mercure` | The Mercure hub, 1.x | Live updates — the mail list refreshing by itself |
|
||||
| `imap-supervisor` | `app:imap:supervise` | Spawns and watches one `app:imap:idle` process per IDLE-enabled mailbox, so standard IMAP mail arrives the moment it lands |
|
||||
| `worker-export` | `messenger:consume export` | Anything leaving plMail, and the only queue somebody is watching. On its own process so a send is never behind a sync |
|
||||
| `worker-ingest` | `messenger:consume ingest` | Mail arriving and the work that immediately follows it |
|
||||
| `worker-maintenance` | `messenger:consume maintenance async` | Backfills, rule runs over existing mail, admin sweeps. Also drains the retired `async` queue |
|
||||
| `worker-bulk` | `messenger:consume bulk` | Whole-view actions: mark every unread read, archive the lot. On its own process because somebody is watching an indicator for it and it must not wait behind a backfill |
|
||||
| `scheduler` | `messenger:consume scheduler_default` | Fires everything recurring. **Nothing schedules itself without this container** |
|
||||
| `ntfy` | ntfy, under the `push` profile | Optional. Android push without Google — start it with `docker compose --profile push up -d` |
|
||||
| `scheduler` | `messenger:consume scheduler_default` | Fires everything recurring. **Nothing schedules itself without this process** |
|
||||
|
||||
Four processes rather than four transports on one worker, because a worker already inside a long
|
||||
Four queue processes rather than four transports on one, because a worker already inside a long
|
||||
handler cannot pick up anything else however the queues are prioritised. That was the original
|
||||
problem: pressing Send behind a Gmail batch waited for the batch.
|
||||
problem: pressing Send behind a Gmail batch waited for the batch. They used to be one container
|
||||
each; they are one process each now, which is the part that mattered. Each keeps its old name as
|
||||
`APP_CONTAINER_NAME`, which the heartbeats, `/healthz` and the admin log viewer go by, so they read
|
||||
exactly as they did.
|
||||
|
||||
**The failure mode is dropping the `scheduler` service.** Without it nothing recurring fires at all
|
||||
— no polling sync, no snooze wake, no calendar sync, no reminders, no pruning — and there is no
|
||||
error anywhere, because nothing failed. `php bin/console debug:scheduler` lists what should be
|
||||
running.
|
||||
The hub is the official Mercure 1.x binary, copied into the plMail image rather than run from its
|
||||
own. It is not the hub FrankenPHP has built in, which is a 0.x release, and plMail speaks the 1.0
|
||||
protocol.
|
||||
|
||||
**Splitting it up again** is `app:work --only=…` and `--without=…`. A container running
|
||||
`app:work --only=worker-bulk` beside one running `app:work --without=worker-bulk` gives the bulk
|
||||
queue a container, and a memory limit, of its own. Only one container may run `mercure`, and it is
|
||||
the one that needs the `mercure` network alias.
|
||||
|
||||
**Upgrading from one container per process** needs nothing at once: the image still runs each of
|
||||
those services as it did, and the separate `dunglas/mercure` hub still works. Replace the compose
|
||||
file with the current one when convenient. The hub keeps its history, because the worker mounts the
|
||||
same `mercure_data` volume.
|
||||
|
||||
**The failure mode is leaving the `scheduler` process out** with `--only` or `--without`. Without
|
||||
it nothing recurring fires at all — no polling sync, no snooze wake, no calendar sync, no
|
||||
reminders, no pruning — and there is no error anywhere, because nothing failed.
|
||||
`php bin/console debug:scheduler` lists what should be running.
|
||||
|
||||
## Storage, and what the stock file does not persist
|
||||
|
||||
@@ -144,11 +170,11 @@ The compose file declares ten named volumes:
|
||||
|
||||
| Volume | Holds |
|
||||
|---|---|
|
||||
| `app_secrets` | `generated.env`, `postgres_password`, the JWT keypair. Mounted by **every** app service, and read-only into `database` and `mercure` |
|
||||
| `app_secrets` | `generated.env`, `postgres_password`, the JWT keypair. Mounted by **every** app service, and read-only into `database` |
|
||||
| `app_attachments`, `app_raw`, `app_uploads` | Attachments, raw message sources, and staged JMAP uploads. Mounted by every app service, because the workers write them and the web container serves them |
|
||||
| `database_data` | The PostgreSQL cluster |
|
||||
| `caddy_data`, `caddy_config` | Caddy's TLS material and state |
|
||||
| `mercure_data`, `mercure_config` | Hub state |
|
||||
| `mercure_data`, `mercure_config` | Hub state, mounted by the worker |
|
||||
| `ntfy_data` | Notification topic state |
|
||||
|
||||
The three blob volumes were missing until recently, and the failure was silent in an instructive
|
||||
|
||||
@@ -101,7 +101,8 @@ and the `http://` in it is the entire clue.
|
||||
## Mercure's public URL
|
||||
|
||||
The hub is proxied on the app's own origin: `frankenphp/Caddyfile` routes `/.well-known/mercure*`
|
||||
to the `mercure` container, so the browser reaches the hub same-origin, the subscriber cookie is a
|
||||
to `mercure`, the worker container, which runs the hub and answers to that name. The browser reaches
|
||||
the hub same-origin, the subscriber cookie is a
|
||||
first-party cookie, and no CORS configuration exists anywhere.
|
||||
|
||||
Two variables, and they point in opposite directions:
|
||||
|
||||
@@ -49,9 +49,10 @@ answer 200. Watch the `checks` object, not just the HTTP status.
|
||||
|
||||
## The queue
|
||||
|
||||
Four transports on the Doctrine transport, each with its own worker process:
|
||||
Four transports on the Doctrine transport, each with its own consumer process in the worker
|
||||
container:
|
||||
|
||||
| Queue | Container | What is on it |
|
||||
| Queue | Process | What is on it |
|
||||
|---|---|---|
|
||||
| `export` | `worker-export` | Anything leaving plMail — sends, flag pushes, Gmail label changes, mail from the notifier. The only queue somebody is waiting on |
|
||||
| `ingest` | `worker-ingest` | Mail arriving, Gmail and Graph message batches, calendar syncs, event extraction |
|
||||
@@ -151,10 +152,13 @@ really dead.
|
||||
|
||||
```bash
|
||||
docker compose logs -f php
|
||||
docker compose logs -f worker-ingest
|
||||
docker compose logs -f scheduler
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
Everything that is not the web server logs through the worker: the queue consumers, the scheduler,
|
||||
the IMAP supervisor and the hub. Each JSON record names the process it came from, so a filter on
|
||||
`worker-ingest` or `scheduler` finds one of them.
|
||||
|
||||
The main handler is `fingers_crossed` at `error` with a 50-message buffer, which means routine info
|
||||
lines are discarded *until* something errors — and then the 50 preceding messages are flushed with
|
||||
it. That is why an error in the log usually arrives with its own context attached, and why you will
|
||||
@@ -232,5 +236,6 @@ every user, the stored files. It exists so an install whose unreadable data is g
|
||||
can start over, and the encryption-key probe deliberately allows it to run — see
|
||||
[CONTRIBUTING](../../CONTRIBUTING.md#when-the-keys-disagree).
|
||||
|
||||
**Logs from `docker compose logs php` are only the web container's.** Six containers run the same
|
||||
image, and the interesting line is usually in whichever worker owns the queue the work was on.
|
||||
**Logs from `docker compose logs php` are only the web container's.** The interesting line is
|
||||
usually in whichever worker process owns the queue the work was on, and those all log through
|
||||
`docker compose logs worker`.
|
||||
@@ -8,15 +8,17 @@ rather than pulling the previous tag.
|
||||
## Migrations run automatically, on every boot
|
||||
|
||||
`frankenphp/docker-entrypoint.sh` waits for the database and then runs `app:db:migrate` in every
|
||||
app container — the web server, the IMAP supervisor, all four workers. That command is
|
||||
app container — the web server and the worker, and each container of an older layout that ran
|
||||
one process per container. That command is
|
||||
`doctrine:migrations:migrate --all-or-nothing --no-interaction` with one addition: it holds a
|
||||
Postgres advisory lock for the whole run.
|
||||
|
||||
The lock is not decoration. Six containers start within milliseconds of each other against one
|
||||
database, all six read the migration ledger before any of them has written to it, and all six
|
||||
The lock is not decoration. The app containers start within milliseconds of each other against one
|
||||
database, all of them read the migration ledger before any has written to it, and all of them
|
||||
decide the same migration is pending. Without the lock one wins, the rest block on its table lock,
|
||||
are released when it commits, and die on a schema that has already moved —
|
||||
`SQLSTATE[42701]: Duplicate column`. Under `set -e` that is five services that never start. With
|
||||
`SQLSTATE[42701]: Duplicate column`. Under `set -e` those are services that never start. It was
|
||||
five of them when every process had a container of its own. With
|
||||
it, the losers wait, read a ledger that is already current, and exit having found nothing to do.
|
||||
|
||||
Three consequences follow, and they are the reason this page exists:
|
||||
|
||||
@@ -163,7 +163,8 @@ delayed retry is never left waiting on a restart.
|
||||
|
||||
Separate transports are not enough on their own. A worker already inside a long handler
|
||||
cannot pick up a send however it is prioritised, so each transport has its own process:
|
||||
`worker-export`, `worker-ingest`, `worker-maintenance` and `worker-bulk` in `compose.yaml`. A
|
||||
`worker-export`, `worker-ingest`, `worker-maintenance` and `worker-bulk`, which `app:work` runs side by side
|
||||
in the worker container. A
|
||||
fifth transport, `async`, is kept routing nothing, so envelopes queued before the split still
|
||||
have somewhere to land; the maintenance worker drains it.
|
||||
|
||||
@@ -201,8 +202,8 @@ envelopes, so a serialised entity is a reference to a manager that no longer exi
|
||||
## The scheduler
|
||||
|
||||
`App\Infrastructure\Scheduler\MaintenanceSchedule` is the one place recurring work is
|
||||
declared. It is consumed by `messenger:consume scheduler_default` — the `scheduler` service
|
||||
in `compose.yaml` — and **nothing runs these otherwise**, which is the state the project was
|
||||
declared. It is consumed by `messenger:consume scheduler_default` — the `scheduler` process
|
||||
in the worker container — and **nothing runs these otherwise**, which is the state the project was
|
||||
in before, with logs and orphaned blobs growing without bound.
|
||||
|
||||
| Cron | Command | Why that cadence |
|
||||
@@ -260,8 +261,8 @@ request silently stays in it. `config/packages/messenger.yaml` names this for
|
||||
`RegisterCalendarPushMessage` and `CalendarSubscriberTest` asserts the routing; a new message
|
||||
gets neither for free.
|
||||
|
||||
**A new command in `MaintenanceSchedule` does nothing until the `scheduler` service is
|
||||
running.** It is a separate container consuming `scheduler_default`. `php bin/console
|
||||
**A new command in `MaintenanceSchedule` does nothing until the `scheduler` process is
|
||||
running.** It is one of the worker container's processes, consuming `scheduler_default`. `php bin/console
|
||||
debug:scheduler` shows the next run of each, and is the fastest way to find out that the
|
||||
answer is "never".
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ plMail holds an **IMAP IDLE** connection to each mailbox, which is the protocol'
|
||||
server reaches plMail in seconds.
|
||||
|
||||
There is nothing to switch on. When plMail discovers an account's folders it marks the ones the
|
||||
server flags as the inbox and the junk folder as IDLE-enabled, and the `imap-supervisor` service
|
||||
runs one connection per such folder, restarting any that drop with a short backoff. Other folders
|
||||
server flags as the inbox and the junk folder as IDLE-enabled, and the IMAP supervisor (one of the
|
||||
worker container's processes) runs one connection per such folder, restarting any that drop with a short backoff. Other folders
|
||||
are synced but not watched, which is a deliberate economy: an IDLE connection is a held TCP socket,
|
||||
and holding thirty of them per account to learn about mail moving into an archive folder costs more
|
||||
than it is worth.
|
||||
@@ -61,9 +61,9 @@ Behind that, a scheduled sweep syncs every account every fifteen minutes regardl
|
||||
arrive immediately; the sweep is what makes it arrive at all when a connection has been dropped by a
|
||||
firewall or a provider has decided to stop answering IDLE for a while.
|
||||
|
||||
If mail is not arriving on its own, the thing to check first is that the `imap-supervisor` container
|
||||
is running. Without it, nothing holds an IDLE connection and mail arrives on the fifteen-minute
|
||||
schedule only.
|
||||
If mail is not arriving on its own, the thing to check first is that the worker container is
|
||||
running, and that the admin dashboard's Processes panel shows the IMAP supervisor beating. Without
|
||||
it, nothing holds an IDLE connection and mail arrives on the fifteen-minute schedule only.
|
||||
|
||||
## App passwords at the big providers
|
||||
|
||||
@@ -142,7 +142,7 @@ to look when one mailbox has quietly stopped.
|
||||
fifteen-minute sweep. A message that appears in plMail four minutes after a rule filed it into a
|
||||
subfolder on the server is this, working as designed.
|
||||
|
||||
**Without the `imap-supervisor` service, nothing holds an IDLE connection.** Mail still arrives, on
|
||||
**Without the IMAP supervisor, nothing holds an IDLE connection.** Mail still arrives, on
|
||||
the schedule, which makes this a very quiet failure — the symptom is "mail is always a few minutes
|
||||
late" rather than anything that looks broken.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ final class GenerateVapidKeysCommand extends Command
|
||||
|
||||
$io->section('Container deployment — set as environment variables instead');
|
||||
$io->writeln('There is no .env.local to edit in a container image. Pass these as env');
|
||||
$io->writeln('vars on the php, messenger-worker and imap-supervisor services; a real');
|
||||
$io->writeln('vars on the php and worker services; a real');
|
||||
$io->writeln('environment variable overrides the .env.local.php that "composer');
|
||||
$io->writeln('dump-env prod" bakes into the image. On TrueNAS they live in the');
|
||||
$io->writeln('x-config block of truenas.compose.yaml.');
|
||||
|
||||
@@ -18,11 +18,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* `doctrine:migrations:migrate`, but only one container at a time.
|
||||
*
|
||||
* A plMail stack starts four containers from the same image — php,
|
||||
* imap-supervisor, messenger-worker and scheduler — and every one of them runs
|
||||
* A plMail stack starts more than one container from the same image — php
|
||||
* and the worker, and more where an installation splits its queues or still
|
||||
* runs the older one-container-per-process layout — and every one of them runs
|
||||
* `frankenphp/docker-entrypoint.sh`, which migrates on boot. They come up
|
||||
* within milliseconds of each other against one database, all four read the
|
||||
* ledger before any of them has written to it, and all four decide the same
|
||||
* within milliseconds of each other against one database, all of them read the
|
||||
* ledger before any has written to it, and all of them decide the same
|
||||
* migration is pending. One wins; the rest block on the table lock it holds,
|
||||
* are released when it commits, and then fail on a schema that has already
|
||||
* moved:
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command\System;
|
||||
|
||||
use App\Domain\DTO\System\BackgroundProcess;
|
||||
use App\Service\System\Work\BackgroundProcesses;
|
||||
use App\Service\System\Work\ProcessSupervisor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Command\SignalableCommandInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* The worker container: every background process plMail runs, in one place.
|
||||
*
|
||||
* The queue consumers, the scheduler, the IMAP supervisor and the Mercure hub
|
||||
* used to be seven containers of one image. They are now the children of this
|
||||
* command, still one process each (see BackgroundProcesses for why that part
|
||||
* cannot change) and kept alive the way Docker kept the containers alive (see
|
||||
* ProcessSupervisor).
|
||||
*
|
||||
* --only and --without pick a subset, for the two cases that want one: an
|
||||
* installation that splits its busiest queue into a container of its own, and
|
||||
* the demo, which has no IMAP server for the supervisor to reach.
|
||||
*
|
||||
* Run it under an init (compose's `init: true`). The IMAP supervisor's own
|
||||
* children are this command's grandchildren, and if the supervisor dies hard,
|
||||
* something has to reap them.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:work',
|
||||
description: 'Run every background process in one container: the queue workers, the scheduler, the IMAP supervisor and the Mercure hub.',
|
||||
)]
|
||||
final class WorkCommand extends Command implements SignalableCommandInterface
|
||||
{
|
||||
/** Below the compose files' stop_grace_period of 30s, so Docker does not kill the lot first. */
|
||||
private const int GRACE_SECONDS = 25;
|
||||
|
||||
private bool $stopping = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly BackgroundProcesses $processes,
|
||||
private readonly ProcessSupervisor $supervisor,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('only', null, InputOption::VALUE_REQUIRED, 'Run only these, comma-separated (e.g. worker-bulk)')
|
||||
->addOption('without', null, InputOption::VALUE_REQUIRED, 'Run everything but these, comma-separated (e.g. imap-supervisor)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$chosen = $this->choose((string) $input->getOption('only'), (string) $input->getOption('without'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('app:work: running %s', implode(', ', array_map(
|
||||
static fn (BackgroundProcess $process): string => $process->name,
|
||||
$chosen,
|
||||
))));
|
||||
|
||||
$this->supervisor->run($chosen, fn (): bool => $this->stopping, self::GRACE_SECONDS);
|
||||
|
||||
$io->writeln('app:work: stopped');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function getSubscribedSignals(): array
|
||||
{
|
||||
return [SIGTERM, SIGINT];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop, but not here: `false` lets execute() finish, and it is execute()
|
||||
* that passes the signal on to every child and waits for them.
|
||||
*/
|
||||
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
|
||||
{
|
||||
$this->stopping = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<BackgroundProcess>
|
||||
*
|
||||
* @throws \InvalidArgumentException naming what is not a process, and what is
|
||||
*/
|
||||
private function choose(string $only, string $without): array
|
||||
{
|
||||
$known = $this->processes->names();
|
||||
$only = $this->names($only);
|
||||
$skip = $this->names($without);
|
||||
$wrong = array_diff([...$only, ...$skip], $known);
|
||||
|
||||
if ([] !== $wrong) {
|
||||
throw new \InvalidArgumentException(sprintf('No such process: %s. There are: %s.', implode(', ', $wrong), implode(', ', $known)));
|
||||
}
|
||||
|
||||
$chosen = array_values(array_filter(
|
||||
$this->processes->all(),
|
||||
static fn (BackgroundProcess $process): bool => ([] === $only || in_array($process->name, $only, true))
|
||||
&& false === in_array($process->name, $skip, true),
|
||||
));
|
||||
|
||||
if ([] === $chosen) {
|
||||
throw new \InvalidArgumentException('That leaves nothing to run.');
|
||||
}
|
||||
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function names(string $list): array
|
||||
{
|
||||
return array_values(array_filter(array_map(trim(...), explode(',', $list)), static fn (string $name): bool => '' !== $name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DTO\System;
|
||||
|
||||
/**
|
||||
* One long-running process the worker container keeps alive: what it is called
|
||||
* and what to run.
|
||||
*/
|
||||
final readonly class BackgroundProcess
|
||||
{
|
||||
/**
|
||||
* @param string $name what it answers to everywhere: the heartbeat key, the
|
||||
* log entries' container name, `app:work --only`
|
||||
* @param list<string> $command argv, run from the project directory
|
||||
* @param array<string, string> $env set on top of the container's own environment
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public array $command,
|
||||
public array $env = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\System\Work;
|
||||
|
||||
use App\Domain\DTO\System\BackgroundProcess;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Everything plMail runs besides the web server, as the worker container runs
|
||||
* it (app:work).
|
||||
*
|
||||
* ── Processes, not containers ───────────────────────────────────────────────
|
||||
* Each queue still gets a process of its own, for the reason it always did: a
|
||||
* consumer already inside a long handler cannot pick up anything else, however
|
||||
* the queues are prioritised, so a send would wait behind a sync. What changed
|
||||
* is only where the processes live. They used to be seven containers of one
|
||||
* image; now they are seven children of one supervisor.
|
||||
*
|
||||
* The names are the containers' old ones, deliberately. Each child gets its
|
||||
* name as APP_CONTAINER_NAME, which is what the heartbeats are keyed by and
|
||||
* what the admin log viewer shows, so the admin dashboard, /healthz and the log
|
||||
* filters read exactly as before.
|
||||
*
|
||||
* ── The hub ─────────────────────────────────────────────────────────────────
|
||||
* The Mercure hub is the one child that is not PHP: the 1.x hub binary the
|
||||
* image copies in (see the Dockerfile's mercure_upstream). It is configured
|
||||
* here the way the separate hub container used to be configured in compose:
|
||||
* plain HTTP on :80, the generated JWT secret as both keys, and the issuer and
|
||||
* audience pinned to `mercure.identifier` (config/services.yaml says why they
|
||||
* cannot be derived from the install's own address). The container answers to
|
||||
* `mercure` on the network, so the web container's proxy and MERCURE_URL reach
|
||||
* it where they always reached the hub.
|
||||
*/
|
||||
final readonly class BackgroundProcesses
|
||||
{
|
||||
public const string HUB = 'mercure';
|
||||
|
||||
/** The consumers' recycling, as compose ran them: hourly, or at 256 MB. */
|
||||
private const array CONSUMER_LIMITS = ['--time-limit=3600', '--memory-limit=256M'];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%mercure.identifier%')]
|
||||
private string $hubIdentifier,
|
||||
// The subscriber cookie's name, as the application sets it. The hub has
|
||||
// to look for the same one: `__Secure-…` by default, and whatever
|
||||
// MERCURE_COOKIE_NAME says on an install serving plain HTTP, where a
|
||||
// browser drops a `__Secure-` cookie. One setting now, where the hub
|
||||
// container needed its own copy of it.
|
||||
#[Autowire('%mercure.cookie_name%')]
|
||||
private string $cookieName = '__Secure-mercure_access_token',
|
||||
#[Autowire('%env(default::MERCURE_JWT_SECRET)%')]
|
||||
private ?string $hubSecret = null,
|
||||
#[Autowire('%env(default::MERCURE_TRUSTED_ISSUERS)%')]
|
||||
private ?string $trustedIssuers = null,
|
||||
#[Autowire('%env(default::MERCURE_EXTRA_DIRECTIVES)%')]
|
||||
private ?string $extraDirectives = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<BackgroundProcess> in start order: the hub first, so publishing has somewhere to go */
|
||||
public function all(): array
|
||||
{
|
||||
return [
|
||||
$this->hub(),
|
||||
new BackgroundProcess('imap-supervisor', ['php', 'bin/console', 'app:imap:supervise']),
|
||||
$this->consumer('worker-export', 'export'),
|
||||
$this->consumer('worker-ingest', 'ingest'),
|
||||
// Also drains `async`, the pre-split queue; see messenger.yaml.
|
||||
$this->consumer('worker-maintenance', 'maintenance', 'async'),
|
||||
$this->consumer('worker-bulk', 'bulk'),
|
||||
$this->consumer('scheduler', 'scheduler_default'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function names(): array
|
||||
{
|
||||
return array_map(static fn (BackgroundProcess $process): string => $process->name, $this->all());
|
||||
}
|
||||
|
||||
private function consumer(string $name, string ...$transports): BackgroundProcess
|
||||
{
|
||||
return new BackgroundProcess($name, ['php', 'bin/console', 'messenger:consume', ...$transports, ...self::CONSUMER_LIMITS]);
|
||||
}
|
||||
|
||||
private function hub(): BackgroundProcess
|
||||
{
|
||||
$secret = (string) $this->hubSecret;
|
||||
|
||||
return new BackgroundProcess(self::HUB, ['mercure', 'run', '--config', '/etc/mercure/Caddyfile', '--adapter', 'caddyfile'], [
|
||||
// Plain HTTP inside the stack; TLS, where there is any, is the web
|
||||
// container's or the reverse proxy's. Set here rather than
|
||||
// inherited: SERVER_NAME in this image is the WEB server's, and a
|
||||
// hub that picked up a real hostname would go asking for a
|
||||
// certificate.
|
||||
'SERVER_NAME' => ':80',
|
||||
'MERCURE_PUBLISHER_JWT_KEY' => $secret,
|
||||
'MERCURE_SUBSCRIBER_JWT_KEY' => $secret,
|
||||
'MERCURE_TRUSTED_ISSUERS' => $this->orDefault($this->trustedIssuers, $this->hubIdentifier),
|
||||
// The audience pinned rather than derived from each request: plMail
|
||||
// reaches the hub by two roads (the browser at the public URL, the
|
||||
// application at the internal one), and a hub working the audience
|
||||
// out per request would refuse every publish.
|
||||
'MERCURE_EXTRA_DIRECTIVES' => $this->orDefault(
|
||||
$this->extraDirectives,
|
||||
sprintf("resource_identifier %s\ncookie_name %s", $this->hubIdentifier, $this->cookieName),
|
||||
),
|
||||
// Five seconds to close its connections when told to stop, then it
|
||||
// closes them. Caddy's own default is to wait for every one, and a
|
||||
// live-update subscription is a connection that never ends by
|
||||
// itself: the worker took the whole 25-second grace period to stop,
|
||||
// every time, and then killed the hub anyway. Browsers reconnect.
|
||||
'GLOBAL_OPTIONS' => 'grace_period 5s',
|
||||
// The hub's Caddyfile shares these names with FrankenPHP's. Emptied
|
||||
// so nothing meant for the web server configures the hub.
|
||||
'CADDY_EXTRA_CONFIG' => '',
|
||||
'CADDY_SERVER_EXTRA_DIRECTIVES' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function orDefault(?string $value, string $default): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return '' === $value ? $default : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\System\Work;
|
||||
|
||||
use App\Domain\DTO\System\BackgroundProcess;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Keeps a set of processes running: what Docker's `restart: unless-stopped`
|
||||
* did for them when each was a container of its own.
|
||||
*
|
||||
* A child that exits is started again. One that exited cleanly (a consumer at
|
||||
* its hourly --time-limit, or told to by the restart signal) comes back at once;
|
||||
* one that failed backs off, from one second doubling to thirty, so a process
|
||||
* that cannot start (the database is down) does not spin. A child that has run
|
||||
* two minutes has its backoff forgotten, the rule app:imap:supervise uses for
|
||||
* its own children.
|
||||
*
|
||||
* ── Output ──────────────────────────────────────────────────────────────────
|
||||
* Every child's output is passed through as it is, a whole line at a time. In
|
||||
* production each line is a JSON log record, and two children writing at once
|
||||
* must not interleave inside one: that is a line nobody can parse. Nothing is
|
||||
* prefixed either, for the same reason; each child already names itself in its
|
||||
* records through APP_CONTAINER_NAME.
|
||||
*
|
||||
* ── Stopping ────────────────────────────────────────────────────────────────
|
||||
* SIGTERM to every child at once, then up to the grace period for them to
|
||||
* finish what they are doing (a consumer finishes its message, the hub closes
|
||||
* its connections), then SIGKILL for whatever is left. The container's
|
||||
* stop_grace_period has to be longer than this, or Docker kills the lot first.
|
||||
*/
|
||||
final class ProcessSupervisor
|
||||
{
|
||||
/** @var array<string, array{definition: BackgroundProcess, process: ?Process, startedAt: float, notBefore: float, backoff: float, restarts: int}> */
|
||||
private array $children = [];
|
||||
|
||||
/** @var array<string, string> partial lines, by child and stream */
|
||||
private array $partial = [];
|
||||
|
||||
/** @var \Closure(string, string): void */
|
||||
private \Closure $write;
|
||||
|
||||
/**
|
||||
* @param (\Closure(string, string): void)|null $write where a child's line goes: (stream, line).
|
||||
* stdout and stderr of this process by default.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
private readonly float $minBackoff = 1.0,
|
||||
private readonly float $maxBackoff = 30.0,
|
||||
private readonly float $forgiveAfter = 120.0,
|
||||
?\Closure $write = null,
|
||||
) {
|
||||
$this->write = $write ?? static function (string $stream, string $line): void {
|
||||
fwrite(Process::ERR === $stream ? STDERR : STDOUT, $line . "\n");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start every process, keep them running until $shouldStop says so, then
|
||||
* stop them all.
|
||||
*
|
||||
* @param list<BackgroundProcess> $processes
|
||||
* @param \Closure(): bool $shouldStop
|
||||
*/
|
||||
public function run(array $processes, \Closure $shouldStop, int $graceSeconds = 25): void
|
||||
{
|
||||
$this->start($processes);
|
||||
|
||||
while (false === $shouldStop()) {
|
||||
$this->tick();
|
||||
usleep(200_000);
|
||||
}
|
||||
|
||||
$this->stop($graceSeconds);
|
||||
}
|
||||
|
||||
/** @param list<BackgroundProcess> $processes */
|
||||
public function start(array $processes): void
|
||||
{
|
||||
foreach ($processes as $definition) {
|
||||
$this->children[$definition->name] = [
|
||||
'definition' => $definition,
|
||||
'process' => null,
|
||||
'startedAt' => 0.0,
|
||||
'notBefore' => 0.0,
|
||||
'backoff' => $this->minBackoff,
|
||||
'restarts' => -1,
|
||||
];
|
||||
|
||||
$this->launch($definition->name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass: pass on what every child has written, notice the ones that
|
||||
* have exited, and start again the ones whose wait is over.
|
||||
*/
|
||||
public function tick(): void
|
||||
{
|
||||
$now = microtime(true);
|
||||
|
||||
foreach ($this->children as $name => $child) {
|
||||
$process = $child['process'];
|
||||
|
||||
if (null === $process) {
|
||||
if ($now >= $child['notBefore']) {
|
||||
$this->launch($name);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// isRunning() is also what reads the pipes, and so what calls the
|
||||
// output callback: every child is asked every tick for that alone.
|
||||
if (true === $process->isRunning()) {
|
||||
if ($now - $child['startedAt'] >= $this->forgiveAfter) {
|
||||
$this->children[$name]['backoff'] = $this->minBackoff;
|
||||
}
|
||||
|
||||
// The callback has had it; do not keep a copy in memory for as
|
||||
// long as the child lives, which for the hub is weeks.
|
||||
$process->clearOutput();
|
||||
$process->clearErrorOutput();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->flush($name);
|
||||
$this->exited($name, $process->getExitCode(), $now);
|
||||
}
|
||||
}
|
||||
|
||||
public function stop(int $graceSeconds): void
|
||||
{
|
||||
foreach ($this->children as $child) {
|
||||
if (true === $child['process']?->isRunning()) {
|
||||
$child['process']->signal(SIGTERM);
|
||||
}
|
||||
}
|
||||
|
||||
$deadline = microtime(true) + $graceSeconds;
|
||||
|
||||
do {
|
||||
$running = 0;
|
||||
|
||||
foreach ($this->children as $child) {
|
||||
if (true === $child['process']?->isRunning()) {
|
||||
++$running;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $running) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(100_000);
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
foreach ($this->children as $name => $child) {
|
||||
if (true === $child['process']?->isRunning()) {
|
||||
$this->logger->warning('app:work: {process} did not stop in time and was killed', ['process' => $name]);
|
||||
$child['process']->stop(0);
|
||||
}
|
||||
|
||||
$this->flush($name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is running now, by name: its pid and how often it has been restarted.
|
||||
*
|
||||
* @return array<string, array{pid: ?int, restarts: int}>
|
||||
*/
|
||||
public function status(): array
|
||||
{
|
||||
$status = [];
|
||||
|
||||
foreach ($this->children as $name => $child) {
|
||||
$status[$name] = [
|
||||
'pid' => true === $child['process']?->isRunning() ? $child['process']->getPid() : null,
|
||||
'restarts' => max(0, $child['restarts']),
|
||||
];
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function launch(string $name): void
|
||||
{
|
||||
$definition = $this->children[$name]['definition'];
|
||||
|
||||
$process = new Process(
|
||||
$definition->command,
|
||||
$this->projectDir,
|
||||
['APP_CONTAINER_NAME' => $definition->name] + $definition->env,
|
||||
);
|
||||
$process->setTimeout(null);
|
||||
|
||||
try {
|
||||
$process->start(function (string $type, string $buffer) use ($name): void {
|
||||
$this->receive($name, $type, $buffer);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// A binary that is not there, a descriptor limit: counted as a
|
||||
// failed start, backed off like one.
|
||||
$this->logger->error('app:work: {process} could not be started: {error}', ['process' => $name, 'error' => $e->getMessage()]);
|
||||
$this->exited($name, null, microtime(true));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->children[$name]['process'] = $process;
|
||||
$this->children[$name]['startedAt'] = microtime(true);
|
||||
++$this->children[$name]['restarts'];
|
||||
}
|
||||
|
||||
private function exited(string $name, ?int $code, float $now): void
|
||||
{
|
||||
$child = $this->children[$name];
|
||||
|
||||
// Ran long enough to have been healthy: whatever ended it, it is not
|
||||
// the crash loop the backoff is for.
|
||||
$backoff = null !== $child['process'] && $now - $child['startedAt'] >= $this->forgiveAfter
|
||||
? $this->minBackoff
|
||||
: $child['backoff'];
|
||||
|
||||
// 0 is a consumer at its time or memory limit, or asked to restart:
|
||||
// the recycling Docker used to do, and it comes straight back.
|
||||
$delay = 0 === $code ? $this->minBackoff : $backoff;
|
||||
|
||||
if (0 === $code) {
|
||||
$this->logger->info('app:work: {process} exited cleanly, starting it again', ['process' => $name]);
|
||||
} else {
|
||||
$this->logger->warning('app:work: {process} exited with code {code}, starting it again in {delay}s', [
|
||||
'process' => $name,
|
||||
'code' => $code,
|
||||
'delay' => $delay,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->children[$name]['process'] = null;
|
||||
$this->children[$name]['notBefore'] = $now + $delay;
|
||||
$this->children[$name]['backoff'] = 0 === $code ? $this->minBackoff : min($backoff * 2, $this->maxBackoff);
|
||||
}
|
||||
|
||||
private function receive(string $name, string $type, string $buffer): void
|
||||
{
|
||||
$key = $name . '|' . $type;
|
||||
$text = ($this->partial[$key] ?? '') . $buffer;
|
||||
$cut = strrpos($text, "\n");
|
||||
|
||||
if (false === $cut) {
|
||||
$this->partial[$key] = $text;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->partial[$key] = substr($text, $cut + 1);
|
||||
|
||||
foreach (explode("\n", substr($text, 0, $cut)) as $line) {
|
||||
($this->write)($type, $line);
|
||||
}
|
||||
}
|
||||
|
||||
/** What a child wrote without a final newline, once it can write no more. */
|
||||
private function flush(string $name): void
|
||||
{
|
||||
foreach ([Process::OUT, Process::ERR] as $type) {
|
||||
$key = $name . '|' . $type;
|
||||
|
||||
if ('' !== ($this->partial[$key] ?? '')) {
|
||||
($this->write)($type, $this->partial[$key]);
|
||||
}
|
||||
|
||||
unset($this->partial[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\System\Work;
|
||||
|
||||
use App\Service\System\Work\BackgroundProcesses;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The worker container's processes keep the names the containers had, because
|
||||
* the heartbeats, /healthz and the admin log filters all go by them; and the
|
||||
* hub is configured the way the hub container was.
|
||||
*/
|
||||
final class BackgroundProcessesTest extends TestCase
|
||||
{
|
||||
private const string IDENTIFIER = 'https://plmail.invalid/.well-known/mercure';
|
||||
|
||||
public function testEveryProcessKeepsTheNameItsContainerHad(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['mercure', 'imap-supervisor', 'worker-export', 'worker-ingest', 'worker-maintenance', 'worker-bulk', 'scheduler'],
|
||||
new BackgroundProcesses(self::IDENTIFIER)->names(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheHubIsConfiguredAsItsContainerWas(): void
|
||||
{
|
||||
$hub = new BackgroundProcesses(self::IDENTIFIER, 'mercure_access_token', 'the-generated-secret')->all()[0];
|
||||
|
||||
self::assertSame(':80', $hub->env['SERVER_NAME'], 'never the web server\'s hostname: that would send the hub after a certificate');
|
||||
self::assertSame('the-generated-secret', $hub->env['MERCURE_PUBLISHER_JWT_KEY']);
|
||||
self::assertSame('the-generated-secret', $hub->env['MERCURE_SUBSCRIBER_JWT_KEY']);
|
||||
self::assertSame(self::IDENTIFIER, $hub->env['MERCURE_TRUSTED_ISSUERS']);
|
||||
self::assertSame(
|
||||
"resource_identifier " . self::IDENTIFIER . "\ncookie_name mercure_access_token",
|
||||
$hub->env['MERCURE_EXTRA_DIRECTIVES'],
|
||||
'the audience pinned, and the cookie the application sets: one MERCURE_COOKIE_NAME for both halves',
|
||||
);
|
||||
|
||||
$overridden = new BackgroundProcesses(self::IDENTIFIER, 'c', 's', 'https://issuer.example', 'cookie_name plmail')->all()[0];
|
||||
|
||||
self::assertSame('https://issuer.example', $overridden->env['MERCURE_TRUSTED_ISSUERS'], 'what an operator sets wins');
|
||||
self::assertSame('cookie_name plmail', $overridden->env['MERCURE_EXTRA_DIRECTIVES']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\System\Work;
|
||||
|
||||
use App\Domain\DTO\System\BackgroundProcess;
|
||||
use App\Service\System\Work\ProcessSupervisor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* What Docker's restart policy did for seven containers, done for seven
|
||||
* processes: brought back when they stop, backed off when they crash, stopped
|
||||
* together, and their output passed on whole.
|
||||
*
|
||||
* Real processes (`php -r`), because what is being tested is the handling of
|
||||
* real exits, real pipes and real signals. Timings are shrunk to milliseconds.
|
||||
*/
|
||||
final class ProcessSupervisorTest extends TestCase
|
||||
{
|
||||
/** @var list<array{string, string}> */
|
||||
private array $written = [];
|
||||
|
||||
public function testAProcessThatStopsCleanlyIsStartedAgainAtOnce(): void
|
||||
{
|
||||
$supervisor = $this->supervisor();
|
||||
$supervisor->start([$this->php('recycles', 'echo "one\ntwo\n"; exit(0);')]);
|
||||
|
||||
$this->tickFor($supervisor, 0.6);
|
||||
|
||||
self::assertGreaterThanOrEqual(2, $supervisor->status()['recycles']['restarts'], 'a clean exit is the hourly recycle: straight back');
|
||||
self::assertSame(['one', 'two'], array_slice(array_column($this->written, 1), 0, 2), 'whole lines, in order, nothing added');
|
||||
}
|
||||
|
||||
/** A process that cannot start must not spin: each failure waits longer. */
|
||||
public function testAProcessThatKeepsFailingBacksOff(): void
|
||||
{
|
||||
$supervisor = $this->supervisor();
|
||||
$supervisor->start([$this->php('fails', 'exit(3);'), $this->php('recycles', 'exit(0);')]);
|
||||
|
||||
$this->tickFor($supervisor, 0.8);
|
||||
|
||||
$status = $supervisor->status();
|
||||
|
||||
self::assertGreaterThan(0, $status['fails']['restarts']);
|
||||
self::assertLessThan($status['recycles']['restarts'], $status['fails']['restarts'], 'the failing one waits longer each time');
|
||||
}
|
||||
|
||||
public function testStoppingSignalsEveryProcessAndWaitsForIt(): void
|
||||
{
|
||||
$supervisor = $this->supervisor();
|
||||
$supervisor->start([
|
||||
$this->php('polite', 'pcntl_async_signals(true); pcntl_signal(SIGTERM, function () { echo "finishing\n"; exit(0); }); while (true) { usleep(10000); }'),
|
||||
]);
|
||||
|
||||
$this->tickFor($supervisor, 0.3);
|
||||
$supervisor->stop(5);
|
||||
|
||||
self::assertNull($supervisor->status()['polite']['pid'], 'nothing left running');
|
||||
self::assertContains('finishing', array_column($this->written, 1), 'it was asked, not killed');
|
||||
}
|
||||
|
||||
private function supervisor(): ProcessSupervisor
|
||||
{
|
||||
return new ProcessSupervisor(
|
||||
new NullLogger(),
|
||||
sys_get_temp_dir(),
|
||||
minBackoff: 0.02,
|
||||
maxBackoff: 0.32,
|
||||
forgiveAfter: 60.0,
|
||||
write: function (string $stream, string $line): void {
|
||||
$this->written[] = [$stream, $line];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function php(string $name, string $code): BackgroundProcess
|
||||
{
|
||||
return new BackgroundProcess($name, [PHP_BINARY, '-r', $code]);
|
||||
}
|
||||
|
||||
private function tickFor(ProcessSupervisor $supervisor, float $seconds): void
|
||||
{
|
||||
$until = microtime(true) + $seconds;
|
||||
|
||||
while (microtime(true) < $until) {
|
||||
$supervisor->tick();
|
||||
usleep(10_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,7 @@ admin:
|
||||
diese Seite von selbst zurück.
|
||||
restart_app_title: 'Web-Container neu starten'
|
||||
restart_confirm: 'Worker, Scheduler und IMAP-Supervisor neu starten? Laufende Arbeit wird zuerst abgeschlossen.'
|
||||
restart_help: 'Beendet Worker, Scheduler und IMAP-Supervisor, damit ihre Container neu erstellt werden. Langlaufende Prozesse halten das Datenbank-Mapping über ihre gesamte Laufzeit im Speicher und brauchen das deshalb nach einer Migration.'
|
||||
restart_help: 'Beendet Worker, Scheduler und IMAP-Supervisor, damit sie frisch neu starten. Langlaufende Prozesse halten das Datenbank-Mapping über ihre gesamte Laufzeit im Speicher und brauchen das deshalb nach einer Migration.'
|
||||
restart_pending: 'Neustart vor %seconds%s angefordert — die Prozesse beenden ihre laufende Arbeit und erscheinen ein bis zwei Minuten später wieder hier.'
|
||||
restart_title: 'Langlaufende Prozesse neu starten'
|
||||
run_help: 'Wird für einen Worker eingereiht, nicht hier ausgeführt. Es sind dieselben Läufe, die der Scheduler automatisch startet.'
|
||||
|
||||
@@ -616,7 +616,7 @@ admin:
|
||||
downtime, and this page comes back on its own.
|
||||
restart_app_title: 'Restart the web container'
|
||||
restart_confirm: 'Restart the workers, scheduler and IMAP supervisor? In-flight work finishes first.'
|
||||
restart_help: 'Stops the workers, scheduler and IMAP supervisor so their containers are recreated. Long-running processes cache database mappings for their whole lifetime, so they need this after a migration.'
|
||||
restart_help: 'Stops the workers, scheduler and IMAP supervisor so they start again fresh. Long-running processes cache database mappings for their whole lifetime, so they need this after a migration.'
|
||||
restart_pending: 'Restart requested %seconds%s ago — processes finish their current work, then reappear here within a minute or two.'
|
||||
restart_title: 'Restart long-running processes'
|
||||
run_help: 'Queued for a worker, not run here. These are the same sweeps the scheduler runs unattended.'
|
||||
|
||||
+29
-229
@@ -132,8 +132,8 @@ x-app-env: &app-env
|
||||
|
||||
services:
|
||||
# Mints whatever was left blank above into the storage directory, then exits. Runs
|
||||
# before everything else: Postgres and Mercure read their secrets when their
|
||||
# containers are created and cannot wait for the app to hand them one.
|
||||
# before everything else: Postgres reads its password when its container is
|
||||
# created and cannot wait for the app to hand it one.
|
||||
# Idempotent — on every later start it finds the file and does nothing.
|
||||
secrets-init:
|
||||
image: *image
|
||||
@@ -215,183 +215,45 @@ services:
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
imap-supervisor:
|
||||
# Everything that is not the web server, in one container: the IMAP
|
||||
# supervisor, one consumer per queue, the scheduler and the Mercure hub —
|
||||
# seven processes, kept running by `app:work`. They used to be seven
|
||||
# containers. Still one PROCESS per queue, so a send never waits behind a
|
||||
# sync; only the containers merged.
|
||||
#
|
||||
# Answers to `mercure` on the network because it runs the hub, so the web
|
||||
# container's proxy and MERCURE_URL reach it where they always reached the
|
||||
# hub container.
|
||||
worker:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console app:imap:supervise
|
||||
command: php bin/console app:work
|
||||
# Reaps the IMAP supervisor's children should it die hard.
|
||||
init: true
|
||||
# Longer than app:work's own 25s, which it gives every process to
|
||||
# finish the message in hand.
|
||||
stop_grace_period: 30s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: imap-supervisor
|
||||
APP_CONTAINER_NAME: worker
|
||||
networks:
|
||||
default:
|
||||
aliases: [mercure]
|
||||
volumes:
|
||||
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
||||
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
||||
# uploads and the generated secrets all land here — and every
|
||||
# service sees the same files, which the encryption key depends on.
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# ── Workers, one per queue ────────────────────────────────────────────
|
||||
# Three processes rather than three transports on one: a worker already
|
||||
# inside a long handler cannot pick up anything else however the queues are
|
||||
# prioritised, which is why pressing Send used to wait for a Gmail batch.
|
||||
#
|
||||
# Anything leaving plMail, and the only queue somebody is watching.
|
||||
worker-export:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-export
|
||||
volumes:
|
||||
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
||||
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
||||
# uploads and the generated secrets all land here — and every
|
||||
# service sees the same files, which the encryption key depends on.
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# Runs the scheduled jobs defined in
|
||||
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
||||
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
||||
# none of them ever fire.
|
||||
|
||||
# Mail arriving and the work that immediately follows it.
|
||||
worker-ingest:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-ingest
|
||||
volumes:
|
||||
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
||||
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
||||
# uploads and the generated secrets all land here — and every
|
||||
# service sees the same files, which the encryption key depends on.
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# Runs the scheduled jobs defined in
|
||||
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
||||
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
||||
# none of them ever fire.
|
||||
|
||||
# Bulk and deliberate. Also drains `async`, the pre-split queue, so
|
||||
# envelopes written before the upgrade still have a consumer. Nothing routes
|
||||
# there any more; drop it once a release has passed.
|
||||
worker-maintenance:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-maintenance
|
||||
volumes:
|
||||
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
||||
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
||||
# uploads and the generated secrets all land here — and every
|
||||
# service sees the same files, which the encryption key depends on.
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# A whole-view mark-read or archive. On its own process for the reason the
|
||||
# send queue is: somebody pressed a button and is watching an indicator,
|
||||
# and this must not wait behind a backfill on `maintenance`. Drop this
|
||||
# service and the indicator fills to nothing — the job is queued and never
|
||||
# handled.
|
||||
worker-bulk:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: worker-bulk
|
||||
volumes:
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
# Runs the scheduled jobs defined in
|
||||
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
||||
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
||||
# none of them ever fire.
|
||||
scheduler:
|
||||
image: *image
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
|
||||
stop_grace_period: 15s
|
||||
environment:
|
||||
<<: *app-env
|
||||
APP_CONTAINER_NAME: scheduler
|
||||
volumes:
|
||||
# app:prune:blobs unlinks files across all three stores, so it
|
||||
# needs the same binds as the workers that write them.
|
||||
# One mount for everything plMail writes, as on php.
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /app/var/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
# The hub's state, in the volumes the hub container used.
|
||||
- type: volume
|
||||
source: mercure_data
|
||||
target: /data
|
||||
- type: volume
|
||||
source: mercure_config
|
||||
target: /config
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
@@ -434,68 +296,6 @@ services:
|
||||
- "-c"
|
||||
- "pg_stat_statements.track=top"
|
||||
|
||||
mercure:
|
||||
image: dunglas/mercure
|
||||
# PULLED ON EVERY `up`. Without this the tag floats and the image does
|
||||
# not: compose reuses whatever is already on the box, so a hub can sit
|
||||
# years behind the tag it claims to be while the update reports success.
|
||||
# That is how an install ended up running a 0.x hub against an
|
||||
# application that had moved to the 1.0 protocol.
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SERVER_NAME: ':80'
|
||||
# The hub image has no _FILE support, so the entrypoint below reads
|
||||
# the generated secret in a shell before exec'ing Caddy. Filling in
|
||||
# mercure_jwt above wins: the generator never overwrites it, so both
|
||||
# sides still read the same value.
|
||||
MERCURE_JWT_SECRET: *mercure_jwt
|
||||
# The issuer this hub accepts and the audience it demands. The
|
||||
# literal from config/services.yaml (`mercure.identifier`), NOT this
|
||||
# install's own address — mercure_public_url above is blank on
|
||||
# purpose here, because the setup screen stores the real one on
|
||||
# first boot, and a hub cannot wait until then to be configured.
|
||||
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
|
||||
MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /plmail/secrets/generated.env ]; then
|
||||
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /plmail/secrets/generated.env)"
|
||||
fi
|
||||
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
|
||||
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
|
||||
exec "$$@"
|
||||
- --
|
||||
# The image's default Caddyfile, NOT dev.Caddyfile — this is a
|
||||
# production stack and the dev one turns on `anonymous`, which accepts
|
||||
# subscribers with no JWT at all. Since the app proxies
|
||||
# /.well-known/mercure* publicly, that let anyone who could reach this
|
||||
# instance subscribe to mail/user/<any id> and watch that account sync.
|
||||
# The default config requires the subscriber JWT the app now mints.
|
||||
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
# NO healthcheck here, deliberately — the image's own is used. The one
|
||||
# that used to be here asked for "/healthz", which the hub moved: it
|
||||
# answers 404 now, `curl -f` reads that as failure, and the container
|
||||
# reported itself unhealthy for ever while serving perfectly.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: *path_data
|
||||
target: /plmail
|
||||
bind:
|
||||
create_host_path: true
|
||||
read_only: true
|
||||
- type: volume
|
||||
source: mercure_data
|
||||
target: /data
|
||||
- type: volume
|
||||
source: mercure_config
|
||||
target: /config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
Reference in new issue
Block a user