Skip to content

Deployment ​

Deployment topology, sizing, and high availability. This is a design-level reference; for step-by-step setup see the Installation Guide.

Architecture model ​

yarilo is a Go application that uses goroutines for concurrency, not a process per user as the reference does. A single process (e.g. yarilo-imap) serves N user sessions through goroutines (~100 KB per session).

Multi-binary, multi-process:

  • 6 binaries in the session role: yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap
  • 6 login proxies in front of them: yarilo-imap-login, yarilo-pop3-login, yarilo-submission-login, yarilo-lmtp-login, yarilo-managesieve-login, yarilo-jmap-login, plus yarilo-sasl-login for a fronting MTA
  • yarilo-director decides which backend serves each user; it carries no client traffic
  • Each is a distinct process with its own address space
  • Coordination between session processes within a backend deployment goes through yarilo-locks

Goroutines vs fork:

  • 1000 users in a single pod ≈ 200–500 MB RAM, not 10 GB
  • No "fat pod" problem
  • Horizontal scaling is for HA, not for resource limits

Components ​

login proxies and the director ​

The login proxies (yarilo-imap-login, yarilo-pop3-login, yarilo-submission-login, yarilo-lmtp-login, yarilo-managesieve-login, yarilo-jmap-login) are separate Deployments. This is where TLS terminate + passdb auth + allow_nets enforcement happens.

yarilo-director is its own Deployment: replicas form a self-organizing ring and answer each login proxy's LOOKUP with the user's backend, through a consistent-hashing ring plus sticky assignments. See Director.

yarilo-jmap-login carries the same duties over HTTP; the flow below describes the byte-pipe protocols, and JMAP — the HTTP frontend/backend split describes where it differs.

Login pod auth flow:

  1. Accept TLS connection from client.
  2. Extract real client IP from HAProxy PROXY protocol header or raw TCP RemoteAddr.
  3. Parse the IMAP/POP3/SMTP AUTHENTICATE or LOGIN command to obtain credentials.
  4. Call yarilo-auth (AUTH command) — passdb chain runs there; receives token= on success. allow_nets and nologin are enforced in the login pod using the real client IP.
  5. On failure: send NO Invalid credentials to the client. Backend is never contacted.
  6. On success: send proto-specific auth OK to the client, then dial the backend and send:
    YARILO\tADDR=<real-client-ip>\tSESSION=<warden-id>\tUSER=<username>\tTOKEN=<64hex>\n
  7. Backend reads the preamble, calls yarilo-auth VERIFY token to confirm the session, and enters pre-authenticated state. No passdb round-trip on the backend side.

backend deployment (one per tag = one per NFS shard) ​

Handles authenticated mail sessions, reading and writing mail + index data to NFS.

