LKE multi-node NodePool scale-up (Draft)
This document summarizes the core assumptions, invariants, and high-level flow for scaling existing LKE node pools in LKE mode, optimized for fast review and feedback.
Goals
- Maintain a strict 1:1 mapping between a Karpenter
NodeClaimand a Linode VM. - Set
nodeClaim.status.providerIDduringCloudProvider.Create(). - Support many concurrent
Create()calls without double-claiming a VM or racing pool mutations.
Terminology
NodePool(Karpenter): Kubernetes resourcekarpenter.sh/NodePool.NodePool(LKE): Linode LKE node pool (linodego.LKENodePool).NodeClaim: Kubernetes resourcekarpenter.sh/NodeClaim.Instance: Linode VM (linodego.Instance), identified byinstanceID.
Core model and invariants
- Pool mapping: exactly 1 LKE pool per
(Karpenter NodePool name, instanceType). - Pool discovery: always list pools and filter client-side (server-side tag filtering is unreliable for LKE pools).
- Determinism: if multiple claimable instances exist, claim the first
instanceIDin the list. - Tag ownership:
- Karpenter writes provider-managed tags to LKE pools through the LKE API and manages identity tags directly on Linode instances.
karpenter.sh/nodeclaim:<nodeClaimName>is instance-scoped only.karpenter.sh/nodeclaimis the authoritative orchestration signal for claim ownership: instances without anykarpenter.sh/nodeclaim:*tag are treated as unclaimed and can only be claimed by writingkarpenter.sh/nodeclaim:<nodeClaimName>.- Pool-scoped tags may be written via LKE pool APIs. Do not change or remove Karpenter-managed tags outside the provider; pool and instance discovery and claim ownership rely on them.
Pool mapping diagram
flowchart LR
KNP_A["Karpenter NodePool: nodepool-a"]
KNP_B["Karpenter NodePool: nodepool-b"]
LKE_A_T1["LKE NodePool (LKE pool)\nkey=(nodepool-a, g6-standard-2)\nType: g6-standard-2"]
LKE_A_T2["LKE NodePool (LKE pool)\nkey=(nodepool-a, g6-standard-4)\nType: g6-standard-4"]
LKE_B_T1["LKE NodePool (LKE pool)\nkey=(nodepool-b, g6-standard-2)\nType: g6-standard-2"]
KNP_A -->|"instanceType=g6-standard-2"| LKE_A_T1
KNP_A -->|"instanceType=g6-standard-4"| LKE_A_T2
KNP_B -->|"instanceType=g6-standard-2"| LKE_B_T1
Tier-specific assumptions
- Enterprise tier
- LKE pool tags propagate to underlying Linode instance tags.
- Linode automatically applies
nodepool=<poolID>andlke<clusterID>tags to instances. - LKE node pool
Linodesinformation can be delayed (30-60s); enumerate membership via the Linode Instance API filtered bynodepool=<poolID>,lke<clusterID>, andkarpenter.k8s.linode/lke-managed:true, then client-side filter byinstanceType. - Instance
Labelis used as the node ID (critical for early discovery beforepool.Linodesis populated).
- Standard tier
- LKE pool tags do not propagate to instances.
- Pool membership discovered via
GetLKENodePool(poolID).Linodes, thenGetInstancefor each node. This is because thenodepool=<poolID>tag is not applied to instances in Standard tier. - Required identity tags (pool tags + nodeclaim tag) are applied directly to the instance at claim time via
UpdateInstance.
Concurrency model
- Assume a single active writer (leader election).
- Use an in-process keyed mutex keyed by
(karpenterNodePoolName, instanceType). - The mutex protects:
- pool lookup/create
- pool
countmutations - candidate selection and instance claiming (
karpenter.sh/nodeclaim:<name>) - node-level delete + orphan GC mutations for that pool key
High-level Create() flow
Inputs:
karpenterNodePoolName := nodeClaim.labels[karpenter.sh/nodepool]instanceType := resolved single instance typepoolKey := (karpenterNodePoolName, instanceType)
Step 1: Fast idempotency
- Query all instance pages for
utils.NodeClaimTag(nodeClaim.Name)(karpenter.sh/nodeclaim=<normalizedName>). - If one or more instances match, return the first instance in the API response.
- If multiple instances match, log the ownership tag, match count, and selected instance ID. Do not request more capacity.
- After the NodeClaim records the selected provider ID, instance garbage collection can remove the other workers. Ownership tags do not prevent this cleanup.
Step 2: Claim-or-scale loop (bounded by DefaultCreateDeadline)
Repeat until deadline:
- Lock mutex for
poolKey. - Repeat the ownership lookup before pool discovery, candidate claiming, or scaling. This recovers a worker when an earlier ownership update succeeded but its response was lost.
- Return a matching worker immediately. If the lookup fails, do not treat the failure as missing capacity.
- Find or create the LKE pool for
(nodepoolName, instanceType). - Determine claimable instances:
- Standard: pool membership from
GetLKENodePool(poolID).Linodes.- Iterate all nodes; for each with
InstanceID != 0, callGetInstanceto check tags. - If any node has
InstanceID == 0, returnErrNodesProvisioningafter checking all nodes (allows claiming already-ready nodes first).InstanceID == 0indicates that the instance is being created but not yet ready and should be retried.
- Iterate all nodes; for each with
- Enterprise: pool membership from Instance API filtered by:
nodepool=<poolID>(Linode auto-tag),lke<clusterID>(Linode auto-tag),karpenter.k8s.linode/lke-managed:true.- Then client-side filter by
instanceType.
- Standard: pool membership from
- Claimability gate: only consider instances that do not already have any
karpenter.sh/nodeclaim:*tag. - If a claimable instance exists:
- Write
karpenter.sh/nodeclaim:<nodeClaimName>to the instance. - (Standard) also write required identity tags to the instance.
- Unlock mutex and return providerID.
- Write
- If no claimable instance exists:
- scale the pool by incrementing
pool.Countonly if thisCreate()call has not already created or scaled a pool.
- scale the pool by incrementing
- Unlock.
- Retry until deadline with brief sleeps:
- Preserve the successful create/scale flags across attempts, so delayed instance discovery does not request extra nodes.
- Sleep for the configured retry delay (default: 2 seconds).
On timeout: return a retryable CreateError (reason NodePoolProvisioning) so Karpenter requeues.
Enterprise Create() sequence diagram
sequenceDiagram
autonumber
actor NC as NodeClaim Controller
participant LKE as LKE Provider
participant INST as Linode Instance API
participant POOL as LKE NodePool API
participant M as Keyed Mutex
NC->>LKE: Create(nodeClaim)
LKE->>INST: List all pages matching ownership tag
alt ownership found
LKE->>LKE: Select first instance and log duplicates
LKE-->>NC: Return selected worker
else ownership missing
loop until create deadline
LKE->>M: Lock(poolKey)
LKE->>INST: Repeat ownership lookup
alt ownership found
LKE->>LKE: Select first instance and log duplicates
LKE->>M: Unlock(poolKey)
LKE-->>NC: Return selected worker
else ownership missing
LKE->>POOL: Find or create pool
LKE->>INST: Discover unclaimed workers
alt unclaimed worker found
LKE->>INST: Apply ownership tag
LKE->>INST: Verify ownership tag
else no unclaimed worker
opt No acknowledged create or scale in this call
LKE->>POOL: Scale existing pool by one
end
end
LKE->>M: Unlock(poolKey)
Note over LKE: Return verified worker or retry with capacity flags intact
else ownership lookup failed
LKE->>M: Unlock(poolKey)
Note over LKE: Retry transient errors without capacity changes
end
end
end
Error policy
- Plan unavailable:
- Karpenter marks a plan unavailable in the cluster’s region only when LKE identifies that plan as unavailable during new pool creation.
- Retryable errors:
- If Karpenter does not find an assignable instance before
DefaultCreateDeadlineexpires, it returns an error. - Karpenter retries while it waits for Linode API data to become consistent.
- Karpenter retries API errors with HTTP status 429 or 5xx.
- If Karpenter does not find an assignable instance before
- Pool and instance matching:
- If more than one pool matches a node pool name and instance type, Karpenter uses the first pool.
- If more than one instance matches a NodeClaim, Karpenter selects the first instance in the API response and logs the duplicate ownership. It leaves the other instances for garbage collection.
- Karpenter does not check whether an instance has multiple NodeClaim tags.
Plan availability
- Region availability check:
- The offering provider marks an offering available only when
GET /regions/{region}/availabilityreports the plan as available. - The instance type controller refreshes this data every 5 minutes.
- A plan that sells out between refreshes stays selectable until the next refresh.
- The offering provider marks an offering available only when
- Scale-up errors:
- Karpenter does not treat an HTTP 400 on pool scale-up as insufficient capacity. These errors also come from validation, networking, and request limits, and the response does not show which cause applies.
- Karpenter reports the error as a creation error. This error does not trigger the NodeClaim deletion that an insufficient capacity error causes.
- Plan unavailable on pool creation:
- If the LKE API rejects a new pool because the plan is unavailable in the region, Karpenter returns an insufficient capacity error. Karpenter then deletes the NodeClaim and selects another offering.
- Karpenter logs
pool create rejected with an unrecognized 400when a pool creation 400 does not match this error.
API call volume and scalability concerns
This section calls out Linode API call volume by function, the key multipliers (N+1, polling, and controller requeues), and why this design can be rate-limit bound under concurrency.
Relevant rate limits and client behavior (external)
- Linode API rate limits (per-user / token):
- GET operations returning paginated collections: 200 requests / minute (default).
- All other operations: 1600 requests / minute (default).
- Create a Linode: 20 requests / 15 seconds (specific override).
- Source: https://techdocs.akamai.com/linode-api/reference/rate-limits
linodegoretry behavior:- The Go SDK is configured to retry and to sleep based on the
Retry-Afterheader when provided. - Reference implementation: https://github.com/linode/linodego/blob/main/retries.go
- The Go SDK is configured to retry and to sleep based on the
Per-function API call “budgets” (current behavior)
Budgets below are per single invocation, excluding extra attempts from transient errors (429/5xx) and excluding Karpenter/controller requeues. Under contention, actual totals can be higher.
LKE provider (pkg/providers/lke/lke.go)
Create()- Fast idempotency (Step 1):
ListInstancesviautils.LookupInstanceByTag(pkg/utils/utils.go) forkarpenter.sh/nodeclaim:<nodeClaimName>.
- Claim-or-scale loop (Step 2, bounded by
DefaultCreateDeadline):- Ownership recovery:
ListInstancesviaLookupInstanceByTagreads all matching pages at the start of each locked attempt. A match returns before pool or worker mutations. - Pool discovery:
ListLKENodePoolsinfindOrCreatePool().
- Standard-tier claim path:
GetLKENodePoolinfindClaimableInstanceStandard().- N×
GetInstance(N+1 pattern) while iteratingpool.Linodesto find a claimable instance. UpdateInstanceinclaimInstance()to writekarpenter.sh/nodeclaim:<name>(+ identity tags).- Read-after-write:
verifyTagsApplied()pollsGetInstance(up to ~5 reads over ~5s).
- Enterprise-tier claim path:
ListInstancesinfindClaimableInstanceEnterprise()filtered bynodepool=<poolID>,lke<clusterID>,karpenter.k8s.linode/lke-managed:true, then client-side filter byinstanceType.UpdateInstanceinclaimInstance()to writekarpenter.sh/nodeclaim:<name>(+ pool tags).verifyTagsApplied()pollsGetInstance(up to ~5 reads over ~5s).
- Scale path:
UpdateLKENodePoolwhen no claimable instance exists.
- Ownership recovery:
- Multipliers to watch:
- Standard tier is inherently O(pool size) for the
GetInstancescan. - The loop repeats within the deadline, so the above can repeat multiple times per
Create()call.
- Standard tier is inherently O(pool size) for the
- Fast idempotency (Step 1):
Get()- Cache hit: 0 Linode API calls.
- Cache miss:
GetInstance
List()ListInstances(managed tag filtered; Enterprise also filters bylke<clusterID>).
Delete()ListLKENodePoolsviafindLKENodePoolFromLinodeInstanceID()(scans all pools to find instance).- Either
DeleteLKENodePoolNode(common) orDeleteLKENodePool(when last node).
CreateTags()GetInstance(merge/dedupe)UpdateInstance
Instance provider (pkg/providers/instance/instance.go)
Create()→CreateInstanceGet()→ cache hit 0 / missGetInstanceList()→ListInstancesDelete()→GetInstance(forced viaSkipCache) + optionallyDeleteInstanceCreateTags()→CreateTagonce per tag (+time.Sleep(1s)between tags)
Instance types / offerings
File: pkg/providers/instancetype/offering/offering.go
createOfferings()can callGetTypeinside a loop over regions (i.e., instanceTypes × regions bursts on cache miss/invalidation).
Cross-layer amplifiers (where “one Create()” becomes many calls)
- CloudProvider orchestration (
pkg/cloudprovider/cloudprovider.go)CloudProvider.Create()callsinstanceTypeProvider.List()and thennodeProvider.Create().CloudProvider.List()callsnodeProvider.List()and then resolves instance types and node class.
- Tagging controller forces freshness (
pkg/controllers/nodeclaim/tagging/controller.go)- Uses
nodeProvider.Get(..., instance.SkipCache), which bypasses provider caches and increases read pressure.
- Uses
- Garbage collection controller (
pkg/controllers/nodeclaim/garbagecollection/controller.go)- Calls
cloudProvider.List()and can fan out deletes (up to 100 workers), which can create bursts ofGet()/Delete()API calls.
- Calls
Why this may not scale
- Key
Create()paths rely on list-then-filter, per-nodeGetInstancescans, and read-after-write polling to implement determinism and avoid double-claim. - Under concurrency (many NodeClaims) and retries (requeues on retryable errors), these patterns can drive call rates into the GET paginated collection limit (200/min) quickly, especially when
ListInstances/ListLKENodePoolsare in tight loops. - Without additional server-side API surfaces (e.g., an atomic “claim next unclaimed node in pool” or a pool membership endpoint that includes claim state) the design remains fundamentally rate-limit/latency bound.
Delete + orphan capacity (summary)
- Delete must be node-level (delete node from pool), deleting the pool only when removing the last node.
- Orphan capacity can occur if scaling succeeds but claiming fails; orphan GC will remove unclaimed capacity after a fixed TTL (5m).
Open questions for review
- Is
DefaultCreateDeadlinea reasonable upper bound inCreate()before returning a retryable error? - Are the invariants and “hard error” cases correct, or should any be relaxed/strengthened?
- Is the orphan TTL (5m) the right tradeoff for cost vs safety?