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.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.
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 instances by tag
karpenter.sh/nodeclaim:<nodeClaimName>. - If exactly one instance exists, return it (covers status update failure after instance tag write).
Step 2: Claim-or-scale loop (bounded by DefaultCreateDeadline)
Repeat until deadline:
- Lock mutex for
poolKey. - 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.Count.
- scale the pool by incrementing
- Unlock.
- Retry until deadline with brief sleeps:
- 500ms after
ErrNodesProvisioning(waiting for instance IDs). - 200ms after scaling or when waiting for newly created pool/instance.
- 500ms after
On timeout: return a retryable CreateError (reason NodePoolProvisioning) so Karpenter requeues.
Enterprise Create() sequence diagram
sequenceDiagram
autonumber
actor NC as NodeClaim Controller
participant CP as CloudProvider.Create()
participant LKE as LKE Provider
participant POOL as LKE NodePool API
participant INST as Linode Instance API
participant M as Keyed Mutex (karpenterNodePool+type)
NC->>CP: Create(nodeClaim)
CP->>LKE: Create(nodeClaim)
%% Step 1: Fast idempotency
LKE->>INST: ListInstances(tag=karpenter.sh/nodeclaim:<nc>)
alt exactly one instance found
INST-->>LKE: instance
LKE-->>CP: return instance (with providerID)
CP-->>NC: return nodeClaim with providerID set
else No instances found with tag karpenter.sh/nodeclaim:<nc>
loop until deadline (`DefaultCreateDeadline`)
critical pool-key mutex
LKE->>M: Lock(poolKey)
LKE->>POOL: ListPools(cluster)
LKE->>LKE: filter by nodePool+type+managed
alt no pool found
LKE->>POOL: CreatePool(count=1, pool tags)
LKE->>INST: ListInstances(nodepool=<poolID>, lke<clusterID>, managed=true)
LKE->>LKE: filter by instanceType and WITHOUT karpenter.sh/nodeclaim:* tag
alt claimable instance found
LKE->>INST: UpdateInstance(tags += karpenter.sh/nodeclaim:<nc>)
LKE->>INST: GetInstance (verify tags applied, poll up to 5s every 1s)
LKE->>M: Unlock(poolKey)
LKE-->>CP: return instance (with providerID)
CP-->>NC: return nodeClaim with providerID set
else none claimable
LKE->>M: Unlock(poolKey)
Note over LKE: sleep 200ms, retry
end
else pool exists
LKE->>INST: ListInstances(nodepool=<poolID>, lke<clusterID>, managed=true)
LKE->>LKE: filter by instanceType and WITHOUT karpenter.sh/nodeclaim:* tag
alt claimable instance found
LKE->>INST: UpdateInstance(tags += karpenter.sh/nodeclaim:<nc>)
LKE->>INST: GetInstance (verify tags applied, poll up to 5s every 1s)
LKE->>M: Unlock(poolKey)
LKE-->>CP: return instance (with providerID)
CP-->>NC: return nodeClaim with providerID set
else none claimable
LKE->>POOL: UpdatePool(count+1)
LKE->>M: Unlock(poolKey)
Note over LKE: sleep 200ms, retry
end
end
end
end
LKE-->>CP: CreateError(NodePoolProvisioning) on timeout
CP-->>NC: CreateError(NodePoolProvisioning) on timeout
end
Error policy (review focus)
- Retryable (
CreateError/NodePoolProvisioning):- timeouts waiting for claimable instance (bounded by
DefaultCreateDeadline) - eventual consistency delays
- retryable API errors (429/5xx)
- timeouts waiting for claimable instance (bounded by
- Invariant violations (design assumptions, not currently enforced as hard errors; each includes an explicit action item):
-
1 pool matches
(karpenterNodePoolName, instanceType)— code returns first match -
1 instance matches
karpenter.sh/nodeclaim:<nodeClaimName>— code returns first match - any instance has multiple
karpenter.sh/nodeclaim:*tags — not validated; Action: add validation in the LKE provider to detect this case and surface it as a hard error (tracked in the team’s issue tracker as a follow-up task).
-
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):- 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.
- Pool discovery:
- 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?