KubeVirt: Running VMs on Kubernetes — and Why That Still Matters

Reading Time: 6 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 10 (Series Finale)
← EP09: Karpenter vs Cluster Autoscaler · EP10: KubeVirt · All Kubernetes Ecosystem Episodes →

11 min read


TL;DR

  • KubeVirt runs full virtual machines as first-class Kubernetes objects — a VirtualMachineInstance wraps a real QEMU/KVM VM inside a special pod, scheduled and managed the same way any other workload is
  • The real reason to run KubeVirt isn’t nostalgia for VMs — it’s giving legacy, non-containerizable, or Windows workloads the same control plane, networking, and operational tooling as everything else, instead of maintaining a separate VM infrastructure stack alongside Kubernetes
  • Multus (multiple network interfaces per pod) and CDI (Containerized Data Importer, for getting disk images into the cluster) are the supporting pieces that make VM networking and storage work inside a Kubernetes-native model
  • Live migration — moving a running VM to another node with no downtime — is KubeVirt’s answer to node maintenance, and it’s meaningfully different from how Kubernetes handles pod disruption
  • Nodes running VM workloads need actual hardware virtualization support (KVM) — the same nested-virtualization requirement that constrained Minikube’s VM drivers back in EP02, now showing up at the production node level
  • Contribution opportunity: GPU and device-plugin support for VM workloads specifically lags behind the maturity of the container device-plugin ecosystem — a real, scoped gap

The Big Picture

CONTAINER POD                                VM VIA KUBEVIRT
──────────────                                ───────────────
Pod                                          VirtualMachineInstance
  └── container                                └── virt-launcher Pod
        runs directly on the                        └── QEMU/KVM process
        node's kernel via                                running a REAL VM,
        containerd/runc                                    with its own kernel
                                                             (Linux, Windows,
Scheduling, networking,                                     anything QEMU
resource limits — standard                                  supports)
Kubernetes primitives
                                              Scheduling, networking, resource
                                              limits — SAME standard Kubernetes
                                              primitives, wrapping a VM instead

What is KubeVirt? It’s the project that makes a virtual machine look like just another workload to Kubernetes — the VM runs inside a virt-launcher pod, so everything Kubernetes already does for scheduling, networking policy, and resource accounting applies to the VM too, without Kubernetes itself needing to understand virtualization at all.


The Core Abstraction: VirtualMachine, VirtualMachineInstance, and virt-launcher

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: legacy-app-vm
spec:
  running: true
  template:
    spec:
      domain:
        cpu: {cores: 2}
        memory: {guest: 4Gi}
        devices:
          disks:
          - name: rootdisk
            disk: {bus: virtio}
      volumes:
      - name: rootdisk
        dataVolume: {name: legacy-app-disk}
$ kubectl get vmi
NAME            AGE   PHASE     IP            NODENAME
legacy-app-vm   2m    Running   10.244.1.15   worker-03

$ kubectl get pods
NAME                                READY   STATUS
virt-launcher-legacy-app-vm-x7k2l   2/2     Running
#     ^^^^^^^^^^^^ — this IS the pod hosting the real QEMU process

VirtualMachine is the persistent declaration (should this VM exist, running or stopped); VirtualMachineInstance is the actual running instance; virt-launcher is the pod — an ordinary Kubernetes pod from the scheduler’s point of view — that contains the QEMU process actually executing the VM. This layering mirrors Deployment/ReplicaSet/Pod deliberately, the same pattern CAPI (EP05) uses for Cluster/Machine.


Why Run VMs on Kubernetes At All

The honest case for KubeVirt isn’t “VMs are better than containers” — it’s consolidation. Most real infrastructure estates have some workloads that genuinely can’t be containerized cleanly: legacy applications tightly coupled to a specific OS version, Windows-only software, or workloads with licensing/compliance requirements demanding a VM-level boundary. Without KubeVirt, those workloads need an entirely separate VM infrastructure stack — its own hypervisor management, its own networking, its own monitoring — running alongside Kubernetes rather than on it. KubeVirt collapses that into one control plane, one set of operational tooling, one team’s expertise, for both categories of workload.


Networking and Storage for VMs: Multus, DataVolumes, CDI

# Multus lets a VM's pod attach to more than one network —
# often needed for VMs expecting a dedicated network interface,
# unlike a typical single-network container workload
$ kubectl get network-attachment-definitions

# CDI (Containerized Data Importer) gets an existing VM disk image
# into the cluster as a DataVolume
$ kubectl apply -f - <<EOF
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
  name: legacy-app-disk
spec:
  source:
    http:
      url: "https://images.example.com/legacy-app.qcow2"
  pvc:
    accessModes: ["ReadWriteOnce"]
    resources:
      requests: {storage: 20Gi}
EOF

Regular container images assume a filesystem layer model that doesn’t map cleanly onto “import this existing 20GB qcow2 disk image a legacy app already runs from.” CDI exists specifically to bridge that gap — pulling real VM disk images from HTTP, S3, or a registry into a DataVolume a VirtualMachine can boot from.


Live Migration: KubeVirt’s Answer to Node Maintenance

$ virtctl migrate legacy-app-vm
VM legacy-app-vm was scheduled to migrate

$ kubectl get vmim
NAME                    PHASE       VMI
legacy-app-vm-migrate   Running     legacy-app-vm

# The VM keeps running, on a different node, with no restart —
# genuinely different from how Kubernetes handles pod disruption

A pod being evicted for node maintenance simply gets rescheduled — it restarts fresh somewhere else. A live-migrated VM keeps running throughout, its in-memory state transferred to the new node without a restart, the same live-migration capability traditional hypervisor platforms have offered for years. This is one behavior category where “VM on Kubernetes” is genuinely doing something a plain pod cannot.


⚠ Production Gotchas

Nodes need real KVM support — nested virtualization if the node itself is already a VM (common on cloud instances). This is the same underlying requirement that constrained Minikube’s VM drivers back in EP02, now at the production node level: if your cloud instance type doesn’t expose nested virtualization, KubeVirt workloads simply won’t schedule there.

VM disk I/O performance depends heavily on the underlying storage class and driver (virtio vs alternatives) — don’t assume container-workload storage benchmarks transfer to VM workloads on the same cluster.

Live migration isn’t guaranteed to succeed for every VM under every condition — a VM under heavy memory-write load, or a migration across a congested network path, can fail or take far longer than expected. Test migration behavior under realistic load before depending on it for a real maintenance window.

Windows VMs specifically need the right virtio drivers pre-installed in the image, or networking/storage won’t work post-boot — this is a common first-VM stumbling block distinct from anything Linux-VM users hit.


Quick Reference

kubectl get vm                       # VirtualMachine declarations
kubectl get vmi                      # running VirtualMachineInstances
kubectl get vmim                     # migration status
virtctl start / stop / restart <vm>  # lifecycle control
virtctl console <vm>                 # serial console access
virtctl migrate <vm>                 # trigger a live migration
kubectl get datavolumes              # CDI-imported disk images

Contribution Opportunity: Device-Plugin Parity for VM Workloads

The limitation: Kubernetes’ device-plugin ecosystem for GPUs and other specialized hardware is mature and well-supported for container workloads. Extending that same hardware access cleanly into KubeVirt-managed VMs — GPU passthrough with the same operational ergonomics containers already have — is real, current work with meaningfully more rough edges than the container-side equivalent.

Why it’s hard to fix: Passing a physical device through to a VM (as opposed to a container, which can often share the host’s device more directly) involves IOMMU configuration, PCI passthrough mechanics, and driver considerations inside the guest OS that simply don’t exist for container device access — it’s a genuinely harder problem, not a lagging implementation of an equally-easy one.

What a contribution-shaped fix looks like: KubeVirt’s own documentation and issue tracker identify specific, scoped GPU/device-passthrough gaps — particular hardware combinations without a documented working configuration, or specific device-plugin integrations that work for containers but haven’t been extended to the VM path. Reproducing one of these documented gaps on real hardware you have access to, and contributing either the fix or a verified, tested configuration guide back to the project, is exactly the kind of practitioner-shaped contribution this series has pointed toward in every episode — you need access to the hardware and patience to reproduce, not novel research.


Key Takeaways

  • KubeVirt makes a VM look like an ordinary Kubernetes workload by wrapping it in a virt-launcher pod — the same scheduling, networking, and resource primitives apply to both
  • The real case for running VMs on Kubernetes is infrastructure consolidation — one control plane for both container and VM workloads, not VM enthusiasm for its own sake
  • Multus and CDI solve the networking and storage problems that don’t map cleanly from the container model onto VM disk images and multi-interface networking
  • Live migration is a genuine capability gap between VMs and plain pods — Kubernetes’ own disruption model for pods has no equivalent
  • The clearest current contribution opportunity is closing specific, documented device-passthrough gaps for VM workloads — hardware-access work, not abstract feature design

Series Wrap: From User to Contributor

Ten episodes, ten tools, and one repeated pattern: every project in this series exists because something else fell short — k3s and MicroK8s because upstream Kubernetes was too heavy for the edge, Karpenter because Cluster Autoscaler’s node-group abstraction had a ceiling, Crossplane because Terraform’s model didn’t fit a fully Kubernetes-native platform team. Each one of those gaps was found by someone who was using the previous tool closely enough to feel exactly where it stopped working.

That’s the actual path from user to contributor this series has been pointing at in every Contribution Opportunity section: not a certification, not a novel research breakthrough — close, careful attention to a tool you already run, precise enough to name what’s actually missing and specific enough to describe what fixing it would look like. Every gap named across these ten episodes is real, documented, and waiting for exactly that kind of attention.

Get future series and episodes in your inbox → linuxcent.com/subscribe

Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 9
← EP08: Karpenter · EP09: Karpenter vs Cluster Autoscaler · EP10: KubeVirt →

10 min read


TL;DR

  • Karpenter vs Cluster Autoscaler comes down to an architectural ceiling: Cluster Autoscaler has to work generically across every cloud’s own autoscaling-group abstraction, which caps how smart its instance-selection can ever be
  • Karpenter throws away the node-group abstraction and talks to the cloud’s instance-provisioning API directly — that’s the actual reason AWS built a new tool instead of extending Cluster Autoscaler
  • Karpenter provisions faster and consolidates more aggressively for cost savings; Cluster Autoscaler’s scale-down behavior is deliberately more conservative
  • Cluster Autoscaler remains the only mature option for several smaller cloud providers that don’t have a Karpenter provider implementation yet
  • Recommendation: use Karpenter on AWS (and increasingly GKE) if you want its cost and speed advantages; stay on Cluster Autoscaler if you need one consistent tool across multiple clouds or you’re on a cloud Karpenter doesn’t support yet
  • Contribution opportunity: building a Karpenter provider for a smaller, currently-unsupported cloud is real, meaningful, and directly helps teams stuck on Cluster Autoscaler’s more limited model purely for lack of an alternative

The Big Picture

CLUSTER AUTOSCALER                          KARPENTER
───────────────────                          ─────────
Generic abstraction: "a node group           Direct: "the cloud's actual
that can scale from N to M"                  instance-provisioning API"
        │                                            │
Has to work the same way whether             Talks to EC2's RunInstances API
it's an AWS ASG, a GCP MIG, or an            (or the equivalent) directly —
Azure VMSS — lowest common                   no generic abstraction ceiling
denominator by necessity                     to work around
        │                                            │
Instance type is WHATEVER the                Instance type is COMPUTED per
node group was pre-configured with           pending pod, from a flexible
                                              allowed range

Karpenter vs Cluster Autoscaler isn’t “new tool, old tool” — it’s a direct consequence of Cluster Autoscaler’s cross-cloud genericness being both its strength (works everywhere) and its ceiling (can never be smarter than the lowest common denominator of every cloud’s node-group abstraction).


