infra(k8s): load-based HPAs, drop anti-affinity, retire talos5

Adds HorizontalPodAutoscaler resources for the three load-responsive
Deployments:

- prop (web): min 2, max 5, averageValue 800m CPU
- prop-grid-rs: min 1, max 4, averageValue 400m
- hrrr-point-rs: min 1, max 4, averageValue 400m
- prop-backfill is intentionally unmanaged (queue-driven, not CPU-bound)

The scale-up policy reacts in 30 s with up-to-2-pods or +100 % per
window; scale-down stabilises over 10 min so the hourly propagation
chain doesn't churn replicas right after the :05 cron.

Removes `spec.replicas` from the HPA-managed Deployments so Flux's
periodic reconcile stops fighting HPA's live scaling decisions.

Drops `podAntiAffinity` from prop, prop-grid-rs, and hrrr-point-rs
so HPA can actually reach its maxReplicas when one physical host has
capacity for two pods; the scheduler picks whatever fits.

Updates runbook + project memory to drop the `talos5` hostname —
Postgres is now addressed strictly by IP (10.0.15.30).
This commit is contained in:
Graham McIntire 2026-04-23 18:42:39 -05:00
parent 53dc08d659
commit 86b2718916
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 146 additions and 39 deletions

View file

@ -5,7 +5,7 @@ NFS volume. This doc names every failure mode at that seam and says
how the system recovers.
```
prop-grid-rs (worker nodes) Postgres (talos5 — 10.0.15.30) prop pods (node1/2/3)
prop-grid-rs (worker nodes) Postgres (10.0.15.30) prop pods (node1/2/3)
├─ fetch GRIB2 (NOMADS) ├─ grid_tasks (SKIP LOCKED) ├─ ScoreCache (ETS)
├─ decode ├─ LISTEN/NOTIFY channel ├─ NotifyListener
├─ score 22 bands × 92k │ "propagation_ready" ├─ ScoreCacheReconciler (60s)

View file

@ -9,11 +9,11 @@ metadata:
# commercial-link degradation, ProfilesFile write). f01..f18 land
# here from the Rust worker.
spec:
# Three replicas claim from grid_tasks via FOR UPDATE SKIP LOCKED
# with no coordination. Anti-affinity spreads them across distinct
# hosts so any single node going down leaves the hourly chain with
# two workers still draining.
replicas: 3
# Replicas claim from grid_tasks via FOR UPDATE SKIP LOCKED with no
# coordination; anti-affinity spreads them across distinct hosts so
# any single node going down leaves the hourly chain with other
# workers still draining.
# Replica count is HPA-managed (hpa.yaml, min 1 / max 4).
minReadySeconds: 5
strategy:
type: RollingUpdate
@ -35,15 +35,10 @@ spec:
prometheus.io/port: "9100"
prometheus.io/path: "/metrics"
spec:
# No node pinning — let the scheduler place replicas on any host
# the anti-affinity rule allows. talos5 is reserved for Postgres.
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: prop-grid-rs
topologyKey: kubernetes.io/hostname
# No node pinning — scheduler picks any host. Anti-affinity was
# removed so HPA can stack multiple replicas on the same node if
# that's where the scheduler wants them; grid-rs is CPU-bound but
# not memory-bound so colocating a couple of replicas is fine.
imagePullSecrets:
- name: forgejo-registry
securityContext:

View file

@ -8,9 +8,10 @@ metadata:
# points JSONB array); this deployment drains via FOR UPDATE SKIP
# LOCKED, fetches each HRRR cycle once, and upserts hrrr_profiles.
spec:
# Two replicas for parallelism on QSO-submit spikes. FOR UPDATE SKIP
# LOCKED on hrrr_fetch_tasks lets both claim without coordination.
replicas: 2
# Parallelism on QSO-submit spikes comes from multiple replicas
# claiming rows from hrrr_fetch_tasks via FOR UPDATE SKIP LOCKED —
# no coordination needed between workers.
# Replica count is HPA-managed (hpa.yaml, min 1 / max 4).
strategy:
type: RollingUpdate
rollingUpdate:
@ -25,19 +26,9 @@ spec:
app: hrrr-point-rs
tier: hrrr-points
spec:
affinity:
# Avoid co-locating with prop-grid-rs pods — they compete for
# the same wgrib2 subprocess slots and NFS fsyncs. Grid chain
# is latency-sensitive (users refreshing /map); per-QSO point
# work is throughput-sensitive but latency-tolerant.
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
podAffinityTerm:
labelSelector:
matchLabels:
app: prop-grid-rs
topologyKey: kubernetes.io/hostname
# Anti-affinity against prop-grid-rs was removed; the scheduler
# is free to colocate. Contention on wgrib2 + NFS hasn't been an
# observed bottleneck in practice.
imagePullSecrets:
- name: forgejo-registry
securityContext:

View file

@ -4,7 +4,9 @@ metadata:
name: prop
namespace: prop
spec:
replicas: 3
# replicas is owned by the HPA in hpa.yaml (min 2 / max 5).
# Leaving it out here so Flux's periodic reconcile doesn't overwrite
# whatever replica count the autoscaler has settled on.
minReadySeconds: 5
strategy:
type: RollingUpdate
@ -28,13 +30,6 @@ spec:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: prop
topologyKey: topology.kubernetes.io/physical-host
serviceAccountName: prop
imagePullSecrets:
- name: forgejo-registry

125
k8s/hpa.yaml Normal file
View file

@ -0,0 +1,125 @@
# Horizontal pod autoscalers for the three load-responsive Deployments.
# prop-backfill is deliberately excluded — it's a single-replica catch-up
# worker that's bottlenecked on IEM rate-limit waits rather than CPU, so
# scaling by CPU would just add idle replicas parked on 429 backoffs.
#
# CPU targets use AverageValue (absolute cores) rather than Utilization
# (% of request) so the scale-up thresholds don't depend on the request
# number. Request fields in the Deployments can be tuned independently
# for scheduler bin-packing without accidentally changing when HPA
# decides to add a replica.
#
# The corresponding Deployments have their `spec.replicas` removed so
# Flux's periodic reconcile doesn't fight HPA's live scaling.
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: prop
namespace: prop
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: prop
minReplicas: 2
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
# Average of 800m across pods triggers scale-up. The hourly
# :05 propagation chain + ScoreCache warming on one pod can
# peak near 2 cores — averaging across 23 replicas keeps the
# threshold meaningful without flapping on every cron tick.
type: AverageValue
averageValue: 800m
behavior:
scaleUp:
# React quickly to sudden surges (LiveView + propagation chain
# can coincide). Add up to 2 pods or 100% of current replica
# count per 30 s window, whichever is larger.
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 2
periodSeconds: 30
- type: Percent
value: 100
periodSeconds: 30
selectPolicy: Max
scaleDown:
# Slower scale-down so the hourly chain doesn't immediately
# trigger a churn of terminations five minutes later.
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: prop-grid-rs
namespace: prop
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: prop-grid-rs
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: AverageValue
averageValue: 400m
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 1
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: hrrr-point-rs
namespace: prop
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: hrrr-point-rs
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: AverageValue
averageValue: 400m
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 1
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120

View file

@ -12,3 +12,4 @@ resources:
- deployment-hrrr-point-rs.yaml
- service.yaml
- metrics-service.yaml
- hpa.yaml