Skip to content

[AK-278] Karpenter auto-scaling for clusters #73

Description

@robinbraemer

Migrated from Linear: AK-278

GitHub is the canonical source of truth. The Linear issue is marked as a duplicate.


Why

Manual cluster scaling is tedious. Karpenter provides intelligent auto-scaling (both up and down), but direct cloud provider integration requires updating drivers on every managed control plane when adding new providers. CNAP API as a centralized broker solves this.

Architecture

Custom Karpenter CloudProvider implementation that proxies to CNAP API instead of calling cloud providers directly.

Karpenter Driver (Go binary):

  • Implements cloudprovider.CloudProvider interface
  • Runs as sidecar to K0s container in same pod
  • Authenticated via injected secret (ServiceAccount token)
  • Translates Karpenter CRDs → CNAP API calls
  • Handles both upscaling (Create) and downscaling (Delete)

CNAP API (centralized control plane):

  • Upscaling: POST /api/v1/clusters/{clusterId}/nodes
  • Downscaling: DELETE /api/v1/clusters/{clusterId}/nodes/{nodeID}
  • Uses Temporal workflows for idempotent operations
  • Handles cloud provider integrations (Hetzner, AWS, etc.)

Benefits:

  • Add cloud providers in CNAP API without updating drivers
  • Centralized credential management
  • Idempotent via Temporal (duplicate requests → same result)
  • Reuse existing K0s token generation and worker.sh bootstrap logic

Upscaling Flow

Timeline: Create() → Instance Boots → Node Self-Registers

T+0s:    Karpenter creates NodeClaim (pending pods detected)
         └─> Driver's Create() called
         
T+0s:    CNAP API provisions server:
         1. Generate K0s worker join token (30min expiry)
         2. Build cloud-init with embedded token
         3. Call cloud provider API (Hetzner/AWS/etc)
         4. Create() RETURNS immediately with providerID
         
T+30s:   Server boots, cloud-init executes:
         - curl -sSLf https://get.k0s.sh | sh
         - k0s install worker --token-file /tmp/join-token
         - k0s start
         
T+60s:   K0s worker kubelet registers with API server
         └─> Node appears in cluster automatically
         
T+90s:   Node reports Ready status
         └─> Available for pod scheduling

Key: Nodes self-register via cloud-init - Karpenter watches for them to appear

Cloud-Init Script (CNAP generates)

#!/bin/bash -xe
exec > >(tee /var/log/user-data.log) 2>&1

# Install k0s
curl -sSLf https://get.k0s.sh | sh

# Write join token
cat > /tmp/join-token <<'EOF'
<base64-encoded-k0s-join-token>
EOF

# Join cluster
k0s install worker --token-file /tmp/join-token
k0s start

# Cleanup token
rm -f /tmp/join-token

Lifecycle Controller (Driver watches for nodes)

Karpenter's lifecycle controller watches for nodes to appear and matches them to NodeClaims:

Registration Phase:

  • Watches for new Node objects via Kubernetes API
  • Matches node to NodeClaim using providerID or instance ID labels
  • Updates node labels/taints to match NodePool spec
  • Removes karpenter.sh/unregistered taint

Timeout: If node doesn't register within 15 minutes, driver deletes instance and removes NodeClaim

Downscaling Flow

Timeline: Consolidation Decision → Pod Eviction → Instance Termination

T+0s:    Karpenter consolidation detects underutilized node
         └─> Scheduling simulation: pods fit on existing capacity
         
T+0s:    Disruption initiated:
         1. Cordon node (mark unschedulable)
         2. Apply taint: karpenter.sh/disrupted:NoSchedule
         3. Add finalizer: karpenter.sh/termination
         
T+1s:    Pod eviction begins:
         - Group pods by PodDisruptionBudget
         - Evict serially per PDB (respects disruptionsAllowed)
         - Skip: static pods, do-not-evict annotation
         
T+30s:   Pods gracefully terminate (terminationGracePeriodSeconds)
         └─> Controllers reschedule on other nodes
         
T+45s:   All pods evicted, node drained
         └─> Driver's Delete() called
         
T+45s:   CNAP API terminates server:
         - Call cloud provider API (DELETE instance)
         - Mark server status: TERMINATING
         - Return success (async termination)
         
T+60s:   Server terminating (cloud provider side)
         └─> Karpenter retries Delete()
         
T+90s:   Server terminated, no longer exists
         └─> Delete() returns NodeClaimNotFoundError
         
T+90s:   Finalizer removed, NodeClaim deleted
         └─> Cost savings realized

Consolidation Triggers

Karpenter reduces cluster cost through consolidation by detecting:

1. Empty Nodes (highest priority)

  • No running non-daemon pods
  • Deleted in parallel for faster cost reduction

2. Underutilized Nodes

  • Pods can be rescheduled to existing capacity
  • Node has been underutilized for consolidateAfter duration (default: 0s)

3. Multi-Node Consolidation

  • 2+ nodes can be replaced with single cheaper instance
  • Pre-spins replacement before deleting originals

