Published on

AWS EKS in Production — Node Groups, Karpenter, and the Operational Gotchas

Authors

Introduction

AWS EKS abstracts Kubernetes control plane management, but node orchestration remains your responsibility. Managed node groups simplify node provisioning, but lack sophistication for cost optimization. Karpenter, AWS's intelligent autoscaling solution, replaces Cluster Autoscaler with performance and cost improvements. Add to this EKS add-ons for networking and DNS, IAM Roles for Service Accounts (IRSA) for pod-level permissions, cluster upgrade complexity, and the operational surface area grows. This post covers node group strategies, Karpenter, add-on management, IRSA, upgrades, and cost optimization.

Managed Node Groups vs Self-Managed

Managed node groups: EKS handles EC2 instance provisioning and lifecycle. Simpler, recommended for most teams.

resource "aws_eks_node_group" "main" {
  cluster_name    = aws_eks_cluster.main.name
  node_group_name = "main-nodes"
  node_role_arn   = aws_iam_role.node_role.arn
  subnet_ids      = var.private_subnet_ids

  scaling_config {
    desired_size = 3
    max_size     = 20
    min_size     = 2
  }

  instance_types = ["t3.large", "t3a.large", "m5.large"]
  disk_size      = 100

  labels = {
    workload = "general"
  }

  tags = {
    Name        = "eks-main"
    Environment = "prod"
  }

  lifecycle {
    ignore_changes = [scaling_config[0].desired_size]
  }
}

The ignore_changes on desired_size prevents Terraform from fighting with autoscaler.

Self-managed nodes: You create Launch Templates and ASGs. More control, more operational burden.

resource "aws_launch_template" "node" {
  name_prefix = "eks-node-"

  image_id      = data.aws_ami.amazon_linux_2_ecs.id
  instance_type = "t3.large"

  block_device_mappings {
    device_name = "/dev/xvda"
    ebs {
      volume_size           = 100
      volume_type           = "gp3"
      delete_on_termination = true
      encrypted             = true
    }
  }

  iam_instance_profile {
    name = aws_iam_instance_profile.node.name
  }

  vpc_security_group_ids = [aws_security_group.node.id]

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name = "eks-node"
    }
  }

  user_data = base64encode(templatefile("${path.module}/user_data.sh", {
    cluster_name        = aws_eks_cluster.main.name
    cluster_endpoint    = aws_eks_cluster.main.endpoint
    cluster_ca          = aws_eks_cluster.main.certificate_authority[0].data
  }))
}

resource "aws_autoscaling_group" "node" {
  name                = "eks-node-asg"
  vpc_zone_identifier = var.private_subnet_ids
  min_size            = 2
  max_size            = 20
  desired_capacity    = 3

  launch_template {
    id      = aws_launch_template.node.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "eks-node"
    propagate_at_launch = true
  }

  lifecycle {
    ignore_changes = [desired_capacity]
  }
}

Karpenter for Intelligent Node Provisioning

Karpenter replaces Cluster Autoscaler with a more efficient provisioner. It groups pods by scheduling requirements, right-sizes instances, and terminates underutilized nodes faster.

Install Karpenter:

helm repo add karpenter https://charts.karpenter.sh
helm install karpenter karpenter/karpenter \
  --namespace karpenter --create-namespace \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::ACCOUNT_ID:role/karpenter-controller \
  --set settings.aws.clusterName=my-cluster \
  --set settings.aws.interruptionQueue=my-cluster-queue

Karpenter Provisioner:

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: default
spec:
  template:
    metadata:
      labels:
        pool: default
    spec:
      nodeClassRef:
        name: default
      expireAfter: 2592000s  # 30 days
  limits:
    resources:
      cpu: "1000"
      memory: "1000Gi"
  consolidationPolicy:
    nodes: "WhenUnderutilized"
  disruption:
    consolidateAfter: 30s
    consolidateOnDeletion: true
    budgets:
    - nodes: "10%"
      duration: 5m
      schedule: "0 9 * * mon-fri"
      timezone: "America/New_York"