Why AWS Built Karpenter Instead of Improving Cluster Autoscaler

Cluster Autoscaler was designed to work identically across clouds by scaling pre-existing node groups — ASGs on AWS, Managed Instance Groups on GCP, VM Scale Sets on Azure. That design constraint is exactly what limits it: it can only ever choose among the instance types and sizes someone already configured into a node group ahead of time, and it can only scale that group up or down as a unit.

AWS’s actual motivation for building Karpenter was to remove that ceiling entirely for their own cloud — by talking to EC2’s provisioning APIs directly, Karpenter can select from the full range of instance types AWS offers for every single provisioning decision, not just whatever a handful of pre-configured node groups happen to offer.


Provisioning Speed Compared

# Cluster Autoscaler: must first identify which existing node group to
# scale, then wait for that group's own scaling mechanism (an ASG launch,
# for instance) to complete
$ kubectl get events --field-selector reason=TriggeredScaleUp
# typically 1-3 minutes to a new node being schedulable

# Karpenter: computes the instance directly and calls the provisioning
# API without an intermediate node-group scaling step
$ kubectl get nodeclaims
# typically under a minute from pending pod to a schedulable node

The speed difference isn’t marginal at scale — for workloads with bursty, latency-sensitive scaling needs (batch job spikes, CI runner fleets), the extra minute or two Cluster Autoscaler’s node-group indirection adds is a real, felt difference, not a rounding error.


Cost Efficiency: Consolidation vs CA’s More Conservative Scale-Down

Karpenter’s consolidation behavior (EP08) actively looks for cheaper node configurations continuously, not just when pods are pending. Cluster Autoscaler’s scale-down logic is deliberately more conservative — it removes clearly-empty or clearly-underutilized nodes, but doesn’t proactively repack workloads onto fewer, better-fitting nodes the way Karpenter does by design. Teams migrating from Cluster Autoscaler to Karpenter commonly report meaningful compute cost reductions purely from this behavioral difference, independent of any instance-selection improvement.


Where Cluster Autoscaler Is Still the Right Choice

Multi-cloud consistency needs: if your platform runs on AWS, GCP, and Azure and you want one autoscaling tool behaving identically everywhere, Cluster Autoscaler’s cross-cloud design is a genuine advantage — Karpenter’s provider maturity still varies significantly by cloud.

Clouds without a mature Karpenter provider: several smaller cloud providers have no Karpenter implementation at all — Cluster Autoscaler, or that cloud’s own native autoscaler, remains the only real option.

Teams not hitting Cluster Autoscaler’s actual limits: if your workloads are stable, predictable, and your existing node groups already fit them well, Karpenter’s advantages may not be worth a migration — Cluster Autoscaler is mature, stable, and well-understood.


The Recommendation

On AWS specifically, and increasingly on GKE: default to Karpenter. The provisioning speed and consolidation cost savings are real and well-documented at this point, and this is where Karpenter’s provider maturity is strongest.

On any cloud without a mature Karpenter provider, or in a genuinely multi-cloud platform wanting one consistent tool: stay on Cluster Autoscaler. Don’t migrate for the sake of using the newer tool if your actual cloud or requirements don’t play to Karpenter’s strengths yet.

Don’t run both against the same node pool. Pick one scaler per cluster (or per clearly-separated node pool if you’re genuinely transitioning) — having both react to the same pending pods produces exactly the kind of conflicting-controller behavior you’d expect.


⚠ Production Gotchas

Migrating from Cluster Autoscaler to Karpenter mid-cluster requires careful sequencing, not a simultaneous cutover. Run them against separate, clearly labeled node pools during migration, and fully decommission Cluster Autoscaler’s management of a pool before letting Karpenter manage the same workloads.

Cluster Autoscaler’s node-group-based cost estimates and Karpenter’s per-instance cost awareness aren’t directly comparable without normalizing for what each is actually measuring. Don’t assume a raw percentage cost-savings figure from a vendor blog post transfers directly to your own workload mix.

Karpenter provider maturity genuinely varies by cloud — check the specific provider’s current feature list, not just “does Karpenter support my cloud” as a yes/no question.


Quick Reference

Cluster Autoscaler Karpenter
Abstraction Pre-defined node groups Direct instance provisioning
Cross-cloud consistency Strong (by design) Varies — provider maturity differs by cloud
Provisioning speed Slower (node-group indirection) Faster (direct API calls)
Cost optimization Conservative scale-down Active, continuous consolidation
Best fit Multi-cloud, stable workloads, unsupported clouds AWS/GKE, dynamic workloads, cost-sensitive fleets

Contribution Opportunity: A Karpenter Provider for an Unsupported Cloud

The limitation: Teams running on smaller cloud providers — several exist with real production Kubernetes usage but no Karpenter implementation — are stuck with Cluster Autoscaler’s node-group model purely because nobody has built the equivalent Karpenter provider for their cloud, not because Cluster Autoscaler is actually the better fit for their workload.

Why it’s hard to fix: Building a new cloud provider for Karpenter means implementing that cloud’s instance-provisioning API integration, its spot/preemptible-equivalent interruption handling, and its networking/subnet discovery model from scratch — a genuine, multi-week engineering effort with no existing template for that specific cloud, even though the AWS and GCP providers exist as architectural references.

What a contribution-shaped fix looks like: Karpenter’s core (kubernetes-sigs/karpenter) is explicitly designed to support multiple cloud providers as separate implementations of a defined interface — the AWS and GCP provider source code is the reference for what a new provider needs to implement. For an engineer who already runs production Kubernetes on an unsupported cloud, building a minimal provider — even one covering just basic on-demand instance provisioning, without full spot/consolidation parity at first — is a real, high-value, currently-missing contribution that directly serves other teams on that same cloud stuck with no alternative to Cluster Autoscaler.


Key Takeaways

  • Karpenter exists because Cluster Autoscaler’s cross-cloud node-group abstraction is a genuine architectural ceiling, not because Cluster Autoscaler was poorly built
  • Karpenter provisions faster and consolidates more aggressively for cost savings — real, measurable advantages on the clouds it supports well
  • Cluster Autoscaler remains the right choice for genuine multi-cloud consistency needs and for clouds without a mature Karpenter provider
  • Never run both scalers against the same node pool simultaneously
  • Building a Karpenter provider for a currently-unsupported cloud is a real, high-value contribution with existing architectural references (AWS, GCP) to learn from

What’s Next

Every tool so far in this series has assumed container workloads. EP10 closes the series with KubeVirt — running actual virtual machines as first-class citizens alongside pods on the same cluster, and why that migration path still matters in a container-first world.

Next: EP10 — KubeVirt: Running VMs on Kubernetes — and Why That Still Matters

Get EP10 in your inbox when it publishes → linuxcent.com/subscribe

Karpenter: Just-in-Time Node Provisioning for Kubernetes

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 8
← EP07: Crossplane vs Terraform · EP08: Karpenter · EP09: Karpenter vs Cluster Autoscaler →

11 min read


TL;DR

  • Karpenter node provisioning means no pre-defined node groups at all — it looks at pending pods’ actual resource requests and provisions the specific instance type and size that fits, directly
  • NodePool and NodeClass are Karpenter’s two core CRDs: NodePool declares provisioning constraints and instance-type flexibility, NodeClass declares the cloud-specific details (AMI, subnets, security groups)
  • Consolidation is Karpenter’s continuous bin-packing behavior — it doesn’t just scale up when pods are pending, it actively replaces underutilized nodes with better-fitting ones to reduce cost
  • Karpenter handles spot interruption notices natively, draining gracefully before the two-minute warning expires, rather than relying on a separate spot-handling daemon
  • Originally AWS-only, Karpenter has been donated to Kubernetes SIGs specifically to become a cross-cloud project — provider parity for GKE, AKS, and others is real, current, in-progress work
  • Contribution opportunity: non-AWS provider feature parity is an explicitly open area with active upstream tracking — a genuinely current place to contribute

The Big Picture

CLUSTER AUTOSCALER MODEL                    KARPENTER MODEL
─────────────────────────                    ────────────────
Pre-defined node groups                      No node groups
(ASG A: m5.large, ASG B: m5.xlarge, ...)     Pending pod: needs 2 vCPU, 4Gi
        │                                            │
Pod pending, no capacity                     Karpenter evaluates: cheapest
        │                                    instance type that actually
Scale UP the node group                      fits, from a flexible list —
that (roughly) fits                          could be any instance family
        │                                    allowed by the NodePool
New node joins — may be                              │
oversized or undersized                      Provisions exactly that instance
for the actual pod                           — right-sized to the real
                                              pending workload

Karpenter node provisioning removes the middle abstraction layer entirely — instead of scaling a pre-sized group and hoping the group’s instance type roughly matches what’s pending, it computes the actual best-fit instance for the actual pending pods, every time.


NodePool and NodeClass: Karpenter’s Core CRDs

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      requirements:
      - key: karpenter.k8s.aws/instance-category
        operator: In
        values: ["c", "m", "r"]      # flexible across instance families
      - key: karpenter.k8s.aws/instance-generation
        operator: Gt
        values: ["4"]
      nodeClassRef:
        name: default
  disruption:
    consolidationPolicy: WhenUnderutilized
    expireAfter: 720h
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2023
  subnetSelectorTerms:
  - tags: {karpenter.sh/discovery: my-cluster}
  securityGroupSelectorTerms:
  - tags: {karpenter.sh/discovery: my-cluster}

NodePool says “here’s the range of instance types you’re allowed to choose from, and here’s the disruption policy” — it’s about scheduling flexibility. EC2NodeClass (or the equivalent for other providers) says “here’s the actual cloud-specific detail” — AMI, subnets, security groups. Splitting these two concerns is deliberate: a platform team can offer multiple NodePools with different cost/performance trade-offs, all referencing the same underlying NodeClass.


How Karpenter Actually Picks an Instance Type

$ kubectl get nodeclaims
NAME            TYPE          ZONE         NODE               READY   AGE
general-x7k2l   c6a.xlarge    us-east-1a   ip-10-0-1-42...    True    45s

$ kubectl describe nodeclaim general-x7k2l
...
Events:
  Reason              Message
  ------              -------
  Launched            Launched instance: i-0abc123... c6a.xlarge
  #                    ^^^^^^^^^^ — chosen because it was the cheapest
  #                    instance type in the allowed range that fit
  #                    the pending pods' actual CPU/memory requests

A NodeClaim is the record of one provisioning decision — it shows exactly which instance type Karpenter chose and why, unlike a node-group scale-up event, which just tells you the group’s already-fixed instance type was used again regardless of fit.


Consolidation: Karpenter’s Continuous Bin-Packing

# Karpenter continuously evaluates whether existing nodes could be
# consolidated into fewer, better-utilized nodes
$ kubectl get nodeclaims -o wide
NAME            TYPE         CPU-UTIL   MEM-UTIL
node-a          m5.2xlarge   15%        20%
node-b          m5.2xlarge   18%        22%
#                                             both underutilized — Karpenter
#                                             may consolidate these two onto
#                                             a single, smaller instance

This is the behavior that most differentiates Karpenter from a traditional autoscaler: it doesn’t just react to pending pods by scaling up. It continuously looks for opportunities to replace a set of underutilized nodes with fewer, better-fitting ones — actively working to reduce cost, not just meet demand.


Interruption Handling: Spot Instances Done Right

# Karpenter watches for AWS's spot interruption notice natively
$ kubectl get events --field-selector reason=DisruptionTerminating
LAST SEEN   REASON                  MESSAGE
5s          DisruptionTerminating   Node terminating due to spot interruption,
                                     draining pods gracefully before 2-minute deadline

Before Karpenter, handling spot interruptions gracefully typically meant running a separate tool (like AWS Node Termination Handler) alongside your autoscaler. Karpenter builds this in directly — it’s part of the same controller making the original provisioning decision, not a bolted-on separate system watching for the same signal independently.


⚠ Production Gotchas

Aggressive consolidation without a properly configured PodDisruptionBudget can cause more pod churn than teams expect. Karpenter respects PDBs, but if you haven’t set them, consolidation can evict pods more freely than a team used to Cluster Autoscaler’s more conservative default behavior anticipated.

A misconfigured NodeClass (wrong subnet tags, wrong security group selector) fails silently from the scheduler’s point of view — pods just stay pending, and the actual error is in Karpenter’s controller logs or NodeClaim events, not anywhere the standard kubectl get pods workflow surfaces by default.

Karpenter’s own controller needs real resource requests and, ideally, its own dedicated nodes or a stable node pool — running the thing that provisions your nodes on a node that might itself get consolidated away is a bootstrapping problem worth designing around explicitly.


Quick Reference

kubectl get nodepools                       # provisioning policies defined
kubectl get nodeclasses                     # cloud-specific node configuration
kubectl get nodeclaims                      # individual provisioning decisions
kubectl describe nodeclaim <name>            # why this specific instance was chosen
kubectl get events --field-selector reason=DisruptionTerminating   # interruption/consolidation activity

Contribution Opportunity: Closing Non-AWS Provider Feature Parity

The limitation: Karpenter started as an AWS-specific project and has since been donated to Kubernetes SIGs specifically to become a genuinely cross-cloud tool. The GKE provider and others are real and actively developed, but feature parity with the mature AWS provider — specific instance-selection heuristics, certain disruption/consolidation behaviors, provider-specific NodeClass capabilities — isn’t complete yet, and this is openly tracked, not hidden.

Why it’s hard to fix: Each cloud’s instance-provisioning API, spot-interruption signaling mechanism, and networking model differs meaningfully — replicating AWS provider behavior on GCP or Azure isn’t a port, it’s a re-implementation against a different API with different constraints and different edge cases, done by a provider team with less historical runtime than the original AWS implementation had.

What a contribution-shaped fix looks like: The kubernetes-sigs/karpenter-provider-gcp (and other provider) repositories maintain their own issue trackers with specific, scoped feature-parity gaps against the AWS implementation — this isn’t a vague “make it better,” it’s a list of concrete, individually-tractable items. Picking one specific parity gap, understanding how the AWS provider solved the equivalent problem, and implementing the analogous behavior for the target cloud is real, wanted, trackable upstream work — precisely the shape of contribution this series has been pointing at throughout.


Key Takeaways

  • Karpenter provisions the actual best-fit instance for pending pods directly, with no pre-defined node-group middle layer
  • NodePool (scheduling flexibility) and NodeClass (cloud-specific detail) are deliberately separated concerns
  • Consolidation is active, continuous bin-packing — Karpenter looks for cost savings, not just capacity needs
  • Native spot interruption handling removes the need for a separate termination-handling tool
  • Non-AWS provider feature parity is explicitly open, tracked work — a real, current, well-scoped contribution opportunity in a project under active cross-cloud expansion

What’s Next

EP09 puts Karpenter head-to-head against the tool it’s increasingly replacing — Cluster Autoscaler — and gives a clear recommendation for when the older, node-group model is still the right choice.

Next: EP09 — Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler

Get EP09 in your inbox when it publishes → linuxcent.com/subscribe

Crossplane vs Terraform: Composition vs HCL for Infrastructure as Code

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 7
← EP06: Crossplane · EP07: Crossplane vs Terraform · EP08: Karpenter →

11 min read


TL;DR

  • Crossplane vs Terraform is fundamentally a continuous-reconciliation model against a plan/apply model — not just two different syntaxes for the same idea
  • Crossplane needs a live Kubernetes cluster to run at all; Terraform needs nothing but a state file and network access to the providers it’s calling
  • Terraform’s provider registry is a decade deep and covers services Crossplane’s younger ecosystem hasn’t reached yet — SaaS tools, monitoring platforms, and services with no cloud-infrastructure angle at all
  • Crossplane’s Compositions give app teams a genuinely self-service, in-cluster API; Terraform’s modules give infra teams reusable code, but consuming a module still means running Terraform yourself
  • Recommendation: many real platform teams use both — Terraform (or CAPI, EP05) to bootstrap the cluster and its surrounding VPC/networking, then Crossplane running inside that cluster for the self-service, app-team-facing layer
  • Contribution opportunity: Crossplane’s provider coverage gap against Terraform’s registry is real, specific, and a legitimate place to build a brand-new provider

The Big Picture

TERRAFORM                                   CROSSPLANE
──────────                                   ──────────
terraform plan                               kubectl apply -f resource.yaml
  │  (shows what WOULD change)                    │
  ▼                                                ▼
terraform apply                              Crossplane controller reconciles
  │  (changes happen once, here)                   │  (continuously, forever,
  ▼                                                 │   not just at apply time)
State file (local or remote backend)               ▼
tracks what Terraform created                 Kubernetes etcd IS the state —
                                                the CR's status field tracks
No live cluster or control                     sync state
plane required to run this
                                               Requires a running Kubernetes
                                               cluster as the control plane

Crossplane vs Terraform is best understood through that control-flow difference first, before comparing any specific feature: Terraform changes things at discrete moments you trigger; Crossplane’s controllers are always watching, always correcting drift, for as long as the cluster runs.


The Fundamental Model Difference: Continuous Reconciliation vs Plan/Apply

Terraform’s model gives you an explicit review step — terraform plan shows exactly what will change before anything does, and nothing changes again until you run apply a second time. Crossplane’s model (covered in EP06) has no equivalent pause: once a Managed Resource or Composition claim exists, Crossplane’s controllers reconcile it toward the desired state continuously, including reverting manual out-of-band changes automatically.

Neither is objectively better — they’re suited to different operating assumptions. Terraform’s model fits teams who want a deliberate, reviewed change process. Crossplane’s fits teams who want infrastructure to behave like every other Kubernetes-native resource: self-healing, always converging, no separate “did anyone remember to re-apply” step.


State Management: etcd + CRDs vs Terraform State Files

# Terraform: state lives in a file (local or remote — S3, Terraform Cloud, etc.)
$ terraform state list
aws_s3_bucket.uploads
aws_db_instance.main

# Crossplane: "state" is just the live cluster's etcd — the CR's own status
$ kubectl get bucket uploads -o jsonpath='{.status.conditions}'
[{"type":"Ready","status":"True"},{"type":"Synced","status":"True"}]

Terraform’s state file is a single point of coordination that has to be locked correctly for concurrent runs to be safe — a well-understood but real operational concern (remote state backends, state locking, occasional manual state surgery after a botched apply). Crossplane sidesteps a separate state file entirely, but that means the health of your Kubernetes cluster’s etcd is the health of your infrastructure’s state — a different, not smaller, operational responsibility.


Composition vs Modules: Reusable Infrastructure Patterns Compared

Terraform modules are reusable code that whoever runs Terraform includes in their own configuration — genuinely reusable, but still something each consumer runs themselves. Crossplane Compositions (EP06) are reusable inside the cluster — an app team doesn’t run anything, they just create a custom resource the platform team already defined, and Crossplane’s controllers do the rest without the app team ever touching Terraform or Crossplane’s own tooling directly.

That’s the real practical difference for organizational self-service: Compositions remove the “app team needs to know how to run our IaC tool” step entirely. Modules still require the consumer to run Terraform, even if they didn’t write the module.


Ecosystem Maturity: Terraform’s Decade-Deep Provider Registry vs Crossplane’s Younger One

Terraform’s provider registry covers not just the major clouds but a long tail of SaaS platforms, monitoring tools, DNS providers, and internal enterprise systems that have no “cloud infrastructure” angle at all — a decade of community and vendor-contributed providers. Crossplane’s provider ecosystem, while actively growing and covering the major clouds thoroughly, has real, documented gaps once you look past core compute/storage/networking/database resources into more specialized or less common services.


The Recommendation: Which One, and When to Use Both

If your platform team is Kubernetes-native and wants to offer app teams a true self-service infrastructure API without teaching them a separate IaC tool: Crossplane. That’s the specific problem its Composition model solves better than anything Terraform offers.

If you need broad provider coverage beyond core cloud infrastructure, or you don’t want infrastructure lifecycle tied to a live Kubernetes control plane’s uptime: Terraform. Its registry depth and its independence from any running cluster are real advantages Crossplane doesn’t currently match.

The honest answer for a lot of real platform teams is both, at different layers. Use Terraform (or Cluster API, EP05) to bootstrap the Kubernetes cluster itself and its surrounding cloud networking — the layer that has to exist before Crossplane can run at all — then run Crossplane inside that cluster for the ongoing, self-service, app-team-facing infrastructure requests. This isn’t a compromise; it’s matching each tool to the layer it’s actually better suited for.


⚠ Production Gotchas

Don’t manage the same cloud resource with both Terraform and Crossplane simultaneously. Both tools will detect the other’s changes as drift and fight to revert them — pick one owner per resource, even when both tools are in use across your stack at different layers.

Terraform’s plan/apply gives you a review window Crossplane doesn’t — build your own review gate if you need one with Crossplane (a PR-based GitOps flow with required approval before a claim manifest merges is the common substitute).

Crossplane’s continuous reconciliation means a broken provider or a cloud API outage shows up as a stuck Synced: False condition, not a failed one-time command — monitoring needs to watch for stuck conditions over time, not just command exit codes the way Terraform CI pipelines typically do.


Quick Reference

Terraform Crossplane
Change model Plan → Apply (explicit) Continuous reconciliation
Requires a live cluster No Yes
State State file (local/remote) Kubernetes etcd + CR status
Reusable patterns Modules (you still run them) Compositions (app team just creates a claim)
Provider breadth Very broad, decade-deep Growing, strong on core cloud, gaps elsewhere
Manual drift Detected at next plan, not auto-reverted Auto-reverted on next reconcile

Contribution Opportunity: Building a Missing Crossplane Provider

The limitation: For a meaningful number of services Terraform has supported for years — smaller SaaS platforms, specialized monitoring tools, niche infrastructure services — there’s no Crossplane provider equivalent yet. Anyone wanting to manage that service the Crossplane way currently can’t, full stop.

Why it’s hard to fix: Building a new provider means implementing a real API client, defining CRD schemas that faithfully map the service’s actual parameters, and maintaining it as that service’s API evolves — real, ongoing engineering commitment, not a one-time script. That’s exactly why the ecosystem’s provider list still trails Terraform’s, despite Crossplane’s core reconciliation engine being mature: the core is one thing to maintain, but each provider is its own ongoing surface area.

What a contribution-shaped fix looks like: Crossplane’s provider-template repository exists specifically to make starting a new provider tractable — it scaffolds the boilerplate (code generation, CRD structure, controller wiring) so a new provider author focuses on the actual API mapping, not plumbing. Picking one service you already use via Terraform that has no Crossplane equivalent, and building a minimal provider covering just the 2-3 resource types you actually need, is a real, bounded, achievable contribution — and one the Crossplane community actively wants, given how directly it grows the ecosystem.


Key Takeaways

  • Crossplane’s continuous reconciliation and Terraform’s plan/apply are different operating models, not different syntaxes for the same thing — pick based on which review/change process fits your team
  • Crossplane requires a live cluster to function at all; Terraform doesn’t, which matters for bootstrapping order
  • Compositions remove the “app team has to run our IaC tool” step that Terraform modules still require
  • Terraform’s provider registry breadth remains a real advantage for anything beyond core cloud infrastructure
  • Many real platform teams run both at different layers — Terraform/CAPI to bootstrap the cluster, Crossplane inside it for self-service — and that’s a legitimate architecture, not indecision

What’s Next

Everything so far in this series has been about provisioning clusters and the infrastructure around them. EP08 shifts to what happens inside an already-running cluster when pods can’t be scheduled: Karpenter’s just-in-time node provisioning, and why it replaced the node-group model most teams started with.

Next: EP08 — Karpenter: Just-in-Time Node Provisioning for Kubernetes

Get EP08 in your inbox when it publishes → linuxcent.com/subscribe

Crossplane: Kubernetes as the Universal Control Plane

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 6
← EP05: Cluster API · EP06: Crossplane · EP07: Crossplane vs Terraform →

12 min read


TL;DR

  • Crossplane extends the exact reconciliation pattern EP05 covered for cluster infrastructure to any cloud resource — an S3 bucket, an RDS instance, a DNS record all become Kubernetes CRDs, continuously reconciled
  • Managed Resources represent one real cloud resource each; Compositions bundle several Managed Resources behind a single, simpler custom API a platform team defines and app teams consume
  • Composition Functions are Crossplane’s newer, more flexible replacement for its older YAML-based patch-and-transform templating — real code (Go, Python, or others) instead of declarative patches
  • Crossplane continuously reconciles like any Kubernetes controller — a manual change to a cloud resource outside Crossplane gets reverted on the next reconcile loop, which is a real surprise for teams used to Terraform’s plan/apply model
  • Provider CRD counts can bloat a cluster’s etcd significantly — this drove the ecosystem’s move toward smaller, split “provider families” instead of one monolithic provider per cloud
  • Contribution opportunity: several providers still haven’t migrated to the family-split pattern — a real, currently-tracked, achievable upstream contribution

The Big Picture

App team writes:                    Platform team defined this Composition
                                     once, behind the scenes:
apiVersion: platform.example.com/v1
kind: Database                       XRD "Database" ─── composes ───┐
metadata:                                                             │
  name: my-app-db                                                     ▼
spec:                                                        ┌────────────────┐
  size: small                                                │ RDSInstance    │
                                                                │ SecurityGroup  │
   │                                                            │ ParameterGroup │
   │ app team never sees                                        └────────────────┘
   │ or touches these three                                     each a real Managed
   ▼                                                            Resource, a real
Crossplane reconciles all three,                                cloud API call
continuously, forever

Crossplane’s pitch as a universal control plane is literal: instead of app teams filing tickets or writing their own Terraform for a database, they request a Database — a custom API the platform team designed — and Crossplane’s controllers translate that into the actual RDS instance, security group, and parameter group underneath, then keep reconciling all three toward the declared state indefinitely.


Managed Resources: Cloud Infrastructure as Kubernetes CRDs

$ kubectl apply -f - <<EOF
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: app-uploads-prod
spec:
  forProvider:
    region: us-east-1
  providerConfigRef:
    name: aws-prod
EOF

$ kubectl get bucket app-uploads-prod
NAME               READY   SYNCED   AGE
app-uploads-prod   True    True     30s
#                  ^^^^    ^^^^^^ — READY: resource exists and is healthy
#                          SYNCED: Crossplane's last reconcile succeeded

Every field under forProvider maps directly to that cloud API’s actual parameters — this is a thin, honest translation layer, not an abstraction hiding what’s actually being created. READY/SYNCED becoming True means an actual S3 bucket now exists in that AWS account, exactly as declared.


Compositions and XRDs: Building Your Own Abstract Platform API

This is Crossplane’s real differentiator over just using individual Managed Resources directly:

# The platform team defines the abstract API app teams will see
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XDatabase
    plural: xdatabases
  claimNames:
    kind: Database        # ← this is what app teams actually create
    plural: databases
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        properties:
          spec:
            properties:
              size: {type: string, enum: ["small", "medium", "large"]}

App teams interact only with the simple Database claim shown in the Big Picture diagram above. The Composition resource (not shown here for brevity) is what actually maps size: small to a specific RDS instance class, storage size, and backup configuration — the platform team’s opinions, encoded once, consumed self-service by every app team afterward.


Composition Functions: Crossplane’s Newer, More Flexible Approach

Older Crossplane Compositions used a YAML-based “patch and transform” templating language to map the abstract API’s fields onto Managed Resource fields — functional, but limited for anything beyond straightforward field mapping. Composition Functions replace that with actual executable code:

$ crossplane beta render xr.yaml composition.yaml functions.yaml
---
apiVersion: rds.aws.upbound.io/v1alpha1
kind: Instance
metadata:
  name: my-app-db-instance
spec:
  forProvider:
    instanceClass: db.t3.micro   # ← computed by real Go logic based on
                                  #   spec.size, not a static YAML patch
    engine: postgres

Composition Functions run as small, packaged pieces of logic (often distributed as OCI images) that Crossplane’s engine invokes during reconciliation — giving platform teams real conditionals, loops, and validation instead of the older templating language’s more limited patch syntax.


Providers and the Provider Ecosystem

Each cloud’s resources are supplied by a separate providerprovider-aws, provider-gcp, provider-azure, and increasingly split into smaller provider families (provider-aws-s3, provider-aws-rds, etc.) rather than one enormous provider per cloud:

$ kubectl get providers
NAME                   INSTALLED   HEALTHY   AGE
provider-aws-s3        True        True      10d
provider-aws-rds       True        True      10d
#         ^^^^^^ — installing only the families you actually use, instead
#                  of one monolithic provider-aws with every AWS service's
#                  CRDs installed regardless of whether you use them

The family split exists specifically because a single monolithic cloud provider can register thousands of CRDs — a real, measurable strain on a cluster’s etcd and API server that the ecosystem is still in the process of migrating away from.


⚠ Production Gotchas

Crossplane reconciles continuously — a manual change to a cloud resource outside Crossplane gets reverted on the next loop. Teams coming from Terraform’s plan/apply model, where nothing changes until you explicitly run apply again, are frequently surprised the first time a manual “quick fix” in the AWS console gets silently undone minutes later.

Monolithic providers can register thousands of CRDs, and that has a real, measurable etcd and API-server cost. If you’re on an older, non-family provider version and seeing API server memory pressure, check CRD count before assuming it’s an unrelated capacity issue.

Deleting a Composition’s underlying claim doesn’t always tear down cleanly if finalizers on the Managed Resources are stuck — a Managed Resource that failed to delete cleanly from the cloud side (a non-empty S3 bucket, for instance) will block the whole claim’s deletion until that’s resolved manually.


Quick Reference

kubectl get managed                        # every Managed Resource, all providers
kubectl get compositeresourcedefinitions   # XRDs — the abstract APIs defined
kubectl get compositions                   # the mapping logic behind each XRD
kubectl get providers                       # installed providers + health
crossplane beta render <xr> <comp> <fns>    # render a Composition locally, no cluster needed
kubectl describe <managed-resource-kind> <name>   # sync status + underlying cloud errors

Contribution Opportunity: Migrating Providers to the Family Pattern

The limitation: Not every Crossplane provider has migrated from the older, monolithic-per-cloud model to the smaller “provider family” pattern that registers only the CRDs for services actually in use. Clusters running an un-migrated provider carry the etcd and API-server overhead of thousands of unused CRDs, and this is a known, actively-discussed problem in the Crossplane community — not a hypothetical one.

Why it’s hard to fix: Splitting a monolithic provider into families isn’t a mechanical find-and-replace — it means restructuring code generation, versioning, and release processes for every resource type the provider covers, while keeping a migration path that doesn’t break existing users who depend on the old provider’s CRDs. It’s real, unglamorous engineering work that has to happen provider-by-provider, cloud-by-cloud, and each provider’s maintainer bandwidth varies.

What a contribution-shaped fix looks like: The Crossplane and Upbound-maintained provider repositories publicly track which providers still need family-splitting — this is documented, wanted work, not a gap you’d have to go discover yourself. A concrete starting contribution: pick one still-monolithic provider (checking the project’s own tracking issues for an unclaimed one), and work through the documented family-split process the already-migrated providers (like provider-aws) used as a reference implementation. This is real upstream OSS work with an existing template to follow, not a design problem you have to solve from scratch.


Key Takeaways

  • Crossplane’s Managed Resources make individual cloud resources real Kubernetes CRDs, continuously reconciled rather than applied once
  • Compositions and XRDs are the actual value proposition: platform teams define a simple, opinionated API once; app teams self-serve against it without needing to know what’s underneath
  • Composition Functions replace older YAML patch-and-transform templating with real executable logic — a genuinely evolving, more flexible part of the project
  • Continuous reconciliation means manual out-of-band changes get reverted — a real behavioral difference from Terraform’s plan/apply model, not just a implementation detail
  • The provider family migration is documented, wanted, achievable contribution work — not a gap you’d need to discover on your own

What’s Next

Crossplane’s composition model and Terraform’s HCL module model solve the same underlying problem — reusable, parameterized infrastructure definitions — from genuinely different architectural starting points. EP07 puts them side by side and gives a clear recommendation for which fits which team.

Next: EP07 — Crossplane vs Terraform: Composition vs HCL for Infrastructure as Code

Get EP07 in your inbox when it publishes → linuxcent.com/subscribe

Cluster API: Declarative Cluster Lifecycle — Rancher’s Foundation Layer

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 5
← EP04: Rancher · EP05: Cluster API · EP06: Crossplane →

11 min read


TL;DR

  • Cluster API (CAPI) declares Kubernetes clusters themselves — not just workloads running inside them — as Kubernetes objects: Cluster, Machine, MachineDeployment, reconciled by controllers the same way a Deployment reconciles pods
  • CAPI itself is infrastructure-agnostic — the actual provisioning logic lives in separate infrastructure providers (AWS, Azure, GCP, vSphere, and dozens more), each implementing the same core contract
  • Bootstrapping is genuinely awkward by necessity: you need a Kubernetes cluster to run CAPI’s controllers before CAPI can create your real cluster — solved by a temporary “kind” cluster and a pivot step that moves CAPI’s own resources into the cluster it just created
  • Rancher’s own newer provisioning (EP04) increasingly builds on CAPI patterns rather than reinventing cluster lifecycle management from scratch
  • Provider version compatibility is a real, ongoing constraint — CAPI core and each infrastructure provider version independently, and not every combination is supported
  • Contribution opportunity: clusterctl move, the pivot operation, has well-documented fragility with resources it doesn’t natively understand — a concrete, scoped gap

The Big Picture

Cluster (the K8s object, not the K8s cluster itself)
  │
  ├── Represents: this Cluster SHOULD exist
  │
  ▼
MachineDeployment  ──── mirrors Deployment/ReplicaSet/Pod exactly ────┐
  │                                                                     │
  ▼                                                                     │
MachineSet                                                              │
  │                                                                     │
  ▼                                                                     │
Machine  ────────► Infrastructure Provider (AWS/Azure/GCP/vSphere/...)  │
  │                  actually creates the VM/instance                  │
  ▼                                                                     │
Bootstrap Provider (kubeadm, typically)                                │
  actually turns that VM into a working Kubernetes node ────────────────┘

Cluster API’s declarative cluster lifecycle model is the same reconciliation pattern Kubernetes already uses for workloads, applied one layer up: instead of a Deployment controller reconciling Pod objects into running containers, CAPI’s controllers reconcile Machine objects into running cloud instances that then join a cluster as nodes.


The Core Abstraction: Clusters and Machines as Kubernetes Objects

$ kubectl apply -f - <<EOF
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
  name: prod-us-east
spec:
  clusterNetwork:
    pods:
      cidrBlocks: ["192.168.0.0/16"]
  infrastructureRef:
    apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
    kind: AWSCluster
    name: prod-us-east
EOF

$ kubectl get clusters
NAME           PHASE          AGE
prod-us-east   Provisioning   45s

