Director configuration
yarilo-director keeps the routing state of a cluster: which backend serves each user. Login pods ask it with a LOOKUP before they open a session to a backend.
The director does not accept client connections. TLS, the PROXY protocol, XCLIENT and authentication all happen in the login pods; see General settings and Deployment.
How it works
A session reaches its backend in these steps:
mail client
│ IMAP / POP3 / Submission / ManageSieve / LMTP / JMAP
▼
login pod (imap-login, pop3-login, …)
│ 1. TLS, PROXY header, authentication
│ 2. LOOKUP <user> <tag> ──────────► yarilo-director
│ ◄────────── backend ip:port
│ 3. dial the backend pod IP directly, send the preamble
▼
backend pod (yarilo-imap, yarilo-pop3, …)- The director answers from a consistent-hash ring of backends, weighted by
vhosts, plus the sticky assignments it has already made. - Backends join the ring either from the static
mail_list or by registering themselves with a heartbeat.servers - With more than one replica, the directors form a ring of their own to share membership, the backend set and sticky assignments. See Ring formation.
Listening ports
The director listens on three ports:
listen
- Default
:9102- Type
- string
- Helm
components.director.directorPort
Address for LOOKUP from the login pods and for the ring protocol between directors. The chart takes a port number and renders :<port>.
listen
- Default
:9103- Type
- string
- Helm
components.director.api.port- See also
allowed_nets
Address of the admin API; see Director API. The chart takes a port number.
allowed_nets
- Default
[]- Type
- list
- Helm
components.director.api.allowed_nets
CIDRs the admin API accepts requests from, on top of its token. Empty allows all.
listen
- Default
:8080- Type
- string
- Helm
telemetry.listen
Address of the Prometheus metrics and health endpoints.
Login pods reach the director through the ClusterIP <release>-director Service, set as each login component's director_. The headless <release>-director-ring Service is for the ring only.
director_service
These keys are the routing and lifecycle core. user_ and backend_ are described in Deployment, with the other placement keys.
ping_interval
- Default
30- Type
- integer, seconds
- Helm
components.director.ping_interval- See also
ping_timeout
Keepalive pings to login pods and ring peers.
ping_timeout
- Default
10- Type
- integer, seconds
- Helm
components.director.ping_timeout- See also
ping_interval
How long to wait for a PONG before the connection is closed.
write_timeout
- Default
10- Type
- integer, seconds
- Helm
components.director.write_timeout
How long one push or reply write may take. 0 is no bound.
session_grace_period
- Default
30- Type
- integer, seconds
- Helm
components.director.shutdown.session_grace_period
How long the director waits after SIGTERM before it exits. It leaves the ring gracefully in that time.
peers
- Default
[]- Type
- list
- Helm
components.director.peers
Seeds for a one-time ring join. Empty with more than one replica, the chart seeds from the headless -director-ring Service.
backend_unreachable_reporters
- Default
2- Type
- integer
- Helm
components.director.backend_unreachable_reporters- See also
backend_unreachable_window
Distinct login pods that must report a backend unreachable before it is evicted early. A single-replica login fleet cannot give two, so set 1 there; the lease stays the backstop.
backend_unreachable_window
- Default
5- Type
- integer, seconds
- Helm
components.director.backend_unreachable_window- See also
backend_unreachable_reporters
The window those reports must arrive in.
join_allowed_nets
- Default
[]- Type
- list
- Helm
components.director.join_allowed_nets
CIDRs a ring join is accepted from. Empty allows all.
anti_entropy_interval
- Default
3- Type
- integer, seconds
- Helm
components.director.anti_entropy_interval
Each director re-sends its member and tombstone list over every live ring connection this often, repairing a view that has diverged. 0 turns it off.
tombstone_ttl
- Default
600- Type
- integer, seconds
- Helm
components.director.tombstone_ttl
How long a dead member's tombstone is kept. 0 keeps it for good.
The chart also renders shutdown., but the director does not read it.
Backend eviction:
- A static backend from
mail_, or one added through the admin API, never heartbeats and never expires.servers - The last backend of a tag is never evicted, by either the lease or the reports; the director logs it instead.
director_service.mail_servers
This is the static backend list loaded at startup. Each host resolves to one or more pod IPs through DNS; a headless Service returns one A record per pod, and every IP joins the ring.
host
- Default
- none
- Type
- string
- Helm
components.director.backends[].host
Hostname of a static backend, typically a headless Service such as yarilo-imap.. Every IP it resolves to joins the ring.
port
- Default
0- Type
- integer
- Helm
components.director.backends[].port
Backend port the login pod dials.
tag
- Default
- none
- Type
- string
- Helm
components.director.backends[].tag
Pool label. Empty is the default pool.
vhosts
- Default
100- Type
- integer
- Helm
components.director.backends[].vhosts
Ring weight, 1 to 100. 0 routes no users to the backend; under assignment_ it drains.
Configure two static backends:
director_service:
mail_servers:
- host: yarilo-imap.yarilo-backend.svc.cluster.local
port: 993
tag: ""
- host: yarilo-lmtp.yarilo-backend.svc.cluster.local
port: 24
tag: ""mTLS
With internal_, the director_ port requires a client certificate from everyone who connects: login pods sending LOOKUP and other directors on the ring.
Ring peers are dialled by pod IP, so the dial verifies a fixed server name, ring_, which defaults to <release>-director-ring. That name must be a SAN in the director's certificate.
ring_tls_server_name
- Default
- none
- Type
- string
- Helm
components.director.internalTLS.ringTlsServerName
The name a ring dial verifies, since peers are dialled by pod IP. The chart sets <release>-director-ring, and the director's certificate must carry it as a SAN.
The certificate the chart makes for the director role carries it, along with <release>-director and the role SAN director.. With cert-manager, components. renders the same names; a Secret of your own in components. must carry them too. The director port also checks the role per command: ring commands only from director, registration only from backend-reg (Peer roles).
WARNING
With internal TLS on and no ring name in the certificate, peers cannot verify each other and the ring never converges. The director logs an error when this happens.
Helm values
All director settings live under components. in helm/, and each card on this page names its chart value. Most keep the config key's name; the listen addresses take a port number (directorPort, api.), and the static backends are backends[].
Run three replicas with one static backend:
components:
director:
enabled: true
replicas: 3
backends:
- host: yarilo-imap.yarilo-backend.svc.cluster.local
port: 993
tag: ""This setup:
- seeds the ring from the headless
-director-ringService, sincepeersis empty; - generates the ring secret and the API token into Secrets;
- leaves client listeners to the login components, each pointing
director_at theaddr -directorService.
Placement and kick pacing
These keys are described in Deployment:
assignment_: how a new user gets a backend;policy director_and the domain rebalance settings, underdomain_ expire assignment_;policy: domain user_,kick_ delay max_andparallel_ kicks max_: kick pacing.parallel_ moves
Session routing & sticky assignments
Every login proxy (imap/pop3/submission/managesieve/lmtp) routes sessions one of two ways: backend_ (standalone, a fixed backend) or director_ (director mode, per-session LOOKUP via yarilo-director) — at least one is required, and backend_ wins when both are set, in all five components. backend_ overrides the port a director LOOKUP returns when it differs from the backend's protocol-specific containerPort.
Migration note: lmtp-login previously had the opposite precedence — director_ won when both were set. If your lmtpLogin Helm values set both backend_ and director_, the login pod now logs a startup warning and silently switches from director routing to the static backend. Remove backend_ from lmtpLogin values to keep director routing.
director_ (default true, Helm: components.) lowercases usernames before they're hashed for ring routing or used as keys for sticky assignments and admin (USER-MOVE) overrides — without it, two spellings of the same account (User@d. / user@d.) can hash to different values and land on different backends, defeating sticky routing. Migration note: enabling this on an already-running cluster changes hashes for mixed-case usernames — their existing sticky entries just expire naturally via user_, no special migration step is needed.
Sharing mailboxes between users of a domain? Set
assignment_: a domain is placed on the backend with the fewest connections and every user of it follows, with no hash template to get right. A user's sticky entry holds only while it names the domain's backend: after a move, users who logged in before it follow at their next login instead of riding their entry to the old backend. The older pairing below still works and stays supported.policy: domain The older pairing: set
username_andhash: "%Ld" assignment_. The default hashes the whole address, so users of one domain land on different backends — harmless where those backends share one PV, fatal where each has its own storage. Andpolicy: hash least_never reads the username at all, so it silently defeats the hash template. See Owner/shared namespaces.sessions
director_ (default "", Helm: components.) is the username→hash-key template, which uses the reference's username-hash expression syntax, so an existing template migrates verbatim — %Lu stays %Lu. Supported variables: %u (whole username), %n (local part, before the first @), %d (domain, after the first @), each with an optional %L lowercase modifier, plus %% for a literal percent. This is a real routing lever, not just config parity: %Ld hashes on the domain only so a whole domain (and its shared mailboxes/ACLs) lands on one backend, and %Ln hashes on the local part only for alias-domain installs. A domain-less username follows the reference semantics — %n is the whole username, %d is empty (so a %d template routes every domain-less account to one backend). When username_ is set it — not username_ — governs case-folding (the ingress no longer pre-lowercases, so %u is truly case-sensitive), and the USER-KICKED payload keeps the session's original-case username so login-side kick matching still lands. An empty value derives the template from username_ (%Lu / %u) for byte-identical back-compat with clusters that predate username_. An invalid template aborts director startup. (yarilo's uint32 fold is little-endian — deliberately not byte-compatible with the reference's ring, a scenario our architecture never produces; we borrow the routing semantics, not the byte layout.)
%d domain-hash — read before you use it. The hash is applied within a tag, not globally: every LOOKUP carries a per-user tag (tag = NFS shard) and the ring is tag-scoped (LookupBackendByTag), exactly like the reference's per-tag lookup. So a %Ld template routes a domain to one backend per tag — if the domain's mailboxes are spread across tags (the tag is assigned per user by userdb, independent of the hash), those users land on different tag rings and different backends; %d does not collapse a multi-tag domain onto a single host. The failure mode to avoid is putting one large domain entirely in one tag: there %Ld pins the whole domain to a single backend with no load rebalancing (yarilo, like the reference, distributes only by consistent hashing + vhost capacity weighting; it never auto-spreads a hot key). The cure is spreading the domain across tags via userdb, not changing the hash. Use %Ld only when a domain's shared mailboxes/ACLs genuinely must be storage-local within a tag.
Backend evacuation — graceful vs force. yarctl director backends flush <ip|all> drains a backend. By default the drain is graceful and throttled: the host is taken out of the ring and its users are migrated in a self-clocked window of at most director_ (default 5, Helm: components.) confirmed-kills — each user's old sessions must confirm gone before the next user is pulled in, so a planned drain (rolling upgrade, maintenance) spreads the re-login across the surviving pods instead of stampeding them all at once. --force kicks every session immediately; --max-parallel N overrides the window for one run. Re-login is deterministic without a proactive pin move: the evacuating host is already excluded from the ring, so a kicked user's re-LOOKUP rehashes to the same surviving backend it would have been moved to, and the confirmed-kill hold makes that re-LOOKUP wait until the old session is gone (no split-writer window). The drain is orchestrated by the single director that receives the request; if that director is lost mid-drain the operator re-runs flush (drain-state is not replicated across directors — matching the reference, where the originating director drives the drain). Matches the reference's forced-flush and max-parallel drain behaviour.
Per-user flush hook. director_ (default "" = disabled, Helm: components.) is an optional external executable run once per user after a deliberate relocation — an admin USER-MOVE or a graceful evacuation — has been confirmed ring-wide, i.e. after that user's old sessions are gone. Operators hook mailbox-cache flush, external session cleanup, metrics, etc. It is called as flush_ (new_ is empty when the user was kicked with no surviving backend to land on). It is best-effort and asynchronous with a bounded timeout: a slow or failing hook is logged and never blocks the ring/LOOKUP path or fails the move (the routing change already committed). The bound is director_ (seconds, default 10, Helm: components.); 0 selects the default rather than disabling the bound. Raise it for a hook that legitimately takes longer — and note what best-effort means for diagnosing one: a run that exceeds the bound is killed, and the only trace is a WARN on the director (flush hook failed (best-effort, move already committed) with err="signal: killed"). Nothing surfaces on the hook's own side, so a script that always overruns simply never completes, silently, until someone reads the director's log. Only the director that originated the move runs it — mass/reactive paths (backend-down auto-kick, --force flush) deliberately do not trigger it, and a move that creates a fresh pin with no prior host is skipped. The hook runs at the same point as the reference's flush hook, after the ring-wide USER-KILLED-EVERYWHERE; the program runs in the director pod's context, exec. only (no fork). A unix-socket hook variant is a possible future follow-up. An offline user — one with no active sessions when the move starts — confirms after user_ (~1s) instead of waiting out user_, so the hook fires promptly for the common admin case of moving a user who isn't currently connected; a user with live sessions still confirms only once those sessions have drained.
Tag sharding models
Every director LOOKUP carries a mandatory tag field — there is no full-ring mode: "" selects the untagged backend pool, not "any tag." Two sharding models are supported:
- Static (dedicated login fleet per tag pool). Each login component's
director_(Helm:tag components., e.g.<login>. director_ tag components.) restricts that component's lookups to one tag-pool — set this when running a dedicated login Deployment per tag pool, per the tag-based sharding model in Deployment. In a deployment with no tags configured at all, every backend is untagged, so the defaultimapLogin. director_ tag director_behaves exactly like the old (buggy) full-ring lookup — untagged/standalone deployments see no behavior change.tag: "" - Shared (per-user tag from passdb/userdb). One login fleet serving users of every tag-pool: a
director_extra field on the passdb or userdb response (SQL: an ordinary column intag passdb_/sql_ query userdb_, same generic column-forwarding assql_ query allow_— no driver code change needed) picks the tag for that one user'snets LOOKUP, overriding the component's staticdirector_. IMAP/POP3/Submission/ManageSieve pick it up from the AUTH response; LMTP resolves it with a per-recipient userdb lookup before the directortag LOOKUP. A user with nodirector_field falls back to the component's static value.tag
Ring formation
Director replicas form a ring on their own. Members are ordered by IP and port, and each one dials only its right neighbour, never every other member. Any member count serves requests, and a single director is a ring of one.
Joining the ring
components. is a list of seeds. A new director sends DIRECTOR-JOIN to them, and membership then spreads around the ring.
Left empty with more than one replica, the seed is the headless <release>-director-ring:9102 Service. Each cycle the seed name is resolved and every address but the director's own is polled. The ClusterIP <release>-director Service is only for the login pods' LOOKUP: as a seed it would load-balance, and a director could dial itself.
The seed is polled every seed_ seconds (default 2; a negative value polls once). Once the ring holds min_ directors (default 3), the interval becomes seed_ (default 2). Raising it saves polling, and a dead member is evicted more slowly.
components. (generated into the Secret <release>-director-ring-secret) authenticates every join and every ring connection. Without it a director never joins and runs alone. When join_ is set, only those addresses may join. Every director must run a release that speaks the same join proof, so a ring being upgraded re-forms once the rollout completes.
seed_poll_interval
- Default
2- Type
- integer, seconds
- Helm
components.director.seed_poll_interval- See also
seed_poll_idle_intervalmin_members
How often the seed is polled while the ring is below min_. A negative value polls once.
seed_poll_idle_interval
- Default
2- Type
- integer, seconds
- Helm
components.director.seed_poll_idle_interval- See also
seed_poll_interval
The poll interval once the ring holds min_. Raising it saves polling, and a dead member is evicted more slowly.
min_members
- Default
3- Type
- integer
- Helm
components.director.min_members
The ring size the seed polling aims for. Below it, the director polls at seed_.
ring_secret
- Default
- none
- Type
- string
- Helm
components.director.ring_secret
Authenticates every join and every ring connection. Without it a director never joins and runs alone. The chart generates it into the Secret <release>-director-ring-secret.
Keeping the view consistent
- Every ring connection exchanges the member list and the list of removed members (tombstones).
- Every
anti_seconds (defaultentropy_ interval 3), each director re-sends its view over every live ring connection. This repairs a view that has diverged. - A tombstone expires after
tombstone_seconds (defaultttl 600). - An event is forwarded over every live ring connection except the one it came in on.
Detecting a dead member
- Both neighbours of a director watch it: the right side through its dial, the left side through the connection it accepted. A few short probes confirm a loss before it is declared.
- Each ring connection exchanges
PING/PONGeveryping_seconds (defaultinterval 30). A peer silent forping_(defaultinterval + ping_ timeout 10) counts as lost. - A director that closes a connection on purpose says why with
QUIT, so the other end does not mistake it for a death. - On SIGTERM a director announces its own removal, sends
QUITon every ring connection and refuses new joins. A director killed outright is detected by its neighbours.
Sharing sticky assignments
On every ring connection a director sends its sticky user assignments, so a restarted director starts with the cluster's routing state, not an empty one. A new assignment made by a LOOKUP travels around the ring as USER-ASSIGN.
Assignments are ordered by a logical clock, not by wall time: the higher sequence wins, and a tie goes to the lower director id. When a merge moves a user to another backend, the director whose assignment lost kicks that user's sessions on the old backend, so one mailbox is never served by two backends.