Standing up a Kubernetes cluster has become almost trivial over the last few years. Between tooling, managed services, and widely available infrastructure as code (IaC) examples, getting a cluster up and running has never been easier.

Creating an OKE cluster is only the beginning. Day 2 operations require teams to keep worker nodes current, maintain application availability, and adapt capacity to changing workload requirements.

Karpenter Provider for OCI (KPO) automates much of this work. KPO evaluates unscheduled pods and provisions OCI compute capacity that matches their requirements. It can also replace nodes that have drifted from the desired configuration and consolidate empty or underutilized capacity

This blog explains four operational patterns: rotating worker nodes, controlling disruption, using custom OKE-derived images, and separating workload policies across multiple NodePool

Prerequisites

This blog assumes:

  • You have access to an OKE cluster
  • Karpenter is installed in the cluster
  • To install Karpenter see the OCI KPO documentation

The examples below use placeholders such as <subnet-ocid> and <compartment-ocid-containing-oke-derived-images>. Replace those values for your tenancy. The examples are provided for guidance only. Validate and test before making any changes to production environments.

How NodePools and OCINodeClasses divide responsibilities

KPO follows the upstream Karpenter pattern.

  • NodePool defines scheduling and lifecycle intent: allowed capacity types, allowed shapes, disruption settings, limits, labels, taints, and weights. Karpenter NodePool documentation
  • OCINodeClass defines OCI-specific infrastructure: subnet, VNICs, boot volume, OKE image selection, shape sizing for flexible shapes, tags, kubelet settings, capacity reservations, and bootstrap customization. OCINodeClass documentation

That separation is useful for Day 2 operations. Platform teams can keep infrastructure details in a small number of node classes, while application teams continue using Kubernetes-native scheduling primitives: resource requests, node selectors, affinities, topology spread constraints, taints, and tolerations.

Diagram showing the life-cycle of how Karpenter on OCI manages the worker nodes for OKE.

In the diagram above, we see the evaluation loop Karpenter continually runs to ensure pending pods are scheduled, consolidation occurs when allowed, and drift is kept in control.

Day 2 operations patterns

Example 1: Flexible OKE worker nodes with image filtering and expiration

The first example is a general-purpose NodePool that uses OCI flexible shapes, selects the latest OKE worker images through imageFilter, and rotates nodes using expireAfter.

Karpenter examples generally use expireAfter: Never to keep things simple, but for production we want to use an actual maximum node lifetime value. For example, 720h gives you a 30-day upper bound, if we wanted a more aggressive turnover we might use 336h for 14 days. Upstream Karpenter disruption documentation

# Relevant NodePool fields

expireAfter: 720h
terminationGracePeriod: 120m
      
# Relevant OCINodeClass fields

  imageConfig:
    imageType: OKEImage
    imageFilter:
      osFilter: "Oracle Linux"
      osVersionFilter: "9"

What this gives you:

  • imageFilter lets Karpenter resolve an OKE image instead of pinning the node class to a single image OCID.
  • When the desired image changes, nodes launched with an older desired image can be marked drifted and replaced through disruption policy.
  • expireAfter gives every Karpenter-managed node a maximum lifetime to prevent long-running infrastructure being taken down for underlying maintenance or other reasons.
  • terminationGracePeriod prevents old nodes from staying around indefinitely when pods are hard to evict by adding a drain deadline and will force deletion at the expiration of the time
  • Flexible shape configs let the same NodePool consider multiple CPU and memory sizes across allowed OCI flexible shapes.

Operational guidance:

  • Start with expireAfter: 720h unless your security policy requires faster worker rotation.
  • Consider setting terminationGracePeriod when setting expireAfter.
  • Use disruption budgets to keep drift and consolidation from rolling too much of the data plane at once.
  • Make sure critical workloads have PodDisruptionBudgets that match the availability you expect.
  • osVersionFilter will keep us current for minor and patch versions as they’re released within Oracle Linux 9, moving to a new major version could introduce breaking changes and is therefore an explicit action that must be taken

Example 2: Pause drift and underutilized consolidation during business hours

Image filters help keep the data plane current, but platform teams still need control over when and how the disruption occurs. This configuration prevents drift-based replacements and the cluster from contracting because of underutilization during weekday business hours. Empty nodes can still be removed, allowing Karpenter to reclaim unused capacity without disrupting workloads.

# Relevant NodePool fields

spec:
  template:
    spec:
      expireAfter: Never
      
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30m
    budgets:

       - nodes: "10%"
         reasons:
           - Underutilized
           - Drifted

      - nodes: "0"
        schedule: "0 14 * * mon-fri" # 14:00-22:00 UTC
        duration: 8h
        reasons:
          - Underutilized
          - Drifted

The node fleet can move forward as images and cluster versions change, but the platform team still controls the pace.