$ kubectl get machines
NAME                     CLUSTER        PHASE         VERSION
prod-us-east-cp-x7k2l    prod-us-east   Provisioning  v1.28.5

The Cluster object is a declaration of intent, not the cluster itself — a management cluster (a separate, already-running Kubernetes cluster whose only job is to run CAPI’s controllers) watches these objects and does the actual work of calling out to AWS, Azure, or whatever provider is referenced, creating instances, and bootstrapping Kubernetes on them.


The Provider Model: How CAPI Stays Infrastructure-Agnostic

CAPI’s core (cluster-api) knows nothing about AWS, Azure, or any specific cloud. That knowledge lives in separate, independently-versioned infrastructure providers:

$ clusterctl init --infrastructure aws
Fetching providers
Installing cert-manager
Installing Provider="cluster-api" Version="v1.6.2"
Installing Provider="bootstrap-kubeadm" Version="v1.6.2"
Installing Provider="control-plane-kubeadm" Version="v1.6.2"
Installing Provider="infrastructure-aws" Version="v2.4.0"
#                                          ^^^^^^ — versioned independently
#                                          from core CAPI above

This split — core lifecycle logic separate from provider-specific implementation — is the same architectural pattern CNI and CSI use elsewhere in Kubernetes: a stable core contract, swappable implementations. It’s also exactly why CAPI’s ecosystem includes dozens of infrastructure providers (AWS, Azure, GCP, vSphere, OpenStack, Docker, bare metal, and many more) maintained by different teams at different paces.


A Management Cluster Managing Itself and Others: The Pivot

The genuinely awkward part of CAPI’s bootstrap story: you need a running Kubernetes cluster to host CAPI’s controllers before CAPI can create your first real cluster. The standard pattern:

# Step 1: spin up a throwaway local cluster just to run CAPI controllers
$ kind create cluster --name capi-bootstrap
$ clusterctl init --infrastructure aws

# Step 2: use that temporary management cluster to provision the REAL cluster
$ clusterctl generate cluster prod-us-east --infrastructure aws | kubectl apply -f -

# Step 3: move CAPI's own resources OFF the throwaway cluster and ONTO
# the cluster that was just created — "pivoting" management to itself
$ clusterctl move --to-kubeconfig=./prod-us-east.kubeconfig
Performing move...
Discovering Cluster API objects
Moving Cluster API objects: Clusters=1, Machines=3, ...

After the pivot, the cluster CAPI created is now managing its own lifecycle (and can go on to manage other clusters too) — the temporary kind cluster can be torn down. This bootstrap-then-pivot dance is elegant in theory and one of the more fragile operational moments in CAPI’s lifecycle in practice.


How Rancher and Others Build On CAPI

Rancher’s newer cluster provisioning (EP04) increasingly leans on CAPI patterns rather than maintaining entirely separate provisioning logic — the industry direction across the Kubernetes ecosystem has been toward CAPI as the shared substrate for “declare a cluster, get a cluster,” with vendors building their own UX and opinionated defaults on top rather than reinventing the reconciliation model itself.


⚠ Production Gotchas

Provider version compatibility is a real support matrix, not a “probably fine” assumption. Core CAPI and each infrastructure provider version independently — upgrading one without checking the compatibility matrix for the other is a common source of cryptic reconciliation failures.

clusterctl move is a rare, high-stakes operation — most teams run it once per cluster’s lifetime, if ever, which means nobody on the team has recent hands-on experience when something goes wrong. Test the pivot in a non-production scenario before relying on it for anything real.

A Machine stuck in Provisioning can mean the infrastructure provider, the bootstrap provider, or the actual cloud API — three different places to look, and the Machine object’s own status doesn’t always make it obvious which. Check the infrastructure-specific object (AWSMachine, AzureMachine, etc.) directly, not just the generic Machine.


Quick Reference

clusterctl init --infrastructure <provider>   # install CAPI + a provider on the management cluster
clusterctl generate cluster <name> --infrastructure <provider>   # generate cluster manifests
kubectl get clusters                           # cluster lifecycle phase
kubectl get machines                           # per-node provisioning phase
kubectl get awsmachines / azuremachines / ...   # provider-specific detail
clusterctl move --to-kubeconfig=<path>          # pivot management to another cluster
clusterctl describe cluster <name>              # human-readable status tree

Contribution Opportunity: clusterctl move‘s Fragility With Non-Native Resources

The limitation: clusterctl move knows how to move CAPI’s own well-known resource types between management clusters cleanly. When a provider or an operator has added custom resources that reference or extend CAPI objects — a common real-world pattern — move doesn’t always understand the relationship, and teams have reported needing manual intervention (patching, reapplying, or reordering) to get a full pivot to succeed cleanly. This is documented in multiple open issues against the project, not a rare edge case.

Why it’s hard to fix: move‘s core logic has to correctly identify and preserve object references and ownership across an arbitrary graph of custom resources it wasn’t necessarily designed to know about — building a fully general solution risks either false confidence (silently missing a reference) or false failure (over-cautiously blocking a move that would have been fine). The CAPI maintainers have to weigh correctness against usability here, and it’s a genuinely hard design problem, not a simple bug.

What a contribution-shaped fix looks like: Two realistic, scoped starting points: (1) a --dry-run-style pre-flight checker for clusterctl move that specifically scans for custom resources referencing CAPI objects and flags them before the move attempt, rather than discovering the gap mid-operation; or (2) contributing a documented, tested procedure (and ideally a small helper tool) for the specific pattern of “extra resources referencing Machine/Cluster objects” that’s already been reported in the project’s issue tracker — turning a known, recurring support question into a documented, repeatable procedure.


Key Takeaways

  • CAPI applies Kubernetes’ own reconciliation pattern one layer up — Cluster and Machine objects are declarations, reconciled into real infrastructure by provider-specific controllers
  • The core/provider split keeps CAPI infrastructure-agnostic, at the cost of independent versioning you have to track across a real compatibility matrix
  • The bootstrap-then-pivot pattern is CAPI’s most elegant and most operationally fragile moment — rehearse it before you need it for real
  • Rancher and other platform tools increasingly build their own provisioning UX on top of CAPI’s reconciliation model rather than replacing it
  • The clearest contribution opportunity is clusterctl move‘s handling of non-native custom resources — a documented, scoped gap with real prior art in the issue tracker

What’s Next

CAPI treats infrastructure — VMs, networks, load balancers — as the thing being reconciled into existence from Kubernetes objects. EP06 takes that same idea and generalizes it as far as it can go: Crossplane turns Kubernetes into a control plane for effectively any cloud resource, not just the ones needed to run Kubernetes itself.

Next: EP06 — Crossplane: Kubernetes as the Universal Control Plane

Get EP06 in your inbox when it publishes → linuxcent.com/subscribe

Rancher: Multi-Cluster Kubernetes Management at Scale

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 4
← EP03: k3s vs MicroK8s vs Minikube · EP04: Rancher · EP05: Cluster API →

11 min read


TL;DR

  • Rancher multi-cluster management means one Rancher server managing many downstream Kubernetes clusters — its own RKE2/k3s clusters, or imported EKS/GKE/AKS clusters — from a single pane of glass
  • Rancher doesn’t proxy every API call through itself; it deploys a lightweight agent into each downstream cluster that phones home, then aggregates each cluster’s API through that agent
  • Fleet, Rancher’s built-in GitOps engine, is what actually pushes manifests to potentially hundreds of clusters from a single git repository — this is the feature that makes “fleet” in the product’s marketing literal, not aspirational
  • Rancher’s Projects group namespaces within one cluster for permission management — they are not a cross-cluster grouping, a common misunderstanding
  • The Rancher server itself becomes something you now have to operate: HA, upgrades, and version compatibility with every downstream cluster’s Kubernetes version are real, ongoing operational work
  • Contribution opportunity: Fleet’s multi-cluster drift visibility has real, specific gaps — covered below

The Big Picture

                    ┌─────────────────────────┐
                    │      RANCHER SERVER       │
                    │  (itself a K8s cluster,   │
                    │   ideally HA, 3+ nodes)   │
                    └────────────┬────────────┘
                                 │ agents phone home,
                                 │ API aggregated back
              ┌──────────────────┼──────────────────┐
              │                  │                  │
        ┌─────▼─────┐     ┌──────▼──────┐    ┌──────▼──────┐
        │  RKE2      │     │  Imported    │    │  Imported    │
        │  cluster   │     │  EKS cluster │    │  GKE cluster │
        │ (Rancher-  │     │ (Rancher     │    │ (Rancher     │
        │  provisioned)│    │  didn't      │    │  didn't      │
        │            │     │  create it)  │    │  create it)  │
        └────────────┘     └─────────────┘    └─────────────┘

Rancher multi-cluster management works by inverting the connection direction most people assume: Rancher doesn’t reach out and control downstream clusters directly. Each downstream cluster runs a small agent that establishes an outbound connection back to the Rancher server — which is why Rancher can manage a cluster sitting behind NAT or a restrictive firewall, as long as that cluster can reach out.


How Rancher Actually Manages Clusters It Didn’t Create

# Import an existing cluster Rancher never touched at creation time
$ kubectl apply -f https://rancher.example.com/v3/import/<token>.yaml
# This installs the cattle-cluster-agent into the target cluster —
# that agent is the only thing Rancher needs to start managing it

$ kubectl get pods -n cattle-system
NAME                                    READY   STATUS    RESTARTS
cattle-cluster-agent-7d8f9c-x2k9l       1/1     Running   0

Once the agent is running, Rancher’s UI and API present that cluster’s resources as if you were talking to it directly — the agent maintains the tunnel and relays API calls both ways. This is the architectural reason Rancher can manage a genuinely heterogeneous fleet: RKE2, k3s, EKS, GKE, AKS, and on-prem clusters all look identical to Rancher once the same agent is running in each.


RKE2 and k3s: Rancher’s Own Cluster Distributions

Rancher can also provision brand-new clusters directly, using its own distributions:

# Provisioning a new downstream cluster via Rancher's cluster API
# (typically done through the UI, but expressible as a CR)
$ kubectl apply -f - <<EOF
apiVersion: provisioning.cattle.io/v1
kind: Cluster
metadata:
  name: edge-fleet-01
  namespace: fleet-default
spec:
  kubernetesVersion: v1.28.9+rke2r1
  rkeConfig:
    machinePools:
    - name: pool-01
      quantity: 3
EOF

RKE2 (“RKE Government,” a CIS-hardened, more security-focused distribution) and k3s (the lightweight distribution covered in EP03) are both Rancher/SUSE projects, and Rancher treats them as first-class provisioning targets — this is the direct product connection between “the lightweight Kubernetes distro you picked in EP03” and “the fleet manager covered in this episode.”


Fleet: GitOps at Fleet Scale

# Fleet watches a git repo and deploys its manifests to a TARGETED
# set of clusters based on label selectors — not necessarily all of them
$ kubectl apply -f - <<EOF
apiVersion: fleet.cattle.io/v1alpha1
kind: GitRepo
metadata:
  name: platform-baseline
  namespace: fleet-default
spec:
  repo: https://github.com/example-org/platform-manifests
  branch: main
  targets:
  - clusterSelector:
      matchLabels:
        env: production
EOF

$ kubectl get gitrepo -n fleet-default
NAME                REPO                                          COMMIT     BUNDLESREADY
platform-baseline   https://github.com/example-org/platform-...   a1b2c3d    12/14
#                                                                              ^^^^^ — 2 clusters
#                                                                              haven't converged yet

BUNDLESREADY 12/14 is the number that matters at fleet scale — it tells you how many of the targeted clusters have actually converged to the git state, but notice it doesn’t tell you why the other 2 haven’t, or which 2 they are, without drilling into each bundle individually. That’s the exact gap covered in this episode’s contribution section.


