Patterns — Scaling Backend Services
Contents
- Connection-pool arithmetic
- pgbouncer minimal config
- Kubernetes HPA on a custom metric
- Load balancer health + draining (nginx)
- Queue-based load leveling sketch
- Load-test skeleton (k6)
- Gotchas
Connection-pool arithmetic
text
per-instance pool = cores × 2 # CPU-bound starting point
fleet demand = replicas × per-instance pool
must satisfy : fleet demand < db max_connections − superuser_reserved
Example: 12 replicas × 10 pool = 120 > Postgres default 100 → pgbouncer required.IO-wait-heavy workloads (slow downstreams inside transactions) justify larger pools — but first shorten the transaction, don't widen the pool.
pgbouncer minimal config
ini
[databases]
app = host=10.0.0.5 port=5432 dbname=app
[pgbouncer]
listen_port = 6432
auth_type = scram-sha-256
pool_mode = transaction ; multiplexes: many clients, few server conns
default_pool_size = 20 ; server-side connections per db/user pair
max_client_conn = 2000
server_idle_timeout = 60pool_mode = transaction breaks session state (prepared statements, advisory
locks, SET) — verify the driver's compatibility mode before enabling.
Kubernetes HPA on a custom metric
yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
minReplicas: 3
maxReplicas: 24 # capped by DB pool arithmetic, not by budget alone
metrics:
- type: Pods
pods:
metric: { name: http_p95_latency_ms }
target: { type: AverageValue, averageValue: "250" }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react to spikes immediately
policies: [{ type: Percent, value: 100, periodSeconds: 60 }]
scaleDown:
stabilizationWindowSeconds: 300 # shrink slowly — thrash guard
policies: [{ type: Pods, value: 1, periodSeconds: 120 }]Load balancer health + draining (nginx)
nginx
upstream api {
least_conn;
server 10.0.1.10:8000 max_fails=3 fail_timeout=10s;
server 10.0.1.11:8000 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8000 slow_start=30s; # ramp recovered instances gradually
}App side (draining): on SIGTERM, fail the readiness endpoint, keep serving in-flight requests, exit after they finish or a 30 s deadline:
python
def handle_sigterm(*_):
ready.set_unhealthy() # LB stops sending new traffic
server.shutdown(grace=30) # finish in-flight, then exitQueue-based load leveling sketch
python
# ingest: accept fast, defer work
@app.post("/imports")
def create_import(req):
job_id = queue.enqueue("imports", req.body, idempotency_key=req.headers["Idempotency-Key"])
return 202, {"job_id": job_id, "status_url": f"/imports/{job_id}"}
# worker pool drains at a sustainable rate; consumers are idempotent
def worker():
for job in queue.consume("imports", prefetch=1):
if already_processed(job.idempotency_key): # replay-safe
job.ack(); continue
process(job)
mark_processed(job.idempotency_key)
job.ack()Worker count — not producer rate — sets DB write pressure; scale workers only while the DB stays under its ceiling.
Load-test skeleton (k6)
javascript
import http from 'k6/http';
export const options = {
stages: [
{ duration: '2m', target: 200 }, // ramp
{ duration: '5m', target: 200 }, // steady — realistic think time below
{ duration: '2m', target: 800 }, // find the knee
],
thresholds: { http_req_duration: ['p(95)<300'] },
};
export default function () {
http.get('https://staging.example.com/api/orders');
}Test against staging with production-shaped data; an empty database lies.
Gotchas
- Sticky sessions are hidden state — they break draining and uneven-load the fleet; externalize the session instead.
- Autoscaling on CPU while blocked on IO does nothing: instances idle at 20% CPU while every request waits on the pool. Scale on the metric that saturates.
least_connbeats round-robin once request costs vary, but health checks matter more than the algorithm.- Replica lag is not constant — it spikes exactly when you're overloaded, i.e., when you rerouted reads there. Monitor lag and fail reads back gracefully.
- Local caches per instance multiply cold starts during scale-up; a scale-out event can stampede the DB (see caching-strategies for stampede locks).
- Max-instance caps get deleted in incidents — document WHY the cap exists (DB arithmetic) next to the setting.