Cell Architecture — Per-Client Placement, Automated Failover, Cost-Elastic Scaling¶
Design adopted 2026-07-04, immediately after the France→Mumbai cutover. This is the concrete architecture behind roadmap Phases 3–6 and 8 (Recovery & Scale Roadmap). It exists because the July-3 prod deletion proved we need org-mobility and unattended failover — and because the cutover itself proved we already own the core machinery.
1. Goal¶
Run Astradial as a fleet of small, disposable cells (one VDS = one cell) so that:
- A high-value client gets a dedicated cell ("big room") within ~1 hour of asking — and when they churn, the cell is deleted the same day. Cost tracks revenue.
- A cell failure heals itself: the orgs on the dead cell are regenerated onto a standby cell and traffic repointed, unattended, in minutes (target RTO ≤ 10 min; RPO ≤ 1 h from the backup program).
- Zero downtime for existing prod while any of this is built — the live telephony VM is not touched until each pattern is proven on a pilot cell.
Non-goals: Kubernetes for the telephony layer (Asterisk/RTP is stateful UDP with a 10k-port range — the workload containers handle worst; every serious PBX operator runs cells-on-VMs). Multi-master databases. Live-call preservation across a cell failure (calls in flight on a dying cell drop; the next call succeeds).
2. Why not Kubernetes (decision record)¶
| K8s promise | How the cell model delivers it cheaper |
|---|---|
| Scheduling / placement | org_placement table + config regeneration (already exists) |
| Self-healing | health probe + auto-regenerate script (watchdog pattern already running) |
| Rolling upgrades | Kamailio dispatcher drains a cell, upgrade, undrain (Kamailio design) |
| Horizontal scale | order another VDS, run the bootstrap playbook (~30 min) |
| Cost | no 3-node control plane, no ingress LB, no CNI complexity — a VDS-S-class node is cheaper than the k8s tax |
K8s/k3s remains a fine fit later for stateless workloads only (pipecat bot workers), as a separate track. Nothing in this design blocks that.
3. Architecture overview¶
flowchart TB
subgraph edge [Routing plane]
NUC["NUC gateway (on-prem)\nTata NNI inbound\nDID -> cell map"]
KAM["Kamailio edge (Phase 5)\nclient REGISTER/INVITE -> cell\ndispatcher per org"]
end
subgraph cells [Telephony cells]
C1["Cell shared-1 = today's prod\n147.93.168.216\n~20 small orgs"]
C2["Cell bigclient-1 (on demand)\nnew VDS, 1 org"]
CF["Cell standby\n(France box after soak /\ncheap Mumbai VDS)"]
end
subgraph control [Control plane]
API["astrapbx API\norg regenerate -> any cell"]
DB[("MariaDB pbx_api_db\n+ PG workflow_db\n(central, on shared-1;\nreplica -> standby, Phase 8)")]
end
subgraph store [State plane]
GCS[("GCS: db dumps, restic images")]
FB[("Firebase: recordings")]
end
NUC -- "wg per cell (10.10.10.x)" --> C1
NUC -- wg --> C2
NUC -.-> CF
KAM --> C1
KAM --> C2
API --> C1
API --> C2
C1 --- DB
C2 -- "wg to DB" --> DB
C1 --> FB
C2 --> FB
C1 --> GCS A cell runs: Asterisk + the per-org generated configs + fail2ban + GeoIP + UFW + watchdog + backup crons. A cell does NOT run: the API, editor, workflow-engine, pipecat, or the DB — those stay on the control-plane node (today: the same box as cell shared-1; separable later).
4. The five primitives (what we own vs what we build)¶
| # | Primitive | Status |
|---|---|---|
| P1 | Org regeneration — POST /api/v1/organizations/:id/regenerate (x-internal-key) rebuilds an org's complete dialplan/pjsip/queues/MOH from the DB | ✅ exists, battle-proven in the 2026-07-04 cutover audit (22/22 orgs) |
| P2 | Placement map — which cell hosts which org / receives which DID | 🔨 build: org_placement table + generator awareness (roadmap Phase 4) |
| P3 | Inbound steering — NUC dialplan routes each DID to its org's cell tunnel IP | 🔨 build: generate the NUC DID→cell include from the placement map |
| P4 | Client steering — registrations/calls reach the right cell | Phase A: per-org DNS SRV; Phase B: Kamailio edge (full spec) |
| P5 | Cell bootstrap — bring a blank VDS to cell-parity in ~30 min | 🔨 build: playbook from §6 (every line learned from Errors 84/85) |
5. Data & state plane (the small details that bite)¶
Prime principle (Hari, 2026-07-04): cells are disposable pointers
All durable state lives on the static state plane (central DB + Firebase/GCS object storage). A cell holds only derived artifacts: configs regenerated from the DB and a local audio cache synced at placement time. Therefore: moving an org is a re-derivation, not a data migration; losing a cell loses nothing durable; and the protection budget concentrates on ONE node (dumps + images + Phase-8 replica) instead of being smeared across the fleet. The single deliberate exception: Asterisk must play prompt/MOH audio from local disk (network-mounted audio = dead air when storage hiccups), hence cache-not-mount.
Residual cell-state elimination (work items): 1. Recordings: mover cadence hourly → every 5 min (cell loss costs ≤5 min of recordings). 2. Voicemail: switch app_voicemail to ODBC storage in the central DB (staging first). 3. MOH/greetings: canonical store on the control plane; cells hold auto-synced caches.
- DB access from cells: cells reach central MariaDB over WireGuard (
10.10.10.1:3306today). In-Mumbai WG round-trip ≈ 1–3 ms — fine forfunc_odbc/CDR. Each cell needs thepbx_api,pipecatMariaDB users created for its tunnel IP — a dump does NOT carrymysql.user(Error 84). - Per-org file state travels WITH the org at move time (none of it is in the DB):
/var/lib/asterisk/moh/<org>*,/var/lib/asterisk/sounds/greetings/*,/var/spool/asterisk/voicemail/<org>/. The move procedure (§7) rsyncs exactly these. - Recordings: each cell runs
move-recordings.shhourly → the SAME Firebase bucket; the playback API fetches by filename regardless of which cell recorded it. Each cell therefore needsffmpeg, the gcloud ADC json, and the rclonefirebase:+gcs:remotes (Error 85). - Media/RTP: direct client↔cell (no central media hop). Each cell's
pjsip_transport.confcarries its OWN public IP inexternal_media_address/external_signaling_address— the single highest-risk line in any cell bring-up. - Registrations converge, they don't switch: clients re-register on their own timers. Lesson from the cutover: to force convergence, stop answering on the old cell (clients fail → re-resolve → land on the new cell in ~2 min). Never "wait and see" with both cells answering.
- WireGuard keys are per-cell, never copied. Reusing a keypair across two live cells lets one roam/steal the other's tunnel (we relied on same-keypair for the cutover on purpose; in a multi-cell fleet it's a footgun). Every new cell generates fresh keys; peers are added to the NUC explicitly.
6. Cell bootstrap playbook (blank Contabo VDS → cell in ~30 min)¶
Run top-to-bottom; every item exists because its absence broke something real.
- Base: Debian 13,
apt installbaseline from the dpkg manifest of a healthy cell — includesasterisk(20.x),mariadb-client,ffmpeg,libgsm1,libsrtp2-1,libspeex1,libspandsp2,rclone,restic,fail2ban,ipset,wireguard,ufw,netdata. Gate:dpkg -ldiff vs reference cell is clean (the sweep that caught ffmpeg). - Keys & creds: fresh WG keypair; copy gcloud ADC json + rclone remotes (
gcs:withbucket_policy_only = true,firebase:) + restic pass file + ntfy env. Never in git. - Network: UFW profile (22, 80/443 if edge-fronted, 5060/5080/udp, 8089/tcp, 10000:20000/udp, 51820/udp); GeoIP
india_sipipset + DROP rules; fail2banasterisk-pjsipjail; add cell as peer on the NUC wg0 (next free10.10.10.x). - Asterisk statics:
/etc/asteriskbase from the reference cell (features.conf##/*2/*5,[astradial-star5], Tata gatewayallow=alaw, ODBC config) — then set THIS cell's public IP inpjsip_transport.confexternals, andCLOUD_PUBLIC_IPif the cell hosts app code. - DB users: create
pbx_api/pipecatgrants for the cell's WG IP on central MariaDB (viaSHOW CREATE USERhash replication). - Crons/services:
astradial-backup-check,move-recordings.shhourly, GeoIP monthly,astradial-image-backup.shdaily (stagger the hour per cell — GCS cost cap!), watchdog scripts (/usr/local/bin/nuc-*.sh— NOT in the image, copy them),systemctl enable asterisk. - Verification gate (from the cutover audit): features show 3 rows;
pjsip show transports; ODBC connected; test org regenerated onto the cell +dialplan show org_test__incoming; echo call over the tunnel; recording of that call plays via the API.
Deliverable: scripts/bootstrap-cell.sh in astradial-platform encoding the above (roadmap Phase 4 work item).
7. Moving an org between cells (the core operation, step by step)¶
Zero-to-low downtime: seconds of registration convergence for that org only; all other orgs untouched. Everything is reversible at every step.
- Pre-stage on target cell (no traffic impact):
POST /organizations/:id/regenerate?cell=<target>→ writes the org's pjsip/ext/MOH confs on the target; rsync the org's moh/greetings/voicemail files;core reloadon target. - Verify on target:
dialplan show org_<id>__incoming, MOH classes present, a staged test endpoint registers against the target and completes an echo call. - Flip inbound: regenerate the NUC DID→cell include (this org's DIDs → target tunnel IP);
dialplan reloadon the NUC. Inbound now lands on the target (its endpoint registrations may still be converging — do steps 3–4 back-to-back). - Flip clients: Phase A: update the org's SRV/hostname → target IP (TTL 60 s). Phase B (Kamailio): update the org's dispatcher set — instant, no DNS wait. Force convergence: remove the org's endpoints on the source cell (
regeneratesource WITHOUT the org, orpjsip qualify+unload) so client refreshes fail fast and re-resolve. Lesson: never leave both cells answering for the same org. - Verify: live call to one of the org's DIDs → target cell (
core show channels); agent registration count on target ≥ source's pre-move count; recording plays. - Clean source: delete the org's conf files + audio from the source cell (avoid the stale
org_mqzglv1q-style leftovers the audit found).
Rollback at any step: the source still has everything until step 6 — re-flip NUC + SRV back.
8. High-value client onboarding ("big room" flow)¶
- Order VDS sized to the client (calls ≈ 30–40 per dedicated core with recording; the VDS-S class ≈ 3 cores / 24 GB — plenty for a single hospital/hotel).
- Bootstrap cell (§6, ~30 min). 3. Move their org (§7, minutes).
- Tag the cell
dedicated:<org>in the placement map; its monthly cost is now attributable to that client's invoice. 5. On churn: move the org back to a shared cell (§7 reversed), destroy the VDS. Cost returns to zero the same day.
9. Automated failover (the July-3 insurance)¶
Detector (extends the existing nuc-watchdog pattern; runs on the control plane + NUC): - Per-cell probes every 15 s: WG handshake age, OPTIONS ping to :5060 over the tunnel, asterisk -rx "core show uptime" via SSH. Trip = all three failing for 90 s (one probe can lie; three can't — and 90 s avoids flapping on a reboot).
Actor (failover-cell.sh, dry-runnable, also invocable manually): 1. Page ntfy + WhatsApp immediately (CELL <x> DOWN — auto-failover starting). 2. Freeze the dead cell's WG peer on the NUC (prevents zombie return mid-failover). 3. For each org on the dead cell (placement map): regenerate onto the standby cell (P1); rsync org audio from the hourly GCS full backup if the source is unreachable (RPO ≤ 1 h; voicemail/greetings only — recordings are already in Firebase). 4. Regenerate NUC DID→cell include → standby; flip the orgs' SRV / dispatcher sets. 5. Verify loop: one synthetic call per moved org (echo DID) — page results. 6. Mark placement map; the dead cell, if it comes back, boots with its WG peer disabled and its orgs' configs marked stale (no split-brain).
Standby sizing: one warm standby per region, bootstrapped and idle (France after its soak = free EU standby; a second Mumbai VDS-S when budget allows = proper standby; Phase 8 adds DB replica to it, removing the last single point).
Drill (quarterly, and before first real use): kill asterisk on the pilot cell, watch the detector trip, measure RTO end-to-end, restore, write the numbers into this doc.
10. Admin-panel Fleet Control (UI) — REQUIREMENT¶
Hari, 2026-07-04: "Complete structure and control should be there in admin panel, with UI." Nothing in this design is CLI-only at the end state; every operation in §6–§9 is a button with a progress view. The scripts are the engine; the admin panel is the only steering wheel operators use.
New top-level Fleet section in the editor's admin area (platform-admin RBAC only; every action writes audit_log; long operations stream progress over the existing socket channel):
10.1 Cells page¶
Table: cell name · role (shared / dedicated:<org> / standby) · region · public IP · WG IP · org count · health badge (live from the §9 probes: WG handshake, OPTIONS, uptime) · active calls · CPU/RAM · monthly cost tag. Actions: ➕ Add Cell (wizard: paste new-VDS IP + root key → runs bootstrap-cell.sh remotely, renders the §6 checklist as live check-marks, ends with the verification gate) · Drain · Decommission (blocked unless org count = 0) · Re-run verification gate.
10.2 Placement page¶
Org ⇄ cell matrix (the org_placement table, live). Select org → Move to cell… wizard: runs §7 step-by-step with per-step status (pre-stage ✓ / verify ✓ / inbound flipped ✓ / clients converging n/m endpoints / cleanup), a convergence meter (registered endpoints on target vs expected), and a Rollback button active until step 6.
10.3 Failover page¶
Standby readiness (bootstrap gate age, last drill RTO) · per-cell detector state (three probes, trip timer) · event timeline of every automated action · Manual failover NOW (typed- confirmation, same actor script as auto) · Drill mode (dry-run against the pilot cell, records measured RTO into this doc's §9).
10.4 Cell detail page¶
Live: registered endpoints, core show channels, per-org list with per-org "Move" shortcut, recent asterisk log tail, backup/cron status, the cell's §6 checklist state (drift = red).
10.5 API surface backing the UI (control plane, admin-authenticated)¶
GET /api/v1/admin/cells list + health + load
POST /api/v1/admin/cells add (bootstrap) — body: {ip, ssh_key_ref, role}
POST /api/v1/admin/cells/:id/drain|verify|decommission
GET /api/v1/admin/placement org ⇄ cell matrix
POST /api/v1/admin/orgs/:id/move body: {target_cell} → §7 job, progress via socket
POST /api/v1/admin/failover/:cell manual trigger (typed confirmation token)
POST /api/v1/admin/failover/:cell/drill dry-run
GET /api/v1/admin/fleet/events failover/move/bootstrap audit timeline
11. Cost model (indicative)¶
| Fleet state | Monthly infra (VDS-S-class ≈ €10–18/node) |
|---|---|
| Today: 1 prod + frozen France + staging | ~3 nodes |
| + 1 dedicated big-client cell | +1 node, billed to that client |
| + warm Mumbai standby (real failover) | +1 node — the price of never repeating July-3 |
| Client leaves | −1 node same day |
No k8s control-plane nodes, no load-balancer fees; Kamailio runs on the control-plane node (or a €5 VPS) when Phase 5 lands.
12. Rollout plan (zero prod downtime, gated; each step ships its UI)¶
| Step | Engine work | UI shipped with it (§10) | Gate | Roadmap |
|---|---|---|---|---|
| A | org_placement table + ?cell= on regenerate + NUC include generator | Placement page (read-only matrix) | unit: moving a TEST org between staging boxes | Phase 4 |
| B | bootstrap-cell.sh + pilot cell (cheapest VDS) | Cells page + Add-Cell wizard | §6 verification gate passes from the wizard | Phase 4 |
| C | §7 move procedure as a job | Move-org wizard + convergence meter + rollback | live call each way; convergence < 3 min | Phase 4 |
| D | Failover detector + actor | Failover page + drill mode | measured RTO ≤ 10 min unattended, from the UI | Phase 8 |
| E | Kamailio edge on staging → prod front | dispatcher set editor per org | Kamailio rollout plan | Phases 3+5 |
| F | First real dedicated-client cell | cost tags on Cells page | client-funded | Phase 6 |
Prod is only ever fronted (E) or sourced from (C/D on test orgs) — never rebuilt or moved.
13. Risks & mitigations¶
- Central DB is a SPoF until Phase 8 → hourly dumps already land in GCS (RPO 1 h); Phase 8 puts a MariaDB replica on the standby cell (RPO → seconds).
- Kamailio edge becomes a SPoF in Phase B → the Kamailio doc's own HA section (keepalived pair / DNS failover) applies; until then per-org SRV keeps cells directly reachable.
- Placement map drift vs reality → the cutover taught us: configs are DB-derived artifacts. A nightly auditor compares each cell's on-disk org list vs the placement map and pages on drift (the
org_mqzglv1qclass of bug, automated away). - Contabo panel remains the ultimate blast radius → 2FA + no team delete rights + cells named after their role (
cell-shared-1,cell-standby) so no human ever again deletes "the wrong France".
Related¶
- Kamailio SIP Proxy design — the P4 edge, dispatcher, RTPengine
- High Availability & Reliability — the SPoF analysis this design closes
- Recovery & Scale Roadmap — phases this fills
- Prod Cutover — France → Mumbai — the live proof of P1 and origin of every §6 checklist line
- Troubleshooting Error 84, Error 85 — the outside-the-dump state classes the bootstrap playbook encodes