Configuration (NodePool spec):

spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m  # Wait 5min after pod change

Blockers preventing consolidation:

  • Pods without controller owners
  • Pods with karpenter.sh/do-not-evict annotation
  • PodDisruptionBudgets with disruptionsAllowed = 0
  • Pods unable to reschedule (affinity/topology constraints)

Pod Eviction Process

Karpenter uses Kubernetes-native Eviction API (not forceful deletion):

1. Grouping: Pods grouped by PodDisruptionBudget

2. Serial eviction per PDB:

  • Prevents simultaneous violations of same PDB
  • Respects disruptionsAllowed count
  • Exponential backoff on 429 (PDB violation)

3. Pod exclusions:

  • Static pods (not evicted)
  • Succeeded/failed pods
  • Pods tolerating karpenter.sh/disrupted taint
  • Pods with karpenter.sh/do-not-evict (wait for natural termination)

4. Grace periods:

  • Each pod gets terminationGracePeriodSeconds to shut down
  • NodeClaim terminationGracePeriod overrides if shorter
  • Setting to 0 forces immediate eviction (bypasses PDBs)

Design principle: Karpenter avoids force deletion - users must manually intervene if pods won't terminate.

Termination Timeline

Phase Duration Forced By terminationGracePeriod?
Minimum node lifetime 5 minutes No (ineligible for consolidation)
Consolidation decision Milliseconds N/A
Cordoning + taint Instant No
Pod eviction Variable (PDB-dependent) Yes
Pod termination Per-pod terminationGracePeriodSeconds Yes
CloudProvider.Delete() Cloud-dependent (30-120s) No
Stabilization window 0-5 minutes before next consolidation No

Total typical downscaling time: 45-120 seconds

Driver Delete() Implementation

func (c *CNAPCloudProvider) Delete(ctx context.Context, nc *v1.NodeClaim) error {
    serverID := extractServerID(nc.Status.ProviderID)
    
    // Check server state via CNAP API
    server, err := c.client.GetServer(ctx, serverID)
    if err != nil {
        if errors.Is(err, cnap.ErrServerNotFound) {
            // Server already gone — signal success to Karpenter
            return cloudprovider.NewNodeClaimNotFoundError(err)
        }
        return fmt.Errorf("get server: %w", err)
    }
    
    // Check if already terminated
    if server.Status == "TERMINATED" {
        return cloudprovider.NewNodeClaimNotFoundError(
            fmt.Errorf("server %s terminated", serverID)
        )
    }
    
    // If terminating, let it continue (Karpenter will retry)
    if server.Status == "TERMINATING" {
        return nil  // Retry later
    }
    
    // Trigger termination
    err = c.client.DeleteServer(ctx, serverID)
    if err != nil {
        return fmt.Errorf("delete server: %w", err)
    }
    
    // Return success (termination is async)
    // Karpenter will retry until server is gone
    return nil
}

Key behavior:

  • First call: Triggers termination, returns nil
  • Subsequent retries: Checks state, returns nil while terminating
  • Final retry: Server not found, returns NodeClaimNotFoundError
  • Karpenter removes finalizer only after NodeClaimNotFoundError

CNAP API Server Termination (Temporal Workflow)

async function terminateServerWorkflow(
  clusterID: string,
  serverID: string
): Promise<void> {
  // Idempotency check
  const server = await ctx.run(() => convex.getServer(serverID));
  if (!server || server.status === 'TERMINATED') {
    return; // Already gone
  }
  
  // Mark as terminating
  await ctx.run(() => convex.updateServer(serverID, { 
    status: 'TERMINATING' 
  }));
  
  // Call cloud provider API to delete instance
  await ctx.run(() => cloudProvider.terminateInstance(server.instanceID));
  
  // Poll for termination completion (with timeout)
  await ctx.run(() => waitForTermination(server.instanceID, {
    timeout: '5m',
    pollInterval: '10s',
  }));
  
  // Mark as terminated and clean up
  await ctx.run(() => convex.updateServer(serverID, { 
    status: 'TERMINATED' 
  }));
  
  // Delete from database after confirmation
  await ctx.run(() => convex.deleteServer(serverID));
}

Finalizer-Based Cleanup

Karpenter uses finalizers to prevent instance leaks:

Finalizer: karpenter.sh/termination

Workflow:

  1. Delete request → Webhook adds finalizer
  2. Cordon + drain node
  3. Driver.Delete() called repeatedly
  4. Returns NodeClaimNotFoundError when server gone
  5. Finalizer removed
  6. Kubernetes deletes node object

Purpose:

  • Ensures cloud provider cleanup before Kubernetes deletion
  • Prevents orphaned instances if Karpenter crashes
  • Users can kubectl delete node instead of manual cloud operations

Error Handling

When Delete() fails:

  • Karpenter logs error and retries with exponential backoff
  • NodeClaim status annotated with error details
  • Finalizer prevents node deletion until cleanup succeeds

When server termination hangs:

  • CNAP API timeout after 5 minutes
  • Manual intervention required
  • Workflow can be canceled/retried via Temporal UI

When Karpenter unavailable during termination:

  • Finalizer blocks node deletion
  • Re-deploying Karpenter resumes termination
  • Or manually remove finalizer (may leak instance - requires manual cleanup)

Implementation Details

CRDs Used

NodePool (karpenter.sh/v1) - Scheduling + consolidation rules:

apiVersion: karpenter.sh/v1
kind: NodePool
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.cnap.tech
        kind: CNAPNodeClass
        name: default
      requirements:
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["m5.large", "m5.xlarge"]
      taints:
        - key: workload
          value: batch
          effect: NoSchedule
      terminationGracePeriod: 1h  # Max time before forced eviction
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
  limits:
    cpu: "1000"
    memory: "1000Gi"

NodeClaim (karpenter.sh/v1) - Managed by Karpenter:

apiVersion: karpenter.sh/v1
kind: NodeClaim
metadata:
  finalizers:
    - karpenter.sh/termination  # Prevents deletion until cleanup done
status:
  providerID: "cnap:///cluster-abc123/node-xyz789"
  allocatable:
    cpu: "4"
    memory: "16Gi"
  capacity:
    cpu: "4"
    memory: "16Gi"
  nodeName: "ip-10-0-1-42.ec2.internal"

CNAPNodeClass (karpenter.cnap.tech/v1):

apiVersion: karpenter.cnap.tech/v1
kind: CNAPNodeClass
metadata:
  name: default
spec:
  apiEndpoint: https://api.cnap.tech
  secretRef:
    name: cnap-credentials
    key: api-token
  clusterId: cluster-abc123
  region: us-west-2

Scaling Detection

Karpenter detects scaling needs by:

  1. Watching Kubernetes API for pods with Unschedulable=True condition
  2. Immediate reaction (seconds, not minutes) when kube-scheduler marks pods unschedulable
  3. Reading pod constraints: resource requests, selectors, affinity, tolerations
  4. Simulated scheduling using kube-scheduler logic to check if pods fit existing nodes
  5. Provisioning trigger if no existing nodes match requirements

Authentication

Driver → CNAP API:

  • ServiceAccount token mounted at /var/run/secrets/kubernetes.io/serviceaccount/token
  • Sent as Authorization: Bearer <token> header
  • CNAP API validates token via Convex auth

CNAP API → Cloud Providers:

  • Users configure cloud provider credentials in CNAP dashboard
  • Stored in Convex, retrieved by Temporal workflow
  • Per-cluster or per-workspace configuration

CloudProvider Interface

Must implement (from sigs.k8s.io/karpenter/pkg/cloudprovider):

type CloudProvider interface {
    Create(context.Context, *v1.NodeClaim) (*v1.NodeClaim, error)
    Delete(context.Context, *v1.NodeClaim) error
    Get(context.Context, string) (*v1.NodeClaim, error)
    List(context.Context) ([]*v1.NodeClaim, error)
    GetInstanceTypes(context.Context, *v1.NodePool) ([]*InstanceType, error)
    IsDrifted(context.Context, *v1.NodeClaim) (DriftReason, error)
    Name() string
    GetSupportedNodeClasses() []status.Object
}

CNAP API Endpoints

Create Node (Upscaling):

  • POST /api/v1/clusters/{clusterId}/nodes
  • Body: { uniqueID, cpu, memory, region, labels, taints }
  • Returns immediately after starting provisioning
  • Response: { nodeID, providerID, allocatable, capacity }
  • Temporal workflow: token generation + cloud-init + cloud API

Delete Node (Downscaling):

  • DELETE /api/v1/clusters/{clusterId}/nodes/{nodeID}
  • Triggers server termination via cloud provider API
  • Returns 200 if termination started
  • Returns 404 if already deleted (idempotent)
  • Temporal workflow: terminate + poll + cleanup

Get Node:

  • GET /api/v1/clusters/{clusterId}/nodes/{nodeID}
  • Returns: { nodeID, status, instanceID, providerID }
  • Status: RUNNING | TERMINATING | TERMINATED

List Nodes:

  • GET /api/v1/clusters/{clusterId}/nodes
  • Returns all nodes for cluster with current status

Acceptance Criteria

  • Given Karpenter deployed, when pods unschedulable, then driver calls CNAP API
  • Given CNAP API receives create request, when workflow runs, then server provisioned with cloud-init
  • Given server boots, when cloud-init executes, then node self-registers via K0s token
  • Given node appears, when lifecycle controller sees it, then matches to NodeClaim
  • Given node underutilized, when consolidation triggered, then pods evicted respecting PDBs
  • Given pods evicted, when Delete() called, then CNAP terminates server
  • Given server terminating, when Delete() retried, then returns nil until complete
  • Given server terminated, when Delete() called, then returns NodeClaimNotFoundError

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions