Skip to content

feat(web): chart-level improvements for web UI responsiveness at scale - #124

Open
anchapin wants to merge 6 commits into
developfrom
feat/chart-web-responsiveness
Open

feat(web): chart-level improvements for web UI responsiveness at scale#124
anchapin wants to merge 6 commits into
developfrom
feat/chart-web-responsiveness

Conversation

@anchapin

Copy link
Copy Markdown
Collaborator

What

Chart-only changes to improve web UI responsiveness when many analyses exist, addressing two root causes identified in cluster profiling:

  1. Passenger pool sizing (MAX_POOL): Previous formula used an idle-process memory estimate. At ~950 analyses, 590 processes were derived on a 192 Gi node — each process 1.1 GB RSS, 590 concurrent MongoDB connections, and the dashboard spending 55-70s per request on full scans of 706k data points. New formula: , using measured loaded RSS of 1100 MB per process, reserve of 1500 MB for non-Passenger overhead. Rendered pools: 2Gi → 2, 60Gi → 54.

  2. Readiness probe: Previous httpGet / probe hit PagesController#dashboard, a 55-70s MongoDB-bound request that both amplified load and made failure detection unusable (35 minutes to leave Service endpoints). New exec probe hits a deliberately-unrouted path (~2-4ms, accepts 200 or 404 as healthy). startupProbe gives Rails/Passenger a generous boot window; readiness uses tight timings (20s period, 3 failures) so an overloaded web is pulled from Service endpoints in ~1 minute.

  3. MAX_REQUESTS: Decoupled from (which gave 21 in stock config — recycling every 21 requests). Now uses (Passenger upstream default).

  4. Pod anti-affinity: Added on db/redis/rserve against . On multi-node clusters, places data layer on different nodes from the web process.

Testing

  • helm lint passes for base, small, large variants
  • helm template renders all variants correctly:
    • base: MAX_POOL=2, MAX_REQUESTS=50000, web-background workers=30
    • small: MAX_POOL=2, MAX_REQUESTS=50000, web-background workers=30 (inherited)
    • large: MAX_POOL=54, MAX_REQUESTS=50000, web-background workers=109 (32Gi/300MiB)
  • Probes render valid bash -c commands with correct dollar-sign escaping
  • Pod anti-affinity renders correct labelSelector against web label

Measurement Plan

Deploy via helm upgrade to live cluster (~940 analyses), measure TTFB before/after each commit:

  1. Probes only (commit 1) — no user-facing change expected, but verifies readiness
  2. MAX_POOL formula (commit 2) — 7 → 2 processes, immediate reduction in concurrent connections
  3. Anti-affinity (commit 3) — requires multi-node to observe, no-op on single-node
  4. MAX_REQUESTS (commit 4) — 21 → 50000, eliminates thundering-herd recycling

Upstream Hand-off

The dashboard performance issue (55-70s TTFB) is an upstream Rails application problem — PagesController#dashboard runs unpaginated aggregates on every page load. This PR works around it at the chart level but the fix should be:

  • Add pagination/limit to the dashboard data_points aggregate
  • Add a data_points.count index or materialized count to avoid COLLSCAN
  • Document the MAX_REQUESTS / worker_hpa.maxReplicas coupling history

- worker deployment: remove hardcoded spec.replicas so the HPA owns
  scaling across helm upgrades. Helm's three-way merge preserves fields
  absent from the manifest; a hardcoded value would reset the deployment
  to 1 replica on every upgrade (scale-to-1 cliff at high maxReplicas).
- worker rollout strategy: parameterize maxSurge/maxUnavailable with
  defaults replicating the previous hardcoded 1/1 behavior.
- PriorityClasses: render unconditionally instead of guarding on a
  cluster lookup. When the class already existed, the guard omitted it
  from the manifest, causing helm upgrade to delete the cluster-scoped
  PriorityClass out from under running pods.
… values

Values.web.initContainer is undefined in the default values.yaml, so
.Values.web.initContainer.image raised 'nil pointer evaluating interface
{}.image' and helm template/lint failed outright for anyone installing
without a custom values file.

