Kubernetes Features That Hurt in Production: A Framework for Safer Adoption

Kubernetes Features That Hurt in Production: A Framework for Safer Adoption

If you manage Kubernetes in production, you’ve likely felt the sting of a feature that promised stability but delivered chaos. The community is rich with stories of PodDisruptionBudgets (PDBs) that blocked critical updates, misconfigured liveness probes that created restart loops, and resource limits that turned a simple deployment into a cascading failure. These aren’t inherently “bad” features; they are powerful tools that, like a surgeon’s scalpel, require precise understanding and context to use effectively.

Related reading: liveness probe anti-patterns.

Drawing from a wealth of shared experience—including community discussions and documented failure stories—a clear pattern emerges. The gap between a feature’s theoretical promise and its production reality is often bridged not by more documentation, but by operational rigor. This post analyzes common pitfalls, not to discourage the use of these features, but to provide a decision framework for platform teams to evaluate adoption, focusing on observability, gradual rollout, and clear rollback plans.

The Gap Between Theory and Practice: Features That Bite Back

Kubernetes is designed to automate complex distributed systems patterns. However, this automation can amplify misconfigurations at scale. The following features are frequently cited as sources of production pain, precisely because their power is double-edged.

1. PodDisruptionBudgets (PDBs): The Update Blocker

On paper, a PDB is a safeguard. It ensures a minimum number of pods for a critical application remain available during voluntary disruptions like node drains or cluster upgrades. The theory is flawless.

The practice, as shared by many engineers, reveals the trap: a PDB with overly restrictive minAvailable or maxUnavailable settings can completely halt cluster maintenance. Imagine a deployment with 3 pods and a PDB set to minAvailable: 3. Any drain operation is now impossible, stalling node security patches or Kubernetes version upgrades. The cluster’s ability to heal and evolve is held hostage by a configuration intended to protect it.

The deeper lesson isn’t to avoid PDBs, but to configure them with the system’s evolution in mind. They must allow for the cluster’s own lifecycle operations.

2. Liveness and Readiness Probes: The Self-Inflicted Outage

Probes are the cornerstone of Kubernetes’ self-healing and traffic management. A liveness probe failure restarts the pod; a readiness probe failure removes it from service endpoints. This is essential for resilience.

In production, misconfigured probes are a classic source of instability. Common pitfalls include:

  • Overly sensitive liveness checks: A probe checking an endpoint that briefly spikes in latency due to a downstream cache miss can cause a restart loop, exacerbating the problem and taking the service fully down.
  • Resource-intensive probes: A probe that executes a heavy database query every few seconds can itself become a source of resource exhaustion and latency, creating a feedback loop of failure.
  • Incorrect readiness signals: An application marked “not ready” during its entire startup or lengthy initialization will never receive traffic, appearing as a deployment failure.

As noted in the Kubernetes configuration overview, probes must be designed to reflect the actual health of the application, not an idealized state. They should be cheap, stable, and representative.

3. Resource Requests and Limits: The Silent Strangulation

Setting CPU and memory requests/limits is Kubernetes 101. They ensure fair scheduling and prevent a single pod from consuming all node resources. The theory is fundamental to multi-tenancy.

The production reality is subtler. Setting limits too low (“limit starvation”) is a frequent cause of mysterious, intermittent failures. A pod hitting its CPU limit is throttled, causing increased latency and timeouts. A pod hitting its memory limit is OOMKilled instantly. The symptoms—slow responses or disappearing pods—often point to application bugs, masking the true infrastructure cause.

Conversely, setting requests too high leads to poor cluster utilization and scheduling headaches. The key is continuous observation: limits should be informed by actual usage under load, not initial guesses.

4. Helm Hooks and Complex Operators: The Unpredictable Orchestrator

Helm hooks and custom operators automate complex lifecycle tasks: database migrations, secret injection, or pre-upgrade validation. They abstract away imperative steps.

In production, this abstraction can become a black box. A post-install hook that fails can leave a release in a stuck state. An operator with a bug in its reconciliation logic can enter a loop, endlessly creating and deleting resources. The complexity of debugging an automated system that has gone awry often far exceeds the complexity of the manual process it replaced. The failure stories aggregated in resources like kubernetes-failure-stories are replete with examples of automation gone wrong.

A Framework for Safer Feature Adoption

Banning powerful features is not the answer. The goal is to adopt them with eyes wide open. Platform teams should implement a framework that evaluates risk and mandates safeguards. Here is a practical, four-phase approach.

Phase 1: Evaluation & Contextual Understanding

Before enabling a feature cluster-wide or recommending it to application teams, ask:

  • What problem does this solve for us? Is it a real pain point, or just a “nice-to-have”?
  • What is the failure mode? How can this feature break? (e.g., PDBs block drains, probes cause restarts).
  • What are the observability requirements? What metrics, logs, and alerts do we need to see if it’s misbehaving?
  • What is the rollback procedure? How do we quickly disable or revert this feature if it causes an incident?

Phase 2: Implementation with Guardrails

Deploy the feature with constraints that limit its blast radius.

  • Start with non-critical workloads: Apply PDBs first to staging or low-priority services.
  • Use sane defaults via Policy-as-Code: Use tools like OPA/Gatekeeper or Kyverno to enforce safe defaults. For example, a policy could forbid PDBs with minAvailable: 100% or enforce a maximum probe timeout.
  • Document the “why” and the “how to escape”: Annotate resources or maintain runbooks that explain the configuration and the steps to neutralize it in an emergency.

Phase 3: Gradual Rollout & Observability

Treat feature adoption like a software deployment.

  • Canary the configuration: Apply a new PDB or aggressive probe to one pod or one namespace first. Monitor its effect closely.
  • Implement specific monitoring: Beyond general cluster health, create alerts for:
    – PDBs blocking evictions for > X minutes.
    – Pod restart counts spiking (potential probe issue).
    – Containers hitting CPU throttling or being OOMKilled.
    – Helm releases stuck in a pending hook state.

A simple Prometheus alert for PDB blockage might look like this:

# Alert if a PDB is blocking voluntary pod disruptions for too long
- alert: PDBBlockingDisruption
  expr: kube_poddisruptionbudget_status_current_healthy == kube_poddisruptionbudget_status_desired_healthy
    and (kube_poddisruptionbudget_status_desired_healthy - kube_poddisruptionbudget_status_expected_pods) == 0
    and kube_poddisruptionbudget_status_disruptions_allowed == 0
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "PDB {{ $labels.namespace }}/{{ $labels.poddisruptionbudget }} is blocking all pod disruptions"
    description: "The PDB requires all pods to be available, preventing node drains or updates for 10 minutes."

Phase 4: Review and Iteration

Adoption isn’t a one-time event. Regularly review:

  • Are the features providing the intended value? Are PDBs actually increasing availability during updates?
  • What incidents or near-misses have they been involved in? Use post-incident reviews to refine configurations and policies.
  • Can we improve defaults or abstractions? Can the platform team provide a simplified, safe Custom Resource or Helm chart that encapsulates best practices?

Frequently Asked Questions

Which Kubernetes features cause the most production incidents?

The recurring offenders are the ones that act automatically on your behalf: PodDisruptionBudgets that block node drains forever, liveness probes that restart healthy pods under load, and aggressive affinity rules that make workloads unschedulable. None of them are bad features — they hurt when adopted with defaults copied from a tutorial instead of settings derived from your workload.

Why is a PodDisruptionBudget risky if it protects availability?

Because a PDB with maxUnavailable: 0 (or a selector matching a single replica) makes voluntary disruption impossible: node drains hang, upgrades stall, and cluster maintenance turns into manual pod deletion at 2 AM. A PDB must always leave the cluster a legal way to move your pods.

Should I avoid these features altogether?

No — the article’s point is the opposite. Adopt them deliberately: understand the failure mode each feature introduces, test that failure mode (drain a node, kill a replica) before production, and roll out with conservative settings you tighten over time. Power tools, respected.

How do I evaluate a Kubernetes feature before adopting it?

Four questions: What does it do automatically and when? What is the failure mode when it misfires — and does it fail open or closed? Can I observe it acting (events, metrics)? And can I roll it back under pressure? If you cannot answer all four, you are adopting a behavior, not a feature.

Conclusion: Embrace Power, Respect Complexity

The history of engineering is the history of building more powerful tools and learning to wield them safely. Kubernetes features like PDBs, probes, and resource management are no different. Their potential for causing production pain is a direct reflection of their power to automate complex, critical behaviors.

The path forward is not avoidance, but disciplined adoption. By shifting from a mindset of “enable and hope” to a framework of “evaluate, guard, observe, and iterate,” platform teams can harness these powerful features to build more resilient, self-healing systems without becoming victims of their own automation. The lessons are already written in the community’s failure stories; the task is to learn from them and build a safer, more informed practice.

Why I Built an Investment Tracker That Doesn’t Connect to Your Broker

Every investment tracking app I tried wanted the same thing: my brokerage credentials. Plaid connections, OAuth flows into my bank, screen-scraping logins — all so I could see a number I already knew, refreshed every fifteen seconds, wrapped in notifications engineered to make me check it again.

I didn’t want any of that. I wanted the opposite: a private investment tracker where I enter my portfolio value once a month, and the app does the thinking — trends, allocation, forecasts, goals. No broker login. No server. No account.

So I built Portfolio Journal, a native iOS app. This post covers why manual tracking is a feature (not a limitation), and some of the architecture decisions behind a local-first finance app.

The case for manual, monthly tracking

If you’re a long-term investor — index funds, ETFs, retirement accounts — real-time data is noise. Checking your portfolio daily correlates with worse decisions: panic selling, overtrading, chasing performance. The behavioral finance literature has been consistent on this for decades.

A monthly check-in flips the model. Once a month you open the app, type in what each account is worth, and close it. Thirty seconds. What you get in return:

  • A deliberate ritual instead of anxious refreshing. You see your net worth evolve on a timescale where your decisions actually matter.
  • Any asset class. Manual entry means the app doesn’t care whether it’s a brokerage account, a pension fund your employer manages, real estate, crypto in cold storage, or cash under the mattress. If it has a value, you can track it.
  • No integration rot. Aggregator connections break constantly — banks change their APIs, scrapers get blocked, tokens expire. A manual app works identically forever.

Privacy as an architecture decision

“We take your privacy seriously” is what apps say when they upload your data to their servers with encryption. Portfolio Journal takes a different approach: there is no server.

  • All data lives in Core Data on the device.
  • Sync, if you enable it, goes through CloudKit’s private database — Apple’s infrastructure, your iCloud account, end-to-end within your devices. I never see a byte of your financial data. There is no backend I could be subpoenaed for, no database to breach, no analytics pipeline ingesting your net worth.
  • Export is a CSV you own. Import is a CSV you control.

For a finance app, this is the only architecture I’d trust with my own data — so it’s the only one I’d ship to anyone else.

The interesting technical bits

A local-first iOS finance app sounds simple until you build one. A few things that turned out to be genuinely hard:

CloudKit sync without a backend

NSPersistentCloudKitContainer gives you Core Data ↔ CloudKit sync “for free,” but the free tier of understanding will hurt you. Records only export if they have persistent history; data created before history tracking was enabled silently never syncs. SQLite optimizes away no-op updates, so “touch the record to force an export” does nothing unless the value actually changes. Debugging partial-failure export errors (CKErrorPartialFailure) taught me more about CloudKit internals than any documentation.

Charts that respect a monthly cadence

With one data point per month per account, naive charting libraries produce garbage. Everything — evolution curves, drawdown analysis, year-over-year comparisons, contribution-vs-returns decomposition — is built on Swift Charts with explicit monthly bucketing, and a global range brush that filters every chart at once.

Forecasting without pretending to know the future

The prediction engine fits growth scenarios to your actual contribution and return history, then projects ranges — not a single line promising you’ll be a millionaire by Tuesday. It’s deliberately boring math, cached aggressively because users scrub through time ranges.

What it looks like in practice

You create your accounts once (brokerage, pension, savings — whatever you have). Each month, a notification nudges you; you enter current values in the quick-update sheet. The dashboard shows total net worth, streaks, milestones, allocation drift, and how much of your growth came from contributions versus market returns. Goals track progress toward targets. A journal captures what you were thinking each month — which, a year later, is the most valuable feature of all.

There’s a home screen widget, Face ID lock, multi-currency support, and share cards if you want to post a (privacy-filtered) snapshot of your progress.

Try it

Portfolio Journal is on the App Store — free to use, with an optional premium tier for advanced charts and unlimited accounts. More at portfoliojournal.app.

If you’re the kind of investor who checks their portfolio once a month on purpose — this was built for you. And if you’re an iOS developer curious about local-first CloudKit apps, the sync war stories alone are worth a follow-up post. Let me know in the comments which part you’d want to read about.

Kubernetes Namespace Isolation: When Security Boundaries Fail

Kubernetes Namespace Isolation: When Security Boundaries Fail

If you manage multi-tenant Kubernetes clusters or enforce strict separation between development, staging, and production environments, you likely rely on namespaces as a primary security boundary. It’s a logical, clean model: workloads and users are isolated within their designated namespaces, creating a clear perimeter for access control and network traffic.

Related reading: the 2026 Kubernetes hardening guide.

But what if this boundary is more of a suggestion than a hard wall? The reality is that namespace isolation, as defined by the Kubernetes security model, is a construct built on the correct configuration of several underlying controls. When one of those controls fails—often through misconfigured platform-level tools—the entire boundary can collapse, enabling lateral movement that compromises the security of your entire cluster.

This isn’t a theoretical concern. Recent vulnerabilities, like the one detailed in a discussion on CVE-2026-22039, demonstrate how admission controllers, which are meant to enforce security, can be exploited to bypass namespace isolation entirely. This event isn’t an anomaly; it’s a symptom of a broader pattern where the complexity of the platform layer introduces critical gaps in a core security tenet.

The Illusion of the Hard Boundary

Kubernetes documentation is clear: “Namespaces are a way to divide cluster resources between multiple users.” They are a mechanism for scoping names and organizing objects. However, they are not a security feature by themselves. Isolation is achieved through the combination of:

  • RBAC (Role-Based Access Control): Governing who can do what, and where.
  • Network Policies: Controlling pod-to-pod communication.
  • Admission Controllers: Validating and mutating requests before persistence.
  • Resource Quotas & Limit Ranges: Managing resource consumption.

The security boundary exists only when all these layers are correctly configured and aligned. A flaw in any one layer—especially in a cluster-scoped component like an admission controller, a monitoring agent, or a service mesh sidecar injector—can create a bridge between namespaces.

How the Boundary Fails: Real-World Exploit Paths

Let’s examine common failure modes that break namespace isolation, moving from conceptual to concrete.

1. Privileged Admission Controller Exploits

The CVE-2026-22039 discussion highlights a classic case. An admission controller with broad permissions (e.g., cluster-admin or powerful ClusterRole bindings) is deployed to validate resources. If this controller has a vulnerability—such as improperly validating the requesting user or namespace of the mutated object—an attacker could craft a request that tricks the controller into creating or modifying resources in a namespace they should not have access to.

Attack Flow:

  1. Attacker has compromised a pod in the tenant-a namespace.
  2. They discover a cluster-scoped admission controller (e.g., a policy engine) is vulnerable to a confused deputy attack.
  3. They send a malicious payload to the API server that triggers the admission controller.
  4. The admission controller, operating with high privileges, is tricked into creating a privileged ServiceAccount or a pod with host network access in the kube-system namespace.
  5. Isolation is broken; lateral movement to a critical namespace is achieved.

2. Misconfigured Network Policies (or Their Absence)

By default, Kubernetes networking allows all pods to communicate with each other, regardless of namespace. Without Network Policies, a compromised pod in dev can directly probe and attack pods in production. Even with policies, a single overly permissive rule (e.g., allowing ingress from all namespaces for a debugging port) can create a breach.

3. Over-Permissioned Service Accounts & Pods

ServiceAccounts are namespaced, but the RBAC Roles or ClusterRoles bound to them are not. A common misconfiguration is binding a namespaced ServiceAccount to a powerful ClusterRole. A pod using that ServiceAccount effectively has those cluster-wide privileges, allowing it to read secrets, delete pods, or create bindings in any namespace.

A Framework for Testing Namespace Boundaries

Assuming isolation is dangerous. You must actively test it. Here is a practical framework for platform and security teams.

Phase 1: Discovery & Mapping

  • Inventory Cluster-Scoped Components: List all deployments, daemonsets, and pods in kube-system, gatekeeper-system, istio-system, etc. Document their assigned ServiceAccounts and associated RBAC.
  • Audit RBAC Bindings: Use kubectl get clusterrolebindings -o wide and kubectl get rolebindings --all-namespaces to find any bindings of powerful roles to ServiceAccounts or users in non-critical namespaces.
  • Map Network Policies: Generate a visual or logical map of allowed ingress/egress flows between namespaces. Identify namespaces with no policies applied.

Phase 2: Active Penetration Testing

From the perspective of a compromised pod in a non-privileged namespace (simulate with a benign test pod), run controlled tests.

Test for Privilege Escalation:

# Inside the test pod
# 1. Check the pod's own permissions
kubectl auth can-i --list

# 2. Attempt to list resources in other namespaces
kubectl get pods -n kube-system
kubectl get secrets -n production

# 3. Attempt to create a pod in another namespace
cat <<EOF | kubectl apply -f - --namespace=kube-system
apiVersion: v1
kind: Pod
metadata:
  name: test-breakout
spec:
  containers:
  - name: busybox
    image: busybox
    command: ['sh', '-c', 'sleep 3600']
EOF

Test for Network Access:

# Use netcat or curl to probe internal services in other namespaces
# Find the ClusterIP of a service in the target namespace
kubectl get svc -n production --output=wide

# From test pod, attempt to connect
curl -v http://<production-service-cluster-ip>:<port>
nc -zv <production-service-cluster-ip> <port>

Phase 3: Admission Controller Stress Testing

This is more advanced but critical. Craft resource manifests designed to probe the logic of your policy engines (e.g., OPA Gatekeeper, Kyverno, custom webhooks).

  • Submit requests with mismatched or spoofed namespace fields in the object metadata versus the request path.
  • Attempt to create objects that reference resources (like ConfigMaps or Secrets) in other namespaces.
  • Test if controllers correctly validate the userInfo (username, groups) of the requester in their decision-making logic.

Hardening the Boundary: Defensive Controls

Testing reveals gaps; these controls close them.

1. Implement Zero-Trust Networking

Default-deny is the only sane starting point. Apply a baseline NetworkPolicy to every namespace that denies all ingress from other namespaces.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-cross-namespace
  namespace: <protected-namespace>
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector: {} # Only allow from pods in the SAME namespace

Explicitly allow required cross-namespace communication (e.g., from monitoring or service mesh namespaces) using namespaceSelector rules, not open CIDR blocks.

2. Principle of Least Privilege for Platform Tools

Scrutinize the RBAC for every cluster-scoped component. Does your admission controller really need create permission on pods cluster-wide? Or can its role be scoped to specific namespaces or resource types? Use tools like kubectl audit or third-party RBAC analyzers to find and reduce over-permissive bindings.

3. Namespace-as-a-Boundary for ServiceAccounts

Never bind a namespaced ServiceAccount to a ClusterRole unless it is absolutely necessary. If a component needs to act in multiple namespaces, create separate ServiceAccounts and RoleBindings in each namespace, or use a tightly scoped ClusterRole that only lists the required namespaced resources.

Monitoring for Boundary Violations

Detection is your last line of defense. Monitor the Kubernetes API audit logs for tell-tale signs of boundary crossing.

  • Unauthorized Cross-Namespace API Calls: Alert on create, update, or get requests where the requesting user/service account’s namespace differs from the target object’s namespace, and the action is not explicitly allowed by a known pattern (e.g., cluster-admin, system components).
  • Network Policy Violation Attempts: If using a CNI that supports it (like Cilium), export flow logs and alert on denied connection attempts from one namespace to a sensitive another.
  • Admission Controller Anomalies: Monitor the logs of your admission webhooks for a high rate of denials or errors, which could indicate probing or exploit attempts.

Frequently Asked Questions

Are Kubernetes namespaces a security boundary?

Not by default. A namespace is a logical grouping for names, quotas and RBAC scoping — but the network is flat, and nothing stops a pod in one namespace from calling a Service in another. Namespaces only become a security boundary when you actively enforce one: default-deny NetworkPolicies, scoped RBAC, and admission controls.

Can pods in different namespaces communicate by default?

Yes — any pod can reach any other pod or Service across namespaces out of the box (service.other-ns.svc.cluster.local). If your threat model assumes tenant separation, that default is the first thing to close with a default-deny NetworkPolicy per namespace.

What commonly escapes namespace isolation?

Cluster-scoped resources (nodes, CRDs, ClusterRoles), anything with host access (hostPath, hostNetwork, privileged pods), the shared kernel itself, and RBAC grants that quietly cross namespaces (ClusterRoleBindings). Auditing those is more valuable than adding more controls inside the namespace.

How do I actually harden namespace boundaries?

Layered: default-deny NetworkPolicy (ingress and egress) per namespace, Roles instead of ClusterRoles wherever possible, ResourceQuota and LimitRange to contain blast radius, Pod Security Admission at restricted, and periodic boundary testing — the article’s framework — to verify the isolation you think you have actually holds.

Conclusion: Isolation as an Active Discipline

Namespace isolation in Kubernetes is not a static configuration you set and forget. It is a dynamic security property that must be continuously validated, enforced, and monitored. The platform’s complexity and extensibility, while powerful, are the very factors that introduce fragility into this boundary.

The deprecation of a component like Ingress-NGINX teaches us to build frameworks for change. Similarly, breaches of namespace isolation teach us that security is not a feature of a single object (the namespace), but an emergent property of the entire system’s configuration. Your cluster’s security boundary is only as strong as the weakest link in the chain of RBAC, network policies, and admission control. Treat it as a critical, living part of your platform—one that demands proactive testing, rigorous hardening, and vigilant observation.

Lazy-Pulling in Containerd v2: Why Pull Time Isn’t the Right Metric

Lazy-Pulling in Containerd v2: Why Pull Time Isn't the Right Metric

If you’re running containerized workloads at scale, you’ve likely encountered the cold-start problem: a pod gets scheduled, the container runtime begins pulling a multi-gigabyte image, and your application sits idle while layers download. This latency directly impacts developer velocity, autoscaling responsiveness, and user experience.

Related reading: daemonless image tooling with Skopeo, Crane and regctl.

Containerd’s lazy-pulling feature (also known as on-demand pulling or stargz) promises to solve this by fetching only the metadata initially, then downloading individual file chunks as the container accesses them. The promise is compelling—near-instant container startup regardless of image size. However, the community conversation around this feature has been dominated by a misleading metric: pull time.

In this article, we’ll dismantle the pull time benchmark fallacy and examine the real performance characteristics that matter for production workloads. We’ll explore how lazy-pulling actually works under the hood, what metrics you should be measuring instead, and when this technology makes sense for your infrastructure.

How Lazy-Pulling Actually Works in Containerd

Before we can understand the metrics, we need to understand the mechanism. Traditional container image pulling follows a sequential, all-or-nothing approach:

  1. The container runtime requests an image manifest.
  2. It downloads all layer blobs (compressed tarballs) to local storage.
  3. It decompresses and extracts all layers to create the final root filesystem.
  4. Only then can the container start.

Lazy-pulling fundamentally changes this workflow. Based on the containerd operations documentation and community implementations, the process looks more like this:

  1. The runtime fetches the image manifest and a specialized index file that maps file paths to byte ranges within compressed layers.
  2. It prepares a virtual filesystem that appears to contain the complete image.
  3. When a process inside the container attempts to read a file, the filesystem driver intercepts the request.
  4. It consults the index, fetches only the specific byte ranges needed for that file from the registry, decompresses them on-the-fly, and serves the data to the application.
  5. Fetched chunks are cached locally to avoid redundant network requests.

This approach is conceptually similar to how modern game engines stream assets or how HTTP range requests enable video streaming. The key insight is that most containers only need a small subset of their total image content during initial startup.

The Pull Time Fallacy: What Traditional Benchmarks Get Wrong

When lazy-pulling first gained attention, the most common benchmark comparison was simple: “How long does it take to pull a 1GB image versus lazy-pull the same image?” This metric is not just incomplete—it’s actively misleading for several reasons.

1. Pull Time Measures the Wrong Phase

With traditional pulling, the pull command blocks until all layers are downloaded and extracted. With lazy-pulling, the pull command returns almost immediately after fetching metadata. Comparing these two durations is like comparing the time to download a movie trailer versus the time to buffer the entire film—they’re measuring fundamentally different operations.

The meaningful comparison isn’t pull time versus lazy-pull time. It’s total time to application readiness. This includes:

  • Metadata fetch time
  • Container initialization time
  • Application bootstrap time (where file accesses actually happen)
  • Any network latency incurred during on-demand fetching

2. It Ignores Workload Access Patterns

As detailed analysis has shown, the performance impact of lazy-pulling depends entirely on your container’s filesystem access patterns during startup. Consider two extreme examples:

  • Node.js application: Starts quickly, requires few files (node binary, package.json, a handful of .js files). Lazy-pulling provides near-instant startup.
  • Java application with large classpath: Scans hundreds of JAR files during initialization, triggering many small reads across the filesystem. This could result in hundreds of HTTP range requests, adding significant latency.

A single “pull time” metric cannot capture this complexity. The real question is: How does the filesystem access pattern during application startup map to the image’s layer structure?

3. It Misses the Caching Benefit

Traditional pulling has a clear caching model: once a layer is pulled, subsequent containers using that layer start instantly (assuming no registry changes). Lazy-pulling introduces a more nuanced caching model where individual file chunks are cached after first access.

This means the performance of lazy-pulling improves for frequently accessed files across container instances, but benchmarking typically focuses on cold starts only.

The Metrics That Actually Matter for Production

To properly evaluate lazy-pulling for your workloads, you need to measure these key performance indicators:

1. Time-to-First-Byte (TTFB) for Application Requests

This is the most critical metric for user-facing services. How long from kubectl apply or horizontal pod autoscaler decision until your application can serve its first request? This metric encompasses:

  • Pod scheduling
  • Image provisioning (metadata fetch for lazy-pulling)
  • Container runtime initialization
  • Application startup (including any on-demand file fetching)
  • Health check passes

For autoscaling scenarios, TTFB directly impacts your ability to handle traffic spikes.

2. Application Startup Latency Breakdown

Instead of measuring “pull time,” instrument your application to report key milestones:

# Example instrumentation points for a web application
START_TIME=$(date +%s.%N)
# ... application code ...
CONTAINER_READY=$(date +%s.%N)  # Runtime reports container running
IMPORTS_LOADED=$(date +%s.%N)   # After requiring dependencies
DATABASE_CONNECTED=$(date +%s.%N) # After DB connection established
HTTP_LISTENING=$(date +%s.%N)   # Server listening on port

By comparing these timestamps between traditional and lazy-pulled containers, you can identify exactly where latency is introduced or saved.

3. Network Request Patterns During Startup

Monitor the filesystem layer to understand what’s being fetched:

  • How many HTTP range requests occur during startup?
  • What’s the total bandwidth transferred versus the full image size?
  • What’s the request pattern (sequential vs random access)?
  • How effective is the local chunk cache?

This data helps you optimize image construction. If your application sequentially reads every file in /usr/lib during startup, you might want to ensure those files are contiguous in the image layer to minimize range requests.

4. Registry Load and Scaling Characteristics

Lazy-pulling changes the load pattern on your container registry. Instead of occasional large downloads, you get frequent small range requests. You need to measure:

  • Registry requests per second during scaling events
  • Cache hit ratios at registry CDN level
  • Impact on registry authentication systems (more requests = more token validation)

Implementation Considerations and Gotchas

Based on the Kubernetes images documentation and containerd’s implementation, here are practical considerations for adopting lazy-pulling:

Compatibility and Requirements

Lazy-pulling requires specific configuration and compatible images:

  • Runtime support: Containerd with appropriate snapshotter (e.g., stargz, overlaybd)
  • Image format: Images must be built with lazy-pulling support (e.g., using ctr-remote or nerdctl with stargz compression)
  • Registry support: The registry must support HTTP range requests (most do, but verify)
  • Kubernetes version: No specific version requirement, but newer kubelets have better integration

When Lazy-Pulling Excels

  • Large base images with small applications: e.g., a 2GB ubuntu:latest base running a 50MB Go binary
  • Ephemeral workloads: CI/CD runners, batch jobs, function-as-a-service platforms
  • Memory-constrained environments: Lazy-pulling avoids decompressing entire layers to disk
  • Autoscaling scenarios: When you need to scale from zero quickly

When Traditional Pulling May Be Better

  • Applications with random access patterns: Databases that read many files during startup
  • Network-limited environments: If each range request has high latency, the overhead adds up
  • Air-gapped or registry-constrained deployments: Predictable bandwidth usage may be preferable
  • Images with poor locality: Files accessed together aren’t stored together in the image

Practical Recommendations for Platform Teams

1. Profile Before You Optimize

Don’t implement lazy-pulling globally based on synthetic benchmarks. Start with profiling:

  1. Identify your most frequently scaled or restarted workloads
  2. Measure their actual file access patterns during startup (using strace or eBPF tools)
  3. Build test images with lazy-pulling support
  4. A/B test against traditional images in staging
  5. Measure the real metrics: TTFB, resource usage, registry load

2. Optimize Image Construction

Lazy-pulling performance depends heavily on image layout. Follow these guidelines:

  • Place files accessed during startup close together in the layer
  • Keep startup dependencies in as few layers as possible
  • Consider separating runtime dependencies from build dependencies
  • Use multi-stage builds to minimize final image size