Co-located pod (one pod serves ALL of a user's per-user state). A backend pod is a single StatefulSet whose pod runs one container per protocol — yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap — plus the yarilo-fts full-text-search container, the yarilo-backend-api admin container, and a yarilo-backend-reg registration sidecar, sharing the pod's one IP and the tag's one NFS PV (RWX). yarilo-locks runs as its own per-tag Deployment for cross-pod write coordination.

What goes in the pod vs stays separate — the criterion. The pod holds everything that owns per-user write state: the protocol containers, fts (per-user Xapian index), and backend-api (its fts rescan/optimize and direct mailbox/index access write a user's data — as a standalone Deployment it would be a second writer of the same index/mailbox the user's sticky pod owns; in-pod it uses that pod's localhost fts, so single-writer holds). backend-api listens on the pod IP only; yarctl reaches the owning pod by doing a director LOOKUP itself and dialing that pod. Global-read services with stable external consumers stay separate — yarilo-quota-status is a Postfix policy service the external MTA dials at a fixed endpoint, and it only reads a shared quota dict (any instance answers any user, no per-user write), so co-locating it would break the MTA contract for zero gain. It stays its own Deployment.

This is deliberate and load-bearing for the whole director model: it restores the reference's invariant one mail-host owns every per-user resource. Because the pod at a given IP answers imap and pop3 and lmtp and submission and managesieve, the director needs only one ring and one user→pod map — a user hashes to one pod IP, and that IP is correct for every protocol (the login proxy dials the protocol-specific port on that same IP). See "Director routing" and "Why co-located, not per-protocol StatefulSets" below.

FTS is co-located for the same reason. The Xapian index is per-user, and a session reaches FTS over ftsproto. With a shared ClusterIP as fts.fts_addr, requests for one user's index round-robin across yarilo-fts replicas → two pods cache the same user's write handle → corruption (which is why replicas > 1 used to be unsafe). The chart therefore runs yarilo-fts in the pod and renders fts_addr as localhost:<port>, which makes the user's already-sticky pod the sole owner of that user's index write handle. On the shared NFS every per-user Xapian dir then has exactly one writer — the same "1 user = 1 pod" invariant that keeps a mailbox on one pod gives FTS single-writer for free, so replicas > N is safe (indexing load follows the users on each pod).

Declare the storage in the chart: fts.fts_storage_type: "nfs" for every backend tag (the shards live on the tag's RWX PV), "local" for a standalone on a local PV. It is a declaration, not a detection, and it decides where a durability call is real — see the setting's own note in helm/values.yaml.

The NFS export must be sync

This is a server setting no code path can compensate for. NFSv3/v4 require metadata operations (RENAME, REMOVE, RMDIR) to be committed to stable storage before the reply, and every atomic write in yarilo — index and cache tmp+rename, maildir delivery, FTS shard compaction — rests on that. An async export answers before committing, so a crash can lose a rename the client was told had succeeded; a client-side fsync cannot repair it (there is no commit-a-directory operation, and fsync on a directory is a no-op over NFS). The cost of async is paid in silently rolled-back metadata, not in a visible error.

Login proxies are not needed inside the backend — it accepts plain TCP from the login pods with auth state in the YARILO preamble. The login pods (yarilo-imap-login, …, yarilo-sasl-login) are their own Deployments (see /yarilo_director.svg), never here.

Backend auth logic:

  • Login pod sends YARILO\tADDR=...\tSESSION=...\tUSER=...\tTOKEN=...\n before any protocol exchange.
  • Backend's PreambleListener reads the preamble, calls yarilo-auth VERIFY (service= field enforced), enters pre-authenticated state.
  • Every backend protocol container accepts only the YARILO preamble from login pods (director is control-plane only — it never touches client bytes). Client-IP forwarding into the login layer, when needed, is haproxy protocol / native inbound (ID/XCLIENT) / none, chosen per protocol — it never reaches the backend directly. LMTP preamble originates from yarilo-lmtp-login.

Upgrading the backend StatefulSet ​

A backend pod is replaced in place: the StatefulSet stops the old pod first and only then creates the new one, one pod at a time. The tag runs one backend short until the new pod is Ready, and that time includes the image pull and the scheduling. On the sandbox (assignment_policy: domain) a slow pull kept one backend down for about 4.5 minutes, its users without service, and with CPU requests at 92 % of the node the new pod stayed Pending until other old pods left. Before helm upgrade in production, pull the new image on every node that runs backend pods (for example crictl pull docker.io/0kaba0/yarilo:<tag>), and keep free CPU and memory on those nodes for the requests of one more backend pod.

Backend registration and heartbeat ​

Backends are registered in the director ring by self-registration + heartbeat, not by a one-time DNS resolve or an external prober. This makes the backend list a second application of the exact lifecycle machinery already built for the director ring itself (JOIN on start, ring-wide keepalive, graceful leave, Lamport ordering, tombstone-free convergence) rather than a separate DNS-poll mechanism — and it works in every topology (k8s and non-k8s standalone), consistent with config-not-binary.

One registration per POD, not per protocol. Because the pod is co-located, exactly one yarilo-backend-reg sidecar per pod owns the director registration — it registers the pod IP once, not one stream per protocol container. This is not cosmetic: four protocol containers each registering the same IP would push four independent monotonic-seq streams for one origin, and the strictly-newer lease dedup compares seq only within an origin — four interleaved streams on one IP would fight, each rejecting the others as stale. One sidecar = one origin = one clean seq stream. The sidecar reuses the exact internal/backendreg client, which answers the director's PING with PONG and resets its reconnect backoff on a healthy long-lived connection; without those a co-located pod would flap as a whole.

Model:

  • Register on start. The yarilo-backend-reg sidecar dials the director ClusterIP Service — it does not matter which director replica answers; a load-balanced address is correct here precisely because the registration is gossiped ring-wide. It sends BACKEND-UP\t{ip}\t{port}\t{tag}\t{vhosts}\t{seq} for the pod IP; the receiving director adds it and forwards it as a RING-CHANGE up envelope (carrying port + vhosts) so every director learns it. {port} is nominal — login proxies override it with their own protocol port (internal/login BackendPort / lmtp-login BackendPort), dialing {pod-ip}:{protocol-port}; the ring entry identifies the POD, the login picks the protocol port. (This is the only backend→director control channel — otherwise the director only ever dials backends.)
  • {tag} is the STORAGE SHARD, never the protocol. A co-located pod serves all protocols, so protocol is not a routing dimension — the tag stays a pure NFS-shard label (one tag = one NFS PV = one yarilo-backend release). Per-user shard selection (userdb director_tag) is unaffected.
  • Heartbeat = periodic re-register, gated on the PROTOCOL containers' self-health. Every register_interval seconds (Helm: backend_register.register_interval) the sidecar re-sends BACKEND-UP — but ONLY while every protocol container in the pod is serving (each protocol's /readyz: listener accepting and not wedged). A heartbeat must prove the DATA PATH is alive, not merely that a control goroutine still ticks. Readiness-gate semantics (spell this out for operators): because it is one pod = one ring entry, a single unready protocol container makes the whole pod go silent → the lease expires → the pod is removed for all protocols. This is correct and matches the reference (host down = down for everything on that host), but it is a real coupling: an NFS-wedged yarilo-lmtp will pull the pod's IMAP traffic off too. That is the price of the one-mail-host invariant, and the sidecar's gate makes the wedge fail safe (silence → expiry) rather than routing to a half-dead pod. yarilo-fts is deliberately NOT in the gate: FTS is best-effort (a wedged index degrades SEARCH to a slow fallback but never blocks mail flow), so an unhealthy fts container must not evict the pod's live IMAP/LMTP traffic. Each heartbeat carries a monotonic per-origin sequence (a logical counter, NOT wall-clock — pod clocks are unsynchronized, so freshness is compared only within one backend's own origin; the seq is seeded from the process start time so a same-IP restart resumes above the director's last-recorded seq), gossiped ring-wide as "backend X seen at seq N". Every director keeps the max seen per backend; a heartbeat landing on any director refreshes the lease everywhere — this is what makes a load-balanced heartbeat correct (a per-director timer with LB'd heartbeats would false-expire a live backend on the directors that happened not to receive it).
  • TTL expiry → LEAVE (remove + rehash). A backend whose last-seen has not advanced within backend_expire on the ring is REMOVED (RING-CHANGE down → RemoveBackend everywhere); its hash-slice rehashes onto neighbouring backends in the same tag. This covers the silent hang / crash with no external prober.
  • Graceful leave → LEAVE (remove + rehash). On SIGTERM the backend sends BACKEND-DOWN (like the director's own graceful leave) so it is removed immediately without waiting out the TTL — a planned rollout / scale-down never blackholes a hash-slice for the expiry window.
  • A removed backend stays removed. Removal drops the users and domains placed on it, as well as its ring entry. A peer's ring handshake, an anti-entropy snapshot, a late heartbeat or a gossiped placement naming it is ignored; only a registration newer than the removal brings the address back, as a pod restarted on it does. The block lasts 5 minutes plus backend_expire, the longest another director can keep the backend after its last heartbeat. A backend learned from a handshake carries its lease, so it expires like any other when its heartbeats stop.
  • Overload → FLUSH (drain, NO rehash), never DOWN. Removal and overload are DIFFERENT wire events with different consequences and must not be conflated. A backend shedding load sends BACKEND-FLUSH: it STAYS in the ring, new LOOKUPs stop landing on it, existing sessions keep running — its hash-slice does NOT move. A transient load spike therefore causes zero user reshuffling. Sending BACKEND-DOWN on overload would remove it → rehash the slice away → and then a second rehash back when it re-registers a minute later: double-shuffling users through a transient peak, worse than the peak itself. This matches the reference's down/vhosts semantics (drain by zeroing vhosts, not by removal). BACKEND-DOWN is reserved for genuine LEAVE (SIGTERM / expiry).

Safety + migration:

  • Never remove the last backend of a tag by TTL expiry — a suspect-but-only backend is kept (and logged loudly) rather than guaranteeing a total blackhole for that tag.
  • Static mail_servers entries stay non-expiring — they are a bootstrap / non-k8s fallback and are never pruned by the heartbeat lease, so registration can be rolled out incrementally with no binary branching (config-not-binary).
  • admin-API backends add/remove stay manual and non-expiring — an operator's explicit entry is never touched by the lease.
  • No tombstone needed (unlike ring members whose ephemeral pod identity never returns): a dead backend heartbeats nowhere and never re-registers, so a gossiped ghost simply ages out at TTL.

One backend_register block per pod drives the single yarilo-backend-reg sidecar; there is no per-protocol registration path.

backend_expire ​

Default
30
Type
integer, seconds
Helm
components.director.backend_expire
See also
register_interval

Missed heartbeats for this long remove a backend from the ring on every director, and its users are rehashed onto the other backends of its tag.

director_addr ​

Default
none
Type
string
Helm
backend_register.director_addr

The director Service the registration sidecar sends BACKEND-UP to. Any replica may answer: the registration is spread around the ring.

register_interval ​

Default
10
Type
integer, seconds
Helm
backend_register.register_interval
See also
backend_expire

How often the sidecar re-registers the pod. It sends only while every protocol container of the pod is ready, so a wedged container lets the lease run out.

tag ​

Default
none
Type
string
Helm
backend_register.tag

The storage shard this pod serves: one tag is one NFS volume and one backend release. It is never the protocol; a pod serves every protocol.

vhosts ​

Default
100
Type
integer
Helm
backend_register.vhosts
See also
assignment_policy

The pod's weight on the ring, from 1 to 100. Under assignment_policy: least_sessions, 0 drains the backend: it takes no new users.

Backend watchdog probe ​

Distinct from ring registration above: that governs whether a backend is a routing target, this governs whether a wedged container is restarted.

The three probes on each co-located protocol container answer three different questions, and only one of them can restart the container:

  • startupProbe — dependencies (locks, Redis, DB) are reachable before the container takes traffic. Runs once at start.
  • readinessProbe — the pod's per-protocol freshness file is fresh, so the login proxy only routes to a container that is currently serving. Removes from endpoints, never restarts.
  • livenessProbe — /healthz, which is unconditional until the liveness watchdog trips it. The watchdog is a timer-driven self-check (stat the mail store base, enter a local gate) that fails /healthz when the request path is wedged in a way nothing else sees: a stale NFS handle hanging every mailbox op, or an in-process deadlock, while the accept loop and this HTTP server keep answering. Restarting is then the only correct response, and liveness is the only probe that restarts.

Why liveness is gated on the watchdog. Without a self-check, /healthz answers 200 as long as the telemetry server runs, so a liveness probe on it is inert — it can only ever catch the telemetry server itself dying, which the process's own os.Exit(1) on a dead accept loop already covers. So the backend carried no livenessProbe historically. The probe is therefore rendered only when telemetry.livenessWatchdog.enabled is set: enabling the watchdog is what makes /healthz a real signal, and only then does watching it with a restart make sense. Off by default preserves the prior no-restart behaviour exactly.

Guard rails against restarting a healthy busy pod: the watchdog trips only after several consecutive failed self-checks, the self-check timeout is shorter than its interval, the probe has its own failureThreshold on top, and the self-check touches nothing shared — so a hung NFS handle restarts the one pod that mounts it, and a database hiccup restarts nothing. A single protocol container restarts alone; the other protocols on the pod keep serving.

liveness_watchdog_enabled ​

Default
false
Type
boolean
Helm
telemetry.livenessWatchdog.enabled

Restart a container whose request path is wedged (a deadlock, a hung NFS handle) while the process still answers. The self-check touches nothing shared, so a database hiccup restarts nothing.

liveness_watchdog_interval_seconds ​

Default
10
Type
integer, seconds
Helm
telemetry.livenessWatchdog.intervalSeconds

How often the self-check runs.

liveness_watchdog_timeout_seconds ​

Default
5
Type
integer, seconds
Helm
telemetry.livenessWatchdog.timeoutSeconds

How long one self-check may take before it counts as failed.

liveness_watchdog_failure_threshold ​

Default
3
Type
integer
Helm
telemetry.livenessWatchdog.failureThreshold

Failed self-checks in a row before the liveness probe fails.

liveness_watchdog_fault_injection_enabled ​

Default
false
Type
boolean
Helm
telemetry.livenessWatchdog.faultInjectionEnabled

Advanced: this should not normally be changed.

Expose POST /debug/fault/deadlock, which wedges the self-check so you can confirm on a live pod that the watchdog restarts it. Leave it off outside such a check.

Profiling a live pod ​

The Go runtime profilers are served on the telemetry port, and are off by default. They answer questions no metric can: which function a saturated worker is actually in, where a component's allocations come from, and why adding CPU makes a stage slower rather than faster.

One switch turns them on:

telemetry_pprof_enabled ​

Default
false
Type
boolean
Helm
telemetry.pprof.enabled

Serve the Go profilers and /debug/loglevel on the telemetry port. Every component logs a warning at each start while it is on.

It serves:

  • /debug/pprof/profile, trace, allocs, heap, goroutine, block, mutex, threadcreate, cmdline and symbol: stacks, counts and symbol names, which say what code paths the process runs and what they cost;
  • /debug/loglevel: GET returns the level, and POST {"level":"debug","ttl":"10m"} changes it for the whole process. Off, the route answers 404.

block and mutex are in that list but stay silent until a sampling rate is set; see below.

What a profile does not contain is the contents of anything. A pprof profile is a set of sampled stack traces with object and byte counts attached; the format has no field a message body or a credential could appear in. What it does give away is the shape of the workload — which paths run, how often, how much they allocate — and the symbol names of the binary. That is worth something to someone, which is why the switch is off by default and why every component logs a warning at each start while it is on.

telemetry.pprof.heapEnabled was a second switch, on the grounds that the heap route dumped live objects while the rest were only stacks and counts. It is deprecated and does nothing; a process that finds it set says so at start. Go's heap and allocs are the same profile with different default sample types, so the allocation route always served inuse_space as well — the switch withheld nothing.

Asking what is retained rather than what was allocated is a matter of the sample type, not the route:

sh
go tool pprof -inuse_space http://localhost:8080/debug/pprof/allocs

There is no /debug/pprof/ index page. It dispatches any path under it to the runtime profile of that name, so the set of routes would be decided by whatever any package in the binary registered with runtime/pprof rather than by the list above.

While either switch is on, every component logs a warning at every start naming what is exposed. That is deliberate — the failure this guards against is not enabling profiling, it is enabling it for an afternoon's investigation and leaving it on for a year.

Profiles that need a sampling rate ​

/debug/pprof/block and /debug/pprof/mutex are served by the switch above, but they answer with an empty profile until sampling is turned on — a diagnostic that returns 200 and says nothing. Two more values do that:

telemetry_block_profile_rate ​

Default
0
Type
integer
Helm
telemetry.pprof.blockProfileRate
See also
telemetry_mutex_profile_fraction

One sample per this many nanoseconds of blocked time; 0 is off, and /debug/pprof/block then answers with an empty profile. Every blocking operation pays while it is on.

telemetry_mutex_profile_fraction ​

Default
0
Type
integer
Helm
telemetry.pprof.mutexProfileFraction
See also
telemetry_block_profile_rate

One sample per this many mutex contention events; 0 is off, and /debug/pprof/mutex then answers with an empty profile. Every contended mutex pays while it is on.

They are separate from enabled because serving a route and collecting samples are different costs. The route is free while nobody fetches it; the sampling is paid by every blocking operation and every contended mutex in the process, whether or not anyone ever takes the profile.

This is the instrument for time spent waiting — a CPU profile cannot see it, because a goroutine asleep on a lock or a socket burns nothing. Turn the rates on for the measurement window, take the capture, set them back to 0:

yaml
telemetry:
  pprof:
    enabled: true
    blockProfileRate: 10000      # ns of blocked time per sample
    mutexProfileFraction: 100    # one in N contention events
sh
go tool pprof -http=: http://localhost:8080/debug/pprof/block

Both rates warn at every start while they are on, for the same reason the switches above do.

Taking a profile, with the telemetry port reachable only inside the cluster:

sh
kubectl -n <ns> port-forward pod/<pod> 8080:<telemetry-port>

# 30 seconds of CPU, which is what identifies a hot stage
go tool pprof -http=: "http://localhost:8080/debug/pprof/profile?seconds=30"

# where allocations are made, and -inuse_space for what is still held
go tool pprof -http=: http://localhost:8080/debug/pprof/allocs

The telemetry port differs per container in the co-located backend pod — each is told its own via TELEMETRY_LISTEN — so take it from the container's ports: rather than assuming 8080.

JMAP frontend and backend ​

JMAP is HTTP, so the layers split the same way as every other protocol but the hop between them is per-request HTTP proxying rather than a TCP byte pipe. That is the only difference, and it is what removes the need for fd-passing: a JMAP client carries its credentials on every request, so there is no session pinned to a connection to hand over.

Dutyyarilo-jmap-loginyarilo-jmap
Client TLS (general.ssl), HAProxy PROXY protocolterminatesno knowledge of client TLS
Authorization Basic/Bearer → yarilo-auth passdb chainyesaccepts identity only from the login layer
warden: auth-failure penalty, per-user@IP accounting, kick busyesno
director lookup → proxy to the user's backend podyes—
Storage via pkg/mailbox, session object, JMAP methodsnoyes

The internal hop. After auth, yarilo-jmap-login resolves the user through the director and proxies the request to that pod's yarilo-jmap over internal mTLS, carrying X-Session-ID, X-Proxy-TTL and Forwarded (RFC 7239). Forwarded is the HTTP-native equivalent of XCLIENT, giving the backend the real client IP and TLS state for logging and warden attribution. allow_nets is enforced in the login pod against the real client before proxying, as for every other protocol — one enforcement point, not two.

Trust boundary. Identity travels in headers, so a request carrying X-Session-ID or Forwarded is honoured only from a peer the backend has been told to trust. Three modes, all default-deny — there is no branch in which an unknown peer is believed:

AnchorWho may set identity headers
internal_tls.enabled: trueonly the login layer's client certificate. The k8s mode.
mTLS off, general.xclient.trusted_nets covers the peeronly peers inside those CIDRs. The same key that already gates XCLIENT and IMAP ID x-originating-ip: Forwarded is the HTTP member of that family, not a new concept. Enabled per listener with services.jmap.xclient_protocol, as elsewhere.
neithernobody: identity requests answer 403, and startup logs that no trust anchor is configured.

The third case keeps the listener up on purpose. A dead port reads as a network fault and sends the operator to the wrong place; a live port answering 403 with a named cause diagnoses itself, and /healthz and /readyz keep working instead of the pod entering CrashLoop.

The compose standalone has no certificate infrastructure by design — one host, a private docker network — so it adds its docker subnet to general.xclient.trusted_nets in the shipped config. The stock default is empty, trusting no peer, so this stays an explicit entry rather than an assumption.

Connection limits are warden's. Per-user and per-IP connection limits are held in warden and shared across replicas. Listeners have no connection_limit key: nothing ever read it. A config that still carries one logs a retired-key warning at start.

Scaling. yarilo-jmap-login is stateless beyond TLS and scales on request load in both shapes. yarilo-jmap scales differently per shape, for the same reason every other session process does: behind a director it is a container in the co-located pod and scales with it, because it reads the same per-user index and mailbox as that pod's other protocol containers and must not become a second writer of them; in the standalone shape it is its own Deployment with its own replicaCount, and cross-pod write contention is resolved through yarilo-locks like every other session Deployment there.

Ports. Client-facing :8443 on yarilo-jmap-login; the backend listens on :10443, in the same range as the other backend data ports (10143, 10110, 10587, 10024, 14190) and deliberately clear of :8080, which is the telemetry port every component pod already uses.

shared services (one deployment per installation) ​

  • yarilo-auth — passdb (for the director) + userdb (for everyone)
  • yarilo-warden — connection/session limits (read + write from both sides)

Why yarilo-locks is per backend rather than shared ​

  • Each backend tag has its own NFS share — a separate data scope.
  • Locks apply only to files in that share; there is no reason to coordinate with other tags.
  • Lower latency (local to the backend pod).
  • Blast-radius isolation: a yarilo-locks failure in tag A does not affect tag B.
  • No global bottleneck.

Who writes and reads yarilo-warden ​

WriterWhat it writes
director's login proxies (imap/pop3/submission)CONNECT/DISCONNECT events (pre-auth connection tracking, per-IP rate limit)
yarilo-lmtp-loginCONNECT/DISCONNECT per recipient (per-user delivery rate tracking)
backend's session processes (imap/pop3/submission)SESSION_START / SESSION_END (post-auth, active mail sessions)
ReaderWhen and why
director's login proxiesBefore admitting a new connection — enforce per-user/per-IP limits on connections + sessions
yarilo-lmtp-loginPer RCPT TO — enforce per-user delivery concurrency limits before fan-out

Warden merges conn-state (from the director / lmtp-login) with session-state (from the backends) — written from different processes, read from a single place (the login proxy).

Scaling yarilo-warden ​

Warden holds three pieces of shared state: the per-IP auth-failure penalty counter, the per-user@ip session accounting (the connection limit), and the kick bus (EMIT/SUBSCRIBE). Where that state lives is a single config choice:

state_backend ​

Default
memory
Type
string
Values
memory redis
Helm
components.warden.service.state_backend

Where warden keeps its penalty counters, session accounting and kick bus. memory keeps them in the pod and allows one replica only; redis shares them and allows any number.

How the two values differ:

state_backendState lives inReplicasDeploy strategy
memory (default)the pod's processmust be 1Recreate
redisshared Redisany NRollingUpdate
  • memory is the standalone / dev / test default. All three pieces of state are per-pod, so a second replica would see a different subset of sessions, enforce the limit independently, and — the decisive one — miss kicks: a login pod's SUBSCRIBE lands (via the ClusterIP) on one pod while an EMIT may land on another, and an in-process bus never crosses that gap. The chart therefore fails closed on replicas > 1 with memory, and the deploy strategy is Recreate so a rolling update never briefly runs two memory pods and splits their state.
  • redis moves all three pieces into shared Redis: penalty is a key with TTL, sessions are hashes + an atomic counter reconciled by a sweep, and the kick bus is Redis Pub/Sub — an EMIT on any pod PUBLISHes, and every pod's subscribers receive it, so a kick published on pod-B is delivered by pod-A. Pods are then stateless and scale to any N behind the existing ClusterIP; the strategy is a normal RollingUpdate. This is the required setting before raising replicas.

Both the subscribe path (go-redis auto-reconnects and re-subscribes on a Redis blip) and the login→warden transport (the login pod redials + re-subscribes) recover on their own, so a Redis or warden restart does not permanently deafen a pod to kicks. Kick delivery is best-effort / at-most-once and is not a correctness guarantee — the director's confirmed ring-wide kill holds LOOKUP until the ring-wide session count is stably zero, so split-writer safety never depends on a kick landing; the kick only makes teardown prompt.

Observability for the scale-out (all always-on, no debug flag): yarilo_warden_connect_total{result}, yarilo_warden_kick_emitted_total / yarilo_warden_kick_delivered_total (different pods, proving cross-replica delivery under a scrape), yarilo_warden_redis_errors_total{op} (fail-open made visible), yarilo_warden_reconcile_adjustments_total, and yarilo_warden_penalty_updates_total{result}. Structured logs carry pod= so a kick can be traced across replicas directly.


Write locking ​

Purpose: coordinate writes to mailbox and index files across the session processes (yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap) — uniformly across every deployment shape.

Why needed: the session binaries live in distinct address spaces. In-process sync.Mutex does not cross process boundaries. In backend deployments there is the additional dimension of coordination between StatefulSet replicas within the same tag (notably during failover).

Deployment modes ​

A single pkg/locks API and a single wire protocol, with two implementations behind it. Production k8s always uses remote mode (Redis-backed, mTLS TCP), regardless of how many replicas the deployment runs — this is the only mode that supports horizontal scaling without a config or code rework. Embedded mode is reserved for non-k8s use only.

ModeProduction k8s (standalone or backend)Dev / CI
When to useEvery k8s Helm release. Scales from 1 → N replicas via replicaCount in values.yaml.Local CLI runs, unit tests, single-process smoke runs. Never in k8s.
Processyarilo-locks as its own Deployment (typically replicaCount=2 for HA).yarilo-locks --embedded co-located in the same process tree.
State backendRedis (bundled subchart or external).In-memory map (ephemeral, pod-local).
TransportmTLS TCP :9104 reached through a ClusterIP Service.Unix socket /run/yarilo/locks.sock.
HA2+ replicas behind the Service; Redis HA via Sentinel/Cluster.None — state dies with the process.
Scales to N session podsYes. All session pods reach the same locks Service and share state via Redis.No. Unix socket is pod-local and in-memory state is per-process.
RTT (typical)~1–2 ms (in-cluster Redis on the same node ~0.5 ms).~100–300 µs.
Wire protocolidenticalidentical

Why embedded is not a k8s option, even at replicaCount=1:

  • Unix-socket coordination is pod-local. Bump replicaCount: 1 → 2 and the second pod cannot see lock state held by the first — two writers collide on the same NFS file.
  • A scheduled rolling restart of a single replica drops every in-flight lock. Remote mode survives this: clients reconnect to the surviving replica and pick up state from Redis.
  • Operator surprise is the worst kind of incident. The deployment must not silently switch semantics when the operator scales it up. One mode in production, end of story.

Two-tier locking convention: sync.Mutex inside a process is the fast-path for goroutine-level contention (no RTT). yarilo-locks (always remote, in production) is engaged only when a cross-process barrier is required (any write to a shared file).

Configuration ​

Two independent sections in yarilo.yaml. The yarilo-locks process consumes locks_service; every session binary (yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap) consumes locks_client. mTLS material is shared with the rest of the stack via internal_tls — no separate keys live under the locks sections.

yaml
# k8s production (standalone or backend): yarilo-locks listens on TCP+mTLS,
# Redis backs the state.
locks_service:
  mode: remote
  listen: ":9104"
  redis: "redis://redis.yarilo.svc.cluster.local:6379/0"
  redis_password: "${YARILO_REDIS_PASSWORD}"   # from a Secret, see below

# session binaries reach yarilo-locks via the ClusterIP Service.
locks_client:
  mode: remote
  endpoints: ["yarilo-locks.yarilo.svc.cluster.local:9104"]

internal_tls:
  enabled: true
  cert: /etc/yarilo/tls/tls.crt
  key:  /etc/yarilo/tls/tls.key
  ca:   /etc/yarilo/tls/ca.crt

mode ​

Default
none
Type
string
Values
remote embedded

remote serves locks over mTLS TCP with Redis behind it, and is the only mode for Kubernetes. embedded keeps them in memory behind a Unix socket, for tests and single-process runs.

listen ​

Default
none
Type
string
Helm
components.locks.service.listen

Address yarilo-locks serves on in remote mode, :9104 in the chart.

redis ​

Default
none
Type
string

Redis URL that holds the lock state in remote mode. Leave the password out of the URL; the ConfigMap renders the URL as written.

socket ​

Default
none
Type
string

Unix socket yarilo-locks serves on in embedded mode.

mode ​

Default
none
Type
string
Values
remote embedded

How a session process reaches yarilo-locks; matches the service's mode.

endpoints ​

Default
[]
Type
list

yarilo-locks addresses a session process dials in remote mode, normally the ClusterIP Service on :9104.

socket ​

Default
none
Type
string

Unix socket a session process dials in embedded mode.

Redis password ​

A Redis that requires a password gets it from a Secret, never from the ConfigMap. In the chart, redis.passwordSecret (name, key, default password) puts it into the YARILO_REDIS_PASSWORD environment variable of every process that opens Redis: auth (session tokens), warden, locks, dict, quota-status, backend-api and the session containers. The bundled Redis then starts with --requirepass from the same Secret.

passwordSecret ​
Set in
Helm values
Default
name: , key: password
Type
map

The Secret (name, key) whose value becomes YARILO_REDIS_PASSWORD in every process that opens Redis. The bundled Redis starts with the same password.

The config names the variable next to each Redis URL, and the loader expands it at startup:

redis_password ​
Default
none
Type
string

Password of the Redis that holds session tokens. Set it to ${YARILO_REDIS_PASSWORD}; it wins over a password in the URL.

redis_password ​
Default
none
Type
string

Password of warden's Redis. Set it to ${YARILO_REDIS_PASSWORD}; it wins over a password in the URL.

redis_password ​
Default
none
Type
string

Password of the Redis behind yarilo-locks. Set it to ${YARILO_REDIS_PASSWORD}; it wins over a password in the URL.

The chart renders these three. A dict with driver: redis (quota clone, ACL owner registry, Sieve vacation and duplicate) is the operator's, so add password: "${YARILO_REDIS_PASSWORD}" to its settings. A separate password wins over one inside the URL. Do not put the password in the URL: the URL is rendered into the ConfigMap as written.

Server name of internal dials ​

Every internal client dial (auth / warden / locks / backend-api / backend / fts / the login pods) verifies the peer against a single pinned name, internal_tls.server_name — not the dialed host. Internal services are reached by short name, FQDN, or pod IP interchangeably, so host-based verification is unreliable; every internal certificate instead carries one stable SAN and every client pins it. Which component a peer is comes from its role, below.

The chart defaults server_name to <release>-internal and puts that name in every certificate it makes; a certificate you provide MUST carry <release>-internal as a SAN. internal_tls.enabled=true with an empty server_name fails loudly at startup (mtls.ClientConfig errors with a fix hint) rather than as a cryptic "ServerName must be specified" on the first dial. saslLogin and quotaStatus now also carry an internalTLS block/mount — every component that dials an internal service needs the cert mounted.

server_name ​
Default
none
Type
string
Helm
internalTLS.serverName

The one name every internal client verifies its peer against, instead of the host it dialed. The chart sets it to <release>-internal and puts it in every certificate it makes; a certificate you provide must carry it as a SAN. Empty with internal TLS on fails at startup.

The director ring dial is separate — see below.

Peer roles ​

Each internal certificate names the one component it speaks for in a DNS SAN <role>.role.yarilo.internal, and each internal listener accepts only the roles that are meant to call it. .internal is reserved for private use and never resolves publicly, so the name cannot collide with a service. Other DNS SANs are not identity.

ListenerAccepted roles
auth client portimap-login, pop3-login, submission-login, managesieve-login, jmap-login, sasl-login, imap, pop3, lmtp, managesieve, submission, admin
auth master portimap, pop3, lmtp, managesieve, backend-api, fts, jmap, quota-status, lmtp-login, admin
wardenauth, imap-login, pop3-login, submission-login, managesieve-login, lmtp-login, jmap-login, backend-api, imap
locksimap, pop3, lmtp, managesieve, backend-api, fts, jmap, admin
dictimap, pop3, lmtp, managesieve
directordirector (ring), the login roles and backend-api (LOOKUP), backend-reg (registration)
director admin APIadmin, director-admin
backend-apiadmin, backend-api
backend imap / pop3 / lmtp / managesieve / submissionthe matching login: imap-login, pop3-login, lmtp-login, managesieve-login, submission-login
jmap internaljmap-login
FTSimap, pop3, lmtp, managesieve, backend-api, jmap

The roles are auth, warden, locks, dict, director, fts, backend-api, backend-reg, imap, pop3, lmtp, managesieve, submission, jmap, imap-login, pop3-login, submission-login, managesieve-login, lmtp-login, jmap-login, sasl-login, quota-status, admin for the operator tools (yarctl, yarilo-migrate, the smoketest), and director-admin, the director pod's console, which reaches only the director admin API.

  • A certificate with two role SANs, or a label outside this list, is refused.
  • The director also checks the role per command: ring commands only from director, registration only from backend-reg.
  • A refusal is logged with the peer certificate (subject, serial, role SANs), the listener and the roles it expected.
  • This release accepts a certificate without a role and logs it once per listener and certificate, so a rolling upgrade from a shared certificate keeps internal connections up. The next release refuses it.
  • With internal_tls off, each server logs once at startup that roles are not checked.
  • The FTS port and the director admin API are now behind internal mTLS as well. The admin API keeps its token.

On by default. Internal mTLS is one switch, internalTLS.enabled, true by default. It drives the config, the mounts and the certificates of every component. The former components.<name>.internalTLS.enabled flags are refused with a render error that names the switch: one of them could turn TLS on in the config of a component that got no certificate. Per-component secretName stays, for your own certificates. With the switch off, every internal port is plain and each server logs that roles are not checked.

enabled ​
Default
true
Type
boolean
Helm
internalTLS.enabled

Internal mTLS for every internal port. In the chart this one switch drives the config, the mounts and the certificates of every component. Off, every internal port is plain and each server logs that roles are not checked.

cert ​
Default
/etc/yarilo/tls/tls.crt
Type
string

This component's certificate.

key ​
Default
/etc/yarilo/tls/tls.key
Type
string

The private key of cert.

ca ​
Default
/etc/yarilo/tls/ca.crt
Type
string

The CA every internal peer's certificate must chain to.

Certificates from the chart. Every component without its own secretName gets <release>-<role>-internal-tls, made by the chart (genSignedCert, 10 years) and signed by:

  • internalTLS.ca.secretName, an existing kubernetes.io/tls Secret holding the CA certificate and key, for example the one cert-manager keeps for a CA Issuer. On a real install a missing Secret fails the render;
  • or, when that is empty, a CA the chart makes once and keeps in <release>-internal-ca (with helm.sh/resource-policy: keep).

A certificate is kept from one upgrade to the next and re-issued only when its Secret is missing, the CA changed, or internalTLS.certGeneration was raised. There is no renewal by age: a Helm template cannot read a certificate's expiry. For renewal over time use cert-manager.

A plain helm install needs nothing else: with neither cert-manager nor ca.secretName, the chart makes the CA once and every certificate from it.

Limitation, GitOps renderers. The chart finds its existing Secrets with lookup, which sees the cluster only on helm install / helm upgrade. helm template, --dry-run and renderers such as Argo CD get no answers:

  • without ca.secretName they make a new CA on every render. Pods restarted after different syncs then trust different CAs, and internal links break. Such installs must set internalTLS.ca.secretName to a CA Secret made once outside the chart, or use internalTLS.certManager;
  • with ca.secretName the leaf certificates are still minted again on every render, but they all chain to the same CA, so links hold. The Secrets show a diff on every sync.

The install notes (NOTES.txt) repeat this when the chart makes its own CA.

secretName ​
Set in
Helm values
Default
none
Type
string

An existing kubernetes.io/tls Secret with the CA the chart signs internal certificates with. Empty, the chart makes a CA once and keeps it in <release>-internal-ca. A GitOps renderer must set it (or use cert-manager): without lookup it would make a new CA on every render.

certGeneration ​
Set in
Helm values
Default
0
Type
integer

Raise it to re-issue every certificate the chart made. There is no renewal by age.

Upgrading a release that ran with internal TLS off. Turning it on switches every internal link from plain to TLS at once. While the rollout runs, new pods speak TLS and old ones do not, so internal calls between old and new pods fail until the last pod has restarted. Plan the upgrade as a short outage, or set internalTLS.enabled: false before upgrading and switch it on in a window of your choosing.

cert-manager. With internalTLS.certManager.enabled and internalTLS.certManager.issuerRef, the chart renders one Certificate per role with the same DNS names, and cert-manager makes and renews the Secrets.

enabled ​
Set in
Helm values
Default
false
Type
boolean
See also
issuerRef

Let cert-manager make and renew the internal certificates: one Certificate per role, with the same names.

issuerRef ​
Set in
Helm values
Default
kind: Issuer, name:
Type
map
See also
enabled

The cert-manager issuer that signs them.

Your own certificates. Set secretName per component. Each certificate carries <role>.role.yarilo.internal for that component, plus <release>-internal. The co-located backend pod mounts one Secret per container, so a single operator Secret there carries no role.

yarctl presents the certificate the chart mounts at /etc/yarilo/admin-tls:

  • backend-api containers: the admin certificate. yarctl there addresses the backend plane; admin is also accepted by the director admin API.
  • director pod: the director-admin certificate, with YARILO_ADMIN_TYPE=director, its own API on https://localhost and the pinned name. Run director commands from there, for example yarctl ring status. A compromised director pod gains nothing it does not already hold (its API token and director certificate), and no path to mail.

No other container holds either certificate.

NetworkPolicy ​

With networkPolicy.enabled (the default) the chart renders ingress policies that say on the network layer what the role matrix says in the handshake:

  • one per internal listener: the server pod admits on that port only the pods whose roles the listener accepts (the peer roles table). The co-located backend pod holds the imap, pop3, lmtp, managesieve, submission, jmap, fts, backend-api and backend-reg roles, so on the network it is one source, backend; the per-role split stays with mTLS. Traffic inside a pod is not policed;
  • MTA-facing ports: lmtp-login, sasl-login and quota-status, from networkPolicy.mtaFrom;
  • telemetry and health (8080; 8080-8088 on the backend pod) of every pod a policy selects, from networkPolicy.telemetryFrom.

The client-facing login ports carry no policy and stay open. Egress is not restricted. The policies need a CNI that enforces them (Calico, Cilium, …).

enabled ​
Set in
Helm values
Default
true
Type
boolean

Render ingress policies that state on the network what the peer roles state in the handshake. They need a CNI that enforces them; egress is not restricted.

adminFrom ​
Set in
Helm values
Default
[]
Type
list

Peers for operator tools outside the chart's pods (smoketest, yarilo-migrate) on the admin, auth and locks ports. Empty: kubectl exec into backend-api or the director.

mtaFrom ​
Set in
Helm values
Default
[]
Type
list

Who may reach the MTA-facing ports. Empty is everyone.

telemetryFrom ​
Set in
Helm values
Default
[]
Type
list

Who may reach telemetry and health. Empty is everyone. Kubelet probes come from the node and keep passing when it is narrowed.

Narrowing to a Postfix in a neighbouring namespace, and to Prometheus in monitoring:

yaml
networkPolicy:
  mtaFrom:
    - namespaceSelector:
        matchLabels: { kubernetes.io/metadata.name: postfix }
  telemetryFrom:
    - namespaceSelector:
        matchLabels: { kubernetes.io/metadata.name: monitoring }
  adminFrom:
    - podSelector:
        matchLabels: { job-name: smoketest }

MTA-facing services: sasl-login and quota-status ​

Two services exist only for the MTA (Postfix), never for mail clients:

ServicePortWhat it does
yarilo-sasl-login12345SASL authentication for Postfix (smtpd_sasl_type = dovecot): Postfix hands over the login and password a client sent, and gets yes or no
yarilo-quota-status12340Postfix policy service: answers whether a message fits the recipient's quota

Both answer to anyone who reaches them. sasl-login takes passwords in clear text and is a password oracle; quota-status tells which addresses exist and how full they are. Pick the variant that matches where the MTA runs.

Postfix in the same cluster (recommended). Keep the chart default, service.type: ClusterIP, so neither port leaves the cluster, and narrow networkPolicy.mtaFrom to the MTA's namespace:

yaml
networkPolicy:
  mtaFrom:
    - namespaceSelector:
        matchLabels: { kubernetes.io/metadata.name: postfix }
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:yarilo-sasl-login.<namespace>.svc:12345
smtpd_recipient_restrictions = ..., check_policy_service inet:yarilo-quota-status.<namespace>.svc:12340

Postfix outside the cluster. Publish both with service.type: LoadBalancer, as the production examples and the sandbox do, and restrict the sources on every layer that can:

  • components.saslLogin.trusted_nets: the MTA's addresses; sasl-login refuses any other peer. Empty means everyone.
  • networkPolicy.mtaFrom: the same addresses as an ipBlock. This is the only in-chart restriction for quota-status, which has no trusted_nets.
  • The load balancer: a source-range annotation of your provider, through service.annotations, or an internal (non-public) load balancer. The chart renders no loadBalancerSourceRanges.
  • Keep the path off the public internet (an internal network or a VPN): passwords cross it in clear text.
  • components.saslLogin.haproxy with haproxy_nets when a proxy in front hides the MTA's address.
yaml
components:
  saslLogin:
    service: { type: LoadBalancer }
    trusted_nets: ["203.0.113.10/32"]
  quotaStatus:
    service: { type: LoadBalancer }
networkPolicy:
  mtaFrom:
    - ipBlock: { cidr: 203.0.113.10/32 }

Director ring TLS ​

The director dials its ring peers (JOIN, right-neighbor, seed polls) by ephemeral pod IP. Under internal_tls the dial verifies the peer's certificate against ring_tls_server_name (a stable name), not the pod IP — without it Go would check the pod IP against the cert's SANs and fail closed, so the ring would silently never converge. The dial uses a proper client config (with RootCAs); the ring listener keeps the mTLS server config.

The chart issues a director-specific internal-tls certificate (enable components.director.internalTLS.certificate) whose SANs default to <release>-director-ring and <release>-director, and renders ring_tls_server_name to <release>-director-ring automatically. The stock shared internal-tls secret has no such SAN — enabling internal_tls on the director with that shared cert fails ring handshakes. On misconfiguration the director is loud: an ERROR when internal_tls is on and peers are set but ring_tls_server_name is empty, and a Warn when the configured name is absent from its own loaded certificate.

Certificate rotation. cert-manager renews the Secret and kubelet refreshes the mounted files, but each director loads its tls.Config once at startup — a renewed cert is picked up only after a rolling restart of the director Deployment. The ring survives a rolling restart normally: each director leaves gracefully before it stops.

yaml
# dev / unit tests / non-k8s CLI runs (single process, no Redis).
locks_service:
  mode: embedded
  socket: /run/yarilo/locks.sock

locks_client:
  mode: embedded
  socket: /run/yarilo/locks.sock

Lock model ​

  • Exclusive (X) locks for writes.
  • Reads take no lock — the storage layer provides a consistent snapshot.
  • The protocol also offers shared locks and blocking variants (LOCK-SHARED, LOCK-WAIT, LOCK-SHARED-WAIT); the storage code takes only exclusive locks.
  • TTL-based: a lock releases itself if its holder crashes. The TTL and the renewal interval are chosen per kind of lock by the code that takes it; there is no single global value.
  • Granularity: per-mailbox (mbox:<user>:<folder>).
OperationLock
IMAP SELECT / FETCH / SEARCH / IDLEnone
LIST / LSUBnone
IMAP APPEND / EXPUNGE / STOREX on mailbox
LMTP deliveryX on mailbox
Rename / Delete mailboxX on mailbox
Sieve script updateX on user-scripts

Wire protocol ​

TAB-delimited, LF-terminated. In remote mode the transport is TCP over mTLS on port :9104. In embedded mode the identical byte stream runs over the Unix socket /run/yarilo/locks.sock (no TLS).

> VERSION\t1\n
< VERSION\t1\tOK\n

> LOCK\t<resource>\t<owner>\t<ttl_ms>[\t<site>]\n
< OK\t<lock_id>\n           # acquired
< BUSY\t<current_owner>\t<site>\n   # held by someone else

> UNLOCK\t<lock_id>\n
< OK\n | NOT_FOUND\n

> RENEW\t<lock_id>\t<new_ttl>\n
< OK\n | EXPIRED\n

> EMIT\t<resource>\t<event_type>\t<payload>\n
< OK\n

> SUBSCRIBE\t<resource>\n
< EVENT\t<resource>\t<event_type>\t<payload>\n   # one line per EMIT on that resource

> COUNTER-INC\t<key>\t<delta>\n
< OK\t<new_value>\n

<site> names the code path taking the lock, so a BUSY reply says who holds it and where. LOCK-SHARED takes the same arguments as LOCK.

LOCK-WAIT and LOCK-SHARED-WAIT take one more, a wait limit: LOCK-WAIT\t<resource>\t<owner>\t<ttl_ms>\t<site>\t<wait_ms>. Within that limit the caller waits in the server's queue — the order the backend keeps for every replica — and is answered OK once the lock is its own. When the limit runs out the answer is BUSY with the current owner and site. A malformed limit answers ERROR bad_wait.

What stays inside the session processes ​

  • Writing raw mail data (dbox segments, maildir files).
  • Writing index updates (under the X lock on the mailbox).
  • UID assignment (under the X lock — atomic read-increment-write of NEXTUID).
  • IDLE notifications published over the yarilo-locks EVENT channel.

Deadlock prevention ​

Code convention: always acquire locks in the order idx:<user> → mbox:<user>:<folder> → deliver:<user>:<folder>. If anything hangs, the TTL releases the lock automatically.

Storage backend (remote mode only) ​

Redis (per backend deployment, or in the same namespace). Key: lock:<resource>, Value: <owner>|<acquired_at>, TTL: the one the caller asked for. Atomic acquisition via Lua SET ... NX EX.

Embedded mode keeps the same key/value shape in a local map[string]lockState with a background TTL sweeper — no external dependencies.

HA (remote mode only) ​

  • 2 replicas of yarilo-locks per backend deployment, behind a ClusterIP Service.
  • Stateless (state in Redis).
  • Local Redis, or shared with other components (warden, etc.).

Embedded mode has no HA — state is ephemeral; on process crash every lock is lost. Acceptable for unit tests and CLI dev runs; not used in k8s deployments.

Liveness and readiness ​

The split, stated plainly so it is not "completed" back into a dependency probe:

  • liveness = local process state. It restarts a process that is up but whose own request path is wedged.
  • readiness = Redis reachability. It removes a pod from rotation when its backend is unreachable, and returns it automatically once Redis answers.

yarilo-locks in remote mode deliberately wires no liveness self-check, because it has no local state that could wedge independently of Redis. The grant path is handleLock → RedisBackend.Acquire → Redis Lua script, directly: RedisBackend holds no mutex, no queue, no in-process shard — the acquire is the Redis round-trip. The only in-process grant mutex is MemoryBackend.mu, and that backend is used only in embedded/test mode, never in k8s. So the "up but locally wedged" state a liveness self-check exists to catch does not exist here — there is nothing between accept and the Redis call to deadlock.

A self-check that round-trips Redis is rejected: it re-creates the exact failure the watchdog guard-rail forbids. A hung Redis (TCP alive, no replies) would hang that probe on every locks replica at once, restarting the whole HA pool synchronously into a backoff loop — and a restart cannot fix a hung Redis. Both a down Redis and a hung Redis already have the correct answer: the readiness ping hangs or fails, the pod leaves rotation without a restart, and rejoins the moment Redis responds. Not-ready is the right degradation; dead is not.

The dead-accept-loop and hard-crash cases are covered by os.Exit(1) on Serve error, no probe involved. So the coverage is complete without a locks watchdog: crash → os.Exit; Redis unreachable/hung → readiness; and there is no third, local-only failure mode to add.

Revisit trigger. If remote-mode yarilo-locks ever grows a genuine local stage in the grant path — an in-process cache, a grant queue, an in-process shard — this decision is reopened: that stage can wedge independently of Redis, and it should then get a liveness self-check that acquires and releases a probe-scoped lock through that stage, bounded by a timeout well above the worst-case acquire, and never reaching Redis. Until such a stage exists, there is nothing local to probe.


Standalone deployment ​

The standalone deployment targets a single k8s node, no director, no per-tag sharding. It is built to scale from 1 → N replicas of each component by editing values.yaml — no rewiring, no protocol changes, no code touched. The same pattern carries the operator from a one-pod dev cluster to a small multi-replica production setup.

Docker Compose ​

For local development, evaluation and small self-hosted installs there is a Docker Compose deployment (deploy/compose/, Docker Compose). It runs the standalone topology as one container per role — login proxies, session backends, yarilo-auth, yarilo-warden, yarilo-locks with Redis, yarilo-fts and yarilo-backend-api — on one host, with a SQLite userdb. It is a single-host target and is not highly available — for HA/scale-out use the Helm standalone or backend deployments above.

Components (all as k8s Deployments unless noted) ​

ComponentDefault replicasScale by
yarilo-imap-login, yarilo-pop3-login, yarilo-submission-login1 eachreplicaCount per protocol (login is stateless beyond TLS state)
yarilo-lmtp-login0 (disabled by default)components.lmtpLogin.enabled: true in values.yaml; MTA-facing LMTP proxy — warden CONNECT per recipient, SESSION token, preamble fan-out
yarilo-jmap-login1replicaCount (stateless beyond TLS; scales on request load)
yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-jmap1 eachreplicaCount per protocol (coordination via locks)
yarilo-auth1replicaCount (stateless; userdb in SQL)
yarilo-warden1must stay 1 with state_backend: memory (the default); any N with state_backend: redis
yarilo-locks2replicaCount (state in Redis; 2 = HA default)
redis1 (StatefulSet)external HA or Sentinel for production

Storage ​

A single PersistentVolumeClaim with accessModes: [ReadWriteMany] — every session pod mounts it at the same path. On single-node clusters this can be backed by hostPath, NFS, or a CSI RWX provisioner; on multi-node clusters it must be NFS or CephFS.

Routing ​

A k8s Service per public port (993, 995, 465, 587, 143, 110, 8443) load-balances connections across the matching login pods. Port 24 is a Service in front of yarilo-lmtp-login (when enabled) which fans out per-recipient preamble connections to yarilo-lmtp backends. There is no director — sessions distribute round-robin (or by k8s Service's sessionAffinity setting). yarilo-jmap-login keeps its auth, warden and proxy duties here and simply skips the director lookup, addressing the yarilo-jmap Service directly; the binary is the same artefact as in the director-backed shape, the routing target comes from config. Cross-pod write contention is resolved through yarilo-locks; that adds RTT but stays correct. Once cross-pod contention is a measured problem, the upgrade path is a director deployment (separate document); the session and login binaries do not change.

Scale invariants ​

  • yarilo-locks is always remote mode, even at replicaCount=1 for every other component. This is what keeps the deployment scalable without rework.
  • All session processes call the locks Service via mTLS TCP. Storage code uses pkg/locks.Locker only; no compile-time switch on deployment shape.
  • Storage is RWX from day one. Switching from RWO to RWX later requires a data migration and a full re-deploy. Doing it up front costs nothing extra.

Out of scope for standalone ​

  • Per-tag sharding (multiple NFS shares, different user populations on different backends). That belongs to the backend deployment (see /yarilo_backend.svg).
  • Director-based sticky routing and consistent hashing. Standalone uses k8s Service load-balancing; if measured contention warrants it, switch to director without touching the session binaries.

Helm chart structure ​

There is one chart, helm/. Every component is switched on and sized under components.* in values.yaml; shared settings (TLS, Redis, telemetry) sit at the top level. A backend tag is the same chart installed once per tag, with backend_register.tag set to the tag and its own NFS PV.

Director and login tier ​

  • Deployment yarilo-director — self-organizing ring: each replica dials only its right neighbor in (ip, port) sort order, never a full mesh, and every member count (including N=1, a lone replica) is a fully valid, service-serving state. Plain Deployment, not a StatefulSet — no per-pod stable network identity is needed. The headless -director-ring Service is the join seed: its DNS name resolves directly to every ready pod's IP, so any resolver hit lands on a live replica to DIRECTOR-JOIN against. The ClusterIP -director Service is what login pods send LOOKUP to.
  • One Deployment and Service per login proxy (yarilo-imap-login, yarilo-pop3-login, yarilo-submission-login, yarilo-lmtp-login, yarilo-managesieve-login, yarilo-jmap-login, yarilo-sasl-login). IMAP, POP3, Submission and JMAP default to LoadBalancer Services, the public entry points; yarilo-managesieve-login and yarilo-lmtp-login default to ClusterIP.

Backend (per tag) ​

One co-located StatefulSet per tag — the pod runs every protocol container plus the sidecars:

  • StatefulSet yarilo-backend — components.backend.replicas. Pod containers: yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap, yarilo-fts (fts_addr = localhost), yarilo-backend-api and yarilo-backend-reg (registration sidecar). All share the pod IP.
  • Deployment yarilo-locks — cross-pod write coordination, state in Redis.
  • Deployment yarilo-quota-status — global-read Postfix policy service, own replica count, stable ClusterIP for the external MTA (NOT in the pod; backend-api IS in the pod — see the co-location criterion above).
  • One PVC NFS (RWX) — shared by all pods within the tag.
  • One Headless Service — stable per-pod DNS.

Shared services ​

  • Deployment yarilo-auth — stateless (userdb in an external SQL/LDAP store).
  • Deployment yarilo-warden — 1 replica with state_backend: memory, any N with redis.
  • Redis (bundled or external) — state for warden, locks and session tokens, and redis dicts; its password comes from redis.passwordSecret.

Why co-located, not per-protocol StatefulSets:

  • Routing coherence (the whole point). Consistent hashing cannot give both "1 user = 1 pod across protocols" and independent per-protocol pod pools — they are mathematically incompatible. Separate pools would hash the same user to different pods for IMAP vs LMTP, so every cross-protocol write (LMTP delivery → the mailbox an IMAP session on another pod holds) crosses pods and fights over yarilo-locks / index-cache coherence. Co-locating restores the reference's one-mail-host invariant and makes the single ring + single userDir correct, not merely a shortcut.
  • FTS single-writer for free. The per-user Xapian index has the same single-writer requirement as the mailbox. Co-locating yarilo-fts (localhost) puts the user's index write handle on their already-sticky pod, so each per-user index on the shared NFS has exactly one writer cluster-wide — no fts ring, no ClusterIP round-robin corruption. One invariant ("1 user = 1 pod") covers the mailbox and the index together.
  • One IP per user. The director stores one pod IP per user; that IP serves every protocol, so no protocol dimension is needed on the ring, the userDir, or the wire.
  • Vertical sizing stays per-protocol — each container keeps its own CPU/RAM limits. What is given up is independent per-protocol replica-count / HPA: you scale the pod (all protocols together), not one protocol's pool. This is a deliberate trade of an unused scaling axis for routing correctness.

Trade-off (documented, accepted): the whole pod scales as a unit, and one unready protocol container takes the pod (all protocols) out of the ring — see the readiness-gate semantics under "backend liveness". Cross-protocol writes still go through yarilo-locks for cross-pod coordination (a user's replicas across the tag), but same-user same-protocol traffic is pinned to one pod.

Director routing ​

The director maintains one consistent-hashing ring and one user→pod directory: MD5(user) → yarilo-backend-<tag>-N (the pod IP). The same pod answers IMAP, POP3, Submission and LMTP for that user; the login proxy dials the protocol-specific port on the resolved pod IP. tag selects the NFS shard, never the protocol. There is no per-protocol ring — a single user never lands on different pods for different protocols.

Admin per-user ops reach the owning pod the same way. yarilo-backend-api listens on the pod IP only, so a per-user admin op (yarctl backend fts rescan <user>, folder/quota/index/… on a user) must hit the user's pod. yarctl does the routing client-side: it asks the director GET /api/director/map?user=X — which resolves with the same precedence a login LOOKUP uses (sticky userDir pin → ring hash, the director owning the assignment) — then dials http://{pod-ip}:{backend_api_port}. It never picks a pod itself: a client-side choice would race a concurrent login and put two pods on one user's FTS index, breaking the single-writer invariant. Routing auto-enables when a director URL is configured (co-located); with no director it uses the fixed --backend-url (standalone). --route-by-user=false is a debug escape hatch for talking to one pod directly. If the director is unreachable the op fails with a clear error — never a silent fall-through to a random pod. Global ops (dict, user iterate) carry no user and always use the fixed URL.


Where the chart's CPU limits come from ​

Two of nineteen were measured. The rest are starting points.

Every limits.cpu in helm/values.yaml now says which it is, so the file can be read for what it knows rather than what it appears to advise:

fts — 1measured: one core saturates under sandbox delivery load, which is why fts_index_workers stays at 1
jmap — 500mmeasured once and found short: 1.31 cores wanted under 8 clients and 50 users, with 81% of scheduling periods throttled
the other seventeenunmeasured

The numbers are not even uniform — 500m for director, pop3, lmtp, manageSieve and jmap, 1000m for imap, 200m for most login layers — so there is no single policy behind them to reconstruct.

What an unexplained limit costs. The first time anyone measured against one, JMAP read latency came out at a 5-second median and was investigated as an algorithmic defect twice, by two people, before the limit was found. A wrong limit does not fail; it produces numbers that look like a code problem.

So measure under your own load before trusting any of them. The method is in Testing: run the matching job from hack/loadtest/, read cpu.stat inside the container, and establish the appetite with a second run at a different limit — one run only tells you the limit you chose, not where the workload stops asking.

Throttling is invisible in yarilo's own /metrics. It is a kubelet counter, container_cpu_cfs_throttled_periods_total, exported through cAdvisor. Nothing scrapes it in the sandbox, so the reading has to be taken during a run or recorded with the result — after the fact, "was it throttled yesterday" has no answer.

The tables below are estimates from the same period as the defaults, and carry the same caveat.

Sizing per backend pod ​

WorkloadRAMCPU
5k idle (mostly IMAP IDLE)~300–500 MB~0.1 cores
2k active~500–800 MB~0.3–0.5 cores
Burst (FETCH of a large mail × 100 users)up to ~2 GB peakup to 1–2 cores peak

Recommended Helm values:

yaml
resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 2Gi

Bottlenecks (in order):

  1. Open FDs — ulimit -n 65535 is required (default 1024 is too low). ~10 FD per session.
  2. NFS IOPS — the most likely bottleneck. SSD-backed NFS plus cachefilesd/fscache help.
  3. yarilo-locks RTT — every write does LOCK/UNLOCK. Local Redis → ~1–2 ms per pair.
  4. NFS bandwidth — attachment FETCH. 1 Gbps = 125 MB/s.
  5. RAM on burst — buffers for reading large mails.

Sizing per backend tag (one co-located StatefulSet + one NFS share + local locks) ​

ParameterTypicalMin/Max
yarilo-backend replicaCount (co-located pod)3–51 / scale-out
per-container resources (imap/pop3/submission/lmtp)sized independently within the podsee "Sizing per backend pod"
locks replicaCount2stateless, ClusterIP
Users per pod3–5k mostly idleGoroutines are cheap, but FD/RAM limits apply
Total users per tag10–20kNFS server is the constraint
NFS share size5–10 TB10k users × ~500 MB average quota
NFS sustained IOPS10–30kIndex/mailbox operations
NFS bandwidth1–10 GbpsAttachment transfer

When to create a new tag ​

TriggerThreshold
NFS storage used> 70–75%
NFS sustained IOPS> 70% of capacity
Users per tag> 15–20k
p99 latency for simple IMAP ops> 200 ms

One Helm release yarilo-backend per tag: yarilo-backend-a, yarilo-backend-b, … Each with its own NFS PV and its own yarilo-locks service.

Scale examples:

  • 10k users → 1 tag, 3 replicas, 5 TB NFS.
  • 50k users → 3–4 tags, each with 3 replicas × 5 TB.
  • 200k+ → 10–15 tags; start considering non-NFS storage.

Director routing & stickiness ​

Ring: MD5(username) → co-located backend pod (within a single tag). One ring, one pod IP per user, serving every protocol — the login proxy dials the protocol-specific port on that IP. Tag assignment: a separate user → tag map (admin-defined or hash-based shard) — tag = NFS shard, not protocol.

  1. Client → login proxy (TLS terminate).
  2. Login proxy: passdb through yarilo-auth (password + allow_nets).
  3. Login proxy → director LOOKUP <user> <tag>; the director answers from the sticky assignment or the ring.
  4. Login proxy dials the resolved pod IP on the protocol's port.
  5. It sends the YARILO preamble with the auth state and pipes the session.

userDir in the director is an in-memory cache of active user → pod mappings. Synced between directors over the peer protocol.


HA strategy ​

LayerHA approach
DirectorreplicaCount=3, self-organizing ring
Backend per tagreplicaCount=3–5, shared NFS RWX, ring rebalance
yarilo-locks (per tag)replicaCount=2, state in Redis
yarilo-authreplicaCount=2, stateless
yarilo-wardenstate_backend: redis, then any replicaCount (1 with the default memory)
Redisexternal HA (Sentinel/Cluster) or managed
NFS servera separate HA effort (Pacemaker+DRBD, or managed NFS such as AWS EFS)

Backend failover sequence:

  1. The pod stops heartbeating — the yarilo-backend-reg sidecar sends BACKEND-DOWN on SIGTERM (immediate); a hard kill or any unready protocol container → the sidecar goes silent → TTL expiry. Either way the whole pod leaves the ring for all protocols.
  2. Every director removes it from the ring, together with the users and domains placed on it, and kicks its own login sessions that were proxied there.
  3. Locks the dead pod held expire after their TTL on yarilo-locks.
  4. Users move on their next login, to a live replica in the same tag: one pinned to the pod, or of a domain placed on it under assignment_policy: domain, is placed afresh. Nothing is moved in a wave; a kicked session reconnects and is placed the same way.
  5. The k8s scheduler brings up a new pod (~30 s).
  6. The new pod mounts the same NFS and starts accepting connections.

Initial placement policy ​

assignment_policy ​

Default
hash
Type
string
Values
hash least_sessions domain
Helm
components.director.assignment_policy

How a user's first backend is chosen; sticky pins and moves do not consult it. The choices are described in Initial placement policy.

Only the first (unpinned) user→backend assignment consults the policy; sticky pins and USER-MOVE are unaffected. The director is the single owner of placement — the LOOKUP path, LMTP RouteUser, and the admin per-user resolve all funnel through one assignAndPin, so no caller can independently pick a pod and split a user's per-user writer.

  • hash (default, the reference semantics): the consistent-hash backend for the user's tag. Deterministic — if a sticky entry expires after long inactivity the user returns to the same pod (warm per-user index locality on the shared PV, easy debugging).

  • least_sessions: the least-loaded Up backend in the tag by a two-level, capacity-normalized load — level 1 the requesting protocol's sessions, level 2 total sessions among level-1 ties; each count*100/vhosts (vhosts 1..100; vhosts: 0 = drain → excluded); tie-break lower (ip, port). Session counts come from the live SESSION-OPEN/CLOSE registry (no new plumbing), which now carries a trailing proto field on LOOKUP / SESSION-OPEN. The admin resolve has no protocol → level 1 is skipped, total load decides. Session counts are replicated ring-wide (SESSION-OPEN/CLOSE gossiped as (origin, seq) envelopes), so whichever random replica answers a LOOKUP decides on the full cluster view — not just the sessions on the watch-holding replica.

  • domain: one backend per domain, chosen by load. A domain seen for the first time is placed on the Up backend of its tag with the fewest active connections and stays there; every later login of that domain follows it. This is what a shared mailbox needs — every member of a domain reaches the same backend — without pinning a whole domain by hash and hoping the distribution is even. A username with no domain falls back to the hash.

    The placement is replicated across the director ring, so a director restart does not re-place a domain, and it is forgotten after director_domain_expire without a session.

    Load counted at placement, not at connect. The count a placement compares is the backend's active sessions plus the placements it has already been given whose sessions have not arrived yet. Without that, a burst of first logins across several new domains reads the same zeroes — SESSION-OPEN comes back after the LOOKUP that placed the user — and every one of them lands on one backend. (The reference raises its per-host count at assignment for the same reason.)

    Rebalance. When the busiest backend of a tag rises more than director_domain_rebalance_percent above the quietest, one domain moves down — but only when the move actually narrows the spread, and among such moves the one that disconnects the fewest sessions. A move ends that domain's sessions on the backend it left, one user at a time through the confirmed kill.

    What it does not do. It does not split a domain: one large domain still fills the backend it was placed on, and the cure for that is spreading the domain across tags via userdb, exactly as with %Ld. It moves domains, not users.

Which one to use. hash where nothing is shared between users and determinism is worth more than balance. least_sessions where users are independent and the load is uneven. domain where users of a domain share mailboxes or ACLs — it replaces the older username_hash: "%Ld" + assignment_policy: hash pairing, which achieves the same affinity but leaves the distribution to the hash.

Trade-offs (least_sessions). It gives up hash determinism: an expired-then- returning user may land on a different pod than before. Acceptable because the data lives on the shared tag PV, but it must be a deliberate operator choice — hence the hash default. Second, because a fresh resolve now pins, a bulk admin sweep over never-logged-in users (e.g. yarctl backend fts rescan across a cohort) creates userDir entries for each — a deliberate side effect (the pins TTL-expire), so don't be surprised by a populated userDir after a mass operation. Reference parity: the reference does hash + vhosts weighting only; least_sessions is a yarilo extension, default stays reference-compatible.

Domain placement settings ​

director_domain_expire ​

Default
900
Type
integer, seconds
Helm
components.director.director_domain_expire

How long a domain placed by assignment_policy: domain keeps its backend with no session. Separate from the user TTL so that a domain is not forgotten between two logins.

director_domain_rebalance_percent ​

Default
0
Type
integer
Helm
components.director.director_domain_rebalance_percent
See also
director_domain_rebalance_interval director_domain_rebalance_cooldown

Move one domain down when the busiest backend of a tag is this much above the quietest. 0 is off in the binary; the chart sets 20, and a 0 in the values stays 0.

director_domain_rebalance_interval ​

Default
60
Type
integer, seconds
Helm
components.director.director_domain_rebalance_interval

How often the rebalance is judged.

director_domain_rebalance_cooldown ​

Default
600
Type
integer, seconds
Helm
components.director.director_domain_rebalance_cooldown

How long a moved domain is left alone, so that two backends either side of the line do not trade it.


USER-MOVE and pin longevity ​

An admin USER-MOVE (yarctl director users move <u> --backend …) is an operational tool — "shift this user off that backend now" — not permanent routing configuration. It writes a normal, TTL'd userDir pin at the target (replicated ring-wide via the same USER-MOVED gossip) and immediately kicks the user's sessions on the old backend so the next connection lands on the new one. There is no permanent-override map and no USER-RELEASE — a move just expires.

Longevity is session-driven, not permanent. While the user holds a live proxied session, the director keeps the pin's TTL fresh: every user_expire/2 it touches the pin of every user with an active session in the replicated session registry. Each director refreshes from its own ring-replicated session view, so no extra propagation is needed. Once the user's last session closes, the touches stop and the pin lapses back to the ring hash after user_expire. So a move survives exactly as long as the user stays connected; an idle user deterministically returns to their hash backend.

user_expire ​

Default
900
Type
integer, seconds
Helm
components.director.user_expire

How long a user's pin outlives their last session. While any session is open the pin is refreshed; after it, the user returns to their hash backend.

Operators who need a permanent placement should use tags (a user's tag-pool is routing configuration), not a move.


Kick pacing ​

Three settings shape how sessions are torn down. The last bounds a deliberate evacuation; see Director.

user_kick_delay ​

Default
2
Type
integer, seconds
Helm
components.director.user_kick_delay

An admin kick, usually the tail of a move, waits this long so a command in flight on the old backend completes. A kick for a dead backend and a conflict kick never wait. 0 kicks at once.

max_parallel_kicks ​

Default
100
Type
integer
Helm
components.director.max_parallel_kicks

When a backend goes down, its sessions are kicked in batches of this size, which spreads the re-logins over the surviving backends. 0 kicks all at once.

max_parallel_moves ​

Default
5
Type
integer
Helm
components.director.max_parallel_moves

Confirmed kills in flight while yarctl director backends flush drains a backend.


Stickiness rationale ​

User X is always served by a single backend pod (within the same tag). Reasons:

  • Less cross-pod lock contention in yarilo-locks.
  • Index cache locality.
  • Avoid duplicated IDLE notifications.
  • Faster session startup (cache prewarmed).

Stickiness ≠ data partitioning. Data is shared on NFS; sticky routing is an optimisation.


Why event-loop (goroutines), not fork ​

yarilo is written in Go. The Go runtime and fork() do not mix, so yarilo never forks.

  • exec.Command — to launch child processes at pod startup.
  • Goroutines per user within a process — one goroutine per user session.

This is fundamentally different from a process-per-user model (fork per user → process per user → ~10 MB per session). yarilo holds 1000+ users in a single process without resource overhead.