DEV Community

Cover image for Stop Paying for Availability You Don’t Actually Have

Stop Paying for Availability You Don’t Actually Have

Cloud platforms distribute workloads across Availability Zones (AZs) by default, for resilience. But cross-AZ traffic, replicated storage, and unnecessary spread of stateless and stateful workloads generate ongoing charges.
For platforms that don’t need a strict multi-AZ SLA, intentionally co-locating an application’s compute and its stateful components
not just the database, but any StatefulSet: message queues, search indices, caches, custom stateful services — inside one AZ can reduce infrastructure costs by more than 10%, depending on internal traffic volume and architecture.

Where the cost comes from

Cross-AZ charges typically originate from:

  • Application pods talking to a StatefulSet (database, queue, cache, search index) in a different AZ
  • EBS/PD-backed volumes located in a different AZ from the pods that use them
  • Chatty internal APIs crossing AZ boundaries at high volume
  • Monitoring/logging traffic unnecessarily spread across zones
  • Replication traffic crossing AZs without a real availability requirement behind it

This traffic stays inside the region, but most providers still bill it as regional data transfer. On database- or queue-heavy platforms, this becomes a real monthly cost.

The basic optimization

Any workload where the application tier repeatedly exchanges large volumes of data with a stateful backend — SQL/NoSQL databases, Kafka/RabbitMQ, Elasticsearch/OpenSearch, Redis, custom stateful services is a candidate for this optimization.

The storage reality: your StatefulSet is already single-AZ

This is the part most cost discussions skip, and it’s the strongest technical argument for this pattern.
Block storage such as AWS EBS is zonal.
A StatefulSet pod backed by such a volume can only ever run in the AZ where its volume lives.
Kubernetes cannot move it, and a scheduling rule cannot migrate it. It can only place other pods relative to it.

That means: if you have not built real cross-AZ replication for that stateful workload (e.g., Postgres streaming replica, Kafka multi-AZ, Elasticsearch multi-zone cluster), then that workload is already exposed to a single-AZ failure domain today regardless of how many AZs your stateless application pods are spread across.

So if AZ-2 (where your only Postgres/queue/cache instance lives) goes down:

  • An application tier spread across AZ-1/2/3 still fails for all users, because every app pod depends on the same single-AZ backend.
  • Spreading the app tier bought you nothing for this workload's availability; it only paid cross-AZ transfer fees while the backend was healthy.

Co-locating the app pods with that StatefulSet in its AZ does not meaningfully change your real availability in this common case. It just removes the wasted cost of the app pods talking to it cross-AZ during normal operation. You are not trading availability for cost here; you are removing a cost that was never buying you the availability it appeared to.
If you do have genuine multi-AZ stateful replication, this argument doesn’t apply.

Going from single-AZ to multi-AZ typically moves you from ~99.5% to ~99.99%, roughly the difference between ~1.8 days/year and ~1 hour/year of potential downtime, for the redundant components.
That gap is the real price of true HA. It only applies to components that are actually replicated across AZs, not to a stateless app tier sitting in front of a single-AZ backend.

Why dynamic policy (Kyverno) beats manual nodeAffinity

nodeAffinity/nodeSelector are the underlying Kubernetes scheduling primitives, but they’re static and per-workload. Applied manually across many tenants/workloads, you’d need to:

  1. Identify the current AZ of each StatefulSet anchor per tenant
  2. Hardcode that AZ into every related Deployment
  3. Keep it updated whenever the StatefulSet pod is recreated in another AZ
  4. Avoid ever applying it to PVC-backed pods or DaemonSets That’s manual, error-prone, and drifts over time.

Kyverno centralizes it: at pod admission time it looks up the anchor’s live AZ and injects the constraint automatically, only for eligible stateless pods.

Example — Generic anchor via label

Recommended approach: label whichever StatefulSet pod should act as the AZ anchor (database, queue, cache, search node — anything), instead of hardcoding a name pattern.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: pin-stateless-pods-to-anchor-az
spec:
  background: false          # only mutate newly-created pods; never retroactive
  rules:
    - name: pin-to-anchor-zone
      match:
        any:
          - resources:
              kinds: [Pod]
      context:
        # 1. Namespace must opt in via a generic "tenant" label (any value)
        - name: tenant
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.namespace}}"
            jmesPath: 'metadata.labels."example.com/tenant-name" || ''none'''
        # 1b. Is the namespace deactivated?
        - name: deactivated
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.namespace}}"
            jmesPath: 'metadata.labels."deactivated" || ''false'''
        # 2. Anchor = any running pod explicitly labelled as the AZ anchor
        #    (could be a database, queue, cache, search node, or any StatefulSet)
        - name: anchorNode
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.namespace}}/pods"
            jmesPath: 'items[?metadata.labels."example.com/az-anchor"==''true''] | [?status.phase==''Running''] | [0].spec.nodeName || ''none'''
        # 3. Read that node's AZ
        - name: zone
          apiCall:
            urlPath: "/api/v1/nodes"
            jmesPath: 'items[?metadata.name==''{{anchorNode}}''] | [0].metadata.labels."topology.kubernetes.io/zone" || ''none'''
      preconditions:
        all:
          - key: "{{ tenant }}"
            operator: NotEquals
            value: "none"
          - key: "{{ deactivated }}"
            operator: NotEquals
            value: "true"
          - key: "{{ anchorNode }}"
            operator: NotEquals
            value: "none"
          - key: "{{ zone }}"
            operator: NotEquals
            value: "none"
          # skip DaemonSets (must run on every node)
          - key: "{{ request.object.metadata.ownerReferences[?kind=='DaemonSet'] | length(@) }}"
            operator: Equals
            value: 0
          # skip ANY pod that mounts a PVC — protects the anchor itself and every other StatefulSet
          - key: "{{ request.object.spec.volumes[?persistentVolumeClaim] | length(@) }}"
            operator: Equals
            value: 0
      mutate:
        patchStrategicMerge:
          spec:
            nodeSelector:
              topology.kubernetes.io/zone: "{{ zone }}"
Enter fullscreen mode Exit fullscreen mode

Apply the anchor label once, on whichever StatefulSet should define the AZ for that namespace:

kubectl -n <namespace> label pod <anchor-statefulset>-0 example.com/az-anchor=true
Enter fullscreen mode Exit fullscreen mode

RBAC required

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: kyverno:pin-stateless-pods-to-anchor-az
  labels:
    app.kubernetes.io/component: admission-controller
    app.kubernetes.io/instance: kyverno
    app.kubernetes.io/part-of: kyverno
rules:
  - apiGroups: [""]
    resources: ["namespaces", "pods", "nodes"]
    verbs: ["get", "list"]
Enter fullscreen mode Exit fullscreen mode

Rollout strategy

  1. Measure — cross-AZ transfer cost, which StatefulSets are heavy talkers, current pod/volume AZs.
  2. Classify — genuinely multi-AZ-replicated stateful workloads (keep spread) vs. single-AZ-bound ones (safe to co-locate around).
  3. Label anchors — tag the StatefulSet(s) that should define each namespace’s AZ.
  4. Pilot — apply the policy in TEST/DEV, restart a few Deployments, confirm placement and zero Pending pods.
  5. Validate — compare cross-AZ bytes and cost before/after; confirm latency/error rates unchanged.
  6. Expand gradually — roll out per tenant group; explicitly exclude any workload with a real multi-AZ HA requirement.

Recommendation

  • Keep genuinely multi-AZ-replicated stateful workloads spread — that's a deliberate, paid-for availability trade-off (roughly 99.5% → 99.99%, i.e., ~1.8 days/year → ~1 hour/year of potential downtime).
  • For everything else, recognize that an unreplicated StatefulSet is already a single-AZ failure domain — co-locating its stateless clients with it doesn't reduce real-world availability, it just stops paying cross-AZ transfer fees for no benefit.
  • Use Kyverno, not manual nodeAffinity, to apply and maintain this at scale.
  • Test in DEV/TEST first, then measure the actual savings and availability impact — don't assume a fixed percentage; calculate it from your own traffic and billing data.

Top comments (0)