3. Implement Gradual Rollout

When deploying lazy-pulling in production:

  1. Start with non-critical, ephemeral workloads
  2. Implement comprehensive monitoring for both performance and errors
  3. Set up alerts for abnormal request patterns to your registry
  4. Have a rollback plan (traditional images still available)

4. Monitor the Right Things

Beyond application metrics, monitor infrastructure impacts:

  • Registry request rate and latency percentiles
  • Node filesystem cache efficiency
  • Network bandwidth patterns (many small requests vs few large ones)
  • Container startup failure rates

The Future of Container Image Distribution

Lazy-pulling represents a fundamental shift from treating container images as monolithic blobs to treating them as structured, queryable datasets. This evolution will likely continue with:

  • Intelligent prefetching: Systems that learn access patterns and prefetch likely-needed chunks
  • Content-defined chunking: More efficient than fixed-range requests
  • Peer-to-peer distribution: Nodes sharing fetched chunks locally
  • Integration with GitOps: Knowing which image versions will be needed based on Git changes

The key takeaway is that we’re moving beyond simple “pull time” optimization toward holistic container lifecycle optimization. The metric that matters isn’t how fast we can download bytes, but how quickly we can deliver working applications to users.

Frequently Asked Questions

What is lazy-pulling in containerd?

Starting a container before the full image has been downloaded: the snapshotter mounts the image and fetches file chunks on demand from the registry as the process actually reads them. Formats like eStargz make this possible by making image layers seekable, so containerd can pull “just enough” instead of everything.

Does lazy-pulling make image pulls faster?

It makes time-to-ready faster, which is not the same thing. Total bytes transferred can end up similar or even higher; what changes is that the container starts in seconds instead of waiting minutes for a full pull. That is why judging it by classic pull-time benchmarks misses the point — the metric that matters is how quickly the workload is actually serving.

What do I need to enable lazy-pulling?

Two pieces: a lazy-capable snapshotter (like the stargz snapshotter) configured in containerd, and images converted to a seekable format — a build/CI step, since a standard OCI image cannot be lazily pulled. Both halves matter: the runtime alone does nothing for unconverted images.

What are the trade-offs of lazy-pulling in production?

First-access latency: files fetched on demand pay a network round-trip the first time they are read. Runtime registry dependency: if the registry is unreachable after start, a cold file read can fail — so registry availability becomes a runtime concern, not just a deploy-time one. And cache behavior changes capacity planning on the nodes.

Conclusion

Lazy-pulling in containerd v2 is a powerful technology that can dramatically improve container startup performance, but only if measured and implemented correctly. The traditional “pull time” benchmark is a relic of an earlier approach to container images and should be replaced with application-centric metrics like time-to-first-byte and startup latency breakdowns.

For platform engineers and SREs, the path forward is clear: profile your actual workloads, understand their filesystem access patterns, test lazy-pulling with realistic metrics, and implement gradually with proper monitoring. When applied to the right workloads with optimized images, lazy-pulling can transform your scaling responsiveness and resource efficiency.

Remember: the goal isn’t faster image pulls—it’s faster applications. Measure what matters.

Beyond GPU Drivers: Custom Node Readiness for Specialized Workloads

Beyond GPU Drivers: Custom Node Readiness for Specialized Workloads

If you manage Kubernetes clusters for specialized workloads—think AI/ML, high-performance computing, financial modeling, or real-time data processing—you know that a node being Ready in the Kubernetes sense is often just the starting point. The standard kubelet health checks ensure the node’s core services are running, but they say nothing about whether the specific, often expensive, hardware or software dependencies your workload requires are truly operational.

Related reading: GPU scheduling with Dynamic Resource Allocation.

You might have nodes with GPUs where the driver crashed, nodes with local NVMe volumes that haven’t finished formatting, or nodes where a mandatory security or monitoring agent is unhealthy. Scheduling a sensitive, resource-intensive pod onto such a node is a recipe for silent failures, degraded performance, or security non-compliance.

This is where the Node Readiness Controller moves from a convenience to a critical platform control plane component. While its introductory examples often focus on GPU driver checks, its real power lies in enforcing your platform’s unique definition of “ready.” In this article, we’ll move beyond the basics and explore how to implement custom node readiness gates for advanced, production-grade scenarios.

Why Standard Node Readiness Isn’t Enough

Kubernetes marks a node as Ready when its kubelet can communicate with the API server and reports that essential node conditions (like MemoryPressure, DiskPressure, PIDPressure, and the generic Ready) are false. This is a binary, infrastructure-level view.

For specialized workloads, you need to enforce application-level readiness. Consider these scenarios:

  • Local Storage Provisioning: A DaemonSet formats and mounts a local SSD. A pod requiring that fast storage must not schedule until the mount is confirmed writable.
  • Kernel Module Dependencies: Your workload needs a specific kernel module (e.g., rdma, nf_conntrack, a custom FPGA driver) loaded and configured. The node is healthy, but the module isn’t present.
  • Security & Compliance: Your security policy mandates that a host-based intrusion detection system (HIDS) or a data loss prevention (DLP) agent is running and heartbeating healthily on every node before any workload can run.
  • Hardware Attestation: In confidential computing or regulated environments, you may need cryptographic proof from a Trusted Platform Module (TPM) or AMD SEV-SNP that the node’s firmware and boot chain are in a known, trusted state.
  • External Resource Availability: The node needs access to a licensed software server, a specific network filesystem, or an external hardware security module (HSM).

Scheduling pods without these conditions met leads to runtime errors, inconsistent performance, or policy violations. The Node Readiness Controller, by adding custom conditions to the node’s status, allows you to define these gates explicitly. The Kubernetes scheduler will then treat a node as unschedulable until all conditions—both built-in and custom—are met.

Architecting Custom Readiness Checks

The pattern involves three key components:

  1. The Condition: A custom condition name (e.g., node.alexandre-vazquez.com/LocalStorageReady) you define and add to the node’s .status.conditions.
  2. The Checker: A controller or agent running on the node (often as a DaemonSet) that performs the actual validation (e.g., tests a mount, checks a kernel module, calls a health API).
  3. The Enforcer: The Node Readiness Controller itself, which watches for these conditions and applies a matching taint (e.g., node.alexandre-vazquez.com/local-storage-not-ready:NoSchedule) when the condition is False or Unknown.

Pods that require the specialized resource must then have a corresponding toleration for that taint. This creates a clean, declarative contract: the pod declares its dependency, and the platform guarantees the node meets the requirement before scheduling.

Practical Implementation: Node-Local Storage Readiness

Let’s implement a common pattern: ensuring a local NVMe volume is formatted, mounted, and performance-tested before allowing pods to claim it via a PersistentVolumeClaim.

First, we define a DaemonSet that runs an init container to prepare the storage and a main container that acts as the readiness checker. The checker will periodically validate the mount and update the node’s condition.

1. DaemonSet for Storage Provisioning and Health Checking

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: local-storage-readiness
  namespace: kube-system
spec:
  selector:
    matchLabels:
      name: local-storage-readiness
  template:
    metadata:
      labels:
        name: local-storage-readiness
    spec:
      hostPID: true
      containers:
      - name: checker
        image: alpine:latest
        command:
        - "/bin/sh"
        args:
        - "-c"
        - |
          # Function to update node condition
          update_condition() {
            local condition=$1
            local status=$2
            local message=$3
            local patch=$(cat <<EOF
            {
              "status": {
                "conditions": [
                  {
                    "type": "$condition",
                    "status": "$status",
                    "lastHeartbeatTime": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
                    "reason": "LocalStorageCheck",
                    "message": "$message"
                  }
                ]
              }
            }
            EOF
            )
            curl -k -X PATCH -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" -H "Content-Type: application/strategic-merge-patch+json" 
              "https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}/api/v1/nodes/$(cat /etc/hostname)/status" -d "$patch"
          }

          # Main check loop
          while true; do
            if mountpoint -q /mnt/local-ssd && [ -w /mnt/local-ssd ] && dd if=/dev/zero of=/mnt/local-ssd/test.bin bs=1M count=100 oflag=direct 2>&1 | grep -q 'records out'; then
              update_condition "LocalStorageReady" "True" "/mnt/local-ssd is writable and performant"
            else
              update_condition "LocalStorageReady" "False" "Local storage failed health check"
            fi
            sleep 30
          done
        securityContext:
          privileged: true
        volumeMounts:
        - name: local-ssd
          mountPath: /mnt/local-ssd
        - name: kube-api-access
          mountPath: /var/run/secrets/kubernetes.io/serviceaccount
          readOnly: true
      initContainers:
      - name: provisioner
        image: alpine:latest
        command: ["sh", "-c"]
        args:
          - mkfs.ext4 -F /dev/nvme0n1 && mkdir -p /mnt/local-ssd && mount /dev/nvme0n1 /mnt/local-ssd
        securityContext:
          privileged: true
        volumeMounts:
        - name: local-ssd
          mountPath: /mnt/local-ssd
        - name: device
          mountPath: /dev/nvme0n1
      volumes:
      - name: local-ssd
        hostPath:
          path: /mnt/local-ssd
          type: DirectoryOrCreate
      - name: device
        hostPath:
          path: /dev/nvme0n1
      - name: kube-api-access
        projected:
          sources:
          - serviceAccountToken:
              expirationSeconds: 3607
              path: token

2. NodeReadinessController Configuration

Now, configure the Node Readiness Controller to watch for our LocalStorageReady condition and apply a taint when it’s not True.

apiVersion: v1
kind: ConfigMap
metadata:
  name: node-readiness-controller-config
  namespace: kube-system
data:
  config.yaml: |
    conditions:
    - name: "LocalStorageReady"
      taint:
        key: "node.alexandre-vazquez.com/local-storage-not-ready"
        effect: "NoSchedule"
      state:
        true: "Ready"  # No taint when condition is True
        false: "NotReady"  # Apply taint when condition is False
        unknown: "NotReady" # Apply taint when condition is Unknown

3. Pod Specification with Toleration

A pod that requires this local storage must tolerate the taint, creating an explicit dependency.

apiVersion: v1
kind: Pod
metadata:
  name: data-processor
spec:
  tolerations:
  - key: "node.alexandre-vazquez.com/local-storage-not-ready"
    operator: "Exists"
    effect: "NoSchedule"
  containers:
  - name: app
    image: my-data-app:latest
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    hostPath:
      path: /mnt/local-ssd/pod-data
      type: DirectoryOrCreate

This pattern ensures the pod only schedules onto nodes where the local storage health check has passed. The checker’s simple performance test (dd) helps catch degraded disks that are mounted but failing.

Integrating with Node Feature Discovery and CI/CD

For checks based on hardware capabilities, integrating with Node Feature Discovery (NFD) is powerful. Instead of writing custom hardware detection logic, you can use NFD to label nodes with features (e.g., feature.node.kubernetes.io/cpu-cpuid.AVX512BW: "true"). Your readiness checker can then verify that these expected labels are present and correspond to a working feature.

For example, a checker could:

  1. Use NFD labels to identify nodes with specific CPU extensions or GPUs.
  2. Run a micro-benchmark or diagnostic (e.g., nvidia-smi --query-gpu=health --format=csv).
  3. Set a condition like GpuHealthy to True or False based on the result.

This creates a two-stage validation: NFD discovers the hardware, and your readiness controller certifies its operational health.

CI/CD Pipeline Integration

In a GitOps-driven platform, custom readiness conditions should be treated as first-class citizens. Your deployment pipelines can include steps to verify that target nodes are not only Ready but also meet all custom conditions before proceeding:

# Example pipeline step using kubectl
- name: Validate Node Readiness for Workload
  run: |
    REQUIRED_CONDITIONS=(
      "LocalStorageReady"
      "SecurityAgentReady"
      "FirmwareAttested"
    )
    
    for condition in "${REQUIRED_CONDITIONS[@]}"; do
      if ! kubectl get node $TARGET_NODE -o jsonpath="{.status.conditions[?(@.type=='$condition')].status}" | grep -q "True"; then
        echo "Error: Node $TARGET_NODE not ready. Condition $condition not met."
        exit 1
      fi
    done
    echo "All custom readiness conditions met for $TARGET_NODE."

This proactive check in your CD pipeline can prevent deployments from hanging or failing due to unmet node-level dependencies.

Advanced Use Case: Security Agent Health Gate

Consider a security team that mandates the presence and health of a host-based agent (e.g., Falco, a commercial EDR). A readiness checker DaemonSet can query the agent’s local health API or UNIX socket. If the agent is unresponsive or reports a compromised state, the checker sets a SecurityAgentReady condition to False, tainting the node and preventing any new workloads from scheduling. This hardens your security posture by ensuring no pod runs on a node with a broken security control.

Best Practices and Considerations

  • Condition Naming: Use a domain prefix (e.g., yourcompany.com/) to avoid collisions with future Kubernetes built-in conditions.
  • Checker Resilience: The checker DaemonSet itself must be highly available and lightweight. Ensure it has appropriate resource requests/limits and a restart policy that maintains the check.
  • Taint Effects: Use NoSchedule to prevent new pods. For immediate response to a condition breaking on a running node, you might also consider NoExecute with a tolerationSeconds value on pods, but this requires careful design to avoid unnecessary churn.
  • Combining Conditions: A node can have multiple custom conditions. The scheduler respects all of them; a pod will only schedule if it tolerates all the taints applied due to unmet conditions.
  • Monitoring and Alerting: Treat custom condition states as critical metrics. Alert your platform team when a False condition persists, indicating a node-level issue requiring investigation.

Frequently Asked Questions

What does the Kubernetes node Ready condition actually check?

Only kubelet fundamentals: container runtime up, network configured, no memory/disk pressure. It says nothing about GPU drivers, CSI mounts, security agents or anything workload-specific — a node can be Ready while everything your pods need is still initialising. That gap is what custom readiness closes.

How do I stop pods from scheduling before the GPU driver is ready?

Register a startup taint that a node-local check removes once the driver responds (the pattern NVIDIA’s GPU Operator uses), or gate on the device plugin: pods requesting nvidia.com/gpu stay Pending until the plugin advertises capacity. For everything else — storage, agents — a DaemonSet health-checker that manages the taint is the general-purpose version.

What is Node Feature Discovery (NFD)?

A Kubernetes SIG project that inspects each node’s hardware and kernel (CPU flags, PCI devices, kernel modules) and publishes the result as node labels. Combined with node affinity, it lets workloads target capabilities instead of hand-maintained labels — and it is the discovery half of most custom-readiness setups.

Are node readiness gates a Kubernetes feature like pod readiness gates?

No — pods have first-class readinessGates; nodes do not. Node-level equivalents are built from primitives: taints applied at registration and removed by health checks, node conditions set by problem detectors (like Node Problem Detector), and labels from NFD. The article covers wiring those into a coherent gate.

