Skip to content

Performance Load Test

Anushka Wijesundara edited this page Oct 13, 2026 · 4 revisions

Performance and load test

Sizing for 500 users — 2026-10-12

Question: how many pods does a 500-person company need if everyone works in ServiceOps all day?

Answer (v1.113.38):

Usage Web pods (1 CPU, 2 workers × 4 threads each) PostgreSQL Fits this cluster?
Normal pace — all 500 signed in all day, one page action every 30–60 s 4 (5 for p95 under 1 s) 2 CPU Only by spreading pods over both nodes
Busy — one page action every 10–20 s, all day about 7–8 2–3 CPU No: needs about 10–11 cores, more than both nodes together
Today's production (1 pod, PostgreSQL 1 CPU) — — About 100–150 users at normal pace (estimated from the runs, not measured)

Memory: about 600–800 MiB per web pod under load (raise the limit to 1.5 GiB), 450 MiB for PostgreSQL. One background worker is enough (0.02 CPU).

What was tested

Every run used 500 users. The tests ran on an isolated copy (namespace perftest on homeserver-02, its own PostgreSQL), never on production. The copy ran the production image, Gunicorn settings and resource limits, and held a year of data: 500 agents in six teams, 20,000 tickets and 40,000 comments. k6 replayed what browsers send: sign-in with CSRF, page HTML, first-visit static files, the 25-second notification poll, new incidents and work notes. Users signed in over 5 minutes (one run: 1 minute). Steady state is the 4.5 minutes after everyone signed in. CPU was sampled from each pod's cgroup every 10 s.

Run Version Web pods Pace Steady-state result
1 1.113.36 1 busy Collapsed: 95% failed, 46 of 500 signed in
2 1.113.36 3 busy Collapsed: 68% failed, 11 req/s at 100% CPU
3 1.113.37 (dashboard fix) 3 busy 9% failed, median 17 s
5 1.113.37 3 normal 6% failed, median 14 s
6 1.113.37 5 normal 0.02% failed, median 4.9 s, node 80% busy
9 C driver 3 normal median 78 ms, p95 1.8 s, p99 9.1 s, 0% failed
10 C driver 4 normal median 94 ms, p95 1.3 s, p99 2.9 s, 0.02% failed
11 1.113.38 5 busy median 5.8 s: node 88% busy, PostgreSQL 1.6 of 2 CPU
12 1.113.38 4 normal, all sign in within 1 min all 500 signed in; sign-in median 15 s during the rush, queue cleared in a few minutes

Raw numbers: summary.json. Scripts: flows.js (k6), seed.py, perftest-values.yaml, analyze.py. The test password comes from LOADTEST_PASSWORD.

Fixes made during the evaluation

  1. Dashboard counted every ticket in Python (1.113.37). At 20,000 tickets an agent's dashboard took 915 ms; one grouped query takes 77 ms and returns identical counts.
  2. PostgreSQL driver ran in pure Python (1.113.38). The image removed psycopg's binary wheel (its bundled libraries fail the vulnerability gate), leaving the slow fallback. psycopg's C module is now compiled against Debian's libpq in a build stage. Average CPU per page fell from 122 ms to 53 ms. With the same 500 users and 3 pods, the median went from 14 s to 117 ms.

Where the capacity goes

  • Per page action: about 0.13 s of web CPU and 0.06 s of PostgreSQL CPU under load (1.113.38). The dashboard, task board and search are the heaviest pages.
  • Notification poll: every open browser polls every 25 s. 500 users make 20 polls/s, about two thirds of all requests, whatever their working pace.
  • Hardware: homeserver-02 is a laptop i7-8650U (4 cores, 8 threads); homeserver-01 is an Intel N97 (4 low-power cores). Under sustained load both run well below their single-core turbo speed, so a request costs about twice its idle time. At 5 pods the test node itself was the limit.
  • Database connections: each web process uses up to 4 connections (one per thread), so 4 pods need about 32 plus the worker, well inside PostgreSQL's default 100.

Further gains, cheapest first

Change Expected effect
Poll every 60 s instead of 25 s Superseded (B-439): one tab per browser checks, unchanged checks are bodiless 304s, and with the live stream the check runs every 2 minutes
Request-scoped caching of permission and settings lookups (in progress) Fewer of the 30–40 queries per page; lowers PostgreSQL CPU
Trigram index for search (needs the pg_trgm extension) Done (B-442): most columns already had indexes; comments and ticket numbers did not, and an OR with a comment subquery forced full scans. Ticket search query 86–310 ms → 1–2 ms on 20,000 tickets and 40,000 comments
Static files from the offload sidecar (web.staticOffload) Done: enabled in production since Helm revision 187

History: 2026-10-10 run against production

The 2026-10-11 review remediation implements web scaling, protected static offload and load-tolerant probes. The final isolated run reached 1,000 users and held for ten minutes, but failed the performance target (63.07% page failures, p95 60.01 s); both replicas stayed ready with zero restarts. Production was not upgraded. The measurements below describe the earlier single-replica deployment.