- Guard the lookup: get (default (dict) ...) "image" | default "alpine"
- Add web.initContainer.image=alpine to values.yaml so the override point
  is discoverable
Replaces the dashboard-based readiness probe and NFS-only liveness probe.

Problems with the old setup:
- readiness httpGet / hit PagesController#dashboard, which renders every
  analysis unpaginated; at ~950 analyses each probe was a 55-70s
  MongoDB-bound request that amplified the very load causing UI slowness.
  Its timings (period 200s x failureThreshold 10 + 120s timeout) meant an
  overloaded web took ~35 minutes to leave Service endpoints.
- liveness only grepped /proc/mounts, so a hung Passenger/nginx never
  restarted.

New scheme:
- startupProbe: up to ~10 min for Rails/Passenger boot before liveness
  starts killing the container.
- readiness: exec curl against a deliberately-unrouted path. A k8s
  httpGet cannot accept 404 as healthy; the exec form treats HTTP 200 or
  404 as success, proving nginx -> Passenger -> Rails router without
  touching Mongo-heavy controllers (~2-4ms measured under full load).
  periodSeconds 20 / timeout 6 / failureThreshold 3 => an overloaded web
  is pulled from endpoints in ~1 minute.
- liveness: preserved NFS mount guard AND app check (period 60s), so a
  genuinely hung app restarts within minutes.

NFS blips intentionally excluded from readiness to avoid flapping the
single web pod off the load balancer.
…sed sizing

Previous formula used requests.memory * 0.75 / 250MB. Problems:
- 250 MB assumed idle RSS; production processes measured 1031-1160 MB loaded.
- Opaque 0.75 headroom factor hid real non-Passenger overhead.
- On a 2Gi pod it derived 7 processes (actual 7 x 1.1 GB > 2 Gi request).
- On a 192Gi pod it derived 590 processes, flooding MongoDB with 590
  concurrent client connections (each dashboard hit = 30+ s Mongo scan).

New formula:
  mem_mib = (limits.memory if set else requests.memory) in MiB
  pool    = max(2, floor((mem_mib - passenger_memory_reserve_mb) / passenger_memory_per_process))

Two corrected knobs (users only size the container):
  passenger_memory_per_process: 250 -> 1100 (measured loaded RSS, MB)
  passenger_memory_reserve_mb:  1500 (nginx+Passenger core+resque worker, MB; rarely changes)

Rendered pools: 2Gi->2, 60Gi limit->54, 192Gi->177. Stale comment block replaced with live formula docs.
…e web node

Node-level anti-affinity spreads web and worker pods across the cluster
when multiple nodes are available. In a single-node deployment these
rules are no-ops.

Added preferredDuringSchedulingIgnoredDuringExecution (weight 100) on
db, redis, and rserve pods against app=web. On multi-node clusters this
places the data layer on different nodes from the web process, giving
MongoDB's dataset 32 GB of local memory instead of sharing a 60 Gi node
with 54 Passenger processes. Apply via: helm upgrade (requires existing
pod deletion for scheduling to take effect).
…orkers helper

MAX_REQUESTS:
  Old: ceil(worker_hpa.maxReplicas * 1.05). Stock config (maxReplicas=20)
  gave 21, recycling every 21 requests -- a full 1.1GB RSS respawn per
  process, thundering-herd across the pool.
  New: web.passenger_max_requests (default 50000, Passenger upstream
  default). Decoupled from worker_hpa so operators tune recycling
  independently of horizontal scaling.

webBackgroundWorkers helper:
  - Replaced hardcoded web_background.number_of_workers in rserve-deploy
    and web-background-deploy with the existing helper that derives
    workers from limits.memory / worker_memory_mib when limits exist,
    falling back to web_background.number_of_workers (or rserve's as
    secondary fallback for small/large templates).
  - Fixed helper fallback paths to use .number_of_workers instead of
    the undefined .number_of_workers, adding | default chain for
    templates that only define rserve.number_of_workers.

rendered: base=30, small=30 (inherited), large=109 (32Gi/300MiB).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant