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:
- Delete request → Webhook adds finalizer
- Cordon + drain node
- Driver.Delete() called repeatedly
- Returns
NodeClaimNotFoundError when server gone
- Finalizer removed
- 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:
- Watching Kubernetes API for pods with
Unschedulable=True condition
- Immediate reaction (seconds, not minutes) when kube-scheduler marks pods unschedulable
- Reading pod constraints: resource requests, selectors, affinity, tolerations
- Simulated scheduling using kube-scheduler logic to check if pods fit existing nodes
- 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
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):
cloudprovider.CloudProviderinterfaceCNAP API (centralized control plane):
POST /api/v1/clusters/{clusterId}/nodesDELETE /api/v1/clusters/{clusterId}/nodes/{nodeID}Benefits:
Upscaling Flow
Timeline: Create() → Instance Boots → Node Self-Registers
Key: Nodes self-register via cloud-init - Karpenter watches for them to appear
Cloud-Init Script (CNAP generates)
Lifecycle Controller (Driver watches for nodes)
Karpenter's lifecycle controller watches for nodes to appear and matches them to NodeClaims:
Registration Phase:
providerIDor instance ID labelskarpenter.sh/unregisteredtaintTimeout: If node doesn't register within 15 minutes, driver deletes instance and removes NodeClaim
Downscaling Flow
Timeline: Consolidation Decision → Pod Eviction → Instance Termination
Consolidation Triggers
Karpenter reduces cluster cost through consolidation by detecting:
1. Empty Nodes (highest priority)
2. Underutilized Nodes
consolidateAfterduration (default: 0s)3. Multi-Node Consolidation
Configuration (NodePool spec):
Blockers preventing consolidation:
karpenter.sh/do-not-evictannotationdisruptionsAllowed = 0Pod Eviction Process
Karpenter uses Kubernetes-native Eviction API (not forceful deletion):
1. Grouping: Pods grouped by PodDisruptionBudget
2. Serial eviction per PDB:
disruptionsAllowedcount3. Pod exclusions:
karpenter.sh/disruptedtaintkarpenter.sh/do-not-evict(wait for natural termination)4. Grace periods:
terminationGracePeriodSecondsto shut downterminationGracePeriodoverrides if shorter0forces immediate eviction (bypasses PDBs)Design principle: Karpenter avoids force deletion - users must manually intervene if pods won't terminate.
Termination Timeline
terminationGracePeriodSecondsTotal typical downscaling time: 45-120 seconds
Driver Delete() Implementation
Key behavior:
nilnilwhile terminatingNodeClaimNotFoundErrorNodeClaimNotFoundErrorCNAP API Server Termination (Temporal Workflow)
Finalizer-Based Cleanup
Karpenter uses finalizers to prevent instance leaks:
Finalizer:
karpenter.sh/terminationWorkflow:
NodeClaimNotFoundErrorwhen server gonePurpose:
kubectl delete nodeinstead of manual cloud operationsError Handling
When Delete() fails:
When server termination hangs:
When Karpenter unavailable during termination:
Implementation Details
CRDs Used
NodePool (karpenter.sh/v1) - Scheduling + consolidation rules:
NodeClaim (karpenter.sh/v1) - Managed by Karpenter:
CNAPNodeClass (karpenter.cnap.tech/v1):
Scaling Detection
Karpenter detects scaling needs by:
Unschedulable=TrueconditionAuthentication
Driver → CNAP API:
/var/run/secrets/kubernetes.io/serviceaccount/tokenAuthorization: Bearer <token>headerCNAP API → Cloud Providers:
CloudProvider Interface
Must implement (from
sigs.k8s.io/karpenter/pkg/cloudprovider):CNAP API Endpoints
Create Node (Upscaling):
POST /api/v1/clusters/{clusterId}/nodes{ uniqueID, cpu, memory, region, labels, taints }{ nodeID, providerID, allocatable, capacity }Delete Node (Downscaling):
DELETE /api/v1/clusters/{clusterId}/nodes/{nodeID}Get Node:
GET /api/v1/clusters/{clusterId}/nodes/{nodeID}{ nodeID, status, instanceID, providerID }RUNNING | TERMINATING | TERMINATEDList Nodes:
GET /api/v1/clusters/{clusterId}/nodesAcceptance Criteria