What this gives you:

  • The scheduled budget blocks Drifted and Underutilized consolidation during the defined Monday through Friday window.
  • Empty nodes can still be removed during the window, allowing Karpenter to reclaim unused capacity without disrupting workloads.
  • consolidationPolicy: WhenEmptyOrUnderutilized makes both Empty and Underutilized nodes eligible for consolidation but the budget overrides only Underutilized during the window
  • consolidateAfter: 30m gives workloads time to settle before a node is considered for consolidation.
  • Nodes do not expire automatically because  expireAfter: Never disables expiration. Setting an hourly value such as 1h would allow an expiration driven drain to begin regardless of the scheduled budget window

Operational guidance:

  • Monitor NodeClaim conditions and Kubernetes events to distinguish nodes waiting on a disruption budget from nodes blocked by workload eviction constraints.
  • Test the policy by changing a non-production OCINodeClass setting and observing how quickly drift replacements begin and complete.
  • Karpenter schedules are UTC only, so adjust accordingly
  • This example allows 10% of the nodes to be disrupted for drifted and underutilized reasons outside the window, adjust as necessary

Example 3: Use custom OKE-derived images without giving up image filters

Some teams build hardened or custom images based on OKE base images, and you can still use image filtering by configuring Karpenter to search the compartment that contains those custom OKE-derived images.

Global default through Helm values:

# yaml
settings:
  preBakedImageCompartmentId: "<compartment-ocid-containing-oke-derived-images>"

Per NodeClass override:

# yaml
apiVersion: oci.oraclecloud.com/v1beta1
kind: OCINodeClass
metadata:
  name: custom-workers
spec:
  volumeConfig:
    bootVolumeConfig:
      imageConfig:
        imageType: OKEImage
        imageFilter:
          compartmentId: "<compartment-ocid-containing-oke-derived-images>"
          osFilter: "Oracle Linux"
          osVersionFilter: "9"
  networkConfig:
    primaryVnicConfig:
      subnetConfig:
        subnetId: <subnet-ocid>

For this to work, the custom image needs metadata that lets KPO determine the Kubernetes version. The KPO docs call out two supported approaches: a k8s_version freeform tag, or a BaseImageId chain to an ancestor OKE image with that tag.

What this gives you:

preBakedImageCompartmentId changes the default compartment in which KPO searches for OKE-derived images.

  • A per OCINodeClass imageFilter.compartmentId override lets individual node classes search a different image compartment without changing the global setting.
  • Platform teams can use hardened or customized OKE-derived images while retaining automated image discovery.
  • The osFilter and osVersionFilter fields narrow image selection to the intended operating-system family and major version.
  • A k8s_version free-form tag lets KPO determine the Kubernetes version associated with a custom image.
  • A BaseImageId ancestry chain provides another way for KPO to locate the Kubernetes-version metadata from the underlying OKE image.

Operational guidance:

  • Use the global Helm value only when most or all OCINodeClass resources should search the same custom-image compartment.
  • Use the per-node-class compartmentId override when only selected workload classes should use the custom image pipeline.
  • Ensure the KPO controller has permission to read images in the target compartment.
  • Preserve either the k8s_version tag or a valid BaseImageId chain. Without one of these, KPO may be unable to determine image compatibility.
  • Validate each custom image against the target OKE and Kubernetes version before making it eligible for production selection.
  • Coordinate image publication and disruption budgets so that publishing a new matching image does not initiate more replacements than the application environment can tolerate.

Example 4: Use multiple NodePools to separate operational intent

A single large NodePool is simple, but it eventually becomes hard to operate. Every workload in that pool shares the same lifecycle policy, disruption budget, capacity types, shape choices, node labels, taints, and cost controls. This pattern doesn’t always match reality.

We can use multiple NodePools when different workloads need different operational behavior. Workloads use selectors and tolerations to target the capacity they need, and Karpenter applies the right provisioning policy behind it.

Common NodePool boundaries include:

  • platform-static: a small fixed baseline for critical platform services.
  • general-purpose: dynamic on-demand capacity for normal application workloads.
  • batch-spot: preemptible-first capacity for interruptible jobs.
  • gpu or accelerated: specialized shapes isolated from general workloads.
  • stateful: conservative disruption settings for workloads that are slower to move.
  • dev: aggressive consolidation and shorter expiration for non-production clusters.

The business value here is control. You can be aggressive where the risk is low, conservative where availability matters, and explicit about which workloads are allowed to use lower-cost interruptible capacity.

For example, dynamic Karpenter capacity is ideal for most workloads, but many platform teams keep a small always-on NodePool for cluster services, ingress, observability, or controllers. This avoids tying core platform availability to every application scale event while still letting the rest of the cluster scale elastically. KPO supports upstream static capacity when the feature gate is enabled.

Note that at the time of writing static capacity is an optional upstream alpha feature. If you’re uncomfortable using an alpha feature, a dedicated NodePool that uses scheduled disruption budgets (as in Example 2) to protect platform services from drift replacements and underutilized consolidation during operating hours provides similar operational protection.

Enable static capacity:

# yaml
settings:
  featureGates:
    staticCapacity: true

Then define a separate static NodePool for the platform baseline:

# Relevant NodePool fields
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: platform-static
spec:
  replicas: 3
  template:
    metadata:
      labels:
        workload.oracle.com/tier: platform
    spec:
      nodeClassRef:
        group: oci.oraclecloud.com
        kind: OCINodeClass
        name: platform-static
      
  limits:
    nodes: 4

To direct platform workloads to this NodePool, use a node selector, node affinity, or a taint and toleration model. For example:

# yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: platform-controller
  namespace: platform-system
# yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: platform-controller
  namespace: platform-system
spec:
  replicas: 2
  selector:
    matchLabels:
      app: platform-controller
  template:
    metadata:
      labels:
        app: platform-controller
    spec:
      nodeSelector:
        workload.oracle.com/tier: platform
      containers:
        - name: controller
          image: <region>.ocir.io/<tenancy>/<repo>/platform-controller:latest
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"

It’s important to note that static NodePools maintain a fixed number of nodes. Static NodePools are not considered for consolidation and have constraints that differ from dynamic NodePools. They are useful for a small baseline, not as a replacement for dynamic capacity.

The key point is that NodePools should describe operational intent, not org charts. If two workloads need the same lifecycle, disruption, capacity, and cost policy, they can usually share a NodePool. If those requirements differ, separate NodePools provide a reasonable boundary and are safer to change.

What this gives you:

  • Separate NodePool resources let platform teams assign different lifecycle, disruption, capacity, shape, and cost policies to different workload classes.
  • Labels, taints, tolerations, node selectors, and affinity rules provide explicit control over which workloads can use each pool.
  • A small static NodePool can maintain a fixed baseline for critical platform services such as ingress, observability, DNS, or controllers.
  • Dynamic NodePool resources can continue scaling application capacity in response to pending pod requirements.
  • Specialized pools can isolate preemptible, GPU, stateful, development, or other workloads that require distinct operational policies.
  • Clear NodePool boundaries reduce the blast radius of configuration and lifecycle changes.
  • The replicas field causes the static pool to maintain a fixed number of nodes regardless of pending pod demand.

Operational guidance:

  • Define NodePool boundaries around operational requirements, not organizational ownership. Workloads with the same lifecycle, disruption, capacity, and cost requirements can usually share a pool.
  • Make pools mutually exclusive where practical. A workload that matches multiple pools can be provisioned through an unintended policy.
  • Use labels and selectors for positive placement and use taints and tolerations when workloads must be prevented from landing on a pool by default.
  • Keep the static platform baseline small. Static capacity is intended to provide a dependable floor, not replace demand-driven provisioning for the whole cluster.
  • Static NodePool resources are not considered for consolidation.
  • A static NodePool cannot be switched between static and dynamic mode after replicas is configured; plan the resource boundary before deployment.
  • Static pools cannot use weight, and only the node-count form of limits applies to them.
  • Scaling a static pool bypasses node disruption budgets, although application PodDisruptionBudgets still apply.
  • Confirm that critical platform workloads have enough replicas and topology spread to remain available if a baseline node is replaced.

Day 2 validation commands

As you test and validate Karpenter the following kubectl commands can help validate worker lifecycle, image selection, and NodeClaim behavior.

After applying a NodePool and OCINodeClass:

# bash
kubectl get nodepools
kubectl describe nodepool general-purpose
kubectl get ocinodeclasses
kubectl describe ocinodeclass general-purpose
kubectl get nodeclaims -o wide
kubectl get nodes -L karpenter.sh/nodepool,karpenter.sh/capacity-type,oci.oraclecloud.com/instance-shape

To test scale-out, deploy a temporary workload with resource requests that cannot fit on existing nodes:

# yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 5
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      terminationGracePeriodSeconds: 0 #test only value
      containers:
        - name: pause
          image: registry.k8s.io/pause:3.9
          resources:
            requests:
              cpu: "4"
              memory: "32Gi"

Then watch Karpenter create NodeClaims and OKE worker nodes:

# bash
kubectl get pods -w
kubectl get nodeclaims -w
kubectl get nodes -w

Scale the test workload back down and watch consolidation:

# bash
kubectl scale deployment inflate --replicas=0
kubectl get nodeclaims -w

Day 2 operations takeaway

KPO shifts more of worker-node management to a continuous, policy-driven process. NodePools express how capacity should behave, while OCINodeClasses define how that capacity is created on OCI. Together, they give platform teams a Kubernetes-native way to keep the OKE data plane current, appropriately sized, and aligned with workload requirements.

The examples in this post are intended as illustrative starting points and may not be appropriate for every production environment. Any configuration changes should be tested against your individual use case and vetted through your own deployment processes before using in production.

Start with one well-understood workload or non-production cluster. Observe how Karpenter behaves then refine the policies before expanding adoption. With the right guardrails, worker-node maintenance becomes a controlled background operation rather than a recurring platform migration. To get started, review the Karpenter Provider for OCI documentation and usage examples, then deploy a test NodePool and OCINodeClass in an existing OKE cluster to evaluate the operating model in your environment