Architecture
Why SSH is the transport at all — and why an agent on each host was not chosen — is recorded in ADR 0001.
One binary, one Deployment. Karpenter core (provisioner, disruption,
termination, state) is compiled in; the provider contributes the
CloudProvider implementation plus a small set of controllers around the host
inventory.
flowchart TB
subgraph cluster["workload cluster"]
direction TB
subgraph controller["karpenter-provider-ssh (one Pod)"]
direction TB
core["karpenter core v1.14<br>provisioner · disruption · termination · state"]:::dim
cp["CloudProvider (kpssh)<br>Create / Delete / Get / List<br>GetInstanceTypes · RepairPolicies"]:::prov
hp["host provider<br>claim (CAS) · release · probe"]:::prov
bp["bootstrap provider<br>token mint · cluster-info"]:::prov
ip["instance provider<br>join orchestration · adopt providerID"]:::prov
core --> cp --> ip
ip --> hp
ip --> bp
end
api["API server"]:::dim
crds["SSHHost · SSHNodeClass · SSHJoinProfile<br>NodePool · NodeClaim"]:::dim
controller <--> api
api --- crds
end
subgraph pool["host pool (pre-existing, SSH-reachable)"]
h1["host-a<br>class: big"]:::pay
h2["host-b<br>class: small"]:::pool
h3["host-c<br>class: gpu"]:::pool
end
ip -->|"ssh: sudo bash -s<br>install / join / leave"| pool
classDef pool fill:#e3efec,stroke:#1d6e5e,color:#1c2422
classDef pay fill:#f6ead9,stroke:#b35a00,color:#1c2422
classDef prov fill:#e7ecf9,stroke:#274bb0,color:#1c2422
classDef dim fill:#f6f8f6,stroke:#5c6b66,color:#1c2422
Components
| package | role |
|---|---|
pkg/cloudprovider |
karpenter CloudProvider interface: NodeClaim → instance mapping, instance types from host classes, ownership gate for coexistence |
pkg/providers/host |
SSHHost inventory: list by class/selector, claim via CAS on status.claimRef, release, providerID bookkeeping |
pkg/providers/instance |
the lifecycle engine: pick cheapest fitting class, claim, run install/join, adopt providerID, leave + release on delete |
pkg/providers/instancetype |
one instance type per host class — real capacity, offering priced at vCPUs × pricePerCPUHour |
pkg/providers/bootstrap |
bootstrap token minting (cluster-bootstrap, group system:bootstrappers:kpssh, TTL 1 h), token deletion by id + endpoint/CA discovery (kube-public/cluster-info or NodeClass override) |
pkg/controllers |
hostprobe (SSH health probe, capacity/arch observation, stale-claim release, spent-token collection, zombie guard) · nodeclass (readiness conditions) |
pkg/metrics |
the SSH-facing metric set (probe latency, phase durations, pool gauge, zombie actions) |
internal/profile |
Go-template rendering of profile scripts + KPSSH_*/KPSSH_SECRET_* env assembly, secret redaction, profile validation |
internal/sshexec |
SSH transport: TOFU host-key pinning, env preamble injection, sudo bash -s |
Scale-up sequence
sequenceDiagram
participant K as karpenter core
participant P as provider
participant H as host (pool)
participant A as API server
Note over K: pending pods → cheapest fitting class
K->>P: Create(NodeClaim)
P->>P: claim SSHHost (CAS on status.claimRef)
opt first claim of this host (cold)
P->>H: install (cached via status.installedProfile)
end
P->>A: mint bootstrap token (TTL 1h)
P->>A: record status.bootstrapTokenID
P->>H: join (KPSSH_* env)
H->>A: kubelet TLS bootstrap / nodeadm init|start
A-->>P: Node appears
alt providerIDSource: Adopt
P->>A: match Node by InternalIP, adopt its providerID
end
P-->>K: NodeClaim launched (providerID, capacity)
K->>A: Node registered + initialized → pods schedule
The claim is a compare-and-swap on SSHHost.status.claimRef: whoever
writes their NodeClaim reference first wins; the loser's update fails on
resourceVersion and moves to the next host. One host can never serve two
NodeClaims. Create is resumable — if the controller restarts mid-join, the
existing claim is found via ByClaim and the join continues (scripts are
idempotent by contract).
sequenceDiagram
participant A as Create(NodeClaim A)
participant H as SSHHost host-1
participant B as Create(NodeClaim B)
A->>H: read → Available, rv=7
B->>H: read → Available, rv=7
Note over A,B: both saw a free host — the cache cannot prevent this
A->>H: Status().Update(claimRef=A, if rv=7)
H-->>A: 200 · rv=8 — A owns host-1
B->>H: Status().Update(claimRef=B, if rv=7)
H-->>B: 409 Conflict — stale resourceVersion
Note over B: loser does NOT retry this host<br>it moves on to the next candidate
The apiserver, not the controller, is the arbiter: the optimistic lock turns a stale cache read into a failure, never a silent overwrite. That is why a read-then-write over a possibly-stale informer cache is safe here — it fails closed.
Every other host-status write is a CAS too (optimistic lock on resourceVersion), not a last-write-wins merge: the claim path, the release path and the probe controller all drive the same small state machine, and a merge could resurrect a state another actor just left. Claim and release surface a lost race to the caller, who re-resolves from scratch; the narrow field setters (providerID, install marker, token id) retry on a fresh read — but only while the claim they were issued under still holds, so a host that changed hands still fails the write.
The bootstrap token is minted after install and recorded on the host
(status.bootstrapTokenID) before join runs, so it stays deletable across a
controller crash. The probe controller deletes its Secret as soon as the node
registers — see security.md.
Scale-down sequence
sequenceDiagram
participant K as karpenter core
participant P as provider
participant H as host (joined)
participant A as API server
Note over K: consolidation: node empty / underutilized
K->>A: taint + drain node
K->>P: Delete(NodeClaim)
P->>H: leave (stop kubelet, keep binaries + registration)
P->>P: clear status.providerID, release claim
P-->>K: instance gone
K->>A: delete Node object
Note over H: warm in pool — billing stopped
Ordering matters on EKS: kubelet stops before the Node object goes away, otherwise kubelet re-creates it (observed live — see concepts.md).
Delete is fenced by NodeClaim identity, not by providerID alone. A static
providerID is host-scoped (kpssh://<ns>/<host> never changes) and karpenter
core retries Delete until the provider reports the instance gone — so a Delete
for a long-dead NodeClaim can still resolve a host that a successor claim has
taken. The provider compares the host's claimRef (name and UID) against
the NodeClaim it was handed, and answers NodeClaimNotFound when they differ,
instead of running leave against somebody else's freshly joined node.
Why that is not paranoia — the sequence the fence exists for:
sequenceDiagram
autonumber
participant A as NodeClaim A (dying)
participant K as karpenter core
participant P as provider
participant H as SSHHost host-1
participant B as NodeClaim B (new)
A->>P: Delete(A) — first attempt fails (host unreachable)
Note over K: core retries Delete until the instance reports gone
P->>H: release: claimRef = nil
B->>P: Create(B) → claims host-1
P->>H: claimRef = {name: B, uid: <B>}
Note over H: host-1 is now B's, and B's node is Ready
K->>P: Delete(A) — retry
P->>H: resolve kpssh://<ns>/host-1 → host-1
Note over P,H: same host: the providerID is host-scoped,<br>so it cannot tell A's claim from B's
P->>P: ClaimHeldBy(host-1, A)? name/UID mismatch
P-->>K: NodeClaimNotFound — B's node untouched
Without the name+UID check, that last retry would run leave against a healthy
node that a different NodeClaim owns: kubelet stopped, workloads stranded, and
karpenter told the delete succeeded.
Host state machine
stateDiagram-v2
[*] --> Pending
Pending --> Available: probe OK
Available --> Claimed: NodeClaim CAS
Claimed --> InUse: Node Ready
InUse --> Available: leave OK (warm)
InUse --> Unhealthy: leave failed
Claimed --> Unhealthy: join failed terminally
Claimed --> Pending: stale claim released
InUse --> Pending: stale claim released
Available --> Unhealthy: probe fails
Unhealthy --> Available: probe recovers
Available --> Leaving: zombie guard CAS
Leaving --> Available: zombie leave OK
Leaving --> Unhealthy: zombie leave failed
Available --> Maintenance: annotation set
Maintenance --> Pending: annotation removed
Maintenance and a released stale claim both land on Pending, not
Available — nothing is claimable until a probe has actually reached it again.
Maintenance and Leaving are entered from any unclaimed state, not only
from Available; the diagram draws the common edge.
The probe controller owns Available ⇄ Unhealthy (SSH dial + host-key check +
capacity observation) and also releases claims whose NodeClaim no longer
exists. It doubles as the zombie guard: an active kubelet on an unclaimed
host that backs a Node of this cluster triggers a forced leave and Node
deletion — the reboot-rejoin failure mode. The guard first CASes the host into
Leaving, which fences it against a racing claim while the leave runs.
Maintenance is an operator toggle via the
karpenter.dklesev.github.io/maintenance annotation.
Failure model
| failure | handling |
|---|---|
| SSH unreachable | host probes Unhealthy, never claimed; karpenter picks another class/host or reports unschedulable |
| join script fails on the host | claim released, host Unhealthy, NodeClaim fails → karpenter retries elsewhere |
| profile/nodeclass misconfigured (missing signature, bad secret, template in a verified script) | claim released, host back to Available — the host was never touched; the error surfaces on the NodeClaim |
| controller dies mid-join | new leader resumes via existing claim (idempotent scripts) |
| NodeClaim force-deleted | probe controller notices dangling claim, releases host |
| node NotReady 10 min | RepairPolicies → karpenter force-terminates the NodeClaim → normal leave path |
| host powered off mid-use | leave fails; host stays Unhealthy until probe recovers; Node object still deleted by karpenter |
| warm host reboots and rejoins | profiles prevent it (leave disables kubelet + containerd); if a kubelet still shows up on an unclaimed host, the probe's zombie guard re-runs leave, then deletes the zombie Node (kubelet first, or it re-creates the Node) |
| kubelet active on unclaimed host, and no Node matches its IP, and no install marker | host is parked Unhealthy with an explanatory probe error — possibly a foreign cluster's member; the provider never destroys what it cannot attribute |
One placement note: the controller needs SSH reach to the pool hosts, and it
deliberately refuses to run on nodes it manages itself — the chart's default
affinity requires karpenter.dklesev.github.io/managed to not exist on the
node it lands on. Consolidation must never evict its own controller.
That label is not written by the controller. Nothing in the Go code sets it:
it comes from the NodePool template
(spec.template.metadata.labels, see examples/nodepool.yaml) and karpenter
core syncs it onto the Node at registration. Author a NodePool without it and
the anti-affinity matches nothing — the controller becomes schedulable onto a
pool node, and the first consolidation of that node evicts the very controller
that would have to drain it. Carry the label on every NodePool that references
an SSHNodeClass.