Skip to content

Compute

Karpenter

We use Karpenter for Cluster Autoscaling.

Karpenter dynamically adds and removes nodes in the EKS cluster based on:

  • Pending pod requirements
  • Node utilization
  • Available AWS instance types
  • Current AWS pricing

Node Types

Generally, Constellation has three types of nodes:

1. EKS Managed Node Group (workload-tier: baseline)

These are EKS-managed NodeGroups created by Terraform. EKS MNG Nodes are tainted, preventing non-critical workloads from being scheduled on them.

2. On-Demand Instances (workload-tier: on-demand)

Regular EC2 instances billed per-second/hour with no commitment. They provide guaranteed capacity (within AWS service limits) and are not interrupted.

3. Spot Instances (workload-tier: spot)

Spot instances use spare AWS capacity at a large discount (typically 60–90% cheaper), but AWS may interrupt and reclaim the instance with 2 minutes notice.

Tip

Using Spot instances significantly reduces compute costs for Constellation clusters.

Pod Density & Nitro Instances

Constellation runs Cilium in AWS ENI IPAM mode with prefix delegation: pods draw real VPC IPs, and each ENI is assigned /28 prefixes (16 IPs) from dedicated 100.64.0.0/16 pod subnets (infra/pod_subnets.tf), selected by Cilium via eni.subnetTagsFilter. This gives ~110 pods/node, so kubelet maxPods is set to 110 on both the Karpenter nodeclass and the baseline MNG.

Because ~6–9 platform DaemonSets (Cilium, cilium-envoy, EBS/EFS CSI, node-exporter, Alloy, …) run on every node, a very small node (e.g. the 12-slot m3.medium) can fill up before application pods — or even other DaemonSets — can schedule. Karpenter does not rescue a Pending DaemonSet pod: the DaemonSet controller pins each pod to one existing node, so Karpenter never provisions a new node on its behalf.

All NodePools require Nitro hardware — karpenter.k8s.aws/instance-hypervisor: ["nitro"] (prefix delegation needs Nitro). The spot/on-demand pools also require ≥ large instances (instance-size NotIn [nano,micro,small,medium]): pods use secondary ENIs only (the primary eth0 is in the untagged /24), so capacity is (maxENIs − 1) × IPv4perENI × 16, and 2-ENI instances like m7a.medium top out at ~64 IPs — too few to back maxPods: 110.

Raising maxPods depends on the pod subnets

maxPods: 110 is only safe because pods come from the 100.64.0.0/16 pod subnets where /28 prefix delegation works. Do not enable it on a cluster whose pod subnets aren't applied yet (e.g. prod until its terraform apply runs), and keep the ≥ large NodePool guardrail — without it, small instances overcommit IPs and pods hang in ContainerCreating. Verify prefix delegation is live (ENI Ipv4Prefixes populated) before relying on it. See ADR-0013.

Constellation Compute Strategy

Our workload placement strategy is as follows:

  • Cluster Critical Workloads (ArgoCD, CoreDNS, Cilium, Karpenter, Traefik) -> workload-tier: baseline
  • Business Critical Workloads (Passport, etc.) -> workload-tier: on-demand
  • All Other Applications -> workload-tier: spot

This strategy enables us to reduce compute costs while maintaining stability and predictability for critical services.

Workload Pinning

Using spec.template.spec.nodeSelector

Add a spec.template.spec.nodeSelector to your deployments to specify the workload-tier:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment
spec:
  replicas: 1
  template:
    spec:
      nodeSelector:
        workload-tier: spot # or on-demand, baseline

Using kustomize

Alternatively, apply it via a kustomize patch:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

patches:
  - target:
      kind: Deployment # applies to all Deployments
    patch: |-
      - op: add
        path: /spec/template/spec/nodeSelector
        value:
          workload-tier: on-demand

PodDisruptionBudgets (PDBs)

PodDisruptionBudgets ensure Kubernetes does not evict too many pods at once during events such as:

  • Node scale-down
  • Karpenter consolidation
  • Spot interruptions
  • Planned maintenance

Example: For a Deployment with app=my-service and 2+ replicas:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-service-pdb
spec:
  selector:
    matchLabels:
      app: my-service
  minAvailable: 1
  • This ensures at least 1 pod is always running.
  • It allows voluntary disruptions (Karpenter draining, node upgrades, etc.) as long as that condition stays true.