A one-line config change, swapping a pinned AMI version to latest, looks like a security win. Your nodes will always run the newest, most patched AMI. Except when they won't.
This post walks through how a change meant to track the latest EKS-optimized AMI can leave your most critical nodes stuck on an old, unpatched version. There are no alerts, so nothing looks wrong until you check node versions by hand.
It covers why this happens, why the config that causes it exists for good reason, and what options you have to get both security and stability.
Prerequisites
If you're already familiar with Karpenter, NodePools, and disruption budgets, skip ahead to The harmless-looking commit
Karpenter is a Kubernetes node autoscaler for AWS. Unlike traditional managed nodegroups with fixed Auto Scaling Groups, Karpenter watches for pods that can't be scheduled and provisions right-sized EC2 instances on the fly. When those nodes are no longer needed, it removes them.
Key concepts:
NodePool: defines what kind of nodes can be created (instance types, capacity type, zones) and how they can be disrupted (removed/replaced).
EC2NodeClass: defines the AMI, networking, and boot configuration for nodes.
NodeClaim: Karpenter's record of a provisioned node.
Drift: when a running node no longer matches the desired spec e.g. the AMI has changed. Karpenter detects this automatically.
Disruption: any action that removes or replaces a node, including consolidation, drift replacement, and expiry.
Why multiple node groups exist
A typical EKS cluster has multiple tiers of compute:
Tier | Managed by | Purpose |
|---|---|---|
Managed nodegroup | AWS EKS | System pods (Karpenter itself, CNI, CoreDNS). Must exist before Karpenter starts. |
Karpenter "default" pool | Karpenter | General workloads. Aggressively consolidated: nodes are freely replaced when underutilized, drifted, or expired. |
Karpenter "ondemand" pool | Karpenter | Critical workloads (CI/CD runners, monitoring). On-demand instances with strict disruption controls. |
The separation exists because different workloads have different reliability requirements. A batch job can handle being rescheduled. A CI pipeline halfway through a deployment cannot.
Disruption budgets
Karpenter's disruption budgets control how many nodes it can disrupt at once, and for what reason:
disruption: budgets: - nodes: "0" reasons: - "Underutilized" - "Drifted" - nodes: "20%" reasons: - "Empty"
This says:
Zero nodes may be disrupted for being underutilized or drifted (complete protection).
20% of nodes may be disrupted if they're completely empty (safe: no workloads affected).
Why budget=0 for Drifted makes sense
For the ondemand pool, blocking drift replacement protects against:
Mid-flight CI/CD failures: if a runner is mid-pipeline and Karpenter drains the node, the job fails.
Loss of monitoring: if the observability stack gets restarted, you lose visibility during the transition.
Uncontrolled timing: AWS releases AMIs at unpredictable times. Budget=0 means you choose when to take disruption.
Cascading failures: if PodDisruptionBudgets aren't perfectly configured, node replacement can cause more disruption than expected.
The intent is clear: ondemand nodes should only be disrupted when a human decides it's safe.
The harmless-looking commit
Consider this one-line change to the EC2NodeClass config:
- amiVersion: v20251209 + amiVersion: latest
This reads as a straightforward security improvement. Instead of being stuck on a pinned AMI that requires a manual PR to bump, the cluster now always targets the latest EKS-optimized AMI. New nodes will always be secure.
The problem shows up on the nodes that are already running.
What actually happens
Here's a concrete timeline of how this plays out:
April 20, 12:00 PM - Workload scheduled on ondemand pool. Karpenter launches a node with AMI v20251209 (the latest at the time), running kubelet v1.34.2.
June 5, 12:30 PM - Config deployed:
amiVersionchanged from pinnedv20251209tolatest. At this point,latestresolves to AMI v20260526 (kubelet v1.34.8).June 5, 12:31 PM - Karpenter detects the mismatch. Node AMI (v20251209) != latest AMI (v20260526). Node marked
Drifted: Truewith reasonAMIDrift.June 5, 12:32 PM - Karpenter checks the disruption budget. Budget = 0 for Drifted. Replacement blocked.
June 5, 12:32 PM onward - Node stays on the old AMI. 46 days behind on security patches. No alert, no notification.
Generalizing this, the flow is:

Fig 1: The same drift is fixed in the default pool and blocked in the ondemand pool.
For the default pool (permissive budget), Karpenter replaces the nodes immediately. They get the new AMI. No problem.
For the ondemand pool (
nodes: "0"for Drifted), Karpenter marks the node as drifted but cannot replace it. The node stays on the old AMI indefinitely.
The result: a node running your most critical workloads is permanently stuck on an old, unpatched AMI. The exact opposite of what "latest" was supposed to achieve.
Why this isn't a one-time problem
AWS releases new EKS-optimized AMIs every few weeks for security patches and kubelet updates. Each release triggers the same cycle:

Fig 2: Every AMI release repeats the cycle, so the protected node falls further behind.
Shifts what
latestresolves to.Causes Karpenter to mark all existing ondemand nodes as drifted.
The budget blocks replacement.
The node falls further behind on patches.
As long as the ondemand node has running workloads (which it always will: that's why it exists), it will never become "Empty" and never be eligible for the only allowed disruption reason.
The node you're protecting the most is the node that falls furthest behind on security patches.
The contradiction
The configuration now says two contradictory things:
"Always target the latest AMI" โ security intent
"Never replace a node for drift" โ stability intent
For new nodes created during scale-up, latest works: they get the newest AMI. But existing nodes, provisioned before the latest AMI release, stay on whatever AMI they launched with.
You get latest on creation, fixed after that.
What manual intervention looks like
To actually update the stuck node, someone must:

Fig 3: Without automation, someone repeats this loop after every AMI release.
Patch the NodePool to temporarily allow drift replacement.
Wait for Karpenter to provision a new node, drain the old one, and terminate it.
Revert the patch to restore the protective budget.
This is the same amount of effort as the old pinned approach (bump version in a PR, deploy), but without the clear trigger of a version change in git. The drift builds up without a signal: you only notice when you inspect node versions and find one lagging weeks or months behind.
Approaches to consider
Pin the AMI version (the original approach)
Keep amiVersion set to a specific version e.g. v20260526. Bump it deliberately via PR when ready.
Pros | Cons |
|---|---|
Full control over what version and when | Requires manual PR for every AMI update |
No silent drift accumulation | Easy to fall behind if no one remembers to bump |
Clear git audit trail per upgrade | New nodes during scale-up also get the pinned (possibly stale) version |
Same manual effort for replacement, but intentional |
Latest + budget=0 (the current trap)
Keep amiVersion: latest with nodes: "0" for Drifted.
Pros | Cons |
|---|---|
New nodes always on latest AMI | Existing nodes never get updated automatically |
No unexpected disruption | Drift builds up with no alert, no reminder |
Same manual effort as pinning, but without the PR trigger | |
Contradictory config: "track latest" but "never act on drift" |
Latest + scheduled maintenance window
Keep amiVersion: latest but add a time-based budget that allows drift replacement during off-peak hours:
budgets: - nodes: "0" reasons: ["Drifted"] - nodes: "1" reasons: ["Drifted"] schedule: "0 2 * * MON" duration
Outside the window: fully protected. During the window: Karpenter replaces one drifted node at a time.
Pros | Cons |
|---|---|
Fully automated: no manual intervention | Disruption happens during the window (must be off-peak) |
Controlled, predictable timing | If multiple nodes are drifted, takes multiple weeks |
New nodes always on latest AMI | Requires PDBs to be correctly configured |
Nodes can still be up to 1 week behind on patches |
Latest + budget=1 (fully automatic)
Keep amiVersion: latest with nodes: "1" for Drifted. Karpenter replaces immediately.
Pros | Cons |
|---|---|
Always up to date: no lag | No control over timing |
Zero manual effort | Could disrupt during peak hours or incidents |
New nodes always on latest AMI | Relies entirely on PDBs for safe rollout |
AWS release timing determines your disruption schedule |
The takeaway
amiVersion: latest is not a complete security solution for node pools with strict disruption budgets. It only guarantees that new nodes are on the latest AMI. For existing nodes, the disruption budget determines whether they actually get updated.
Before switching a pinned AMI to latest, ask one question: can Karpenter actually replace the drifted node? If the budget says no, you've written a config that claims to patch your most critical nodes but never will.
If you liked this post, you might find these interesting too
A one-line config change, swapping a pinned AMI version to latest, looks like a security win. Your nodes will always run the newest, most patched AMI. Except when they won't.
This post walks through how a change meant to track the latest EKS-optimized AMI can leave your most critical nodes stuck on an old, unpatched version. There are no alerts, so nothing looks wrong until you check node versions by hand.
It covers why this happens, why the config that causes it exists for good reason, and what options you have to get both security and stability.
Prerequisites
If you're already familiar with Karpenter, NodePools, and disruption budgets, skip ahead to The harmless-looking commit
Karpenter is a Kubernetes node autoscaler for AWS. Unlike traditional managed nodegroups with fixed Auto Scaling Groups, Karpenter watches for pods that can't be scheduled and provisions right-sized EC2 instances on the fly. When those nodes are no longer needed, it removes them.
Key concepts:
NodePool: defines what kind of nodes can be created (instance types, capacity type, zones) and how they can be disrupted (removed/replaced).
EC2NodeClass: defines the AMI, networking, and boot configuration for nodes.
NodeClaim: Karpenter's record of a provisioned node.
Drift: when a running node no longer matches the desired spec e.g. the AMI has changed. Karpenter detects this automatically.
Disruption: any action that removes or replaces a node, including consolidation, drift replacement, and expiry.
Why multiple node groups exist
A typical EKS cluster has multiple tiers of compute:
Tier | Managed by | Purpose |
|---|---|---|
Managed nodegroup | AWS EKS | System pods (Karpenter itself, CNI, CoreDNS). Must exist before Karpenter starts. |
Karpenter "default" pool | Karpenter | General workloads. Aggressively consolidated: nodes are freely replaced when underutilized, drifted, or expired. |
Karpenter "ondemand" pool | Karpenter | Critical workloads (CI/CD runners, monitoring). On-demand instances with strict disruption controls. |
The separation exists because different workloads have different reliability requirements. A batch job can handle being rescheduled. A CI pipeline halfway through a deployment cannot.
Disruption budgets
Karpenter's disruption budgets control how many nodes it can disrupt at once, and for what reason:
disruption: budgets: - nodes: "0" reasons: - "Underutilized" - "Drifted" - nodes: "20%" reasons: - "Empty"
This says:
Zero nodes may be disrupted for being underutilized or drifted (complete protection).
20% of nodes may be disrupted if they're completely empty (safe: no workloads affected).
Why budget=0 for Drifted makes sense
For the ondemand pool, blocking drift replacement protects against:
Mid-flight CI/CD failures: if a runner is mid-pipeline and Karpenter drains the node, the job fails.
Loss of monitoring: if the observability stack gets restarted, you lose visibility during the transition.
Uncontrolled timing: AWS releases AMIs at unpredictable times. Budget=0 means you choose when to take disruption.
Cascading failures: if PodDisruptionBudgets aren't perfectly configured, node replacement can cause more disruption than expected.
The intent is clear: ondemand nodes should only be disrupted when a human decides it's safe.
The harmless-looking commit
Consider this one-line change to the EC2NodeClass config:
- amiVersion: v20251209 + amiVersion: latest
This reads as a straightforward security improvement. Instead of being stuck on a pinned AMI that requires a manual PR to bump, the cluster now always targets the latest EKS-optimized AMI. New nodes will always be secure.
The problem shows up on the nodes that are already running.
What actually happens
Here's a concrete timeline of how this plays out:
April 20, 12:00 PM - Workload scheduled on ondemand pool. Karpenter launches a node with AMI v20251209 (the latest at the time), running kubelet v1.34.2.
June 5, 12:30 PM - Config deployed:
amiVersionchanged from pinnedv20251209tolatest. At this point,latestresolves to AMI v20260526 (kubelet v1.34.8).June 5, 12:31 PM - Karpenter detects the mismatch. Node AMI (v20251209) != latest AMI (v20260526). Node marked
Drifted: Truewith reasonAMIDrift.June 5, 12:32 PM - Karpenter checks the disruption budget. Budget = 0 for Drifted. Replacement blocked.
June 5, 12:32 PM onward - Node stays on the old AMI. 46 days behind on security patches. No alert, no notification.
Generalizing this, the flow is:

Fig 1: The same drift is fixed in the default pool and blocked in the ondemand pool.
For the default pool (permissive budget), Karpenter replaces the nodes immediately. They get the new AMI. No problem.
For the ondemand pool (
nodes: "0"for Drifted), Karpenter marks the node as drifted but cannot replace it. The node stays on the old AMI indefinitely.
The result: a node running your most critical workloads is permanently stuck on an old, unpatched AMI. The exact opposite of what "latest" was supposed to achieve.
Why this isn't a one-time problem
AWS releases new EKS-optimized AMIs every few weeks for security patches and kubelet updates. Each release triggers the same cycle:

Fig 2: Every AMI release repeats the cycle, so the protected node falls further behind.
Shifts what
latestresolves to.Causes Karpenter to mark all existing ondemand nodes as drifted.
The budget blocks replacement.
The node falls further behind on patches.
As long as the ondemand node has running workloads (which it always will: that's why it exists), it will never become "Empty" and never be eligible for the only allowed disruption reason.
The node you're protecting the most is the node that falls furthest behind on security patches.
The contradiction
The configuration now says two contradictory things:
"Always target the latest AMI" โ security intent
"Never replace a node for drift" โ stability intent
For new nodes created during scale-up, latest works: they get the newest AMI. But existing nodes, provisioned before the latest AMI release, stay on whatever AMI they launched with.
You get latest on creation, fixed after that.
What manual intervention looks like
To actually update the stuck node, someone must:

Fig 3: Without automation, someone repeats this loop after every AMI release.
Patch the NodePool to temporarily allow drift replacement.
Wait for Karpenter to provision a new node, drain the old one, and terminate it.
Revert the patch to restore the protective budget.
This is the same amount of effort as the old pinned approach (bump version in a PR, deploy), but without the clear trigger of a version change in git. The drift builds up without a signal: you only notice when you inspect node versions and find one lagging weeks or months behind.
Approaches to consider
Pin the AMI version (the original approach)
Keep amiVersion set to a specific version e.g. v20260526. Bump it deliberately via PR when ready.
Pros | Cons |
|---|---|
Full control over what version and when | Requires manual PR for every AMI update |
No silent drift accumulation | Easy to fall behind if no one remembers to bump |
Clear git audit trail per upgrade | New nodes during scale-up also get the pinned (possibly stale) version |
Same manual effort for replacement, but intentional |
Latest + budget=0 (the current trap)
Keep amiVersion: latest with nodes: "0" for Drifted.
Pros | Cons |
|---|---|
New nodes always on latest AMI | Existing nodes never get updated automatically |
No unexpected disruption | Drift builds up with no alert, no reminder |
Same manual effort as pinning, but without the PR trigger | |
Contradictory config: "track latest" but "never act on drift" |
Latest + scheduled maintenance window
Keep amiVersion: latest but add a time-based budget that allows drift replacement during off-peak hours:
budgets: - nodes: "0" reasons: ["Drifted"] - nodes: "1" reasons: ["Drifted"] schedule: "0 2 * * MON" duration
Outside the window: fully protected. During the window: Karpenter replaces one drifted node at a time.
Pros | Cons |
|---|---|
Fully automated: no manual intervention | Disruption happens during the window (must be off-peak) |
Controlled, predictable timing | If multiple nodes are drifted, takes multiple weeks |
New nodes always on latest AMI | Requires PDBs to be correctly configured |
Nodes can still be up to 1 week behind on patches |
Latest + budget=1 (fully automatic)
Keep amiVersion: latest with nodes: "1" for Drifted. Karpenter replaces immediately.
Pros | Cons |
|---|---|
Always up to date: no lag | No control over timing |
Zero manual effort | Could disrupt during peak hours or incidents |
New nodes always on latest AMI | Relies entirely on PDBs for safe rollout |
AWS release timing determines your disruption schedule |
The takeaway
amiVersion: latest is not a complete security solution for node pools with strict disruption budgets. It only guarantees that new nodes are on the latest AMI. For existing nodes, the disruption budget determines whether they actually get updated.
Before switching a pinned AMI to latest, ask one question: can Karpenter actually replace the drifted node? If the budget says no, you've written a config that claims to patch your most critical nodes but never will.
If you liked this post, you might find these interesting too
A one-line config change, swapping a pinned AMI version to latest, looks like a security win. Your nodes will always run the newest, most patched AMI. Except when they won't.
This post walks through how a change meant to track the latest EKS-optimized AMI can leave your most critical nodes stuck on an old, unpatched version. There are no alerts, so nothing looks wrong until you check node versions by hand.
It covers why this happens, why the config that causes it exists for good reason, and what options you have to get both security and stability.
Prerequisites
If you're already familiar with Karpenter, NodePools, and disruption budgets, skip ahead to The harmless-looking commit
Karpenter is a Kubernetes node autoscaler for AWS. Unlike traditional managed nodegroups with fixed Auto Scaling Groups, Karpenter watches for pods that can't be scheduled and provisions right-sized EC2 instances on the fly. When those nodes are no longer needed, it removes them.
Key concepts:
NodePool: defines what kind of nodes can be created (instance types, capacity type, zones) and how they can be disrupted (removed/replaced).
EC2NodeClass: defines the AMI, networking, and boot configuration for nodes.
NodeClaim: Karpenter's record of a provisioned node.
Drift: when a running node no longer matches the desired spec e.g. the AMI has changed. Karpenter detects this automatically.
Disruption: any action that removes or replaces a node, including consolidation, drift replacement, and expiry.
Why multiple node groups exist
A typical EKS cluster has multiple tiers of compute:
Tier | Managed by | Purpose |
|---|---|---|
Managed nodegroup | AWS EKS | System pods (Karpenter itself, CNI, CoreDNS). Must exist before Karpenter starts. |
Karpenter "default" pool | Karpenter | General workloads. Aggressively consolidated: nodes are freely replaced when underutilized, drifted, or expired. |
Karpenter "ondemand" pool | Karpenter | Critical workloads (CI/CD runners, monitoring). On-demand instances with strict disruption controls. |
The separation exists because different workloads have different reliability requirements. A batch job can handle being rescheduled. A CI pipeline halfway through a deployment cannot.
Disruption budgets
Karpenter's disruption budgets control how many nodes it can disrupt at once, and for what reason:
disruption: budgets: - nodes: "0" reasons: - "Underutilized" - "Drifted" - nodes: "20%" reasons: - "Empty"
This says:
Zero nodes may be disrupted for being underutilized or drifted (complete protection).
20% of nodes may be disrupted if they're completely empty (safe: no workloads affected).
Why budget=0 for Drifted makes sense
For the ondemand pool, blocking drift replacement protects against:
Mid-flight CI/CD failures: if a runner is mid-pipeline and Karpenter drains the node, the job fails.
Loss of monitoring: if the observability stack gets restarted, you lose visibility during the transition.
Uncontrolled timing: AWS releases AMIs at unpredictable times. Budget=0 means you choose when to take disruption.
Cascading failures: if PodDisruptionBudgets aren't perfectly configured, node replacement can cause more disruption than expected.
The intent is clear: ondemand nodes should only be disrupted when a human decides it's safe.
The harmless-looking commit
Consider this one-line change to the EC2NodeClass config:
- amiVersion: v20251209 + amiVersion: latest
This reads as a straightforward security improvement. Instead of being stuck on a pinned AMI that requires a manual PR to bump, the cluster now always targets the latest EKS-optimized AMI. New nodes will always be secure.
The problem shows up on the nodes that are already running.
What actually happens
Here's a concrete timeline of how this plays out:
April 20, 12:00 PM - Workload scheduled on ondemand pool. Karpenter launches a node with AMI v20251209 (the latest at the time), running kubelet v1.34.2.
June 5, 12:30 PM - Config deployed:
amiVersionchanged from pinnedv20251209tolatest. At this point,latestresolves to AMI v20260526 (kubelet v1.34.8).June 5, 12:31 PM - Karpenter detects the mismatch. Node AMI (v20251209) != latest AMI (v20260526). Node marked
Drifted: Truewith reasonAMIDrift.June 5, 12:32 PM - Karpenter checks the disruption budget. Budget = 0 for Drifted. Replacement blocked.
June 5, 12:32 PM onward - Node stays on the old AMI. 46 days behind on security patches. No alert, no notification.
Generalizing this, the flow is:

Fig 1: The same drift is fixed in the default pool and blocked in the ondemand pool.
For the default pool (permissive budget), Karpenter replaces the nodes immediately. They get the new AMI. No problem.
For the ondemand pool (
nodes: "0"for Drifted), Karpenter marks the node as drifted but cannot replace it. The node stays on the old AMI indefinitely.
The result: a node running your most critical workloads is permanently stuck on an old, unpatched AMI. The exact opposite of what "latest" was supposed to achieve.
Why this isn't a one-time problem
AWS releases new EKS-optimized AMIs every few weeks for security patches and kubelet updates. Each release triggers the same cycle:

Fig 2: Every AMI release repeats the cycle, so the protected node falls further behind.
Shifts what
latestresolves to.Causes Karpenter to mark all existing ondemand nodes as drifted.
The budget blocks replacement.
The node falls further behind on patches.
As long as the ondemand node has running workloads (which it always will: that's why it exists), it will never become "Empty" and never be eligible for the only allowed disruption reason.
The node you're protecting the most is the node that falls furthest behind on security patches.
The contradiction
The configuration now says two contradictory things:
"Always target the latest AMI" โ security intent
"Never replace a node for drift" โ stability intent
For new nodes created during scale-up, latest works: they get the newest AMI. But existing nodes, provisioned before the latest AMI release, stay on whatever AMI they launched with.
You get latest on creation, fixed after that.
What manual intervention looks like
To actually update the stuck node, someone must:

Fig 3: Without automation, someone repeats this loop after every AMI release.
Patch the NodePool to temporarily allow drift replacement.
Wait for Karpenter to provision a new node, drain the old one, and terminate it.
Revert the patch to restore the protective budget.
This is the same amount of effort as the old pinned approach (bump version in a PR, deploy), but without the clear trigger of a version change in git. The drift builds up without a signal: you only notice when you inspect node versions and find one lagging weeks or months behind.
Approaches to consider
Pin the AMI version (the original approach)
Keep amiVersion set to a specific version e.g. v20260526. Bump it deliberately via PR when ready.
Pros | Cons |
|---|---|
Full control over what version and when | Requires manual PR for every AMI update |
No silent drift accumulation | Easy to fall behind if no one remembers to bump |
Clear git audit trail per upgrade | New nodes during scale-up also get the pinned (possibly stale) version |
Same manual effort for replacement, but intentional |
Latest + budget=0 (the current trap)
Keep amiVersion: latest with nodes: "0" for Drifted.
Pros | Cons |
|---|---|
New nodes always on latest AMI | Existing nodes never get updated automatically |
No unexpected disruption | Drift builds up with no alert, no reminder |
Same manual effort as pinning, but without the PR trigger | |
Contradictory config: "track latest" but "never act on drift" |
Latest + scheduled maintenance window
Keep amiVersion: latest but add a time-based budget that allows drift replacement during off-peak hours:
budgets: - nodes: "0" reasons: ["Drifted"] - nodes: "1" reasons: ["Drifted"] schedule: "0 2 * * MON" duration
Outside the window: fully protected. During the window: Karpenter replaces one drifted node at a time.
Pros | Cons |
|---|---|
Fully automated: no manual intervention | Disruption happens during the window (must be off-peak) |
Controlled, predictable timing | If multiple nodes are drifted, takes multiple weeks |
New nodes always on latest AMI | Requires PDBs to be correctly configured |
Nodes can still be up to 1 week behind on patches |
Latest + budget=1 (fully automatic)
Keep amiVersion: latest with nodes: "1" for Drifted. Karpenter replaces immediately.
Pros | Cons |
|---|---|
Always up to date: no lag | No control over timing |
Zero manual effort | Could disrupt during peak hours or incidents |
New nodes always on latest AMI | Relies entirely on PDBs for safe rollout |
AWS release timing determines your disruption schedule |
The takeaway
amiVersion: latest is not a complete security solution for node pools with strict disruption budgets. It only guarantees that new nodes are on the latest AMI. For existing nodes, the disruption budget determines whether they actually get updated.
Before switching a pinned AMI to latest, ask one question: can Karpenter actually replace the drifted node? If the budget says no, you've written a config that claims to patch your most critical nodes but never will.
If you liked this post, you might find these interesting too
A one-line config change, swapping a pinned AMI version to latest, looks like a security win. Your nodes will always run the newest, most patched AMI. Except when they won't.
This post walks through how a change meant to track the latest EKS-optimized AMI can leave your most critical nodes stuck on an old, unpatched version. There are no alerts, so nothing looks wrong until you check node versions by hand.
It covers why this happens, why the config that causes it exists for good reason, and what options you have to get both security and stability.
Prerequisites
If you're already familiar with Karpenter, NodePools, and disruption budgets, skip ahead to The harmless-looking commit
Karpenter is a Kubernetes node autoscaler for AWS. Unlike traditional managed nodegroups with fixed Auto Scaling Groups, Karpenter watches for pods that can't be scheduled and provisions right-sized EC2 instances on the fly. When those nodes are no longer needed, it removes them.
Key concepts:
NodePool: defines what kind of nodes can be created (instance types, capacity type, zones) and how they can be disrupted (removed/replaced).
EC2NodeClass: defines the AMI, networking, and boot configuration for nodes.
NodeClaim: Karpenter's record of a provisioned node.
Drift: when a running node no longer matches the desired spec e.g. the AMI has changed. Karpenter detects this automatically.
Disruption: any action that removes or replaces a node, including consolidation, drift replacement, and expiry.
Why multiple node groups exist
A typical EKS cluster has multiple tiers of compute:
Tier | Managed by | Purpose |
|---|---|---|
Managed nodegroup | AWS EKS | System pods (Karpenter itself, CNI, CoreDNS). Must exist before Karpenter starts. |
Karpenter "default" pool | Karpenter | General workloads. Aggressively consolidated: nodes are freely replaced when underutilized, drifted, or expired. |
Karpenter "ondemand" pool | Karpenter | Critical workloads (CI/CD runners, monitoring). On-demand instances with strict disruption controls. |
The separation exists because different workloads have different reliability requirements. A batch job can handle being rescheduled. A CI pipeline halfway through a deployment cannot.
Disruption budgets
Karpenter's disruption budgets control how many nodes it can disrupt at once, and for what reason:
disruption: budgets: - nodes: "0" reasons: - "Underutilized" - "Drifted" - nodes: "20%" reasons: - "Empty"
This says:
Zero nodes may be disrupted for being underutilized or drifted (complete protection).
20% of nodes may be disrupted if they're completely empty (safe: no workloads affected).
Why budget=0 for Drifted makes sense
For the ondemand pool, blocking drift replacement protects against:
Mid-flight CI/CD failures: if a runner is mid-pipeline and Karpenter drains the node, the job fails.
Loss of monitoring: if the observability stack gets restarted, you lose visibility during the transition.
Uncontrolled timing: AWS releases AMIs at unpredictable times. Budget=0 means you choose when to take disruption.
Cascading failures: if PodDisruptionBudgets aren't perfectly configured, node replacement can cause more disruption than expected.
The intent is clear: ondemand nodes should only be disrupted when a human decides it's safe.
The harmless-looking commit
Consider this one-line change to the EC2NodeClass config:
- amiVersion: v20251209 + amiVersion: latest
This reads as a straightforward security improvement. Instead of being stuck on a pinned AMI that requires a manual PR to bump, the cluster now always targets the latest EKS-optimized AMI. New nodes will always be secure.
The problem shows up on the nodes that are already running.
What actually happens
Here's a concrete timeline of how this plays out:
April 20, 12:00 PM - Workload scheduled on ondemand pool. Karpenter launches a node with AMI v20251209 (the latest at the time), running kubelet v1.34.2.
June 5, 12:30 PM - Config deployed:
amiVersionchanged from pinnedv20251209tolatest. At this point,latestresolves to AMI v20260526 (kubelet v1.34.8).June 5, 12:31 PM - Karpenter detects the mismatch. Node AMI (v20251209) != latest AMI (v20260526). Node marked
Drifted: Truewith reasonAMIDrift.June 5, 12:32 PM - Karpenter checks the disruption budget. Budget = 0 for Drifted. Replacement blocked.
June 5, 12:32 PM onward - Node stays on the old AMI. 46 days behind on security patches. No alert, no notification.
Generalizing this, the flow is:

Fig 1: The same drift is fixed in the default pool and blocked in the ondemand pool.
For the default pool (permissive budget), Karpenter replaces the nodes immediately. They get the new AMI. No problem.
For the ondemand pool (
nodes: "0"for Drifted), Karpenter marks the node as drifted but cannot replace it. The node stays on the old AMI indefinitely.
The result: a node running your most critical workloads is permanently stuck on an old, unpatched AMI. The exact opposite of what "latest" was supposed to achieve.
Why this isn't a one-time problem
AWS releases new EKS-optimized AMIs every few weeks for security patches and kubelet updates. Each release triggers the same cycle:

Fig 2: Every AMI release repeats the cycle, so the protected node falls further behind.
Shifts what
latestresolves to.Causes Karpenter to mark all existing ondemand nodes as drifted.
The budget blocks replacement.
The node falls further behind on patches.
As long as the ondemand node has running workloads (which it always will: that's why it exists), it will never become "Empty" and never be eligible for the only allowed disruption reason.
The node you're protecting the most is the node that falls furthest behind on security patches.
The contradiction
The configuration now says two contradictory things:
"Always target the latest AMI" โ security intent
"Never replace a node for drift" โ stability intent
For new nodes created during scale-up, latest works: they get the newest AMI. But existing nodes, provisioned before the latest AMI release, stay on whatever AMI they launched with.
You get latest on creation, fixed after that.
What manual intervention looks like
To actually update the stuck node, someone must:

Fig 3: Without automation, someone repeats this loop after every AMI release.
Patch the NodePool to temporarily allow drift replacement.
Wait for Karpenter to provision a new node, drain the old one, and terminate it.
Revert the patch to restore the protective budget.
This is the same amount of effort as the old pinned approach (bump version in a PR, deploy), but without the clear trigger of a version change in git. The drift builds up without a signal: you only notice when you inspect node versions and find one lagging weeks or months behind.
Approaches to consider
Pin the AMI version (the original approach)
Keep amiVersion set to a specific version e.g. v20260526. Bump it deliberately via PR when ready.
Pros | Cons |
|---|---|
Full control over what version and when | Requires manual PR for every AMI update |
No silent drift accumulation | Easy to fall behind if no one remembers to bump |
Clear git audit trail per upgrade | New nodes during scale-up also get the pinned (possibly stale) version |
Same manual effort for replacement, but intentional |
Latest + budget=0 (the current trap)
Keep amiVersion: latest with nodes: "0" for Drifted.
Pros | Cons |
|---|---|
New nodes always on latest AMI | Existing nodes never get updated automatically |
No unexpected disruption | Drift builds up with no alert, no reminder |
Same manual effort as pinning, but without the PR trigger | |
Contradictory config: "track latest" but "never act on drift" |
Latest + scheduled maintenance window
Keep amiVersion: latest but add a time-based budget that allows drift replacement during off-peak hours:
budgets: - nodes: "0" reasons: ["Drifted"] - nodes: "1" reasons: ["Drifted"] schedule: "0 2 * * MON" duration
Outside the window: fully protected. During the window: Karpenter replaces one drifted node at a time.
Pros | Cons |
|---|---|
Fully automated: no manual intervention | Disruption happens during the window (must be off-peak) |
Controlled, predictable timing | If multiple nodes are drifted, takes multiple weeks |
New nodes always on latest AMI | Requires PDBs to be correctly configured |
Nodes can still be up to 1 week behind on patches |
Latest + budget=1 (fully automatic)
Keep amiVersion: latest with nodes: "1" for Drifted. Karpenter replaces immediately.
Pros | Cons |
|---|---|
Always up to date: no lag | No control over timing |
Zero manual effort | Could disrupt during peak hours or incidents |
New nodes always on latest AMI | Relies entirely on PDBs for safe rollout |
AWS release timing determines your disruption schedule |
The takeaway
amiVersion: latest is not a complete security solution for node pools with strict disruption budgets. It only guarantees that new nodes are on the latest AMI. For existing nodes, the disruption budget determines whether they actually get updated.
Before switching a pinned AMI to latest, ask one question: can Karpenter actually replace the drifted node? If the budget says no, you've written a config that claims to patch your most critical nodes but never will.
If you liked this post, you might find these interesting too
A one-line config change, swapping a pinned AMI version to latest, looks like a security win. Your nodes will always run the newest, most patched AMI. Except when they won't.
This post walks through how a change meant to track the latest EKS-optimized AMI can leave your most critical nodes stuck on an old, unpatched version. There are no alerts, so nothing looks wrong until you check node versions by hand.
It covers why this happens, why the config that causes it exists for good reason, and what options you have to get both security and stability.
Prerequisites
If you're already familiar with Karpenter, NodePools, and disruption budgets, skip ahead to The harmless-looking commit
Karpenter is a Kubernetes node autoscaler for AWS. Unlike traditional managed nodegroups with fixed Auto Scaling Groups, Karpenter watches for pods that can't be scheduled and provisions right-sized EC2 instances on the fly. When those nodes are no longer needed, it removes them.
Key concepts:
NodePool: defines what kind of nodes can be created (instance types, capacity type, zones) and how they can be disrupted (removed/replaced).
EC2NodeClass: defines the AMI, networking, and boot configuration for nodes.
NodeClaim: Karpenter's record of a provisioned node.
Drift: when a running node no longer matches the desired spec e.g. the AMI has changed. Karpenter detects this automatically.
Disruption: any action that removes or replaces a node, including consolidation, drift replacement, and expiry.
Why multiple node groups exist
A typical EKS cluster has multiple tiers of compute:
Tier | Managed by | Purpose |
|---|---|---|
Managed nodegroup | AWS EKS | System pods (Karpenter itself, CNI, CoreDNS). Must exist before Karpenter starts. |
Karpenter "default" pool | Karpenter | General workloads. Aggressively consolidated: nodes are freely replaced when underutilized, drifted, or expired. |
Karpenter "ondemand" pool | Karpenter | Critical workloads (CI/CD runners, monitoring). On-demand instances with strict disruption controls. |
The separation exists because different workloads have different reliability requirements. A batch job can handle being rescheduled. A CI pipeline halfway through a deployment cannot.
Disruption budgets
Karpenter's disruption budgets control how many nodes it can disrupt at once, and for what reason:
disruption: budgets: - nodes: "0" reasons: - "Underutilized" - "Drifted" - nodes: "20%" reasons: - "Empty"
This says:
Zero nodes may be disrupted for being underutilized or drifted (complete protection).
20% of nodes may be disrupted if they're completely empty (safe: no workloads affected).
Why budget=0 for Drifted makes sense
For the ondemand pool, blocking drift replacement protects against:
Mid-flight CI/CD failures: if a runner is mid-pipeline and Karpenter drains the node, the job fails.
Loss of monitoring: if the observability stack gets restarted, you lose visibility during the transition.
Uncontrolled timing: AWS releases AMIs at unpredictable times. Budget=0 means you choose when to take disruption.
Cascading failures: if PodDisruptionBudgets aren't perfectly configured, node replacement can cause more disruption than expected.
The intent is clear: ondemand nodes should only be disrupted when a human decides it's safe.
The harmless-looking commit
Consider this one-line change to the EC2NodeClass config:
- amiVersion: v20251209 + amiVersion: latest
This reads as a straightforward security improvement. Instead of being stuck on a pinned AMI that requires a manual PR to bump, the cluster now always targets the latest EKS-optimized AMI. New nodes will always be secure.
The problem shows up on the nodes that are already running.
What actually happens
Here's a concrete timeline of how this plays out:
April 20, 12:00 PM - Workload scheduled on ondemand pool. Karpenter launches a node with AMI v20251209 (the latest at the time), running kubelet v1.34.2.
June 5, 12:30 PM - Config deployed:
amiVersionchanged from pinnedv20251209tolatest. At this point,latestresolves to AMI v20260526 (kubelet v1.34.8).June 5, 12:31 PM - Karpenter detects the mismatch. Node AMI (v20251209) != latest AMI (v20260526). Node marked
Drifted: Truewith reasonAMIDrift.June 5, 12:32 PM - Karpenter checks the disruption budget. Budget = 0 for Drifted. Replacement blocked.
June 5, 12:32 PM onward - Node stays on the old AMI. 46 days behind on security patches. No alert, no notification.
Generalizing this, the flow is:

Fig 1: The same drift is fixed in the default pool and blocked in the ondemand pool.
For the default pool (permissive budget), Karpenter replaces the nodes immediately. They get the new AMI. No problem.
For the ondemand pool (
nodes: "0"for Drifted), Karpenter marks the node as drifted but cannot replace it. The node stays on the old AMI indefinitely.
The result: a node running your most critical workloads is permanently stuck on an old, unpatched AMI. The exact opposite of what "latest" was supposed to achieve.
Why this isn't a one-time problem
AWS releases new EKS-optimized AMIs every few weeks for security patches and kubelet updates. Each release triggers the same cycle:

Fig 2: Every AMI release repeats the cycle, so the protected node falls further behind.
Shifts what
latestresolves to.Causes Karpenter to mark all existing ondemand nodes as drifted.
The budget blocks replacement.
The node falls further behind on patches.
As long as the ondemand node has running workloads (which it always will: that's why it exists), it will never become "Empty" and never be eligible for the only allowed disruption reason.
The node you're protecting the most is the node that falls furthest behind on security patches.
The contradiction
The configuration now says two contradictory things:
"Always target the latest AMI" โ security intent
"Never replace a node for drift" โ stability intent
For new nodes created during scale-up, latest works: they get the newest AMI. But existing nodes, provisioned before the latest AMI release, stay on whatever AMI they launched with.
You get latest on creation, fixed after that.
What manual intervention looks like
To actually update the stuck node, someone must:

Fig 3: Without automation, someone repeats this loop after every AMI release.
Patch the NodePool to temporarily allow drift replacement.
Wait for Karpenter to provision a new node, drain the old one, and terminate it.
Revert the patch to restore the protective budget.
This is the same amount of effort as the old pinned approach (bump version in a PR, deploy), but without the clear trigger of a version change in git. The drift builds up without a signal: you only notice when you inspect node versions and find one lagging weeks or months behind.
Approaches to consider
Pin the AMI version (the original approach)
Keep amiVersion set to a specific version e.g. v20260526. Bump it deliberately via PR when ready.
Pros | Cons |
|---|---|
Full control over what version and when | Requires manual PR for every AMI update |
No silent drift accumulation | Easy to fall behind if no one remembers to bump |
Clear git audit trail per upgrade | New nodes during scale-up also get the pinned (possibly stale) version |
Same manual effort for replacement, but intentional |
Latest + budget=0 (the current trap)
Keep amiVersion: latest with nodes: "0" for Drifted.
Pros | Cons |
|---|---|
New nodes always on latest AMI | Existing nodes never get updated automatically |
No unexpected disruption | Drift builds up with no alert, no reminder |
Same manual effort as pinning, but without the PR trigger | |
Contradictory config: "track latest" but "never act on drift" |
Latest + scheduled maintenance window
Keep amiVersion: latest but add a time-based budget that allows drift replacement during off-peak hours:
budgets: - nodes: "0" reasons: ["Drifted"] - nodes: "1" reasons: ["Drifted"] schedule: "0 2 * * MON" duration
Outside the window: fully protected. During the window: Karpenter replaces one drifted node at a time.
Pros | Cons |
|---|---|
Fully automated: no manual intervention | Disruption happens during the window (must be off-peak) |
Controlled, predictable timing | If multiple nodes are drifted, takes multiple weeks |
New nodes always on latest AMI | Requires PDBs to be correctly configured |
Nodes can still be up to 1 week behind on patches |
Latest + budget=1 (fully automatic)
Keep amiVersion: latest with nodes: "1" for Drifted. Karpenter replaces immediately.
Pros | Cons |
|---|---|
Always up to date: no lag | No control over timing |
Zero manual effort | Could disrupt during peak hours or incidents |
New nodes always on latest AMI | Relies entirely on PDBs for safe rollout |
AWS release timing determines your disruption schedule |
The takeaway
amiVersion: latest is not a complete security solution for node pools with strict disruption budgets. It only guarantees that new nodes are on the latest AMI. For existing nodes, the disruption budget determines whether they actually get updated.
Before switching a pinned AMI to latest, ask one question: can Karpenter actually replace the drifted node? If the budget says no, you've written a config that claims to patch your most critical nodes but never will.
If you liked this post, you might find these interesting too
Share
Share
On this page
Section
On this page
In this post
section
Share
Related Content

Kong API Gateway for Gaming and Sports Betting Integrations

Modernising Observability for a Billion Dollar Revenue Gaming Platform

Migrating from AWS Glue to Self-Hosted ClickHouse: 70% Cost Reduction for a Fintech Client.

How we reduced Kafka message latency by 90% and increased throughput 4x for a $300M SaaS platform.