---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2
  role: KarpenterNodeRole
  subnetSelector:
    karpenter.sh/discovery: "true"
  securityGroupSelector:
    karpenter.sh/discovery: "true"
  blockDeviceMappings:
  - deviceName: /dev/xvda
    ebs:
      volumeSize: 100Gi
      volumeType: gp3
      iops: 3000
      throughput: 125
  tags:
    ManagedBy: Karpenter
    Environment: production

Karpenter vs Cluster Autoscaler:

FeatureKarpenterCluster Autoscaler
Instance right-sizingYes (groups pods by resource reqs)No (fixed instance types)
ConsolidationYes (packs pods tighter)No
Spot instancesNativeVia ASG tags
Scheduling awarenessYesNo
Cold start latencyMinimalHigher
CostLower (better bin-packing)Higher (fragmentation)

EKS Add-Ons Management

EKS add-ons are managed services for essential cluster components.

resource "aws_eks_addon" "vpc_cni" {
  cluster_name             = aws_eks_cluster.main.name
  addon_name               = "vpc-cni"
  addon_version            = "v1.14.1-eksbuild.1"
  resolve_conflicts_on_update = "OVERWRITE"

  service_account_role_arn = aws_iam_role.vpc_cni_role.arn
}

resource "aws_eks_addon" "coredns" {
  cluster_name             = aws_eks_cluster.main.name
  addon_name               = "coredns"
  addon_version            = "v1.9.3-eksbuild.2"
  resolve_conflicts_on_update = "OVERWRITE"
}

resource "aws_eks_addon" "kube_proxy" {
  cluster_name             = aws_eks_cluster.main.name
  addon_name               = "kube-proxy"
  addon_version            = "v1.27.9-eksbuild.2"
  resolve_conflicts_on_update = "OVERWRITE"
}

resource "aws_eks_addon" "ebs_csi" {
  cluster_name             = aws_eks_cluster.main.name
  addon_name               = "aws-ebs-csi-driver"
  addon_version            = "v1.24.0-eksbuild.1"
  service_account_role_arn = aws_iam_role.ebs_csi_role.arn
}

Always pin add-on versions. Unspecified versions auto-update, which can break workloads.

IAM Roles for Service Accounts (IRSA)

IRSA grants pods IAM permissions without requiring EC2 instance role. Fine-grained, secure.

# Create trust relationship between ServiceAccount and IAM role
data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    effect = "Allow"
    principals {
      type        = "Federated"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${aws_iam_openid_connect_provider.eks.url}"]
    }
    action = "sts:AssumeRoleWithWebIdentity"
    condition {
      test     = "StringEquals"
      variable = "${aws_iam_openid_connect_provider.eks.url}:sub"
      values   = ["system:serviceaccount:production:s3-reader"]
    }
  }
}

resource "aws_iam_role" "s3_reader" {
  name               = "eks-s3-reader"
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}

resource "aws_iam_role_policy" "s3_read" {
  name   = "s3-read"
  role   = aws_iam_role.s3_reader.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:ListBucket"
        ]
        Resource = [
          "arn:aws:s3:::data-bucket",
          "arn:aws:s3:::data-bucket/*"
        ]
      }
    ]
  })
}

Kubernetes ServiceAccount:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: s3-reader
  namespace: production
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/eks-s3-reader
---
apiVersion: v1
kind: Pod
metadata:
  name: s3-reader-pod
  namespace: production
spec:
  serviceAccountName: s3-reader
  containers:
  - name: reader
    image: amazon/aws-cli:latest
    command:
    - /bin/sh
    - -c
    - aws s3 ls s3://data-bucket

EKS Cluster Upgrade Strategy

Upgrading EKS involves updating control plane, then nodes. Plan for downtime.