Conclusion

The Node Readiness Controller is a gateway to a more robust, intentional scheduling model. By moving beyond the assumption that a Kubernetes Ready node is ready for your workloads, you can enforce the precise prerequisites your applications demand. Whether it’s certified hardware, validated storage, verified security controls, or licensed software, custom readiness conditions allow your platform to make smarter scheduling decisions.

This shifts the responsibility from the application developer (who must write complex initialization and error-handling logic) to the platform (which guarantees a capable environment). The result is fewer runtime surprises, more consistent performance, and a cluster that truly aligns with your operational and compliance requirements. Start by identifying one critical, non-standard dependency in your most important workloads, and implement a readiness gate for it. The pattern will quickly prove its value across your entire infrastructure.

Kubernetes Liveness Probe Anti-Patterns: Preventing Cascading Failures

Kubernetes Liveness Probe Anti-Patterns: Preventing Cascading Failures

If you manage production Kubernetes workloads, you’ve likely configured liveness probes. They’re a core Kubernetes feature designed to restart unhealthy containers, a seemingly straightforward mechanism for improving application resilience. Yet, as many seasoned engineers have learned the hard way, this tool can become a weapon of mass destruction against your own cluster’s stability. A misconfigured probe doesn’t just fail to heal an application—it can actively orchestrate its demise through cascading restarts, amplifying a transient issue into a full-blown outage.

Related reading: custom node readiness checks.

Related reading: HPA best practices.

This paradox is well-known in the community. As one engineer succinctly put it on Reddit, liveness probes are a feature that “looked great on paper but turned out to be a footgun” because they can easily “cause cascading failures when they get it wrong.” The problem isn’t the probe itself, but how we implement it. The default mindset of “just add a liveness probe” without deep consideration of application behavior and failure modes is a critical anti-pattern in platform engineering.

In this article, we’ll move beyond the basic documentation and dissect the common liveness probe anti-patterns that undermine system resilience. More importantly, we’ll provide a practical blueprint for designing health checks that act as a true safety net, not a tripwire.

Understanding the Probe Machinery: Liveness vs. Readiness

Before diagnosing anti-patterns, we must clearly distinguish between the two primary health checks. Confusing them is the first and most fundamental mistake.

  • Liveness Probe: Answers “Is the container process running?” A failed liveness probe results in the kubelet killing and restarting the container. Its purpose is to recover from a deadlock or a process that is running but unable to make progress.
  • Readiness Probe: Answers “Is the container ready to serve traffic?” A failed readiness probe causes the container to be removed from Service endpoints. It does not restart the pod. Its purpose is to handle temporary unavailability during startup, heavy load, or dependency failures.

The critical distinction is consequence: liveness restarts, readiness isolates. Using a liveness probe for a condition that should merely take a pod out of rotation is a guaranteed way to create instability.

Common Liveness Probe Anti-Patterns

These patterns are observed repeatedly in production incidents and post-mortems. Recognizing them is the first step toward remediation.

1. The “Everything is Liveness” Anti-Pattern

This is the most prevalent issue. Engineers point the liveness probe at the same endpoint as the readiness probe, or at a general health check that validates dependencies (databases, caches, message brokers).

Why it’s dangerous: If your database experiences a transient network blip, your liveness probe starts failing. Kubernetes dutifully restarts your pods. Now, instead of a few pods temporarily marked “not ready,” you have all your pods simultaneously crashing and restarting. This creates a thundering herd of new connections when they come back up, often overwhelming the recovering dependency and creating a failure cycle. The minor dependency hiccup has now become a total application outage.

2. The Overly Sensitive (Aggressive) Probe

This involves setting extremely tight timeouts (timeoutSeconds: 1) and short failure thresholds (failureThreshold: 2) on a probe that performs non-trivial work (e.g., a complex database query or an external API call).

Why it’s dangerous: Under normal system load (GC pauses, CPU contention, network latency spikes), the probe may occasionally exceed its strict timeout. Kubernetes interprets this as a failure. A couple of these transient delays in quick succession trigger a restart. You now have perfectly healthy pods being killed because your health check was more fragile than the application logic it was monitoring.

3. The Shared Fate Probe Endpoint

The probe endpoint shares the same thread pool, connection pool, or resource limits as the main application service. Under high load, the health check requests themselves can exhaust these resources, causing the probe to fail and triggering a restart on an overloaded pod—making the load situation worse for the remaining pods.

Why it’s dangerous: It creates a self-reinforcing failure mode. Load increases → probe resources are starved → probes fail → pods restart → load redistributes to fewer pods → load on remaining pods increases further. This cascade can quickly take down the entire service.

4. The Liveness Probe as a Readiness Gate

Using the liveness probe to prevent a pod from receiving traffic until it’s “fully ready,” often by setting an initial delay (initialDelaySeconds) that’s guessed rather than measured.

Why it’s dangerous: If the application takes longer to initialize than the initialDelaySeconds (due to a cold cache, large data load, etc.), the liveness probe will start failing immediately after the delay. The pod will be stuck in a crash loop (CrashLoopBackOff) before it ever had a chance to become ready. The correct tool for this job is the startupProbe.

A Blueprint for Resilient Liveness Probe Design

Designing a robust liveness probe requires a shift in philosophy. The probe should check for unrecoverable process failure, not general health. It should be minimally invasive, highly stable, and tolerant of transient issues.

Principle 1: Liveness Checks Must Be Local and Cheap

The liveness probe should check the state of the process itself, not its external dependencies. It should:

  • Run in-memory, without network calls (to other pods or external services).
  • Use minimal CPU and no blocking I/O.
  • Check an internal flag or a very simple, cached piece of internal state.

A classic example is a thread that updates a “last loop iteration” timestamp in shared memory. The liveness probe checks this timestamp. If it hasn’t been updated in X seconds, the main loop is likely deadlocked, and a restart is justified.

Principle 2: Configure Conservative Timeouts and Thresholds

Your probe configuration should account for the “noisy neighbor” reality of shared infrastructure. Use values that allow for occasional GC pauses and network jitter.

Here is a sample configuration reflecting conservative, production-oriented values:

livenessProbe:
  httpGet:
    path: /internal/health/liveness
    port: 8080
  initialDelaySeconds: 10  # Let the process settle
  periodSeconds: 10        # Don't check too frequently
  timeoutSeconds: 3        # Give it time to respond
  successThreshold: 1
  failureThreshold: 3      # Require multiple consecutive failures

Notice the failureThreshold: 3. A single failed probe means nothing. Two could be a coincidence. Three consecutive failures over 30 seconds is a much stronger signal of a real problem. This grace period can prevent countless unnecessary restarts.

Principle 3: Use the Startup Probe for Lengthy Initialization

For applications with slow boot times (Java VMs, apps loading large models), the startupProbe is your best friend. It disables the liveness and readiness checks until the app is up.

startupProbe:
  httpGet:
    path: /health/startup
    port: 8080
  failureThreshold: 30     # Try many times
  periodSeconds: 5         # Check every 5 seconds
# Liveness probe only starts working AFTER startup succeeds
livenessProbe:
  httpGet:
    path: /internal/health/liveness
    port: 8080
  initialDelaySeconds: 0   # No need for an extra delay
  periodSeconds: 10
  failureThreshold: 3

This configuration allows up to 150 seconds (30 * 5) for the application to start before it’s considered failed, while protecting it from being restarted during that sensitive boot period.

Principle 4: Isolate the Probe Endpoint

Ensure the endpoint used for the liveness probe:

  • Has a dedicated, minimal thread pool or runs outside the main request handling framework.
  • Is not subject to the same rate limiting or authentication as public APIs.
  • Returns a static, pre-computed response or checks only in-memory state.

This isolation ensures that application traffic cannot directly cause a liveness failure.

Putting It All Together: A Resilience-First Strategy

Your health check strategy should be a layered defense:

  1. Startup Probe: Guards the initialization phase. Allows slow starts without restarts.
  2. Readiness Probe: The primary traffic gatekeeper. Checks app health + critical dependencies. Fails fast and removes the pod from rotation under load or during dependency issues.
  3. Liveness Probe: The last resort. Checks only for internal process deadlock. Configured to be slow-triggering (high failureThreshold) and stable.

This strategy ensures that pods are restarted only when there is a high-confidence signal of an unrecoverable internal fault. All other issues—slow dependencies, high load, temporary errors—are handled gracefully by the readiness probe, which isolates the pod without triggering a potentially destabilizing restart.

Frequently Asked Questions

What is the difference between liveness and readiness probes?

A failed readiness probe removes the pod from Service endpoints — it stops receiving traffic but keeps running. A failed liveness probe restarts the container. Readiness answers “can this pod serve right now?”; liveness answers “is this process beyond recovery?”. Confusing the two is the root of most probe incidents.

Should every container have a liveness probe?

No. The default should be no liveness probe unless the process has a known failure mode that a restart genuinely fixes — a deadlock, a wedged event loop. A process that crashes on fatal errors already gets restarted by the kubelet; a liveness probe on top only adds a new way to kill healthy pods under load.

Why do my pods restart in a loop under high load?

Almost always a liveness probe timing out precisely because the pod is busy: load rises, the probe endpoint responds slowly, the kubelet kills the container, remaining pods absorb more load, and the cascade spreads. Fixes: generous timeoutSeconds and failureThreshold, a probe endpoint that does no real work, and moving traffic concerns to the readiness probe.

Should a liveness probe check database or downstream dependencies?

Never. If the database is down, restarting your pod does not fix the database — it just turns one outage into two. Liveness must check only process-internal health; dependency health belongs (with care) in readiness, and even there prefer degrading gracefully over dropping out of the Service.

Conclusion: From Footgun to Safety Mechanism

Liveness probes are not a “set and forget” feature. Treating them as such invites the cascading failures that give them a bad reputation. The goal is not to avoid liveness probes, but to implement them with the same care and production rigor as your application code.

By adhering to the blueprint above—keeping checks local and cheap, using conservative thresholds, leveraging startup probes, and isolating endpoints—you transform the liveness probe from a potential source of instability into a genuine resilience mechanism. It becomes a targeted surgical tool for recovering from true process deadlocks, while the readiness probe handles the broader spectrum of application health. In the complex, distributed environment of Kubernetes, this precise separation of concerns is not just a best practice; it’s a fundamental requirement for stable, resilient operations.

Review your probe configurations today. Ask the critical question: “Is this checking for a dead process, or just a busy one?” The answer will determine whether your health checks are preventing outages or causing them.

Skopeo, Crane, and regctl: Container Image Management Without the Docker Daemon (2026)

Skopeo, Crane, and regctl: Container Image Management Without the Docker Daemon (2026)

The Problem: Docker Is Overkill for Image Operations

You need to copy an image from Docker Hub to your private registry. Or inspect a manifest before pulling. Or delete old tags programmatically. Or sync an entire repository during a migration.

Related reading: Kaniko, BuildKit and Image Volumes.

The instinct is to reach for Docker. But Docker requires a running daemon, root access (or group membership that amounts to the same thing), and pulls the entire image to disk just to read its metadata. For CI pipelines, GitOps workflows, and platform tooling, that’s a significant overhead for what should be lightweight registry operations.

This is the problem that daemonless container image tools solve. Skopeo pioneered the category; today it has real competition from crane, regctl, and ORAS — each with different strengths and ideal use cases.

This article gives you the practical comparison to pick the right tool for your workflow.


The Contenders

ToolMaintainerLanguageDaemon required
SkopeoRed Hat / containersGoNo
craneGoogle / ko-buildGoNo
regctlregclientGoNo
ORASCNCFGoNo
cosignSigstore / OpenSSFGoNo

All five are Go binaries, statically compiled, and work directly against the OCI Distribution Spec. None of them require Docker or any container runtime.


Skopeo

Skopeo was the first major tool to address daemonless image operations, released by Red Hat in 2016 as part of the containers/image ecosystem (alongside Podman and Buildah).

What Skopeo does well

Image inspection without pulling:

skopeo inspect docker://registry.k8s.io/pause:3.9

Returns full image metadata — digest, layers, labels, architecture, OS — without downloading a single layer. Useful in admission controllers, policy checks, and pre-deployment validation.

Cross-registry copying:

skopeo copy 
  docker://docker.io/library/nginx:1.27 
  docker://harbor.internal/library/nginx:1.27

Copies image manifests and layers directly between registries, bypassing your local machine entirely. The image never touches your disk.

Multi-arch handling:

skopeo copy --all 
  docker://docker.io/library/nginx:1.27 
  docker://harbor.internal/library/nginx:1.27

The --all flag copies the full manifest list, preserving all architectures (linux/amd64, linux/arm64, etc.). This is critical when mirroring images for multi-arch clusters.

Registry synchronization:

skopeo sync 
  --src docker 
  --dest docker 
  --all 
  docker.io/library/nginx 
  harbor.internal/mirrors/

skopeo sync mirrors an entire repository, including all tags. You can also use a YAML file to define which images and tags to sync — useful for air-gapped environment bootstrapping.

Tag deletion:

skopeo delete docker://harbor.internal/myapp:old-tag

Useful in CI for cleanup pipelines. Note that registry-side deletion requires the registry to have the DELETE method enabled.

Skopeo’s weaknesses

  • No image modification: Skopeo copies and inspects, but doesn’t build or modify images
  • Tag listing is verbose: skopeo list-tags returns JSON you need to parse
  • No retry logic by default: transient network errors in long sync operations require wrapping with retry scripts
  • Auth configuration: relies on containers/auth.json format, which differs from Docker’s ~/.docker/config.json (though it supports both)

When to use Skopeo

  • Air-gapped environment image mirroring
  • CI pipelines that need to copy or inspect images without Docker
  • Platform teams on Red Hat / OpenShift stacks
  • Any workflow already using Podman or Buildah

Crane

Crane is Google’s answer to Skopeo, developed as part of the ko project and later extracted into its own tool. It’s simpler, more scriptable, and has a cleaner CLI design.

What Crane does well

Tag listing:

crane ls registry.k8s.io/pause

No JSON parsing needed. One tag per line. Pipe directly into grep, sort, head.

Digest resolution:

crane digest docker.io/library/nginx:1.27

Returns the image digest. Combine with yq or sed to pin image references in Helm values or Kubernetes manifests.

Image copying:

crane cp docker.io/library/nginx:1.27 harbor.internal/library/nginx:1.27

Same capability as Skopeo’s copy, arguably with a cleaner syntax.

Manifest inspection:

crane manifest docker.io/library/nginx:1.27 | jq .

Returns raw manifest JSON. Useful when you need the exact manifest for digest verification or policy enforcement.

Tagging and retagging:

crane tag harbor.internal/myapp:abc123 harbor.internal/myapp:stable

Adds a new tag to an existing image without re-uploading layers. The tag operation is purely a manifest pointer update.

Flattening images:

crane flatten docker.io/library/ubuntu:24.04 -t harbor.internal/ubuntu:flat