Projects and RBAC: Rancher’s Multi-Tenancy Layer

A common misconception worth correcting directly: Rancher’s Projects group namespaces within a single cluster for permission and resource-quota management — they are not a mechanism for grouping resources across clusters. Cross-cluster access control is handled separately, through Cluster-level and Global roles assigned per user or group.

Global scope        → applies across every cluster Rancher manages
  └── Cluster scope  → applies to all namespaces in one specific cluster
        └── Project scope → applies to a defined subset of namespaces
              within that one cluster (Rancher's own grouping construct)

Getting this hierarchy backwards — assuming a Project spans clusters — is one of the most common Rancher RBAC mistakes teams make when first designing their permission model.


⚠ Production Gotchas

Rancher server itself needs HA, and losing it doesn’t take down downstream clusters — but it does take down your ability to manage them centrally. Downstream clusters keep running their workloads fine if Rancher server is unreachable; you just lose the single-pane-of-glass view and Fleet’s GitOps reconciliation until it’s back.

Version skew between Rancher server and downstream Kubernetes versions is a real, documented compatibility matrix — not a “should mostly work” situation. Upgrading Rancher server ahead of your downstream clusters’ Kubernetes versions (or vice versa, letting downstream clusters drift too far ahead) can break agent compatibility. Check Rancher’s official support matrix before any upgrade, not after something breaks.

Agent reconnection storms after a Rancher server upgrade or restart are a known operational event, not a bug report. If you manage dozens of downstream clusters, expect a burst of reconnection activity immediately after any Rancher server maintenance — plan maintenance windows with that in mind.


Quick Reference

kubectl apply -f import.yaml              # import an existing cluster
kubectl get clusters.provisioning.cattle.io -A   # all clusters Rancher manages
kubectl get gitrepo -n fleet-default       # Fleet GitOps repo status
kubectl get bundles -n fleet-default       # per-cluster deployment bundle status
kubectl get pods -n cattle-system          # agent health, on a downstream cluster

Contribution Opportunity: Fleet’s Multi-Cluster Drift Visibility

The limitation: Fleet’s BUNDLESREADY count tells you how many targeted clusters have converged, but drilling into why a specific cluster hasn’t — a stuck rollout, a resource conflict, a cluster that’s unreachable — still requires checking that cluster’s bundle status individually. At a fleet of dozens or hundreds of clusters, there’s no aggregated view that surfaces “these 3 clusters are all failing for the same underlying reason” without manual cross-referencing.

Why it’s hard to fix: Aggregating meaningful failure reasons across a heterogeneous fleet is genuinely harder than it sounds — a “failed” bundle on one cluster might be a transient network blip, on another a real manifest conflict, and on a third a resource quota limit. Building a dashboard that correctly buckets and summarizes those different failure classes without producing a wall of noise is a real UX and data-modeling problem, and it’s not the kind of thing that gets prioritized ahead of core provisioning reliability work.

What a contribution-shaped fix looks like: A scoped, achievable starting point: a fleet CLI plugin or a Rancher UI extension that queries all Bundle resources across the fleet’s clusters, groups them by failure-reason similarity (using the existing status conditions Fleet already populates — this is a client-side aggregation problem, not a new backend feature), and surfaces a ranked summary. This is buildable against Fleet’s existing CRDs and status fields without needing to modify Fleet’s core reconciliation logic — exactly the kind of contribution an operator who’s felt this specific pain at scale is positioned to build and upstream.


Key Takeaways

  • Rancher manages downstream clusters through an outbound-connecting agent, not by reaching in — this is why it can manage clusters behind NAT or restrictive firewalls
  • Fleet is the actual mechanism for GitOps at fleet scale, targeting clusters by label selector and reporting convergence via BUNDLESREADY counts
  • Projects group namespaces within one cluster, not across clusters — a frequent RBAC design mistake starts from getting this backwards
  • The Rancher server becomes real infrastructure you operate: HA, version-compatibility matrices, and post-upgrade agent reconnection are ongoing operational realities
  • The clearest contribution opportunity is Fleet’s drift-visibility gap at scale — a client-side aggregation problem buildable against existing CRDs, not a core-logic change

What’s Next

Rancher’s own cluster provisioning sits on top of a more general pattern: declaring cluster lifecycle as Kubernetes resources. EP05 covers Cluster API directly — the CNCF project Rancher’s own provisioning increasingly builds on, and the pattern several other tools in this series also depend on.

Next: EP05 — Cluster API: Declarative Cluster Lifecycle — Rancher’s Foundation Layer

Get EP05 in your inbox when it publishes → linuxcent.com/subscribe

k3s vs MicroK8s vs Minikube: Which Lightweight Kubernetes Fits Your Use Case

Reading Time: 6 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 3
← EP02: Minikube · EP03: k3s vs MicroK8s vs Minikube · EP04: Rancher →

10 min read


TL;DR

  • k3s vs MicroK8s vs Minikube comes down to one question first: do you need this to run in production on real hardware (k3s, MicroK8s), or only on a developer’s laptop (Minikube)?
  • k3s (built by Rancher, now part of SUSE) is a single ~70MB binary using SQLite or embedded etcd, designed explicitly for edge and IoT production deployments, not just local dev
  • MicroK8s (Canonical) is a snap-packaged cluster using Dqlite for HA — covered in EP01 — closer to k3s in intent than to Minikube
  • Minikube is the odd one out here: it’s VM/container-isolated and explicitly a local development tool, not something you’d run in production
  • Recommendation: for production edge/IoT, pick k3s or MicroK8s based on your packaging preference (binary vs snap) and datastore comfort (SQLite/etcd vs Dqlite); for local development and CI, pick Minikube when you need real isolation, or either k3s/MicroK8s when you just need “a cluster, fast”
  • Contribution opportunity: none of the three has a first-class way to migrate a running cluster’s workloads to another — a real, currently-unfilled gap

The Big Picture

                    k3s              MicroK8s           Minikube
                    ────             ────────           ────────
Packaging           Single binary    Snap package       VM/container
Intended for         Edge/IoT prod    Edge/IoT prod       Local dev only
Datastore (HA)       SQLite / etcd    Dqlite             etcd (per-node)
Multi-node HA        Yes              Yes                No (single profile
                                                           node, though multi-
                                                           profile exists)
Isolation from host   None (bare)      None (bare)        Full (VM or
                                                            container boundary)
Default footprint     ~70MB binary     ~200MB snap         500MB-1GB+ VM/image
Add-on model          Helm charts      snap add-ons        minikube addons
                      via manifests

k3s vs MicroK8s vs Minikube isn’t really a three-way tie — it’s two production-oriented, bare-metal tools (k3s, MicroK8s) and one deliberately isolated local-dev tool (Minikube) that happen to get compared because all three market themselves as “lightweight Kubernetes.”


Architecture at a Glance

k3s strips Kubernetes down to a single binary by removing in-tree cloud provider integrations, dropping alpha features, and swapping etcd for embedded SQLite by default (though it supports real etcd or external datastores for HA). It was purpose-built by Rancher Labs for resource-constrained edge devices and CI, and that heritage still defines its design decisions today.

MicroK8s — covered in full in EP01 — takes a different packaging route (a snap bundle rather than a single binary) but lands in almost the same use-case space: edge, IoT, and CI, with its own HA datastore (Dqlite) instead of etcd.

Minikube — covered in EP02 — is architecturally unlike either: it isolates the entire cluster inside a VM or container specifically so your laptop’s Kubernetes environment doesn’t interact directly with your laptop’s actual kernel and network stack. That isolation is a feature for local development and actively unwanted overhead for a production edge deployment.


Resource Footprint: What Each One Actually Costs to Run

# k3s — single binary, starts in seconds, minimal base memory
$ curl -sfL https://get.k3s.io | sh -
$ k3s kubectl get nodes
NAME       STATUS   ROLES                  AGE   VERSION
my-node    Ready    control-plane,master   12s   v1.28.5+k3s1

# MicroK8s — snap install, slightly heavier than k3s due to bundled containerd/Dqlite
$ sudo snap install microk8s --classic
$ microk8s status --wait-ready

# Minikube — heaviest by design, provisions a full VM or container first
$ minikube start --driver=docker
# (30-90 seconds depending on driver, before Kubernetes even starts booting)

On a resource-constrained edge device (a Raspberry Pi, an industrial gateway), the difference between k3s’s ~70MB binary and Minikube’s VM-based footprint isn’t a rounding error — it’s the difference between fitting on the device at all and not. This is why Minikube essentially never appears in edge deployment discussions: it was never built for that use case.


The Add-on / Component Model Compared

k3s MicroK8s Minikube
CNI Flannel (default), swappable Calico (default), swappable via add-on Varies by driver, addon-enabled
Ingress Traefik (bundled by default) nginx via add-on nginx via addon
Storage local-path-provisioner (bundled) hostpath-storage add-on default-storageclass addon
Extending Standard Helm charts, manifests microk8s enable <addon> minikube addons enable <name>

k3s ships more “batteries included” by default (Traefik and local-path storage are on unless you disable them) — a meaningfully different default posture from MicroK8s and Minikube, which both start closer to bare and expect you to opt in to what you need.


Recommendation: Which One Actually Fits Your Use Case

Running Kubernetes on real edge/IoT hardware in production: choose between k3s and MicroK8s based on packaging preference and datastore comfort, not raw features — they solve the same problem. If you’re already inside the snap ecosystem (Ubuntu Core, other Canonical tooling) or want a specific datastore, MicroK8s’s Dqlite. If you want the smallest possible footprint and the option of real etcd for HA, k3s. If you’re evaluating Rancher for fleet management (EP04), note that Rancher created k3s specifically to be its default downstream cluster type — that pairing has more operational precedent than any other combination here.

Local development, testing against something close to a real cloud node: Minikube, specifically when you need the VM isolation boundary — testing kernel-adjacent behavior, simulating a genuinely separate node, or needing multiple isolated profiles side by side.

CI pipelines needing a disposable cluster fast: k3s’s single-binary startup is hard to beat for raw speed; MicroK8s’s snap install is a close second. Minikube is the wrong tool here unless the CI environment specifically needs VM-level isolation for security reasons.

Don’t pick based on “most popular” or “newest” alone — all three are actively maintained, CNCF-conformant, and the “right” one is entirely determined by whether you’re targeting production hardware or a local workstation.


⚠ Production Gotchas

k3s’s default SQLite datastore is single-node only — HA requires explicit configuration. Don’t assume curl | sh gives you production HA out of the box; it gives you a working single node, and HA (embedded etcd or external datastore) is a deliberate follow-up step.

Comparing “footprint” numbers from marketing pages is misleading without matching workloads. A k3s binary’s on-disk size and MicroK8s’s snap size aren’t measuring the same thing (a binary vs. a bundle including containerd and a datastore) — benchmark actual running memory under your real workload, not install-time size.

None of these three are drop-in replacements for each other operationally, despite the “lightweight Kubernetes” label all three carry. Add-on names, default CNI, and default ingress all differ — migrating a manifest set between them is not guaranteed to work unmodified.


Quick Reference

# k3s
curl -sfL https://get.k3s.io | sh -
k3s kubectl get nodes
sudo systemctl status k3s

# MicroK8s
sudo snap install microk8s --classic
microk8s status --wait-ready
microk8s kubectl get nodes

# Minikube
minikube start --driver=<docker|kvm2|hyperkit|virtualbox>
minikube status
kubectl get nodes   # uses minikube's kubeconfig context directly

Contribution Opportunity: No First-Class Migration Path Between Them

The limitation: If you outgrow Minikube for local dev and want to mirror your production k3s environment more closely, or you’re running MicroK8s at the edge and want to evaluate switching to k3s, there’s no tooling in any of the three projects that translates the other’s add-on configuration, ingress setup, or storage class definitions into its own equivalent. You’re reproducing configuration by hand, from documentation, project by project.

Why it’s hard to fix: Each project’s add-on/component model evolved independently, solving the same category of problem (ingress, storage, networking) with different defaults and different configuration surfaces — there’s no shared standard to translate through, and no single maintainer group owns “compatibility between lightweight Kubernetes distros” as a problem, because each project’s maintainers are reasonably focused on their own users, not on easing exit to a competitor.

What a contribution-shaped fix looks like: A standalone, community-maintained translation tool or even a well-structured comparison-and-migration guide (living in a neutral location like a CNCF sandbox project or a widely-referenced GitHub repo, not owned by any one vendor) that maps common add-on configurations (ingress-nginx settings, storage class parameters, CNI policy syntax) between the three. This doesn’t require deep contribution to any single project’s core — it requires someone who has actually run workloads on more than one of these and is willing to document the translation precisely, which is exactly the kind of gap a practitioner (not a maintainer) is best positioned to fill.


Key Takeaways

  • k3s and MicroK8s are both production-oriented, bare-metal tools for edge/IoT; Minikube is a deliberately isolated local-dev tool — they’re not really three-way competitors on the same axis
  • k3s’s single-binary packaging and MicroK8s’s snap packaging solve the same problem differently — pick based on ecosystem fit and datastore preference, not raw capability
  • Minikube’s VM/container isolation is the right tool specifically when you need a real isolation boundary for local testing, not for general “I want Kubernetes on my laptop”
  • Default component choices differ meaningfully (Traefik vs nginx, bundled storage vs addon-based) — verify defaults before assuming any two of these behave the same out of the box
  • The most concrete, currently-unfilled contribution opportunity is configuration translation between the three — a documentation and tooling gap any experienced user could start closing

What’s Next

k3s was built by Rancher as the default cluster type for its own fleet-management platform. EP04 covers Rancher itself — what it actually does when you’re managing more than one cluster, and where its own control plane becomes another thing you have to operate.

Next: EP04 — Rancher: Multi-Cluster Kubernetes Management at Scale

Get EP04 in your inbox when it publishes → linuxcent.com/subscribe

Minikube: Local Kubernetes Done Right — and Where It Breaks Down

Reading Time: 6 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 2
← EP01: MicroK8s Explained · EP02: Minikube · EP03: k3s vs MicroK8s vs Minikube →

11 min read


TL;DR

  • What is Minikube? A tool that runs a single-node Kubernetes cluster inside a VM or a container on your local machine — the oldest and most widely adopted “local Kubernetes” tool in the ecosystem
  • Unlike MicroK8s’s bare-metal snap install, Minikube’s default drivers isolate the cluster inside a VM (VirtualBox, HyperKit, Hyper-V, KVM2) or a Docker container — a deliberate isolation trade-off, not an accident
  • minikube addons, minikube profile, and multi-node support let you run several named clusters side by side, each with its own driver and Kubernetes version
  • LoadBalancer services don’t resolve to anything real on their own — minikube tunnel or minikube service are required, and this trips up almost everyone the first time
  • The VM overhead that makes Minikube heavier than MicroK8s is also what makes it a more faithful stand-in for a real cloud node, particularly for testing kernel-adjacent behavior
  • Contribution opportunity: feature parity across Minikube’s own driver list is uneven, and closing specific gaps there is a well-scoped, achievable contribution

The Big Picture

MICROK8S: BARE-METAL SNAP                   MINIKUBE: ISOLATED VM/CONTAINER
──────────────────────────                   ──────────────────────────────
Host OS
  └── microk8s snap                         Host OS
        ├── kubelet                           └── Driver (VirtualBox / KVM2 /
        ├── kube-apiserver                        HyperKit / Docker / Podman)
        ├── containerd                              └── VM or container
        └── Dqlite                                        ├── kubelet
                                                            ├── kube-apiserver
No VM boundary — cluster                                   ├── etcd
runs directly on the host                                  └── containerd
kernel and network stack
                                              Full isolation boundary between
                                              cluster and host — closer to how
                                              a real cloud node actually looks

What is Minikube? It’s the tool that popularized “just run a Kubernetes cluster on your laptop” — a single command that provisions a VM or container, installs a full Kubernetes control plane and node inside it, and hands you a working kubectl context. The isolation boundary that VM makes MicroK8s’s bare-metal install avoid is the entire point: Minikube trades startup speed and resource overhead for a cluster that behaves more like a real, separate node — the same control-plane/node split covered in detail in this site’s Kubernetes history series, just shrunk down to fit on a laptop.


The Driver Model: How Minikube Actually Runs Your Cluster

Minikube doesn’t run Kubernetes directly on your host. It provisions a driver-specific environment first, then runs Kubernetes inside that:

$ minikube start --driver=docker
😄  minikube v1.32.0 on Darwin 14.2
✨  Using the docker driver based on user configuration
👍  Starting control plane node minikube in cluster minikube
🚜  Pulling base image ...
🔥  Creating docker container (CPUs=2, Memory=4000MB) ...
🐳  Preparing Kubernetes v1.28.3 on Docker 24.0.7 ...
🔎  Verifying Kubernetes components...
🌟  Enabled addons: default-storageclass, storage-provisioner
🏄  Done! kubectl is now configured to use "minikube" cluster

$ minikube status
minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

The --driver flag is the real decision point. docker/podman drivers run the cluster as a container, which is fast to start but shares the host kernel — you don’t get true kernel-level isolation. virtualbox/hyperkit/kvm2/hyperv drivers run a full VM, which is slower to start (30–90 seconds, versus 10–20 for the container driver) but gives the cluster its own kernel, its own network namespace, and behavior much closer to an actual cloud instance.


Addons and Profiles: Minikube’s Answer to Multi-Environment Testing

$ minikube addons list
|-----------------------------|----------|--------------|
| ADDON NAME                  | PROFILE  | STATUS       |
|-----------------------------|----------|--------------|
| ingress                     | minikube | disabled     |
| metrics-server              | minikube | disabled     |
| dashboard                   | minikube | disabled     |
| registry                    | minikube | disabled     |

$ minikube addons enable ingress
🔎  Verifying ingress addon...
🌟  The 'ingress' addon is enabled

# Run a second, independent cluster on a different Kubernetes version
$ minikube start -p old-version --kubernetes-version=v1.26.0
$ minikube profile list
|----------|-----------|---------|--------------|------|
| Profile  | VM Driver | Runtime | IP           | Ver  |
|----------|-----------|---------|--------------|------|
| minikube | docker    | docker  | 192.168.49.2 | v1.28.3 |
| old-version | docker | docker  | 192.168.58.2 | v1.26.0 |

Profiles are Minikube’s way of running multiple, fully independent clusters side by side — useful for testing an upgrade path or comparing behavior across Kubernetes versions without tearing anything down. MicroK8s has no equivalent to this; it’s a genuine Minikube differentiator, not just a different flavor of the same feature.


Where the VM Overhead Actually Shows Up

The isolation Minikube provides isn’t free, and it shows up in three concrete places: startup time (a VM driver cold-start is measured in tens of seconds, not the few seconds a bare-metal snap install takes), memory floor (a VM needs to reserve memory for its own kernel and init system before Kubernetes gets any of it), and CI runners specifically — many hosted CI environments (GitHub Actions’ standard runners, for example) don’t support nested virtualization, which rules out VM drivers entirely and forces the docker driver, quietly giving up the isolation benefit that was the reason to pick Minikube over MicroK8s in the first place.


Networking Quirks: LoadBalancer Services and minikube tunnel

This is the single most common point of confusion for anyone coming from a real cloud cluster:

$ kubectl expose deployment web --type=LoadBalancer --port=80
service/web exposed

$ kubectl get svc web
NAME   TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)
web    LoadBalancer   10.96.34.201   <pending>     80:31234/TCP
#                                    ^^^^^^^^^ stays pending forever —
#                                    there's no cloud load balancer to provision one

