Deployment topologies
How to run a Wagoe application as more than one process. Sizing & Scaling explains what scales and what does not; this is the running configuration.
Every topology here has been brought up locally. Where a claim is untested, it says so.
The four shapes
| Topology | When | Reference |
|---|---|---|
Single instance |
Development, and production until one process is not enough. |
The root |
Web + worker |
Background work must not compete with request handling, or must survive a web rollout. |
|
N web replicas behind a load balancer |
One process is not enough, or a rollout must not drop requests. |
|
Module as its own service |
One module needs to scale, deploy or fail separately from the rest. |
|
The same image runs all of them. The container argument selects the mode:
server (default), worker, or service <module>….
Before N replicas
Three things default to a single-process adapter. All three have a replica-safe one; each is configuration rather than code, and none of them is on by default. Only the rate limiter below was exercised across running replicas; the realtime and event-bus claims are read from the adapters and their tests, not from a topology brought up here.
Rate limiting needs a shared cache. A limit counted in one JVM is not a limit when there are three. With Redis behind it the counter is shared — verified by setting the limit to 5 across two replicas and sending twelve requests through the load balancer:
/web/login 200 upstream=172.21.0.4
/web/login 200 upstream=172.21.0.4
/web/login 200 upstream=172.21.0.4
/web/login 200 upstream=172.21.0.4
/web/login 200 upstream=172.21.0.4
/web/login 429 upstream=172.21.0.4
...
/web/login 429 upstream=172.21.0.6 (1)
-
A replica that had served nothing. Per-JVM counting would have answered 200 here; it answered 429 because the counter is in Redis.
The application refuses to start with rate limiting enabled and no reachable
cache, which is the right failure — but :wagoe/cache ships under :inactive
in resources/conf/prod/config.edn. Move it to :active and point it at
Redis. That is an edit to your own config file; no environment variable does
it, which is why the compose reference deploys Redis but leaves
HTTP_RATE_LIMIT_ENABLED off.
Realtime pub/sub needs :provider :redis. Under the default :in-memory
provider a WebSocket connected to replica A does not see an event published on
replica B, and sticky sessions do not fix it — the publisher and the subscriber
are different connections. The Redis provider (BOU-85, ADR-035) fans routing
envelopes out over a pub/sub channel and keeps topic subscriptions in Redis
sets, so a broadcast reaches clients on any replica. Sockets stay node-local
under both providers; only the bus and the pub/sub manager differ.
Note what "configuration" means here: the monorepo’s wagoe.config/ig-config
does not emit :wagoe/realtime at all, so an application that wants realtime
wires the component itself against wagoe.realtime.shell.module-wiring. Getting
this wrong is quiet — a config with no :provider gets :in-memory, and the
single-node behaviour is a default rather than an error. Config shape is in that
namespace’s docstring.
The event bus needs :provider :redis-streams (BOU-93, libs/events) —
:redis-streams, not :redis, which is the cache’s and realtime’s spelling and
not this one’s. Redis Streams with consumer groups delivers an event once per
logical subscriber across replicas; the in-memory adapter delivers it only
within the publishing process.
Two ways this differs from realtime above. :wagoe/events is wired from
config — move it into :active and the component is emitted — though it is
absent from the shipped prod profile, so it is off until you add it. And it has
no default: any provider other than :redis-streams or :in-memory, including
none at all, throws Unknown event bus provider at boot. Loud rather than
quiet, which is the better of the two failures here.
Scheduled jobs are not on this list, contrary to the usual expectation.
Promotion of a due job is an atomic claim — ZREM on the scheduled sorted set,
and only the worker whose ZREM returns 1 owns it — so N workers polling
concurrently promote each due job exactly once (BOU-88). There is no recurring
or cron scheduler in the framework, so there is nothing that fires per process
at start-up either. Running more than one worker is safe; job handlers must be
idempotent for the ordinary at-least-once reason, not because of duplicate
scheduling.
Health endpoints
| Endpoint | Meaning |
|---|---|
|
The process is up. Liveness probes point here. It never checks a dependency — a database outage should not have Kubernetes restart every pod. |
|
The process can serve. Checks the database, and the cache when configured. Answers 503 when a dependency is down, which is what takes a pod out of the load balancer instead of letting it fail requests. |
|
Coarse liveness, kept for existing probes. |
Point readiness probes at /health/ready and liveness at /health/live. Both
are already served; the manifests wire them.
Environment
The prod profile reads these. Names matter: POSTGRES_*, not DB_* — the wrong
name fails the boot at :wagoe/db-context with "Invalid database
configuration", and until BOU-89 the shipped manifests had it wrong.
| Variable | Notes |
|---|---|
|
Required, at least 32 characters. |
|
Required. |
|
Read only when |
|
Required, because error reporting defaults to |
|
Only for a process running |
|
How long to finish in-flight requests on |
N replicas, locally
docker build -t wagoe:latest .
export JWT_SECRET="at-least-32-characters-............."
docker compose -f deploy/compose/multi-instance.yml up -d --scale web=3
curl -i http://localhost:8080/health/ready
web publishes no host port. The load balancer is the only way in, which is
what stops the replicas being decorative — with a published port, --scale
collides and only one instance is ever reached.
To see the balancing, the load balancer logs which replica served each request:
docker compose -f deploy/compose/multi-instance.yml logs lb | grep -oE 'upstream=[0-9.]+:3000' | sort | uniq -c
# 3 upstream=172.20.0.5:3000
# 5 upstream=172.20.0.6:3000
# 4 upstream=172.20.0.7:3000
The application’s own access logging skips the health endpoints, so without this there is nothing to look at.
A module as its own service
export JWT_SECRET="at-least-32-characters-............."
export RPC_SERVICE_KEY="at-least-32-characters-............."
docker compose -f deploy/compose/per-service.yml up -d
Both are required — the compose file refuses to interpolate without them, which is deliberate: a secret with a default is a secret nobody sets.
service user boots the user module and the platform it needs — 20 of 29
components in the prod profile — and serves IUserService on port 3001 for the
rest of the deployment. The composition is logged at start-up, including what
was left out, so a service that quietly kept everything is visible rather than
merely healthy.
The app container in that file is an ordinary server, and still runs its own
copy of the user module. Pointing it at the remote one means building the
adapter in place of the local component:
(rpc-client/remote-adapter user-ports/IUserService
(System/getenv "USER_SERVICE_URL")
{:service-key (System/getenv "RPC_SERVICE_KEY")})
That is a wiring decision in an application’s own config — which component a key resolves to — not something a framework reference can make for it. The compose file says so rather than setting a URL that nothing reads.
One process serves one protocol. The catalogue already declares an RPC
protocol for both user and payments, so service user payments is refused
at start-up with both named, in any profile where both modules are active.
Serving the first and carrying on would leave the other reachable by nobody,
with a healthy process and nothing in the log. Run them as separate services, or
drop :wagoe/rpc to run them together with neither reachable.
The RPC port is published to nothing. It invokes port methods directly, so it belongs on the internal network:
# from another container on the network — no service key
curl -X POST http://identity:3001/rpc -d '[]' # 401
# with the key, malformed body
curl -X POST http://identity:3001/rpc -H "x-rpc-service-key: $RPC_SERVICE_KEY" -d '[]' # 400
# from the host
curl http://localhost:3001/rpc # connection refused
Which modules can run this way is declared in the service catalogue — see
Running a module as a
service. A module that is :inactive in your config cannot: service payments
against the stock prod profile is refused with "This configuration builds
nothing for: payments. The module is probably disabled in config." Enable the
module first.
Kubernetes
deploy/k8s/wagoe.yaml is one file with the whole set: Secret, ConfigMap, the
server Deployment and its Service, the worker Deployment, Redis, an Ingress, and
a module-as-a-service Deployment with a ClusterIP Service on the RPC port only.
kubectl apply -f deploy/k8s/wagoe.yaml
Replace REGISTRY/wagoe:TAG and the Secret values. The manifests validate
against upstream schemas (kubeconform -strict); they have not been applied to
a running cluster as part of this work, so treat resource requests, the Ingress
class and storage as starting points for your environment rather than as tested
values.
Two details worth keeping when you adapt them:
-
terminationGracePeriodSecondsis aboveHTTP_DRAIN_TIMEOUT_MS, and apreStopsleep gives the endpoints time to remove the pod before it stops accepting. Without both, a rollout drops requests that were already in flight. -
proxy-next-upstreamiserror timeout— retry only a connection that was never established. Addinghttp_500replays a POST that already reached a pod, which is how a payment is taken twice.