Squashes all layers into one. Reduces layer count for images where layer history doesn’t matter.

Crane’s weaknesses

  • No sync command: unlike Skopeo, crane has no built-in repository sync. You script it yourself with crane ls + crane cp in a loop
  • Less mature multi-arch support: crane cp supports multi-arch but the UX is less explicit than Skopeo’s --all
  • No delete command: doesn’t implement registry deletion

When to use Crane

  • CI/CD scripting where you want clean, pipeable output
  • Digest pinning workflows
  • Lightweight image tagging operations
  • When you’re already in the ko / Google Cloud ecosystem

regctl

Regctl is the least known of the three but arguably the most feature-complete. It’s the CLI for the regclient Go library and covers use cases that Skopeo and crane leave out.

What regctl does uniquely well

Image modification without rebuild:

regctl image mod myimage:tag 
  --label "org.opencontainers.image.version=1.2.3" 
  --replace

You can add/change labels, annotations, and config fields directly on an existing image in the registry — without pulling, rebuilding, or pushing a new image. This is impossible with Skopeo or crane.

Layer operations:

# Remove a specific layer from an image
regctl image mod myimage:tag 
  --layer-rm sha256:abc123... 
  --replace

Useful for removing accidentally included secrets or large unnecessary layers from published images.

OCI artifact support:

regctl artifact put 
  --media-type application/vnd.example.config.v1+json 
  --config config.json 
  file.tar.gz 
  harbor.internal/myartifacts:v1

Regctl has solid OCI artifact support alongside standard image operations.

Formatting and output:

regctl tag list harbor.internal/myapp --format '{{range .}}{{println .}}{{end}}'

Go template formatting throughout. Useful for integrating into shell scripts without jq.

Referrers (OCI 1.1):

regctl manifest get-list harbor.internal/myapp:v1 --referrers

Lists referrers (signatures, SBOMs, attestations) attached to an image via the OCI 1.1 referrers API.

Regctl’s weaknesses

  • Smaller community: fewer examples, less StackOverflow coverage
  • Steeper learning curve: more commands, more flags
  • Less packaging: not in most distro repos by default

When to use regctl

  • Image post-processing (labels, annotations, layer removal) without rebuild
  • Advanced manifest and referrer workflows
  • When you need OCI artifact operations alongside image operations

ORAS

ORAS (OCI Registry As Storage) is a CNCF project focused specifically on OCI artifact management — pushing and pulling arbitrary files to container registries, not necessarily container images.

# Push a Helm chart as an OCI artifact
oras push harbor.internal/charts/myapp:1.0.0 
  --artifact-type application/vnd.helm.chart.v1+tar 
  mychart.tgz

# Push SBOM
oras push harbor.internal/myapp:v1 
  --artifact-type application/spdx+json 
  sbom.spdx.json

# Pull
oras pull harbor.internal/charts/myapp:1.0.0

ORAS is not a direct Skopeo replacement — it’s for when your registry is a general-purpose artifact store, not just a container registry. Helm OCI, SBOMs, attestations, and policy bundles all benefit from ORAS.

ORAS vs Skopeo in one line: Skopeo (and crane) move container images between registries; ORAS pushes and pulls arbitrary artifacts (charts, SBOMs, ML models) to a registry. If your object is an OCI image, use Skopeo or crane. If it is a file you want to store in a registry, use ORAS. They overlap far less than the shared “registry client” label suggests, and most platform teams end up with both installed for different jobs.


cosign

Cosign from Sigstore is not a general-purpose image tool — it’s specifically for supply chain security. But it’s increasingly part of any container image workflow.

# Sign an image
cosign sign --key cosign.key harbor.internal/myapp:v1@sha256:abc123...

# Verify
cosign verify --key cosign.pub harbor.internal/myapp:v1

# Attach SBOM
cosign attach sbom --sbom sbom.spdx harbor.internal/myapp:v1

# Keyless signing (Sigstore)
cosign sign harbor.internal/myapp:v1

Cosign integrates with OIDC providers for keyless signing (no key management required), which is the direction the ecosystem is moving. If you’re building a supply chain security practice, cosign is mandatory, not optional.


Side-by-side comparison

OperationSkopeoCraneregctl
Inspect imageinspectmanifestmanifest get
Copy imagecopycpimage copy
Copy all archescopy --allcp (auto)image copy
Sync repositorysyncscript itscript it
List tagslist-tags (JSON)ls (plain)tag list
Delete tag/imagedeletetag delete
Modify labelsimage mod
Remove layerimage mod
OCI artifactslimitedlimitedartifact
Referrers (1.1)manifest get-list

Practical workflows

Copy an image from Docker Hub to a private registry (crane cp vs skopeo copy)

This is the single most common reason people reach for these tools, so here are the two one-liners side by side. Both copy the image directly registry-to-registry — the image never touches your local disk and no Docker daemon is involved.

# skopeo copy — explicit docker:// transport on both sides
skopeo copy
  docker://docker.io/library/nginx:1.27
  docker://harbor.internal/library/nginx:1.27

# crane cp — shorter, no transport prefix
crane cp docker.io/library/nginx:1.27 harbor.internal/library/nginx:1.27

They are functionally equivalent for a single image. Reach for skopeo copy when you want --all to force the full multi-arch manifest list, or when you are on a Red Hat / Podman stack. Reach for crane cp when you want the shortest possible command and cleaner output for scripting — it copies the manifest list automatically when the source is multi-arch. To authenticate against the private registry first, both read ~/.docker/config.json, so a prior docker login harbor.internal (or crane auth login / skopeo login) is enough. To copy many images or whole repositories in one go, jump to the air-gapped mirroring recipe below, which uses skopeo sync.

Mirror images for air-gapped clusters (Skopeo)

# sync-list.yaml
docker.io:
  images:
    library/nginx:
      - "1.25"
      - "1.26"
      - "1.27"
    library/redis:
      - "7.2"
      - "7.4"
skopeo sync 
  --src yaml 
  --dest docker 
  --all 
  sync-list.yaml 
  harbor.internal/mirrors/

Pin image digests in CI (Crane)

#!/bin/bash
# Update image digests in values.yaml
for image in nginx:1.27 redis:7.4; do
  digest=$(crane digest docker.io/library/${image})
  echo "docker.io/library/${image}@${digest}"
done

Combine with yq to update Helm values files automatically, ensuring reproducible deployments.

Retag without re-pushing (Crane or regctl)

# After a successful deploy to staging, promote to production
crane tag harbor.internal/myapp:${GIT_SHA} harbor.internal/myapp:production

No layer transfer. The operation is a metadata update in the registry.

Add OCI annotations post-build (regctl)

regctl image mod harbor.internal/myapp:v1.2.3 
  --annotation "org.opencontainers.image.source=https://github.com/org/repo" 
  --annotation "org.opencontainers.image.revision=${GIT_SHA}" 
  --replace

Attaches build metadata to an image already in the registry, without a rebuild.

Supply chain security pipeline

# 1. Build and push
docker buildx build --push -t harbor.internal/myapp:${GIT_SHA} .

# 2. Generate SBOM
syft harbor.internal/myapp:${GIT_SHA} -o spdx-json > sbom.spdx.json

# 3. Attach SBOM
cosign attach sbom --sbom sbom.spdx.json harbor.internal/myapp:${GIT_SHA}

# 4. Sign (keyless with OIDC in CI)
cosign sign harbor.internal/myapp:${GIT_SHA}

# 5. Verify in admission controller or deployment pipeline
cosign verify 
  --certificate-identity-regexp="https://github.com/org/repo" 
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" 
  harbor.internal/myapp:${GIT_SHA}

Installation

All tools install as single static binaries:

# Skopeo (via package manager)
brew install skopeo                    # macOS
dnf install skopeo                     # RHEL/Fedora
apt install skopeo                     # Debian/Ubuntu

# Crane
brew install crane
# or binary release
curl -sL https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz | tar xz crane

# regctl
curl -sL https://github.com/regclient/regclient/releases/latest/download/regctl.linux.amd64 -o /usr/local/bin/regctl
chmod +x /usr/local/bin/regctl

# ORAS
brew install oras

# cosign
brew install cosign

Which tool should you use?

Use Skopeo if: you’re on a Red Hat / OpenShift stack, you need repository sync, or you’re building air-gapped environment pipelines. It’s the most battle-tested and widely packaged.

Use Crane if: you’re scripting image operations in CI and want clean, composable CLI output. crane ls + crane cp + crane digest cover 80% of automation use cases with minimal friction.

Use regctl if: you need to modify images post-build, work with OCI referrers, or want the most complete feature set for a registry client. It has a higher learning curve but can replace both Skopeo and crane for advanced workflows.

Use ORAS if: you’re using a registry to store non-image artifacts — Helm charts, SBOMs, policy bundles, ML models.

Use cosign regardless of which of the above you pick, as soon as supply chain security matters to your organization. It’s not a replacement for the others — it’s a complement.

In practice, most platform teams end up using 2-3 of these tools together. Crane for day-to-day scripting, Skopeo for sync jobs, cosign for signing, ORAS for artifact storage.


FAQ

Can I use these tools with private registries?

Yes. All support standard registry authentication. Crane and Skopeo both read from ~/.docker/config.json. Regctl has its own config file (~/.regctl/config.json) but can import Docker credentials. Set DOCKER_CONFIG to point to your credentials file in CI environments.

Do these work with Docker Hub rate limits?

Yes, and they’re often more efficient than the Docker CLI because they only fetch manifest metadata for inspect operations, not full layers. For heavy pull workloads, authenticate with your Docker Hub credentials to get higher rate limits.

What about ECR, GCR, and Azure Container Registry?

All tools support these with the appropriate credential helpers. For ECR, use docker-credential-ecr-login. Crane has native ECR support via the --platform flag and crane auth commands. Skopeo supports ECR via --creds "AWS:$(aws ecr get-login-password)".

Are these tools safe to run in Kubernetes pods?

Yes. Since they require no daemon and no elevated privileges for read operations, they’re well-suited to run as init containers or sidecar containers in Kubernetes. Skopeo is commonly used in image pre-pulling init containers. Use a dedicated service account with least-privilege registry credentials.

Can I copy a multi-arch image and keep all platforms?

Skopeo: skopeo copy --all. Crane: crane cp copies the index automatically when the source is a manifest list. Regctl: regctl image copy preserves manifest lists by default.

Radar: A New Kubernetes IDE Worth Knowing About (vs OpenLens, FreeLens)

Radar sweep showing OpenLens, FreeLens and Radar as a Kubernetes IDE comparison

Related reading: FreeLens vs OpenLens vs Lens: which one to standardize on.

If you’ve been following Kubernetes tooling, you’ve probably already been through the Lens saga: Lens went commercial, OpenLens emerged as the community fork, then FreeLens appeared when OpenLens maintenance slowed. The pattern is familiar — a useful desktop tool, a licensing decision, a fork, another fork. Radar is not a fork. It’s a different approach to the same problem: giving engineers a useful interface for Kubernetes clusters without the friction of kubectl for every task. Built by Skyhook (YC-backed, Google Cloud Partner), it’s been live since 2025, has 1.7k+ GitHub stars, releases weekly, and the founder reaches out to the community directly. That’s usually a good signal that someone is genuinely building in public. This article covers what Radar actually does, where it pulls ahead of OpenLens and FreeLens, and when those tools are still the right choice.

The State of Kubernetes Desktop Tooling in 2026

Before getting into Radar specifically, it’s worth naming the landscape clearly:
  • Lens — the original. Electron-based, polished, now commercial (Mirantis). The free Personal tier is non-commercial only. Pro is ~$22-35/user/month.
  • OpenLens — the community fork of Lens before Mirantis closed exec/logs/shell in v6.3 (January 2023). Maintenance has slowed significantly. No active release cadence.
  • FreeLens — a more active community fork, filling the gap left by OpenLens’ decline. Restores the missing features. No commercial backing.
  • k9s — terminal TUI, fast, keyboard-driven, single-cluster. Different audience.
  • Headlamp — CNCF Sandbox project, plugin-extensible, web-based.
  • Radar — Go binary, Apache 2.0, team-oriented, topology and event timeline focused.
The problem with OpenLens and FreeLens is not that they’re bad tools — they’re genuinely useful for the solo developer with one or two clusters. The problem is that they’re single-cluster-at-a-time desktop apps with no concept of team, no persistent state, and no awareness of the modern Kubernetes ecosystem (ArgoCD, Flux, Karpenter, KEDA). As your infrastructure grows, you outgrow them.

What Radar Actually Is

Radar is available in two forms:
  • Radar OSS — a single ~30MB Go binary, Apache 2.0, free forever. Can run locally (desktop app) or deployed in-cluster via Helm. No sidecars, no feature gates.
  • Radar Cloud — same binary, adds a hosted control plane with fleet aggregation, 30-day event retention, SSO/SCIM, scoped RBAC, and shared URLs for team incident response. Priced per cluster ($99/cluster/month for Team), not per user.
The per-cluster pricing is a deliberate design decision — teams don’t pay more as they add engineers, only as they add clusters. For a 20-person platform engineering team managing 5 clusters, Radar Cloud runs $495/month. The equivalent Lens Pro seats would cost $2,200-4,200/month. For most self-hosted environments, the OSS version is sufficient and costs nothing.

Key Features

Topology View

This is the most visually distinctive feature. Radar renders a live service graph for your cluster: deployments, services, ingresses, cross-namespace dependencies, and east-west traffic flows — all in a single view without running kubectl get all -A and stitching the output together mentally. OpenLens and FreeLens have resource list views. They show you what exists. Radar shows you how things connect — which is what you actually need when debugging why Service A can’t reach Service B.

Persistent Event Timeline

Kubernetes events are ephemeral by default — they expire after approximately one hour. When something breaks at 2am and you’re looking at it at 9am, the events that explain what happened are gone. Logs may still be there if you’re running a log aggregator, but the Kubernetes-level events (pod restarts, scheduling failures, node pressure events, probe failures) are gone. Radar retains events. The OSS version extends this beyond the default 1-hour cluster retention. The Cloud version retains 30 days. You can rewind the timeline to any point and reconstruct what the cluster looked like at that moment. Neither OpenLens nor FreeLens have any event retention beyond what the cluster itself provides.

GitOps Integration (ArgoCD + Flux)

Radar auto-detects ArgoCD and Flux and surfaces sync state, drift, and health directly in the UI. You can see whether a deployment is in sync, when it last synced, and whether it drifted from the desired state in Git. In OpenLens and FreeLens, ArgoCD resources appear as generic Kubernetes custom resources. You can see the CRDs, but there’s no purpose-built understanding of what they mean — no sync status visualization, no diff view, no rollback trigger.

Helm Management

Radar tracks Helm releases with full revision history and supports one-click rollbacks from the UI. This is similar to what OpenLens/FreeLens offer via the Helm releases view, but Radar adds revision diffing — you can see what changed between release 5 and release 6 before deciding to roll back.

Image Filesystem

You can browse container image filesystems through Radar without needing kubectl exec into a running pod or access to the container registry. Useful for security audits and debugging — you can verify what’s actually in an image at rest.

MCP Server (AI Integration)

Radar ships with an MCP (Model Context Protocol) server, which means you can connect Claude, Cursor, or GitHub Copilot directly to your cluster context and ask questions about it in natural language. The MCP server is token-optimized — it doesn’t dump raw YAML at the model, it structures cluster state into meaningful context. This is something neither OpenLens nor FreeLens have. It’s also something that’s genuinely useful if you’re already using AI assistants for development work.

Cluster Audit

30 built-in best-practice checks — resource requests/limits, RBAC permissions, image pinning, network policies, security contexts. The checks are labeled by compliance framework. This is not a replacement for dedicated security tooling (Trivy, Falco, Polaris), but it’s a useful first-pass audit without leaving the tool you’re already using.

Multi-Cluster Support (Cloud)

The Cloud tier adds fleet-level visibility: a single view across all clusters, cross-cluster search, and drift detection between environments (e.g., staging vs. production). This is the feature that changes the calculus for platform engineering teams managing 5+ clusters. OpenLens and FreeLens require you to switch cluster context manually. There is no fleet view.

Architecture: Why a Go Binary Matters

OpenLens and FreeLens are Electron apps — Chromium + Node.js wrapped in a desktop shell. This means:
  • 200-500MB install size
  • 1-2 second startup time on a fast machine, more on slower ones
  • Memory footprint in the hundreds of megabytes
  • Local kubeconfig required on each engineer’s machine
Radar’s in-cluster deployment is a single Go binary (~30MB) that runs as a Pod with a ServiceAccount. It connects to the hosted control plane over outbound WebSocket + TLS. No inbound firewall rules, no kubeconfig distribution, no per-engineer setup. The local desktop app is also a lightweight Go binary — 65-second startup was demonstrated on a 322-node cluster. That’s not a typo. For in-cluster deployment, the architecture means security is handled at the ServiceAccount level, not by distributing kubeconfigs to engineer laptops. That matters for teams with security requirements around credential management.

Feature Comparison

FeatureRadar OSSRadar CloudOpenLensFreeLens
LicenseApache 2.0Proprietary (hosted)MIT/GPLMIT
MaintenanceActive (weekly releases)ActiveStalledActive (community)
ArchitectureGo binary / in-clusterIn-cluster + hostedElectronElectron
Multi-clusterBasicFleet view
Event retentionExtended30 daysCluster default (~1h)Cluster default (~1h)
Topology view
GitOps (ArgoCD/Flux)CRDs onlyCRDs only
Helm management
kubectl exec / logs / shell✅ (restored)
MCP / AI integration
Cluster audit
SSO / SCIM
Shared incident URLs
Image filesystem browser
Cost tracking✅ (OpenCost)
PriceFree$99/cluster/monthFreeFree

When Radar Makes Sense

You’re managing multiple clusters. Even with the OSS version, the topology view and event timeline make Radar more useful than OpenLens/FreeLens at 3+ clusters. The Cloud fleet view is the compelling option at 5+. Your team uses GitOps. If ArgoCD or Flux is part of your workflow, Radar’s native understanding of sync state and drift is meaningfully better than seeing CRDs in a generic list view. You need post-mortem capability. If your incident review process involves looking at what the cluster was doing when the alert fired, you need event retention. Radar has it; OpenLens and FreeLens don’t. You’re adopting AI tooling. The MCP server is the most forward-looking feature here. If you use Claude Code, Cursor, or Copilot for your infrastructure work, having cluster context available to those tools without copy-pasting YAML is a genuine productivity improvement. You have a platform engineering team. Per-cluster pricing, SSO, SCIM, and shared incident URLs are features that only matter if you have more than one person managing infrastructure.

When OpenLens or FreeLens Still Makes Sense

You’re a solo developer with one or two clusters. OpenLens and FreeLens are familiar, local, and have zero setup overhead. If you don’t need team features, event retention, or topology views, they remain perfectly functional tools. You’re deeply invested in the Lens UX. The resource tree, the terminal integration, the way Lens presents namespace-scoped resources — if your muscle memory is built around that interface, switching has a real cost. Radar is different, not just better. You need maximum customization. OpenLens and FreeLens support plugins. Radar does not currently have a plugin system. Your environment is air-gapped or has strict egress restrictions. Radar OSS can run fully in-cluster, but Radar Cloud requires outbound connectivity to the hosted control plane. OpenLens and FreeLens are fully local.

Getting Started

OSS installation takes about two minutes:
# Homebrew (macOS/Linux)
brew install skyhook-io/tap/radar

# Helm (in-cluster)
helm repo add skyhook https://charts.skyhook.io
helm install radar skyhook/radar 
  --namespace radar 
  --create-namespace 
  --set service.type=ClusterIP
Or download the binary directly from radarhq.io.

Verdict

Radar is the most interesting new entrant in the Kubernetes tooling space in a while — not because it replaces everything else, but because it addresses the specific gap that OpenLens and FreeLens never covered: teams, multiple clusters, and persistent state. For a solo developer, OpenLens or FreeLens are still completely reasonable choices. For a platform engineering team managing more than two clusters with ArgoCD or Flux, Radar’s feature set is materially better and the OSS version costs nothing. The active release cadence and the YC backing suggest this isn’t a one-person side project — there’s a team actively working on it. Whether the Cloud pricing sticks long-term is a question only usage will answer, but the Apache 2.0 core with an explicit “always open source” commitment is the right foundation. Worth evaluating if you haven’t already.
Tested with Radar OSS v0.x on Kubernetes 1.29–1.32. Pricing and feature availability as of May 2026.

Related reading: FreeLens extensions: the complete catalogue and how to install them.

Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026)

Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026)

Karpenter vs HPA, VPA and KEDA: Which Autoscaler Does What

A recurring confusion in the comparison searches: Karpenter is not an alternative to HPA or KEDA. They operate on different layers and are designed to run together. Pod autoscalers decide how many replicas you need; node autoscalers decide what hardware those replicas land on. Karpenter and Cluster Autoscaler only ever see the outcome of the pod autoscalers — pending pods.

ComponentLayerWhat it changesInput signalScales to zero
HPAPodNumber of replicasCPU, memory, custom/external metricsNo (min 1)
KEDAPodNumber of replicas (drives an HPA underneath)Event sources: queue depth, Kafka lag, cron, 60+ scalersYes
VPAPodRequests and limits of each podHistorical usageN/A
Cluster AutoscalerNodeSize of predefined node groupsUnschedulable podsNode groups to 0
KarpenterNodeIndividual nodes and their instance typeUnschedulable pods + consolidationYes

The chain in practice

A queue backs up, KEDA sees the lag and raises the replica count, the new pods cannot be scheduled because the cluster is full, and Karpenter provisions a node that fits them. Every layer does its own job. The failure mode people hit is tuning only one of them: an HPA that scales aggressively on a cluster with a slow node autoscaler produces pods that sit pending for minutes, and the latency you were trying to fix stays exactly where it was.

Two pairings deserve care. HPA and VPA on the same metric fight each other — VPA raises requests, which lowers measured CPU utilisation, which makes HPA scale down; run VPA in Off or Initial mode if an HPA already targets CPU on that workload. And KEDA scaling to zero only works if the node autoscaler can drain the last node: with Karpenter, that is consolidation doing its job; with Cluster Autoscaler, you need the node group’s minimum set to 0. See HPA best practices for the pod-level half of this.

Replacing Cluster Autoscaler with Karpenter

If the goal is a straight replacement rather than coexistence, the order matters: install Karpenter with a NodePool that is restricted to the workloads you are moving, scale the Cluster Autoscaler node groups down as Karpenter takes over, and only then remove the Cluster Autoscaler deployment. Removing it first leaves the old node groups with nobody managing scale-down, and you pay for idle nodes until someone notices.

Your pods are pending. Your on-call engineer is getting paged. Somewhere in the chain between “I need more compute” and “compute is available,” something is too slow. That something is almost always node provisioning — and the tool you chose to manage it determines whether that delay is 4 minutes or 45 seconds. Node autoscaling is one of those infrastructure decisions that looks simple until you’re running it in production. Two schedulable pods sitting in Pending state doesn’t just mean a delayed deployment — it means latency spikes, dropped traffic, breached SLOs, and engineers debugging things that should have been invisible. At scale, it also means either burning money on over-provisioned nodes or gambling on under-provisioning at the worst possible moment. Cluster Autoscaler (CA) has been the default answer for years. Karpenter emerged from AWS in 2021, graduated to stable in 2023, and by 2025 had become the default recommendation for most AWS-native clusters. In 2026, both tools are mature, widely deployed, and genuinely good — but they solve the problem differently, and picking the wrong one for your environment has real consequences. This article is a deep technical comparison. It assumes you already know what Kubernetes is and have opinions about infrastructure. The goal is to give you a clear picture of how each tool works, where each one wins, and a decision framework you can actually use.

Karpenter vs Cluster Autoscaler: the short answer

Cluster Autoscaler scales node groups you defined in advance; Karpenter provisions individual nodes on demand, choosing the instance type itself. That single architectural difference drives everything else. Cluster Autoscaler adds nodes to an existing ASG or MIG, so provisioning takes roughly 4–8 minutes and your instance choice is fixed by whatever you put in the node group. Karpenter talks to the cloud provider API directly, bin-packs pending pods against the whole instance catalogue, and typically has capacity in 60–90 seconds — while consolidating underused nodes to cut cost. Use Cluster Autoscaler if you need mature multi-cloud support, you have strict node-group governance, or your capacity is reserved and homogeneous. Use Karpenter if you are on AWS (or now Azure), your workloads are heterogeneous, and you want faster scale-up plus automatic cost consolidation. Whether you phrase it “Cluster Autoscaler vs Karpenter” or the other way round, the decision comes down to three questions — cloud, workload diversity, and how much control you want over instance selection — and there is a full decision framework at the end of this article.

Why Node Autoscaling Is Hard

The fundamental tension in autoscaling is this: you want compute available before you need it, but you don’t want to pay for compute you’re not using. These goals are in direct conflict, and every autoscaling system is an attempt to find the least-bad trade-off. Without autoscaling, you’re doing one of two things:
  1. Over-provisioning — you run enough nodes to handle peak load at all times. Your average utilization sits at 20–30%, and you’re paying for the other 70–80% to sit idle.
  2. Under-provisioning — you run lean, and when traffic spikes, pods go Pending. Your SLOs breach. You get paged at 3am to manually scale.
A common failure mode with poorly tuned autoscaling is the “thundering herd at scale-up” pattern: HPA creates new pods faster than node autoscaling can provision capacity. The provisioning window matters. With CA and typical ASG-backed node groups on AWS, you’re looking at 4–8 minutes. With Karpenter, 60–90 seconds. At 100 RPS and a 3-minute window, that’s 18,000 requests under degraded conditions.

Cluster Autoscaler: How It Actually Works

Cluster Autoscaler is a Kubernetes-native project under the kubernetes/autoscaler repository, in production since 2016, supporting AWS, GCP, Azure, Alibaba, DigitalOcean, and more.

The Node Group Model

CA operates on node groups — ASGs on AWS, MIGs on GCP, VMSSs on Azure. CA’s job is to decide when to increase or decrease the desired capacity of these groups. CA does not provision individual nodes. It scales node groups, and the node group provisions nodes. This indirection adds latency and reduces flexibility.

Scale-Up: Detecting Unschedulable Pods

CA runs a control loop (default scan interval: 10 seconds). For each Pending pod with PodScheduled=False, CA simulates adding a node of each known node group type and checks if the pod would become schedulable. When a node group is selected, CA applies an expander to choose which group to scale:
  • least-waste — minimizes CPU/memory waste after scheduling (best default for cost)
  • most-pods — maximizes pods scheduled per scale-up operation
  • priority — lets you define ordering via ConfigMap
  • grpc — delegates to an external gRPC service
# Cluster Autoscaler deployment — AWS, production-tuned
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cluster-autoscaler
  template:
    metadata:
      labels:
        app: cluster-autoscaler
      annotations:
        cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
    spec:
      priorityClassName: system-cluster-critical
      serviceAccountName: cluster-autoscaler
      containers:
        - image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.3
          name: cluster-autoscaler
          resources:
            requests:
              cpu: 100m
              memory: 600Mi
            limits:
              cpu: 200m
              memory: 1Gi
          command:
            - ./cluster-autoscaler
            - --cloud-provider=aws
            - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-cluster
            - --expander=least-waste
            - --balance-similar-node-groups=true
            - --scale-down-delay-after-add=10m
            - --scale-down-unneeded-time=10m
            - --scale-down-utilization-threshold=0.5
            - --max-graceful-termination-sec=600
            - --scan-interval=10s

Scale-Down: The Conservative Approach

A node is a scale-down candidate only if: – CPU and memory utilization (by requests) is below threshold (default: 50%) – All pods could be rescheduled elsewhere – No pod has cluster-autoscaler.kubernetes.io/safe-to-evict: "false" – The node has been underutilized for at least --scale-down-unneeded-time (default: 10m) This conservatism prevents churn — a feature, not a bug.

Karpenter: How It Actually Works

Karpenter is a CNCF incubating project originally built by AWS, donated to CNCF in 2023, GA (v1.0) in mid-2024. Providers exist for AWS (stable), Azure (stable), and GCP (beta).

The Core Insight: Bypass the Node Group

Karpenter calls the EC2 RunInstances API directly — no ASG involvement. This means: – Any instance type in a single request, without pre-configuring a node group – No intermediary: Karpenter → EC2 API → node joins cluster – Right-size nodes to exactly what workloads need, across the full instance catalog – Karpenter handles full node lifecycle, including termination

NodePool and EC2NodeClass

# EC2NodeClass — cloud-specific parameters
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiSelectorTerms:
    - alias: al2023@latest
  role: "KarpenterNodeRole-my-cluster"
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "my-cluster"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "my-cluster"
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 50Gi
        volumeType: gp3
        encrypted: true
  metadataOptions:
    httpTokens: required
    httpPutResponseHopLimit: 1
---
# NodePool — intent and constraints
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand", "spot"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]
        - key: karpenter.k8s.aws/instance-size
          operator: NotIn
          values: ["nano", "micro", "small", "medium", "large"]
      expireAfter: 720h
  limits:
    cpu: "1000"
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
    budgets:
      - nodes: "5%"
        schedule: "0 8 * * mon-fri"
        duration: 10h
      - nodes: "25%"

Just-in-Time Provisioning and Bin Packing

When pods go Pending, Karpenter watches the event (not polls) and immediately: 1. Collects all Pending pods 2. Simulates bin packing — fewest possible nodes across the full instance catalog 3. Selects instances that satisfy all pod requirements 4. Calls EC2 API to provision the optimal instance(s)

Disruption and Consolidation

Karpenter’s differentiated value: active cluster consolidation. It evaluates whether nodes can be removed by redistributing pods onto others, or replaced with a smaller instance type. A c5.4xlarge running 4 vCPU worth of pods gets replaced with a c5.xlarge. Teams commonly report 30–50% compute cost reduction. consolidationPolicy options: – WhenEmpty — only remove nodes with no workload pods (safest) – WhenEmptyOrUnderutilized — also replace underutilized nodes with smaller ones

Architecture Comparison

DimensionCluster AutoscalerKarpenter
Node provisioning modelScales node groups (ASG/MIG/VMSS)Direct cloud API, no node groups
Instance flexibilityPre-defined node group typesFull instance catalog at runtime
Scale-up triggerPolling (10s scan interval)Watch-based event (near-instant)
Scale-downRemoves underutilized nodesRemoves + consolidates + right-sizes
Spot handlingVia ASG + AWS Node Termination HandlerNative, first-class, no NTH needed
Configuration modelDeployment flagsDeclarative CRDs
Cloud supportAll major + on-premAWS (GA), Azure (stable), GCP (beta)
ConsolidationNoYes
Community maturityVery mature (since 2016)Mature (GA 2024)

Scaling Speed: The Numbers

Cluster Autoscaler on AWS (typical): 1. Pod Pending → CA scan detects (0–10s) 2. ASG UpdateAutoScalingGroup API call (~15–30s) 3. EC2 instance starts (1–2 min) 4. Node bootstrap + kubelet registration (30–60s) 5. Pod scheduled (5–10s) Total: 3–6 minutes (up to 12 min during high-demand periods) Karpenter on AWS (typical): 1. Pod Pending → watch event fires (~1s) 2. EC2 RunInstances API call (~2–3s) 3. EC2 instance starts (same hardware — 1–2 min) 4. Node bootstraps + Ready (30–60s) 5. Pod scheduled (~5s) Total: 90 seconds to 3 minutes

Cost Optimization: Where Karpenter Pulls Ahead

Right-sizing: CA requires pre-defined node groups. Karpenter selects the minimum viable instance for the pending workload from the full catalog. Consolidation vs scale-down: CA removes underutilized nodes. Karpenter replaces a large underutilized node with a smaller one that still fits all pods. This produces compounding savings over time. Spot handling: Karpenter receives EC2 interruption notices, pre-provisions a replacement, and drains the node — all within the 2-minute window. No AWS Node Termination Handler required. It also diversifies spot requests across instance types automatically to reduce simultaneous interruption risk.

What’s New in Karpenter v1.14 (July 2026)

If your mental model of Karpenter was formed around v1.0, three changes shipped in v1.14.0 (11 July 2026) are worth revisiting, because two of them close gaps that used to be genuine reasons to stay on Cluster Autoscaler. 1. Dynamic Resource Allocation (DRA) support. Karpenter now ships a DRA allocator: it understands ResourceClaim objects when deciding what to provision, including pod-level claims, and supports consumable capacity and partitionable devices. In practice this is the GPU story. Under the old model, “this pod needs a slice of an A100” was expressed through labels, taints and affinity rules, and the autoscaler could not reason about it properly — you pinned GPU workloads to a hand-built node group and accepted the waste. With DRA, the device requirement is a first-class scheduling input, so Karpenter can pick the right accelerator instance and pack partitionable devices instead of stranding a whole GPU per pod. If you run ML workloads, this is the release that matters. 2. The CapacityBuffer API (v1beta1). Karpenter’s great weakness was always the cold start: it is fast, but it still provisions after pods go Pending. CapacityBuffer lets you declare headroom that Karpenter keeps warm, which is the “overprovisioning with pause pods” hack that teams have been hand-rolling for years, now a supported API with its own status metrics. It directly narrows the gap against a deliberately over-provisioned Cluster Autoscaler node group. 3. The Balanced consolidation policy. Consolidation used to be a fairly blunt trade — pack tighter, accept more disruption. The new Balanced policy sits between aggressive consolidation and leaving nodes alone, and consolidateAfter now also applies to destination nodes during consolidation, which makes churn considerably more predictable. If you evaluated Karpenter, liked the cost numbers and rejected it because the disruption was too noisy for your workloads, that objection is worth re-testing.

Multi-Cloud Support in 2026

CloudCluster AutoscalerKarpenter
AWS✅ Production-stable✅ Production-stable (reference impl.)
GCP✅ Production-stable⚠️ Beta (karpenter-provider-gcp)
Azure✅ Production-stable✅ Stable (karpenter-provider-azure)
Alibaba✅ Supported❌ No provider
DigitalOcean✅ Supported❌ No provider
On-premises / Cluster API✅ Supported❌ Not supported

When Cluster Autoscaler Is Still the Right Choice

  1. Non-AWS environments — GCP, Alibaba, DigitalOcean, on-prem with Cluster API
  2. Existing node group architecture — significant investment in ASG design, compliance tooling
  3. Regulatory constraints — some frameworks require ASG-backed provisioning audit trails
  4. Cluster API / bare metal — CA is the only mature option
  5. Team familiarity and working-well CA deployment — migration cost may not justify benefit

When Karpenter Is the Right Choice

  1. AWS-native, cost optimization priority — right-sizing + consolidation = meaningful cost reduction
  2. Diverse and variable workloads — batch, spot, GPU, stateless APIs — Karpenter handles all with a few NodePools
  3. Spot-heavy clusters — native interruption handling, diversification, no NTH
  4. Declarative infrastructure-as-code culture — NodePools version cleanly in Git
  5. Low-latency scaling requirements — event-driven workloads, KEDA-triggered jobs, sharp traffic spikes

Running Both: Migration Path and Gotchas

Separating Responsibility

Use labels and taints to prevent CA and Karpenter from managing the same nodes:
# NodePool with taint — CA-managed pods won't tolerate this
spec:
  template:
    metadata:
      labels:
        provisioner: karpenter
    spec:
      taints:
        - key: karpenter.sh/provisioned
          value: "true"
          effect: NoSchedule

Gradual Migration

  1. Phase 1 — Karpenter manages spot/batch workloads. CA manages on-demand production nodes.
  2. Phase 2 — Migrate spot workloads fully. Remove AWS NTH.
  3. Phase 3 — Migrate on-demand. Reduce CA node group capacity gradually.
  4. Phase 4 — Decommission CA once all groups are empty.

Key Gotchas

  • Karpenter consolidation + permissive PDBsmaxUnavailable: 100% will cause disruptive consolidation. Audit PDBs before enabling WhenEmptyOrUnderutilized.
  • NodePool limits are hard stops — pods go Pending indefinitely at limit. Monitor utilization.
  • AMI drift@latest alias picks up new AMIs on new nodes. Consider pinning for strict change control.
  • Simultaneous scale-down conflicts — use strict label/taint segregation during migration.

Decision Framework

FactorCluster AutoscalerKarpenter
Cloud supportAll clouds + on-premAWS (GA), Azure (stable), GCP (beta)
Provisioning speed4–8 minutes60–120 seconds
Instance flexibilityNode group pre-config requiredFull catalog, runtime selection
Cost optimizationScale-down onlyScale-down + consolidation + right-sizing
Spot integrationVia ASG + NTHNative, first-class
Operational complexityLowerModerate
Cluster API / bare metalYesNo
ConsolidationNoYes
Running on AWS?
├── No → Azure? → Karpenter (stable) or CA
│        GCP?   → CA or GKE NAP (preferred)
│        Other  → Cluster Autoscaler
│
└── Yes → Hard regulatory constraints on non-ASG provisioning?
          ├── Yes → Cluster Autoscaler
          └── No → Cost optimization priority or diverse workloads?
                   ├── Yes → Karpenter
                   └── No → Either (flip for team preference)

Frequently Asked Questions

Is Karpenter a drop-in replacement for Cluster Autoscaler?

No. Different configuration model, different concepts. Migration requires re-expressing node group config as NodePools/NodeClasses, auditing PDBs, and running both in parallel. Budget at least a sprint for a medium-sized cluster.

Can I run Karpenter on self-managed Kubernetes (not EKS)?

Yes, but non-trivial. Karpenter requires IAM credentials (IRSA or equivalent) to call EC2 APIs. On self-managed clusters, this requires more setup than on EKS where IRSA is built-in.

How does Karpenter interact with HPA and VPA?

No conflict. HPA creates pods u2192 pods go Pending if insufficient nodes u2192 Karpenter provisions nodes u2192 pods scheduled. VPA adjusts pod resource requests, which Karpenter uses as inputs for bin packing.

What happens when Karpenter itself goes down?

Existing nodes and pods continue normally. New pods requiring provisioning go Pending until Karpenter recovers. Scale-down and consolidation pause. Deploy multiple replicas with leader election for production.

Does Karpenter support GPU nodes?

Yes. GPU instance types (p3, p4, g4, g5) can be included in NodePool requirements. Create dedicated NodePools with appropriate taints for GPU-requesting pods.

How does Karpenter handle AMI updates?

The expireAfter field forces node rotation. When a node expires, Karpenter pre-provisions a replacement with the latest AMI per EC2NodeClass, then drains and terminates the old node — a rolling AMI update mechanism without additional tooling.

Is Cluster Autoscaler still actively maintained?

Yes. CA remains under active development in kubernetes/autoscaler, with releases tracking Kubernetes minor versions. It is not being deprecated. For non-AWS environments and working CA deployments, it remains a fully supported and rational choice.

Karpenter vs Cluster Autoscaler: Quick Decision Recap

Searching for Karpenter vs Cluster Autoscaler? The one-paragraph version: choose Karpenter on AWS when provisioning speed (60–90 seconds vs 4–8 minutes), instance-type flexibility and cost consolidation matter. Choose Cluster Autoscaler when you need multi-cloud portability, you already operate stable node groups, or your platform depends on the node-group model (compliance-pinned AMIs, strict capacity planning).

Related reading: GPU scheduling with Dynamic Resource Allocation.

Related reading: Kamal vs Kubernetes.

Related reading: EKS Auto Mode.

Both can coexist during a migration: run Cluster Autoscaler for existing node groups while Karpenter handles new dynamic capacity, then consolidate once you trust the disruption behavior.


Tested against Kubernetes 1.28–1.32. Karpenter v1.x API (GA). CA v1.30.x. AWS provider examples; Azure and GCP provider details may differ.

Kubernetes Resource Requests and Limits: The Complete Production Guide

Kubernetes Resource Requests and Limits: The Complete Production Guide
Your pods are being OOMKilled at 3 AM. Your latency p99 spikes every few minutes with no obvious cause. Your cluster scheduler is placing workloads on nodes that can’t sustain them. In most production Kubernetes incidents, misconfigured resource requests and limits are either the direct cause or an accelerating factor. This is not a “what are requests and limits” tutorial. It is a deep technical guide for engineers who run Kubernetes in production and need to understand what actually happens inside the kernel when these values are set — and what the consequences are when they are wrong.

What Requests and Limits Actually Are

The Kubernetes documentation explains requests and limits at the API level. What it underexplains is the enforcement mechanism: cgroups. When the kubelet admits a pod onto a node, it creates a cgroup hierarchy for that pod under /sys/fs/cgroup/. Each container in the pod gets its own cgroup. The values you set in your pod spec translate directly into cgroup parameters: CPU requestcpu.shares (cgroups v1) or cpu.weight (cgroups v2) CPU limitcpu.cfs_quota_us and cpu.cfs_period_us Memory requestmemory.soft_limit_in_bytes (advisory, used for eviction scoring) Memory limitmemory.limit_in_bytes (hard enforcement, triggers OOMKill) The scheduler uses requests to make placement decisions. It does not know about actual utilization — it knows about committed capacity. A node with 4 cores where running pods have a total CPU request of 3.5 cores has 0.5 cores of schedulable capacity remaining, even if actual CPU utilization is 15%. This is why you can have a fully “utilized” cluster (by requests) where nodes are idle, and why you can have nodes at 95% CPU utilization that still accept new pods because their requests are low. The kubelet uses limits to enforce runtime constraints via those cgroup parameters. The scheduler never sees limits.

CPU vs Memory: Why They Behave Fundamentally Differently

This is the most consequential thing to understand about Kubernetes resource management, and it is routinely misunderstood even by experienced engineers.

CPU Is Compressible

CPU is a time-shared resource. If your container tries to use more CPU than its limit allows, the Linux CFS scheduler simply throttles it — it stops getting CPU time until the next scheduling period. The process continues. It just waits. From the application’s perspective: things slow down. Latency increases. Throughput drops. But the process does not die.

Memory Is Not Compressible

Memory is not time-shared. If your container tries to allocate memory beyond its limit, there is no “slow down” path. The Linux OOM killer selects a process in the cgroup and kills it. The container dies. From the application’s perspective: the process is terminated. Kubernetes restarts the container. You see OOMKilled in kubectl describe pod.
PropertyCPUMemory
EnforcementCFS throttlingOOM Kill
Process survives?Yes (degraded performance)No (killed and restarted)
Compressible?YesNo
Scheduler visibilityRequests onlyRequests only
Over-limit consequenceLatency spikesContainer restart
Setting limits: recommended?Situational (see below)Always
This asymmetry drives every recommendation in the rest of this guide.

QoS Classes: Eviction Priority Under Pressure

Kubernetes assigns each pod a Quality of Service (QoS) class based on the requests and limits set across all its containers. This class determines eviction priority when a node is under memory pressure.

Guaranteed

Condition: Every container has CPU and memory requests and limits set, and requests equal limits for both CPU and memory.
resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"
Guaranteed pods are the last to be evicted. The kubelet will exhaust BestEffort and Burstable pods before touching these. They get the most predictable resource allocation on the node. Warning: Guaranteed does not mean “always available.” It means “last to be killed.” On a heavily overloaded node, even Guaranteed pods can be evicted.

Burstable

Condition: At least one container has a CPU or memory request or limit set, but the pod does not meet Guaranteed criteria.
resources:
  requests:
    cpu: "250m"
    memory: "256Mi"
  limits:
    cpu: "1000m"
    memory: "1Gi"
Burstable pods are evicted after BestEffort but before Guaranteed. They can burst above their request when capacity is available, but they are not protected when the node is under pressure.

BestEffort

Condition: No container in the pod has any CPU or memory requests or limits set.
# No resources block at all
BestEffort pods are evicted first, always. They get whatever capacity is left over after scheduled workloads consume their requested share. On a loaded node, they may be starved entirely. In production: never run stateful workloads or business-critical services as BestEffort. The Kubernetes scheduler will place them anywhere, and the kubelet will kill them first.

Common Misconfiguration Patterns and Their Consequences

Pattern 1: No Requests or Limits Set

Effect: BestEffort QoS. First to be evicted under memory pressure. Scheduler places pods arbitrarily — it has no data for placement decisions, so it defaults to LeastRequestedPriority, which effectively means these pods may land on the same nodes as heavily-loaded workloads. Real consequence: Your “lightweight” background jobs kill your API servers at 3 AM when a memory spike triggers eviction and BestEffort pods happen to be sitting next to them on the same node.