Minikube has no cloud provider to actually satisfy a LoadBalancer request. Two ways to actually reach the service:

# Option 1: minikube tunnel — creates a real route to LoadBalancer services,
# must stay running in a foreground terminal the whole time
$ minikube tunnel
✅  Tunnel successfully started

# Option 2: minikube service — opens the service in a browser via NodePort,
# no LoadBalancer semantics, but doesn't require a background process
$ minikube service web --url
http://192.168.49.2:31234

minikube tunnel is the closer match to real LoadBalancer behavior, but it’s a foreground process that silently stops working if the terminal closes or the machine sleeps — a frequent source of “it worked five minutes ago” confusion.


⚠ Production Gotchas

Nested virtualization isn’t available everywhere. Many hosted CI runners and some cloud dev environments don’t expose the CPU virtualization extensions Minikube’s VM drivers need — you’ll get a driver failure that looks like a Minikube bug but is actually a host capability gap. Falling back to --driver=docker works, but changes the isolation guarantees you were relying on.

The docker driver shares your host’s Docker daemon resource limits. If your host Docker Desktop is capped at 4GB, that’s a hard ceiling for everything running inside the Minikube container too — VM drivers get their own explicit memory allocation instead.

minikube tunnel dying silently is the most common “why can’t I reach my LoadBalancer” support question. It doesn’t reliably surface a clear error when it stops — check minikube tunnel‘s own terminal output before assuming the Kubernetes side is broken.

Addon behavior differs meaningfully by driver. The ingress addon’s interaction with host networking is different between a VM driver (which gets its own IP on a virtual network) and the docker driver (which shares the host’s Docker network) — a setup that works on one driver doesn’t automatically work identically on another.


Quick Reference

minikube start --driver=<docker|virtualbox|hyperkit|kvm2|hyperv>
minikube status                    # cluster health
minikube addons list                # available and enabled add-ons
minikube addons enable <name>       # enable one
minikube profile list               # all named clusters
minikube start -p <name>            # start/create a named profile
minikube tunnel                     # real LoadBalancer routing (foreground)
minikube service <name> --url       # NodePort-based access, no LB semantics
minikube delete -p <name>           # tear down a specific profile
minikube ssh                        # shell into the cluster's VM/container

Contribution Opportunity: Closing Minikube’s Driver Feature-Parity Gaps

The limitation: Minikube supports over a dozen drivers (docker, podman, virtualbox, hyperkit, kvm2, hyperv, vfkit, qemu, and more), and features don’t land on all of them at the same time or with the same fidelity. GPU passthrough, specific CNI plugin support, and certain addon behaviors work reliably on some drivers and only partially — or not at all — on others. A user picking a driver based on their OS often has no easy way to know upfront which features they’re implicitly giving up.

Why it’s hard to fix: Each driver wraps a fundamentally different underlying technology (a type-2 hypervisor, a container runtime, a different hypervisor API per OS), so a feature that’s straightforward on one driver can require an entirely separate implementation path on another — this isn’t a matter of one team finishing a checklist, it’s N different integration surfaces that each need their own maintainer attention, and Minikube’s driver maintainers are a much smaller, more fragmented group than the core Kubernetes maintainers.

What a contribution-shaped fix looks like: The achievable starting point isn’t “add GPU support to every driver” — it’s picking one specific, well-documented gap (say, a particular addon’s known behavior difference on hyperv versus kvm2), reproducing it precisely, and either fixing the driver-specific code path in kubernetes/minikube or, just as valuably, contributing a clear compatibility matrix to the project’s docs so the next person doesn’t discover the gap by trial and error. Minikube’s own GitHub issues are full of exactly these driver-specific reports sitting unresolved for lack of someone who reproduces and narrows them down.