Run: 2026-10-10 18:59–19:01 JST against production (operations namespace, MicroK8s) running v1.113.24, Helm revision 173, image ghcr.io/awijesundara/serviceops-server@sha256:d51d83a2d6f5….

Result in one line: with the current production footprint (one web pod, 1 CPU, 8 request slots) ServiceOps saturated at about 450 concurrent users during sign-in, well short of the 1,000-user target. The test was stopped after 1.6 minutes because production stopped serving requests.

What was measured

Target 1,000 concurrent users, 3-minute ramp, 6-minute hold
Reached 452 concurrent users at 1.6 minutes, then stopped to restore service
Requests 814 HTTP requests, 31% failed (refused connections once the web pod went unready)
Throughput peak 14 requests/s
Response time (successful requests) median 17.6 s, p95 38.1 s
Real Chromium page loads 8 page views, median full load 1.35 s, median Largest Contentful Paint 1.24 s (taken in the first minute, before saturation)
Web pod CPU pinned at its 1,000 m limit from minute 0.5; memory peaked at 530 MiB of 1 GiB
PostgreSQL peak 130 m CPU, 179 MiB: not the bottleneck
Production impact readiness failed from about 19:00:10, so the single web pod left the Service; liveness restarted it once at 19:01:38; ready again 19:01:49. Real users could not reach ServiceOps for roughly 1.5–2 minutes.

Concurrent users and throughput

Server response time

Error rate

Production pod resources

Response time by action

Real-browser page loads

Real-browser Largest Contentful Paint

Every recorded request belongs to the sign-in phase (login page, login submit, first dashboard, first-visit static files). Users never reached steady browsing, so this run says nothing yet about steady-state page times under load.

Action Requests Median p95
Static files on first visit (CSS, JS, images) 248 29.7 s 37.3 s
GET /login 139 9.5 s 33.2 s
POST /login 114 22.0 s 38.1 s
GET / dashboard after sign-in 60 33.4 s 40.4 s

Why it saturated

  1. Eight request slots on one CPU. Gunicorn runs 2 workers × 4 threads (gthread). Every request, including health checks and static files, competes for those slots.
  2. Sign-in is deliberately expensive. Each login verifies an Argon2id hash (t=3, 64 MiB, parallelism 4): about 25 ms of wall time and roughly 100 ms of CPU per sign-in. Hundreds of simultaneous sign-ins fill the single CPU.
  3. Static files go through the application. A first visit loads about 22 files from /static. They carry a one-year immutable cache header, so browsers fetch them once, but the whole site sits behind Cloudflare Access, so the edge does not absorb them for new sessions.
  4. Probes share the request queue. /live and /ready wait behind user requests. With a 3-second timeout, a busy pod looks dead: readiness removes the only replica (full outage) and liveness restarts it, which drops work in progress.

Recommendations

# Change Expected effect Decision needed
1 Run at least 2–3 web replicas with 1–2 CPU each, and raise GUNICORN_WORKERS to match CPU Capacity grows roughly with CPU; one busy pod no longer means an outage Yes: departs from the lean single-replica footprint
2 Make liveness tolerant of load (for example 10 s timeout, 6 failures) and readiness slower to evict (5 s timeout) A saturated pod slows down instead of restarting and dropping work No (chart change)
3 Serve /static from a static file server or Cloudflare cache rule, not Gunicorn Frees request slots for application pages on first visits Yes: a cache rule for /static/* must bypass Access for public assets only
4 Consider OWASP's minimum Argon2id cost (t=2, 19 MiB, p=1) About 45% less hashing time and a quarter of the memory per sign-in Yes: a security trade-off
5 Re-run this test after 1–3, with a 10-minute hold Steady-state page times under load, which this run could not reach —

How the test was run

  • Hybrid load. 1,000 virtual users in k6 0.56 replay what a browser sends: the login form with its CSRF token, each page's HTML, first-visit static files, background notification polling, and form posts that create incidents and add work notes, with 3–8 seconds of think time. 1,000 real browsers would need about 50–100 GB of memory, so alongside them 30 real headless Chromium sessions (Playwright 1.49.1) recorded true page-load and paint times. The Chromium pod started late (image download), so only 8 browser page views were captured before the stop.
  • Inside the cluster. Load came from pods on homeserver-02 straight to the ServiceOps Service, bypassing Cloudflare. Each virtual user presented its own documentation-range client address (RFC 5737), as a real audience would.
  • Isolated data. All synthetic users (1,030) and tickets lived in a separate loadtest tenant that real users cannot see. After the run the tenant and every synthetic account were deactivated, and the temporary NetworkPolicy, Jobs, Secret and ConfigMap were deleted.
  • Measurements. k6 per-request samples, Chromium Navigation Timing and Largest Contentful Paint, and web/worker/PostgreSQL CPU and memory from each container's cgroup counters every 10 seconds.
  • Reproduce. provision.py creates the isolated tenant and users, browser_flows.js is the k6 script, real_browsers.py drives Chromium, and analyze.py draws these graphs. The raw summary is summary.json. The test password comes from the LOADTEST_PASSWORD environment variable; none is stored here.

Running this test against production takes production down at the current size; repeat it against a scaled deployment or an isolated copy.

Clone this wiki locally