Pattern 2: Requests Equal Limits (Guaranteed QoS)

This is the common “safe” pattern recommended in older Kubernetes documentation. It is not wrong, but it has a trap: CPU limits = CPU requests means CPU throttling is guaranteed to trigger. Your pod will be throttled the moment it tries to burst above the request — during startup, during GC, during a traffic spike — even if the node has abundant free CPU. For latency-sensitive applications, this means predictable throttling spikes at exactly the moments you need the most CPU. Memory: Setting memory request = memory limit is appropriate and recommended. The behavior is correct: the pod runs in a controlled memory budget.

Pattern 3: Limits Much Higher Than Requests (Burstable with High Ratio)

resources:
  requests:
    cpu: "100m"
    memory: "128Mi"
  limits:
    cpu: "4000m"
    memory: "4Gi"
This is the opposite extreme. The scheduler thinks this pod needs 100m CPU and 128Mi memory. Dozens of these can be scheduled onto a single node. When they all burst simultaneously — which they will, during a deployment, a traffic event, or a GC cycle — the node is overloaded, memory pressure triggers OOMKill cascades, and the scheduler has no idea anything is wrong because the committed capacity (by requests) looks fine. The limit:request ratio matters. A 10x or 20x memory limit:request ratio on many pods is a recipe for node instability. A reasonable starting point is 2x–4x for memory, less for CPU.

Pattern 4: CPU Limits Set to “Be Safe”

This is the subtlest misconfiguration and the one with the most hidden latency impact. We cover it in depth in the next section.

The CPU Throttling Problem: CFS Bandwidth and Hidden Latency

This is where many production Kubernetes deployments have a silent performance problem they cannot easily diagnose.

How CFS Bandwidth Throttling Works

The Linux Completely Fair Scheduler (CFS) enforces CPU limits using bandwidth control. The relevant parameters are:
  • cpu.cfs_period_us: the accounting period, default 100ms
  • cpu.cfs_quota_us: how many microseconds of CPU time the cgroup can use per period
If you set cpu: "500m" as a limit, Kubernetes sets cpu.cfs_quota_us = 50000 (50ms per 100ms period). This means the container can use at most 50% of one CPU core per 100ms window. The problem: quota is enforced per period, not as a moving average. If your container uses its full 50ms allocation in the first 60ms of a period, it is throttled for the remaining 40ms — even if the node has 7 idle CPUs. The CPU sits idle. Your container waits.

Why This Causes Latency Spikes Even at Low Utilization

This is counterintuitive and the source of many production mysteries. You can have a container running at 10% average CPU utilization that is regularly throttled, because its instantaneous CPU usage within a single 100ms window exceeds its quota. Java applications with JVM garbage collection are particularly vulnerable. GC causes a CPU burst of short duration. If that burst exceeds the per-period quota, the GC pause is extended artificially by throttling — even though the GC event itself would have been short. The same applies to Node.js event loop processing, Python import at startup, and any application that has bursty CPU behavior (which is most of them).

The Cloudflare and Netflix Evidence

Cloudflare published findings showing that CPU throttling was responsible for significant tail latency increases in their containerized workloads, and that removing CPU limits reduced p99 latency substantially for services that appeared to have headroom. Netflix has documented similar patterns in their capacity planning work, noting that per-period quota enforcement does not model real application CPU behavior accurately. The kernel community has been aware of this for years. The fix — moving to cgroups v2 with better scheduler integration — helps but does not eliminate the problem. Kubernetes 1.25+ with cgroups v2 nodes experience less throttling under the same limits, but the fundamental issue remains: CPU limits throttle bursty applications unpredictably.

The Recommendation: Consider Not Setting CPU Limits

This is controversial but grounded in the evidence: For latency-sensitive services: do not set CPU limits. Set CPU requests accurately and rely on the scheduler for placement. The argument: – CPU throttling is a soft failure mode that is hard to observe and diagnose – OOMKill is a hard failure mode that is visible and recoverable – CPU requests give the scheduler accurate placement data without creating throttling – Nodes handle CPU oversubscription gracefully through time-sharing; they do not handle memory oversubscription gracefully When to still set CPU limits: – Multi-tenant clusters where noisy neighbor isolation is critical – Batch workloads where predictable CPU allocation matters more than latency – When your monitoring and alerting can catch CPU starvation at the node level When you do not set CPU limits, you must set CPU requests accurately. A request of 100m for a service that normally uses 800m means the scheduler places it on a node that cannot actually sustain it. The result is real CPU starvation, not artificial throttling — but it is CPU starvation nonetheless.

Memory: Always Set Limits

The contrast with CPU is direct. Memory is non-compressible. A container that leaks memory or has a runaway allocation will consume all available node memory if unconstrained. This does not degrade gracefully — it triggers the OOM killer, which may kill unrelated processes on the node. Always set memory limits. Always. The consequence — OOMKill — is visible, logged, and Kubernetes handles it by restarting the container. An OOMKilled exit code is actionable: you either have a memory leak, your limit is too low, or your sizing methodology is wrong. All three are diagnosable. The alternative — no memory limit — means a single leaking pod can destabilize an entire node and trigger eviction cascades affecting unrelated workloads. Set memory requests equal to the p95 steady-state usage of your application. Set memory limits at 1.5x–2x the request to absorb traffic spikes and GC pressure. Profile your application under load to establish these baselines.

Vertical Pod Autoscaler (VPA)

VPA is the Kubernetes component designed to solve the sizing problem automatically. It observes actual resource utilization and recommends (or applies) adjusted requests.

How VPA Works

VPA has three components:
  • Recommender: Watches historical metrics and computes recommended requests based on observed utilization. Does not modify pods.
  • Updater: Evicts pods whose current requests differ significantly from recommendations (when VPA mode is Auto or Recreate).
  • Admission Controller: Mutates pod specs at admission time to apply recommendations from the Recommender.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-server-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  updatePolicy:
    updateMode: "Off"   # Recommend only — do not evict pods
  resourcePolicy:
    containerPolicies:
    - containerName: api-server
      minAllowed:
        cpu: 100m
        memory: 128Mi
      maxAllowed:
        cpu: 4000m
        memory: 4Gi
      controlledResources: ["cpu", "memory"]
      controlledValues: RequestsAndLimits

VPA Modes

ModeBehavior
OffCompute recommendations only. No pod mutations.
InitialApply recommendations to new pods only. Do not evict running pods.
RecreateEvict pods when recommendations change significantly.
AutoCurrently equivalent to Recreate. May change in future versions.

When to Use VPA

Right-sizing during initial rollout: Run VPA in Off mode for 1–2 weeks on a new service. Review recommendations before applying. This is the most valuable use case. Services with unpredictable or seasonal load patterns: VPA adapts requests based on observed behavior. Combined with HPA for horizontal scaling, this gives you right-sized replicas that scale out horizontally. VPA and HPA cannot both manage the same metric. If HPA is scaling on CPU utilization, do not use VPA with controlledValues: RequestsAndLimits for CPU — they will fight each other. Use controlledValues: RequestsOnly and let HPA manage scale. VPA limitations: – Requires pod restarts to apply recommendations (Updater evicts pods) – Does not work well with stateful workloads in strict availability windows – Recommender needs sufficient history (at least a few days) to produce reliable recommendations – Does not account for traffic spikes that haven’t been observed yet

LimitRange and ResourceQuota: Namespace-Level Guardrails

Requests and limits on individual pods solve the per-workload problem. LimitRange and ResourceQuota solve the namespace and cluster-level governance problem.

LimitRange

LimitRange sets default requests and limits for containers in a namespace, and enforces minimum/maximum boundaries. Any pod admitted to the namespace that does not have explicit requests/limits set will receive the defaults.
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: production
spec:
  limits:
  - type: Container
    default:
      cpu: "500m"
      memory: "512Mi"
    defaultRequest:
      cpu: "100m"
      memory: "128Mi"
    max:
      cpu: "4000m"
      memory: "8Gi"
    min:
      cpu: "50m"
      memory: "64Mi"
  - type: Pod
    max:
      cpu: "8000m"
      memory: "16Gi"
  - type: PersistentVolumeClaim
    max:
      storage: "50Gi"
    min:
      storage: "1Gi"
Key behaviors:default applies as the limit for containers that set a request but no limit – defaultRequest applies as the request for containers that set no request – max and min cause admission to fail if violated – LimitRange applies at admission time — changing it does not affect running pods Use LimitRange to: – Prevent BestEffort pods from being admitted (by setting defaultRequest values) – Enforce organizational standards for minimum resource specifications – Protect the cluster from pods requesting unbounded resources

ResourceQuota

ResourceQuota limits the total amount of resources that can be consumed by all pods in a namespace. This is the multi-tenant governance tool.
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    requests.cpu: "20"
    requests.memory: "40Gi"
    limits.cpu: "40"
    limits.memory: "80Gi"
    pods: "100"
    persistentvolumeclaims: "20"
    requests.storage: "500Gi"
    count/deployments.apps: "50"
    count/services: "50"
    count/secrets: "100"
    count/configmaps: "100"
Critical interaction with LimitRange: When ResourceQuota is active in a namespace, every pod must have requests and limits set or it will be rejected. This is why LimitRange defaults are important — they ensure pods without explicit resources are not rejected by the quota system. Use ResourceQuota to: – Enforce team/application resource budgets in shared clusters – Prevent runaway deployments from consuming all cluster capacity – Implement chargeback policies (track resource consumption per namespace)

Practical Sizing Methodology

Step 1: Instrument Before You Set Values

Deploy initially with only requests set (no CPU limits, memory limits set conservatively high) and monitor for 2–4 weeks under realistic load. Useful PromQL queries for sizing:
# p95 CPU usage over the last 7 days
histogram_quantile(0.95,
  rate(container_cpu_usage_seconds_total{
    container="api-server",
    namespace="production"
  }[5m])
)

# p99 memory working set over the last 7 days
quantile_over_time(0.99,
  container_memory_working_set_bytes{
    container="api-server",
    namespace="production"
  }[7d]
)

# CPU throttling ratio (alert if >5%)
rate(container_cpu_cfs_throttled_seconds_total{container="api-server"}[5m])
/
rate(container_cpu_cfs_periods_total{container="api-server"}[5m])

Step 2: Set CPU Requests from p95 Observations

Set CPU request = p95 CPU usage under realistic production load. For latency-sensitive services: do not set CPU limits. For batch or background jobs: set CPU limits at 2x–4x the request.

Step 3: Set Memory Requests and Limits

Set memory request = p95 memory working set over at least 7 days. Set memory limit = max(observed peak, 1.5 × request). For Java/Python with large processing, use 2x.
# Production example: Java microservice
resources:
  requests:
    cpu: "500m"       # p95 observed: ~420m
    memory: "768Mi"   # p95 observed: ~680Mi
  limits:
    # No CPU limit — latency-sensitive service
    memory: "1.5Gi"   # 2x request, covers GC pressure

Step 4: Use VPA Recommendations to Validate

Run VPA in Off mode alongside your manually-set values. After 1–2 weeks, compare VPA recommendations to your current settings.

Step 5: Adjust for Workload Lifecycle Events

Account for: JVM warmup at startup (CPU spike 3–10x steady-state), rolling deployment overlap (namespace quota headroom), and known traffic peaks (size to peak, not average).

Decision Framework: What to Set Based on Workload Type

Workload TypeCPU RequestCPU LimitMemory RequestMemory LimitQoS Target
Latency-sensitive API (Go, Java, Node)p95 observedDo not setp95 observed1.5–2x requestBurstable
Batch / background jobsp50 observed2–4x requestp95 observed1.5x requestBurstable
System-critical (coredns, metrics-server)ConservativeEqual to requestConservativeEqual to requestGuaranteed
Stateful / databases (in-cluster)p95 observedDo not setp99 observed1.25x requestBurstable
Dev/test workloadsLow (100m)2x requestLow (128Mi)2x requestBurstable
Sidecar containers (envoy, otel-collector)Profile individuallyContextualProfile individually1.5x requestMatches primary

Monitoring and Alerting

# OOMKill rate
- alert: ContainerOOMKilled
  expr: increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}[5m]) > 0
  for: 0m
  labels:
    severity: warning

# CPU throttling >10%
- alert: CPUThrottlingHigh
  expr: |
    rate(container_cpu_cfs_throttled_seconds_total[5m])
    /
    rate(container_cpu_cfs_periods_total[5m])
    > 0.10
  for: 5m
  labels:
    severity: warning

# Memory near limit >85%
- alert: MemoryNearLimit
  expr: |
    container_memory_working_set_bytes
    /
    (container_spec_memory_limit_bytes > 0)
    > 0.85
  for: 5m
  labels:
    severity: warning

FAQ

Q: My Java application keeps getting OOMKilled but I’ve set limits at 2x average usage. What am I missing?

The JVM heap (-Xmx) is not the only memory consumer. Off-heap buffers, Metaspace, thread stacks, and JVM overhead add 25–40% on top. Set -Xmx at ~75% of your container memory limit. For a 1Gi limit: -Xmx768m is a safe starting point.

Q: Should I set the same resources in all environments?

No. Dev/test can use lower values. But the ratio between request and limit should be similar, and the resource profile should be close enough to catch misconfigurations before production.

Q: Can I use HPA and VPA together?

Yes, carefully. Use HPA for replica scaling (CPU or custom metrics) and VPA in Off mode or controlledValues: RequestsOnly for right-sizing guidance. Never have both managing the same metric simultaneously.

Q: My cluster uses cgroups v2. Does CPU throttling still apply?

Improved but not eliminated. cgroups v2 uses a weight-based scheduler that reduces throttling artifacts. However, cpu.cfs_quota_us enforcement still exists when CPU limits are set. For latency-sensitive workloads, the case for not setting CPU limits remains valid on cgroups v2.

Q: What is a realistic cluster overcommit ratio?

CPU: 5–10x overcommit (total requests vs physical cores) is common for mixed workloads with accurate requests. Memory: 1.5–2x cluster-level overcommit is manageable at 1.5x request:limit ratios. Beyond 2x, node memory pressure events become frequent.

Q: LimitRange is set but pods are still admitted without resources. Why?

LimitRange defaults only apply to containers with no resource specification at all. If a container specifies requests.cpu but not limits.cpu, the LimitRange default for CPU does not fill in the missing limit. Also verify the LimitRange is in the correct namespace: kubectl get limitrange -n <namespace>.

Q: What does a pod with no memory limit do to a node?

It can consume all available node memory unconstrained. This triggers the Linux OOM killer at the node level, which may kill processes outside the container — including the kubelet itself in extreme cases. Memory limits are non-negotiable in production.


Tested against Kubernetes 1.28–1.32. cgroups v2 behavior noted where it differs from v1. VPA examples use autoscaling.k8s.io/v1 API (VPA 0.14+).