Key Takeaways

  • Minikube isolates the cluster inside a VM or container, trading startup speed and resource overhead for isolation closer to a real cloud node
  • Profiles let you run multiple independent, differently-versioned clusters side by side — a genuine capability MicroK8s doesn’t have
  • LoadBalancer services need minikube tunnel or minikube service — there’s no cloud provider underneath to satisfy the request automatically
  • Driver choice has real consequences: VM drivers need nested virtualization support that not every host or CI runner provides, and feature parity across drivers is uneven
  • The clearest contribution opportunity is narrowing and documenting (or fixing) specific driver feature-parity gaps — achievable without deep hypervisor expertise

What’s Next

EP01 and EP02 covered MicroK8s and Minikube individually. EP03 puts them head-to-head against k3s — the third major lightweight Kubernetes option — on the criteria that actually matter when picking one: resource footprint, HA story, and how much you’re willing to trade control for convenience.

Next: EP03 — k3s vs MicroK8s vs Minikube: Which Lightweight Kubernetes Fits Your Use Case

Get EP03 in your inbox when it publishes → linuxcent.com/subscribe

MicroK8s Explained: The Single-Binary Kubernetes for Edge and IoT

Reading Time: 6 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 1
← Kubernetes: From Borg to Platform Engineering · EP01: MicroK8s Explained · All Kubernetes Ecosystem Episodes →

11 min read


TL;DR

  • What is MicroK8s? A full, CNCF-conformant Kubernetes cluster packaged into a single snap package — install and have a running cluster in under 60 seconds, no separate container runtime or CNI install required
  • Built by Canonical for edge, IoT, and ephemeral CI clusters, where minimal footprint and single-command lifecycle matter more than fine-grained tuning
  • Add-ons (dns, storage, ingress, metallb, cilium, gpu) replace what you’d otherwise hand-install and wire together yourself
  • High-availability mode uses Dqlite (a distributed, Raft-backed SQLite) instead of etcd — a deliberate design choice with a real operational trade-off
  • The same “just works” install model that makes MicroK8s fast to stand up also hides some of the control-plane tuning knobs production operators expect from etcd
  • Contribution opportunity: Dqlite’s operational tooling is years behind etcd’s — a real, specific, achievable gap, covered in depth below

The Big Picture

TRADITIONAL KUBERNETES INSTALL              MICROK8S INSTALL
─────────────────────────────               ──────────────────
Install a container runtime                 $ sudo snap install microk8s --classic
Install kubelet, kubeadm, kubectl            $ microk8s status --wait-ready
Stand up and configure etcd
kubeadm init, join workers                          │
Install a CNI plugin                                ▼
Install an ingress controller                One snap install =
Install a storage provisioner                kubelet + kube-apiserver + kube-scheduler
Wire it all together, hope                   + kube-controller-manager + containerd
the versions are compatible                  + Dqlite + CNI (via add-on)
                                              — one running cluster, one command

What is MicroK8s? It’s Canonical’s single-binary distribution that packages an entire Kubernetes control plane and node into one snap package, so snap install microk8s produces a running, CNCF-conformant cluster without a separate container runtime, etcd install, or CNI setup step. The trade for that speed is control: the same bundling that removes a dozen manual steps also removes a dozen places you’d normally tune something.


How MicroK8s Fits an Entire Cluster Into One Snap

A snap package is a self-contained, confined Linux application bundle — MicroK8s ships its own containerd, its own CNI defaults, and its own datastore inside that bundle, rather than expecting the host to provide them.

$ sudo snap install microk8s --classic
microk8s (1.29/stable) v1.29.1 from Canonical✓ installed

$ microk8s status --wait-ready
microk8s is running
high-availability: no
  datastore master nodes: 127.0.0.1:19001
  datastore standby nodes: none
addons:
  enabled:
    ha-cluster            # always on — this is the Dqlite HA layer, even for a single node
  disabled:
    dns                   # ← you enable what you need, nothing runs by default beyond core
    ingress
    storage
    ...

--classic confinement is required because MicroK8s needs broader host access than a strictly-confined snap allows — it manages network interfaces, iptables/nftables rules, and mounts. This is the first place production operators coming from a bare-metal kubeadm install get surprised: MicroK8s’s snap confinement model means some host interactions happen through paths a traditional install never touches, and debugging network issues sometimes means understanding snap’s confinement boundaries, not just Kubernetes networking.


The Add-on Model: How MicroK8s Replaces a Day of Cluster Bootstrapping

Everything beyond the bare control plane is an add-on, enabled with one command:

$ microk8s enable dns storage ingress
Infer repository core for addon dns
Enabling DNS
...
DNS is enabled
Infer repository core for addon storage
Enabling default storage class
...
Storage is enabled
Infer repository core for addon ingress
Ingress controller is enabled

$ microk8s kubectl get pods -A
NAMESPACE     NAME                                      READY   STATUS    RESTARTS
kube-system   coredns-864597b5fd-x7k2p                  1/1     Running   0
kube-system   hostpath-provisioner-5c65c9c74f-j9qmz      1/1     Running   0
ingress       nginx-ingress-microk8s-controller-abcde    1/1     Running   0

Each add-on is a maintained, version-pinned bundle — enabling ingress doesn’t pull the latest ingress-nginx release, it pulls whatever version that MicroK8s release channel has validated. That’s the same trade seen everywhere else in this tool: predictability and speed, at the cost of being slightly behind upstream and unable to mix-and-match component versions the way a hand-built cluster can.

The cilium add-on is worth calling out specifically for platform teams already standardizing on eBPF-based networking elsewhere — it replaces MicroK8s’s default CNI with Cilium, giving you the same eBPF-enforced network policy model covered in the TC eBPF episode of the eBPF series, without a separate Cilium install process.


Dqlite Instead of etcd: MicroK8s’s Most Debated Design Choice

For high availability, upstream Kubernetes distributions almost universally reach for etcd — a mature, Raft-based key-value store with over a decade of production hardening. MicroK8s uses Dqlite instead: a distributed SQLite built by Canonical, also Raft-based, but SQL-native rather than a plain key-value store.

# Convert a single node into a 3-node HA cluster
$ microk8s add-node
From the target node, run:
microk8s join 10.0.1.15:25000/abc123... --worker

$ microk8s status
microk8s is running
high-availability: yes
  datastore master nodes: 10.0.1.10:19001 10.0.1.12:19001 10.0.1.15:19001
  datastore standby nodes: none

The reasoning behind Dqlite is architectural: Canonical wanted a datastore that could also serve their other projects (LXD, for instance) with a SQL interface, not just Kubernetes’ key-value needs, and one they could tightly control the release cadence of rather than depending on the etcd project’s own timeline. That’s a legitimate engineering decision — but it means MicroK8s’s HA story runs on a datastore with a small fraction of etcd’s operational track record.


Where MicroK8s Actually Runs in Production

MicroK8s’s real fit is narrower than “any Kubernetes workload”: edge and IoT deployments where a device needs a full, conformant cluster with no external dependencies (a factory sensor gateway, a retail point-of-sale cluster); CI/CD pipelines that need a disposable, fast-booting cluster per test run; and single-node developer or demo environments where Minikube’s VM overhead isn’t wanted.

It’s a weaker fit for large multi-tenant production clusters where teams already have deep etcd operational expertise, need fine-grained control-plane component versioning, or run at a scale where Dqlite’s newer, less-battle-tested Raft implementation is a harder sell to a risk-averse platform team.


⚠ Production Gotchas

Dqlite HA needs an odd number of nodes, same as etcd — but the community knowledge base is much thinner. A 2-node or 4-node Dqlite cluster has the identical split-brain risk etcd has at even node counts. The difference is that when something goes wrong, etcd has ten years of Stack Overflow answers and postmortems; Dqlite has a fraction of that.

microk8s kubectl and a separately-installed kubectl are not automatically the same context. Running both on one host is a common source of “why isn’t my change showing up” confusion — always check which kubeconfig each one is actually pointed at.

Add-on versions lag upstream by design. If a CVE fix or a new feature lands in upstream ingress-nginx or Cilium, MicroK8s’s bundled add-on version won’t have it until the next MicroK8s release validates it. Don’t assume microk8s enable X gets you the latest X.

Classic confinement means MicroK8s can conflict with other host-level network tooling. Firewalls, VPN clients, or other snap-confined networking tools on the same host can produce iptables/nftables rule conflicts that look like a Kubernetes networking bug but are actually a confinement-boundary interaction.


Quick Reference

microk8s status --wait-ready       # cluster health, HA state, enabled add-ons
microk8s enable <addon>            # dns, storage, ingress, metallb, cilium, gpu, ...
microk8s disable <addon>           # remove an add-on
microk8s kubectl <args>            # bundled kubectl, uses MicroK8s's own kubeconfig
microk8s add-node                  # generate a join token for HA/worker expansion
microk8s join <token>               # join a node using that token
microk8s inspect                   # generate a full diagnostic tarball for support/debugging
microk8s remove-node <node>         # remove a node from an HA cluster

Contribution Opportunity: Dqlite’s Missing Decade of Tooling

The limitation: etcd operators have etcdctl endpoint status, etcdctl endpoint health, mature snapshot/restore tooling, and a decade of documented failure-mode runbooks. Dqlite’s CLI surface for diagnosing a struggling Raft cluster — checking leader state, log index lag between nodes, or safely restoring from a snapshot after a prolonged partition — is meaningfully thinner. When a MicroK8s HA node fails to rejoin cleanly, there’s far less prior art to lean on than for the etcd equivalent.

Why it’s hard to fix: This isn’t a bug sitting in an issue tracker waiting for a quick patch. It’s a maturity gap that comes from Dqlite being a newer, narrower-scope project (built primarily to serve Canonical’s own products) maintained by a much smaller team than the decade of enterprise-scale usage that produced etcd’s tooling. Closing that gap is a sustained, multi-quarter documentation-and-tooling effort, not a single PR — and it competes for the Dqlite maintainers’ time against Canonical’s own product roadmap, which doesn’t automatically prioritize the broader Kubernetes community’s operational wishlist.

What a contribution-shaped fix looks like: Two concrete, achievable starting points that don’t require deep Raft-internals expertise: (1) a dqlite-side diagnostic command mirroring etcdctl endpoint status — human-readable leader/term/log-index output — contributed to canonical/go-dqlite; or (2) reproducing and documenting specific HA failure scenarios (node rejoin after a prolonged network partition, recovery from a minority-node failure) as runbooks in canonical/microk8s‘s own documentation repo, with the exact recovery commands verified against a real reproduction. The second option in particular is the kind of contribution an engineer who’s actually operated MicroK8s in production is uniquely positioned to make — it needs careful reproduction and clear writing, not kernel-level systems expertise.


Key Takeaways

  • MicroK8s packages a full, CNCF-conformant Kubernetes cluster into a single snap install — no separate runtime, etcd, or CNI setup required
  • The add-on model trades version flexibility for predictability: what you enable is validated and bundled, not necessarily the latest upstream release
  • Dqlite replaces etcd for HA — a legitimate architectural choice, but one with far less operational tooling and community track record behind it
  • MicroK8s’s real fit is edge/IoT, ephemeral CI clusters, and single-node dev environments — not large multi-tenant production clusters with deep etcd expertise already in place
  • The clearest contribution opportunity here doesn’t require Raft internals — it requires operating MicroK8s in production long enough to hit a real failure mode and documenting it precisely

What’s Next

MicroK8s trades control for a near-instant single-command cluster. EP02 looks at Minikube — the other dominant “local Kubernetes” tool, built on a different trade-off entirely: a full VM (or container-based driver) per cluster instead of a bare-metal snap install, and where that heavier model actually earns its overhead.

Next: EP02 — Minikube: Local Kubernetes Done Right — and Where It Breaks Down

Get EP02 in your inbox when it publishes → linuxcent.com/subscribe