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, plusyarilo-sasl-loginfor a fronting MTA yarilo-directordecides 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:
- Accept TLS connection from client.
- Extract real client IP from HAProxy PROXY protocol header or raw TCP RemoteAddr.
- Parse the IMAP/POP3/SMTP AUTHENTICATE or LOGIN command to obtain credentials.
- Call yarilo-auth (
AUTHcommand) — passdb chain runs there; receivestoken=on success.allow_andnets nologinare enforced in the login pod using the real client IP. - On failure: send
NO Invalid credentialsto the client. Backend is never contacted. - 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 - Backend reads the preamble, calls yarilo-auth
VERIFY tokento 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., 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_ 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. 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/.
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 /), never here.
Backend auth logic:
- Login pod sends
YARILO\tADDR=.before any protocol exchange.. . \tSESSION=. . . \tUSER=. . . \tTOKEN=. . . \n - Backend's
PreambleListenerreads 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_) 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.), 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/ 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-regsidecar 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 sendsBACKEND-UP\t{ip}\t{port}\t{tag}\t{vhosts}\t{seq}for the pod IP; the receiving director adds it and forwards it as aRING-CHANGE upenvelope (carrying port + vhosts) so every director learns it.{port}is nominal — login proxies override it with their own protocol port (internal/BackendPort / lmtp-login BackendPort), dialinglogin {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 = oneyarilo-backendrelease). Per-user shard selection (userdbdirector_) is unaffected.tag - Heartbeat = periodic re-register, gated on the PROTOCOL containers' self-health. Every
register_seconds (Helm:interval backend_) the sidecar re-sendsregister. register_ interval BACKEND-UP— but ONLY while every protocol container in the pod is serving (each protocol's/: 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-wedgedreadyz yarilo-lmtpwill 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-ftsis 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_on the ring is REMOVED (expire RING-CHANGE down→RemoveBackendeverywhere); 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_, 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.expire - 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. SendingBACKEND-DOWNon 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-DOWNis 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_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).servers - admin-API
backends add/stay manual and non-expiring — an operator's explicit entry is never touched by the lease.remove - 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_ 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_, 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 —
/, 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 failshealthz /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.healthz
Why liveness is gated on the watchdog. Without a self-check, / 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. on a dead accept loop already covers. So the backend carried no livenessProbe historically. The probe is therefore rendered only when telemetry. is set: enabling the watchdog is what makes / 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 /, 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 / 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,cmdlineandsymbol: stacks, counts and symbol names, which say what code paths the process runs and what they cost;/:debug/ loglevel GETreturns the level, andPOST {"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. 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_ 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:
go tool pprof -inuse_space http://localhost:8080/debug/pprof/allocsThere is no / 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/ 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
/ and / 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 / 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 / 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:
telemetry:
pprof:
enabled: true
blockProfileRate: 10000 # ns of blocked time per sample
mutexProfileFraction: 100 # one in N contention eventsgo tool pprof -http=: http://localhost:8080/debug/pprof/blockBoth 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:
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/allocsThe telemetry port differs per container in the co-located backend pod — each is told its own via TELEMETRY_ — 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.
| Duty | yarilo-jmap-login | yarilo-jmap |
|---|---|---|
Client TLS (general.), HAProxy PROXY protocol | terminates | no knowledge of client TLS |
Authorization Basic/Bearer → yarilo-auth passdb chain | yes | accepts identity only from the login layer |
| warden: auth-failure penalty, per-user@IP accounting, kick bus | yes | no |
| director lookup → proxy to the user's backend pod | yes | — |
Storage via pkg/, session object, JMAP methods | no | yes |
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_ 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:
| Anchor | Who may set identity headers |
|---|---|
internal_ | only the login layer's client certificate. The k8s mode. |
mTLS off, general. covers the peer | only 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., as elsewhere. |
| neither | nobody: 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 / and / 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. 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_ 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-locksfailure in tag A does not affect tag B. - No global bottleneck.
Who writes and reads yarilo-warden
| Writer | What it writes |
|---|---|
| director's login proxies (imap/pop3/submission) | CONNECT/DISCONNECT events (pre-auth connection tracking, per-IP rate limit) |
yarilo-lmtp-login | CONNECT/DISCONNECT per recipient (per-user delivery rate tracking) |
| backend's session processes (imap/pop3/submission) | SESSION_START / SESSION_END (post-auth, active mail sessions) |
| Reader | When and why |
|---|---|
| director's login proxies | Before admitting a new connection — enforce per-user/per-IP limits on connections + sessions |
yarilo-lmtp-login | Per 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
memoryredis- 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_ | State lives in | Replicas | Deploy strategy |
|---|---|---|---|
memory (default) | the pod's process | must be 1 | Recreate |
redis | shared Redis | any N | RollingUpdate |
memoryis 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'sSUBSCRIBElands (via the ClusterIP) on one pod while anEMITmay land on another, and an in-process bus never crosses that gap. The chart therefore fails closed onreplicas > 1withmemory, and the deploy strategy isRecreateso a rolling update never briefly runs two memory pods and splits their state.redismoves 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 — anEMITon any podPUBLISHes, 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 normalRollingUpdate. This is the required setting before raisingreplicas.
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_, yarilo_ / yarilo_ (different pods, proving cross-replica delivery under a scrape), yarilo_ (fail-open made visible), yarilo_, and yarilo_. 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. 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/ 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.
| Mode | Production k8s (standalone or backend) | Dev / CI |
|---|---|---|
| When to use | Every 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. |
| Process | yarilo-locks as its own Deployment (typically replicaCount=2 for HA). | yarilo-locks --embedded co-located in the same process tree. |
| State backend | Redis (bundled subchart or external). | In-memory map (ephemeral, pod-local). |
| Transport | mTLS TCP :9104 reached through a ClusterIP Service. | Unix socket /. |
| HA | 2+ replicas behind the Service; Redis HA via Sentinel/Cluster. | None — state dies with the process. |
| Scales to N session pods | Yes. 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 protocol | identical | identical |
Why embedded is not a k8s option, even at replicaCount=1:
- Unix-socket coordination is pod-local. Bump
replicaCount: 1 → 2and 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. 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.. The yarilo-locks process consumes locks_; every session binary (yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-managesieve, yarilo-jmap) consumes locks_. mTLS material is shared with the rest of the stack via internal_ — no separate keys live under the locks sections.
# 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.crtmode
- Default
- none
- Type
- string
- Values
remoteembedded
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
remoteembedded
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. (name, key, default password) puts it into the YARILO_ 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_ 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_; it wins over a password in the URL.
redis_password
- Default
- none
- Type
- string
Password of warden's Redis. Set it to ${YARILO_; 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_; 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_ 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_ — 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_ to <release>-internal and puts that name in every certificate it makes; a certificate you provide MUST carry <release>-internal as a SAN. internal_ with an empty server_ fails loudly at startup (mtls. 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>., and each internal listener accepts only the roles that are meant to call it. . is reserved for private use and never resolves publicly, so the name cannot collide with a service. Other DNS SANs are not identity.
| Listener | Accepted roles |
|---|---|
| auth client port | imap-login, pop3-login, submission-login, managesieve-login, jmap-login, sasl-login, imap, pop3, lmtp, managesieve, submission, admin |
| auth master port | imap, pop3, lmtp, managesieve, backend-api, fts, jmap, quota-status, lmtp-login, admin |
| warden | auth, imap-login, pop3-login, submission-login, managesieve-login, lmtp-login, jmap-login, backend-api, imap |
| locks | imap, pop3, lmtp, managesieve, backend-api, fts, jmap, admin |
| dict | imap, pop3, lmtp, managesieve |
| director | director (ring), the login roles and backend-api (LOOKUP), backend-reg (registration) |
| director admin API | admin, director-admin |
| backend-api | admin, backend-api |
| backend imap / pop3 / lmtp / managesieve / submission | the matching login: imap-login, pop3-login, lmtp-login, managesieve-login, submission-login |
| jmap internal | jmap-login |
| FTS | imap, 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 frombackend-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_off, each server logs once at startup that roles are not checked.tls - 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., true by default. It drives the config, the mounts and the certificates of every component. The former components. 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., an existingca. secretName kubernetes.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;io/ tls - or, when that is empty, a CA the chart makes once and keeps in
<release>-internal-ca(withhelm.).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. 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., 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.they make a new CA on every render. Pods restarted after different syncs then trust different CAs, and internal links break. Such installs must setsecretName internalTLS.to a CA Secret made once outside the chart, or useca. secretName internalTLS.;certManager - with
ca.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.secretName
The install notes (NOTES.) repeat this when the chart makes its own CA.
secretName
- Set in
- Helm values
- Default
- none
- Type
- string
An existing kubernetes. 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. before upgrading and switch it on in a window of your choosing.
cert-manager. With internalTLS. and internalTLS., 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>. 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 /:
- backend-api containers: the
admincertificate. yarctl there addresses the backend plane;adminis also accepted by the director admin API. - director pod: the
director-admincertificate, withYARILO_, its own API onADMIN_ TYPE=director https:/and the pinned name. Run director commands from there, for example/ localhost 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. (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:
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:
| Service | Port | What it does |
|---|---|---|
yarilo-sasl-login | 12345 | SASL authentication for Postfix (smtpd_): Postfix hands over the login and password a client sent, and gets yes or no |
yarilo-quota-status | 12340 | Postfix 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., so neither port leaves the cluster, and narrow networkPolicy. to the MTA's namespace:
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:12340Postfix outside the cluster. Publish both with service., as the production examples and the sandbox do, and restrict the sources on every layer that can:
components.: the MTA's addresses; sasl-login refuses any other peer. Empty means everyone.saslLogin. trusted_ nets networkPolicy.: the same addresses as anmtaFrom ipBlock. This is the only in-chart restriction for quota-status, which has notrusted_.nets - The load balancer: a source-range annotation of your provider, through
service., or an internal (non-public) load balancer. The chart renders noannotations loadBalancerSourceRanges. - Keep the path off the public internet (an internal network or a VPN): passwords cross it in clear text.
components.withsaslLogin. haproxy haproxy_when a proxy in front hides the MTA's address.nets
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_ the dial verifies the peer's certificate against ring_ (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.) whose SANs default to <release>-director-ring and <release>-director, and renders ring_ to <release>-director-ring automatically. The stock shared internal-tls secret has no such SAN — enabling internal_ on the director with that shared cert fails ring handshakes. On misconfiguration the director is loud: an ERROR when internal_ is on and peers are set but ring_ 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. 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.
# 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.sockLock 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>).
| Operation | Lock |
|---|---|
| IMAP SELECT / FETCH / SEARCH / IDLE | none |
| LIST / LSUB | none |
| IMAP APPEND / EXPUNGE / STORE | X on mailbox |
| LMTP delivery | X on mailbox |
| Rename / Delete mailbox | X on mailbox |
| Sieve script update | X 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 / (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_. 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_.
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-locksEVENT 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_, TTL: the one the caller asked for. Atomic acquisition via Lua SET ..
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-locksper 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., 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., 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. on Serve error, no probe involved. So the coverage is complete without a locks watchdog: crash → os.; 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)
| Component | Default replicas | Scale by |
|---|---|---|
yarilo-imap-login, yarilo-pop3-login, yarilo-submission-login | 1 each | replicaCount per protocol (login is stateless beyond TLS state) |
yarilo-lmtp-login | 0 (disabled by default) | components. in values.yaml; MTA-facing LMTP proxy — warden CONNECT per recipient, SESSION token, preamble fan-out |
yarilo-jmap-login | 1 | replicaCount (stateless beyond TLS; scales on request load) |
yarilo-imap, yarilo-pop3, yarilo-submission, yarilo-lmtp, yarilo-jmap | 1 each | replicaCount per protocol (coordination via locks) |
yarilo-auth | 1 | replicaCount (stateless; userdb in SQL) |
yarilo-warden | 1 | must stay 1 with state_ (the default); any N with state_ |
yarilo-locks | 2 | replicaCount (state in Redis; 2 = HA default) |
redis | 1 (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-locksis always remote mode, even atreplicaCount=1for 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/only; no compile-time switch on deployment shape.locks. Locker - 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
Serviceload-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.; shared settings (TLS, Redis, telemetry) sit at the top level. A backend tag is the same chart installed once per tag, with backend_ 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-ringService is the join seed: its DNS name resolves directly to every ready pod's IP, so any resolver hit lands on a live replica toDIRECTOR-JOINagainst. The ClusterIP-directorService is what login pods sendLOOKUPto.- 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-loginandyarilo-lmtp-logindefault to ClusterIP.
Backend (per tag)
One co-located StatefulSet per tag — the pod runs every protocol container plus the sidecars:
StatefulSet yarilo-backend—components.. Pod containers:backend. replicas yarilo-imap,yarilo-pop3,yarilo-submission,yarilo-lmtp,yarilo-managesieve,yarilo-jmap,yarilo-fts(fts_),addr = localhost yarilo-backend-apiandyarilo-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-apiIS 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 withstate_, any N withbackend: memory 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 / — which resolves with the same precedence a login LOOKUP uses (sticky userDir pin → ring hash, the director owning the assignment) — then dials http:/. 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. in helm/ now says which it is, so the file can be read for what it knows rather than what it appears to advise:
fts — 1 | measured: one core saturates under sandbox delivery load, which is why fts_ stays at 1 |
jmap — 500m | measured once and found short: 1.31 cores wanted under 8 clients and 50 users, with 81% of scheduling periods throttled |
| the other seventeen | unmeasured |
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/, read cpu. 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 /. It is a kubelet counter, container_, 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
| Workload | RAM | CPU |
|---|---|---|
| 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 peak | up to 1–2 cores peak |
Recommended Helm values:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 2000m
memory: 2GiBottlenecks (in order):
- Open FDs —
ulimit -n 65535is required (default 1024 is too low). ~10 FD per session. - NFS IOPS — the most likely bottleneck. SSD-backed NFS plus
cachefilesd/fscachehelp. - yarilo-locks RTT — every write does LOCK/UNLOCK. Local Redis → ~1–2 ms per pair.
- NFS bandwidth — attachment FETCH. 1 Gbps = 125 MB/s.
- RAM on burst — buffers for reading large mails.
Sizing per backend tag (one co-located StatefulSet + one NFS share + local locks)
| Parameter | Typical | Min/Max |
|---|---|---|
| yarilo-backend replicaCount (co-located pod) | 3–5 | 1 / scale-out |
| per-container resources (imap/pop3/submission/lmtp) | sized independently within the pod | see "Sizing per backend pod" |
| locks replicaCount | 2 | stateless, ClusterIP |
| Users per pod | 3–5k mostly idle | Goroutines are cheap, but FD/RAM limits apply |
| Total users per tag | 10–20k | NFS server is the constraint |
| NFS share size | 5–10 TB | 10k users × ~500 MB average quota |
| NFS sustained IOPS | 10–30k | Index/mailbox operations |
| NFS bandwidth | 1–10 Gbps | Attachment transfer |
When to create a new tag
| Trigger | Threshold |
|---|---|
| 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.
- Client → login proxy (TLS terminate).
- Login proxy: passdb through
yarilo-auth(password + allow_nets). - Login proxy → director
LOOKUP <user> <tag>; the director answers from the sticky assignment or the ring. - Login proxy dials the resolved pod IP on the protocol's port.
- 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
| Layer | HA approach |
|---|---|
| Director | replicaCount=3, self-organizing ring |
| Backend per tag | replicaCount=3–5, shared NFS RWX, ring rebalance |
| yarilo-locks (per tag) | replicaCount=2, state in Redis |
| yarilo-auth | replicaCount=2, stateless |
| yarilo-warden | state_, then any replicaCount (1 with the default memory) |
| Redis | external HA (Sentinel/Cluster) or managed |
| NFS server | a separate HA effort (Pacemaker+DRBD, or managed NFS such as AWS EFS) |
Backend failover sequence:
- The pod stops heartbeating — the
yarilo-backend-regsidecar 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. - 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.
- Locks the dead pod held expire after their TTL on
yarilo-locks. - 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_, is placed afresh. Nothing is moved in a wave; a kicked session reconnects and is placed the same way.policy: domain - The k8s scheduler brings up a new pod (~30 s).
- The new pod mounts the same NFS and starts accepting connections.
Initial placement policy
assignment_policy
- Default
hash- Type
- string
- Values
hashleast_sessionsdomain- 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_: 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; eachsessions count*100/(vhosts vhosts1..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 trailingprotofield 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_without a session.domain_ expire 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_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.domain_ rebalance_ percent 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_ where users are independent and the load is uneven. domain where users of a domain share mailboxes or ACLs — it replaces the older username_ + assignment_ 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_ 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_ 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_intervaldirector_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_ 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_. 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.— to launch child processes at pod startup.Command - 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.