# Update Kubernetes version
resource "aws_eks_cluster" "main" {
  name    = "my-cluster"
  version = "1.28"  # Update this field

  vpc_config {
    subnet_ids = var.subnet_ids
  }
}

# After control plane updates, update node groups
resource "aws_eks_node_group" "main" {
  cluster_name    = aws_eks_cluster.main.name
  node_group_name = "main"

  # Update to match cluster version
  version = "1.28"
}

Upgrade process:

  1. Plan: Review changelogs, test in staging
  2. Control Plane: AWS updates control plane (5-10 min downtime per AZ)
  3. Nodes: Update node groups progressively (max_unavailable = 1)
  4. Rollback: Keep previous AMI available for quick rollback

Progressive node update:

resource "aws_eks_node_group" "main" {
  update_config {
    max_unavailable_percentage = 20  # Update 20% of nodes at a time
  }
}

Cluster Autoscaler vs Karpenter

Cluster Autoscaler: Kubernetes' built-in autoscaler. Simpler, but less efficient.

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
    spec:
      serviceAccountName: cluster-autoscaler
      containers:
      - image: k8s.gcr.io/autoscaling/cluster-autoscaler:v1.27.0
        name: cluster-autoscaler
        command:
        - ./cluster-autoscaler
        - --cloud-provider=aws
        - --nodes=2:20:my-asg-name

Cluster Autoscaler scales one node at a time. Karpenter groups pods and provisions right-sized instances in bulk.

Cost comparison (real example):

  • Cluster Autoscaler: 15 t3.large nodes (underutilized due to fragmentation)
  • Karpenter: 8 t3.xlarge + 2 m5.large nodes (better bin-packing)
  • Monthly savings: $300-400 (20% reduction)

Cost Optimization with Spot Instances

Spot instances are 70-90% cheaper than On-Demand but can be interrupted.

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: spot-pool
spec:
  template:
    spec:
      nodeClassRef:
        name: spot
      taints:
      - key: karpenter.sh/capacity-type
        value: spot
        effect: NoSchedule
  limits:
    resources:
      cpu: "500"
  disruption:
    budgets:
    - nodes: "5%"
      duration: 5m
  providerRef:
    name: spot
---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
  name: spot
spec:
  amiFamily: AL2
  subnetSelector:
    karpenter.sh/discovery: "true"
  securityGroupSelector:
    karpenter.sh/discovery: "true"
  capacityType: "spot"
  tags:
    CapacityType: Spot

Pod tolerations:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: batch-processor
spec:
  template:
    spec:
      tolerations:
      - key: karpenter.sh/capacity-type
        operator: Equal
        value: spot
        effect: NoSchedule
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - batch-processor
              topologyKey: kubernetes.io/hostname

Batch and non-critical workloads tolerate Spot. Critical workloads use On-Demand.

Checklist

  • Managed node groups configured with appropriate instance types
  • Karpenter or Cluster Autoscaler scaling policies defined and tested
  • Node group version pinned; auto-updates disabled
  • All EKS add-ons pinned to specific versions
  • IRSA configured for all workloads requiring AWS API access
  • Trust policy documents for OIDC provider reviewed
  • Cluster upgrade strategy documented and tested in staging
  • Spot instance pool configured for non-critical workloads
  • Cluster Autoscaler or Karpenter configured with appropriate limits
  • Pod disruption budgets (PDB) defined for critical workloads
  • Node termination handler deployed (graceful shutdown)
  • Cost monitoring enabled; weekly spend reviews

Conclusion

EKS manages Kubernetes control plane, but node orchestration complexity remains. Managed node groups provide a good starting point. Graduate to Karpenter for intelligent bin-packing and Spot cost optimization. Use IRSA to grant fine-grained IAM permissions. Pin add-on versions to prevent surprise updates. Plan cluster upgrades as major operational events. Combine On-Demand and Spot instances for cost efficiency without sacrificing reliability. With these practices, EKS clusters become cost-effective, reliable, and operationally manageable at scale.