Right-size CPU requests and stage controlled Odroid rebalancing

This commit is contained in:
feedc0de committed 2026-10-31 23:52:50 +01:00
1 parent c4f2f82f29
commit bbabf768ec
23 files changed
+249 -17

No files matched your search

+4 -1
View File
@@ -49,7 +49,7 @@ server:
minAvailable: 1
resources:
requests:
cpu: 500m
cpu: 150m
memory: 1Gi
limits:
# Deliberately leave CPU unlimited so login bursts can use idle cores.
@@ -91,6 +91,9 @@ server:
startupProbe:
timeoutSeconds: 10
worker:
resources:
requests:
cpu: 150m
enabled: true
livenessProbe:
timeoutSeconds: 10
+4
View File
@@ -20,3 +20,7 @@ ingress:
- hosts:
- brunner.ninja
secretName: brunner-ninja-tls
resources:
requests:
cpu: 175m
+5
View File
@@ -23,3 +23,8 @@ certificate:
secretName: coturn
dnsName: brunner.ninja
issuerName: letsencrypt-dns
resources:
requests:
cpu: 50m
limits:
cpu:
+24
View File
@@ -0,0 +1,24 @@
# Odroid CPU rebalancing
The upstream Descheduler chart runs every five minutes. `values.yaml` is the
policy; the bootstrap Application selects its chart version.
The policy uses Metrics Server CPU usage: nodes below 40% are possible
receivers and nodes above 75% are possible eviction sources. Kubernetes still
chooses replacement placement. It does not live-migrate processes or guarantee
that a replacement lands on a different node.
Each cycle permits at most two evictions overall, one per node and namespace.
Pods must be at least one hour old, belong to a controller with at least two
replicas, have a PodDisruptionBudget, have no PVC, and fit another eligible
node. Only the six Odroids are eligible. Storage, networking, monitoring,
certificate management, and GitOps namespaces are excluded. EmptyDir alone is
not a protection for these replicated stateless workloads.
Start with `cmdOptions.dry-run: true`, inspect Job logs, then remove that option
to allow evictions. To stop balancing, set `suspend: true` in values and let Argo
sync. Existing running Jobs must finish before suspension takes full effect.
Review after changes to workload mix; do not remove PVC and infrastructure
protections to force a more even CPU graph. CPU requests determine whether a
replacement can fit even when observed utilization is low.
+58
View File
@@ -0,0 +1,58 @@
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
activeDeadlineSeconds: 120
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 2
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: null
memory: 256Mi
# Observe the first cycle before enabling evictions.
cmdOptions:
dry-run: true
v: 3
deschedulerPolicy:
maxNoOfPodsToEvictPerNode: 1
maxNoOfPodsToEvictPerNamespace: 1
maxNoOfPodsToEvictTotal: 2
metricsProviders:
- source: KubernetesMetrics
profiles:
- name: odroid-cpu-balance
pluginConfig:
- name: DefaultEvictor
args:
nodeFit: true
minPodAge: 1h
minReplicas: 2
nodeSelector: "kubernetes.io/hostname in (odroid-h3-00,odroid-h3-01,odroid-h3-02,odroid-h3-03,odroid-h3-04,odroid-h3-05)"
podProtections:
defaultDisabled:
- PodsWithLocalStorage
extraEnabled:
- PodsWithPVC
- PodsWithoutPDB
- name: LowNodeUtilization
args:
metricsUtilization:
source: KubernetesMetrics
thresholds:
cpu: 40
targetThresholds:
cpu: 75
evictableNamespaces:
exclude:
- kube-system
- rook-ceph
- argocd
- elastic
- prometheus
- metallb-system
- cert-manager
plugins:
balance:
enabled:
- LowNodeUtilization
+2 -2
View File
@@ -1,5 +1,5 @@
managedNamespaces:
- elastic
- elastic
installCRDs: true
createClusterScopedResources: true
@@ -9,7 +9,7 @@ resources:
cpu: 100m
memory: 256Mi
limits:
cpu: 1
cpu: null
memory: 1Gi
config:
+1 -1
View File
@@ -98,7 +98,7 @@ spec:
protocol: UDP
resources:
requests:
cpu: 500m
cpu: 75m
memory: 1Gi
limits:
cpu: "2"
+2 -3
View File
@@ -47,10 +47,9 @@ spec:
- name: elasticsearch
resources:
requests:
cpu: "2"
cpu: 500m
memory: 12Gi
limits:
cpu: "4"
memory: 12Gi
volumeClaimTemplates:
- metadata:
@@ -119,7 +118,7 @@ spec:
- name: kibana
resources:
requests:
cpu: 500m
cpu: 100m
memory: 4Gi
limits:
cpu: "2"
+4
View File
@@ -82,3 +82,7 @@ grafana.ini:
security:
allow_embedding: true
x_frame_options: ""
resources:
requests:
cpu: 75m
+4
View File
@@ -20,3 +20,7 @@ ingress:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: default-authentik@kubernetescrd
resources:
requests:
cpu: 175m
+2 -2
View File
@@ -15,10 +15,10 @@ env:
value: Europe/Vienna
resources:
requests:
cpu: 500m
cpu: 100m
memory: 2Gi
limits:
cpu: "2"
cpu:
memory: 16Gi
ingress:
enabled: true
+3 -3
View File
@@ -49,7 +49,7 @@ mastodon:
replicas: 1
resources:
requests:
cpu: 250m
cpu: 50m
memory: 768Mi
limits:
cpu: "2"
@@ -70,7 +70,7 @@ mastodon:
- fasp
resources:
requests:
cpu: 250m
cpu: 50m
memory: 768Mi
limits:
cpu: "2"
@@ -83,7 +83,7 @@ mastodon:
workers: 1
resources:
requests:
cpu: 100m
cpu: 25m
memory: 256Mi
limits:
cpu: "1"
+5
View File
@@ -4,3 +4,8 @@ serviceMonitor:
enabled: true
labels:
release: prometheus
resources:
requests:
cpu: 175m
limits:
cpu:
+1 -1
View File
@@ -44,5 +44,5 @@ affinity:
values: [arschrock]
resources:
requests:
cpu: 100m
cpu: 50m
memory: 1Gi
+1 -1
View File
@@ -7,7 +7,7 @@ ingress:
traefik.ingress.kubernetes.io/router.middlewares: default-authentik@kubernetescrd
resources:
requests:
cpu: 100m
cpu: 35m
memory: 512Mi
limits:
memory: 2Gi
+3
View File
@@ -77,6 +77,9 @@ prometheus:
kubernetes.io/ingress.class: "traefik"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
prometheusSpec:
resources:
requests:
cpu: 200m
retention: "15d"
storageSpec:
volumeClaimTemplate:
+1 -1
View File
@@ -13,5 +13,5 @@ ingress:
traefik.ingress.kubernetes.io/router.middlewares: default-authentik@kubernetescrd
resources:
requests:
cpu: 100m
cpu: 25m
memory: 256Mi
+1 -1
View File
@@ -11,7 +11,7 @@ podDisruptionBudget:
minAvailable: 1
resources:
requests:
cpu: 500m
cpu: 700m
memory: 1Gi
affinity:
podAntiAffinity:
+1 -1
View File
@@ -20,7 +20,7 @@ readinessProbe:
timeoutSeconds: 5
resources:
requests:
cpu: 100m
cpu: 75m
memory: 256Mi
ephemeral-storage: 128Mi
limits:
+29
View File
@@ -0,0 +1,29 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: descheduler
namespace: argocd
spec:
description: Controlled CPU rebalancing on the Odroid workers
sourceRepos:
- https://kubernetes-sigs.github.io/descheduler/
- https://code.brunner.ninja/feedc0de/homelab-gitops.git
destinations:
- namespace: kube-system
server: https://kubernetes.default.svc
clusterResourceWhitelist:
- group: rbac.authorization.k8s.io
kind: ClusterRole
- group: rbac.authorization.k8s.io
kind: ClusterRoleBinding
namespaceResourceWhitelist:
- group: ""
kind: ConfigMap
- group: ""
kind: ServiceAccount
- group: batch
kind: CronJob
- group: rbac.authorization.k8s.io
kind: Role
- group: rbac.authorization.k8s.io
kind: RoleBinding
+26
View File
@@ -0,0 +1,26 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: descheduler
namespace: argocd
spec:
project: descheduler
sources:
- repoURL: https://kubernetes-sigs.github.io/descheduler/
chart: descheduler
targetRevision: 0.36.0
helm:
releaseName: descheduler
valueFiles:
- $values/apps/descheduler/values.yaml
- repoURL: https://code.brunner.ninja/feedc0de/homelab-gitops.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
enabled: true
prune: true
selfHeal: true
+2
View File
@@ -151,3 +151,5 @@ resources:
- ghostfolio.yaml
- oldipmi-project.yaml
- oldipmi.yaml
- descheduler-project.yaml
- descheduler.yaml
+66
View File
@@ -0,0 +1,66 @@
# CPU sizing and balancing
## Review on 2026-10-31
Prometheus was queried for the 95th percentile of five-minute CPU rates over
its available history within the previous 14 days, sampled every 15 minutes:
```promql
quantile_over_time(0.95,
(sum by(namespace,pod,container)
(rate(container_cpu_usage_seconds_total{container!="",container!="POD"}[5m]))
)[14d:15m])
```
The largest observed replica value was considered for shared workload sizing.
New pods have shorter histories; this is not a guarantee of fourteen complete
days per workload. These measurements include the recent recovery period.
The starting recommendation is p95 times 1.2, rounded upwards. Request floors
are retained for startup, databases, recovery, and bursty workloads. Requests
are not CPU limits. Memory requests and limits are unchanged.
Reviewed changes include Elasticsearch/Kibana, Netflow, Authentik, Traefik,
InfluxDB, MKTXP, Mastodon, Paperless, scanner/PDF editor, Uptime Kuma, TURN,
Home Assistant, WordPress, Grafana and Prometheus. Busy services without CPU
requests receive explicit reservations. Elasticsearch retains a 500m floor
per data node for startup/recovery, substantially above its measured baseline.
Matrix is still a legacy Helm release. Its canonical values live in
`matrix/authentication/values.yaml` in the homelab workspace: the main Synapse
process gets 500m, federation sender 225m, and other workers 50m. Init-container
requests can make effective pod reservations higher than the main container.
The shared RBD claim and worker/offload affinity remain unchanged.
Ceph daemons, the API server, Cilium, CoreDNS, Argo CD, and ingress already have
no CPU limits. Their low or absent requests are not interpreted as spare
physical capacity; Descheduler uses measured CPU. The legacy Ceph CSI operator
CPU cap is removed in `rook/deploy/examples/csi-operator.yaml`. Ceph daemon
resources are left alone to avoid a storage-wide restart. CPU caps are also
removed from the Elastic operator/data nodes, InfluxDB, MKTXP, and TURN.
## Deliberate exclusions
- Do not infer batch, transcoding, game-server or GPU requirements from idle
CPU measurements. Their explicit reservations are retained.
- Do not resize workloads that are down or lack representative history.
- Do not automatically rewrite operator-managed database resources or apply
VPA mutation alongside Argo. This review changes owning configuration only.
- Loops and Pixelfed charts hard-code some resource requests; overriding an
unsupported values key would do nothing. Those require a separate chart
interface change before their requests can be tuned through GitOps.
- Small existing requests are retained where a reduction offers little useful
headroom. The aim is schedulability and contention protection, not identical
requests across applications.
## Ongoing operation
Descheduler runs automatically; request changes are reviewed configuration,
not a continuously mutating autoscaler. Repeat the history review after a
representative week or a substantial workload change. Never set every request
from a single current-usage sample. Monitor pending pods, CPU throttling,
latency, and node CPU usage as well as reserved capacity.
For a new request change, render the owning chart, inspect all container/init
resources, and reconcile through Argo (or the existing legacy Helm installer).
Keep the prior commit as rollback; do not delete PVCs to resolve placement.