AWS EKS Add-Ons: VPC CNI, CoreDNS, kube-proxy and More

AWS EKS Add-Ons Configuration

EKS add-ons are the operational backbone of every production Kubernetes cluster on AWS. They handle networking (VPC CNI), service discovery (CoreDNS), traffic rules (kube-proxy), persistent storage (EBS/EFS CSI), load balancing (AWS Load Balancer Controller), and even runtime security (GuardDuty). AWS manages the lifecycle of these components — patching security vulnerabilities, publishing new versions, and letting you upgrade with a single API call — but you still need to understand their internals to tune them correctly and avoid nasty surprises at scale. This guide goes deep on every major add-on, with real configuration, kubectl commands, YAML manifests, and Terraform.

EKS Add-Ons Overview

An EKS add-on is a curated software package that AWS tests against each Kubernetes version and publishes with a clearly defined compatibility matrix. Before managed add-ons existed, operators had to manually apply the VPC CNI DaemonSet, CoreDNS Deployment, and kube-proxy DaemonSet, then carefully track which versions worked with which Kubernetes release. A missed update would leave a cluster running a CNI with a known CVE or a DNS server incompatible with new API objects.

Managed add-ons change this in three ways:

  • Version lifecycle: AWS publishes new add-on versions within days of an upstream release and marks old versions deprecated. The EKS console shows a badge when an update is available.
  • Conflict resolution policies: You control whether an update overwrites your custom configuration (OVERWRITE) or preserves it (PRESERVE). A third option, NONE, fails the update if conflicts exist — useful for CI gates.
  • Configuration schema: Newer add-ons expose a JSON schema for configuration_values so you can tune parameters (e.g., replica counts, environment variables) without patching the manifest directly.

Managed vs Self-Managed Add-Ons

Self-managed components (e.g., Helm-installed Cluster Autoscaler, Prometheus operator) are not tracked by the EKS add-ons API — you own every aspect of their upgrade path. Managed add-ons are the opposite: EKS tracks their version, reports health, and can auto-update them if you configure the --resolve-conflicts flag. The trade-off is that managed add-ons impose constraints on which fields you can customize without triggering a conflict warning.

Version Compatibility Matrix: Before upgrading your cluster control plane, run aws eks describe-addon-versions --kubernetes-version 1.30 to see which add-on versions are available for the target Kubernetes version. You must update add-ons after the control plane upgrade, not before.

List all add-ons in a cluster and their current status:

# List installed add-ons
aws eks list-addons --cluster-name prod-cluster

# Describe a specific add-on
aws eks describe-addon \
  --cluster-name prod-cluster \
  --addon-name vpc-cni

# Check available versions for an add-on
aws eks describe-addon-versions \
  --addon-name vpc-cni \
  --kubernetes-version 1.30 \
  --query 'addons[0].addonVersions[*].addonVersion'

Install an add-on that isn't yet present in your cluster:

aws eks create-addon \
  --cluster-name prod-cluster \
  --addon-name aws-ebs-csi-driver \
  --addon-version v1.30.0-eksbuild.1 \
  --service-account-role-arn arn:aws:iam::123456789012:role/AmazonEKS_EBS_CSI_DriverRole \
  --resolve-conflicts OVERWRITE

VPC CNI (aws-node)

The Amazon VPC CNI plugin (aws-node DaemonSet in kube-system) assigns real VPC IP addresses to pods. Unlike overlay networks (Flannel, Weave), pods get native VPC IPs — they are routable from other VPCs, on-premises networks, and AWS services without NAT. This makes debugging much simpler and eliminates the performance overhead of encapsulation.

The CNI plugin manages Elastic Network Interfaces (ENIs) on each node. It pre-allocates a warm pool of IPs so that pod scheduling doesn't have to wait for ENI attachment. Two environment variables control this pool:

  • WARM_ENI_TARGET (default: 1) — keep this many spare ENIs attached and ready. Set to 0 on large nodes to reduce wasted IPs.
  • WARM_IP_TARGET — keep this many spare IPs available across attached ENIs. More precise than WARM_ENI_TARGET for dense pods-per-node configurations.
  • MINIMUM_IP_TARGET — floor for the IP pool, prevents aggressive ENI release during scale-down.
# Patch VPC CNI environment variables
kubectl set env daemonset aws-node \
  -n kube-system \
  WARM_ENI_TARGET=0 \
  WARM_IP_TARGET=5 \
  MINIMUM_IP_TARGET=10

# Verify the change
kubectl get daemonset aws-node -n kube-system -o yaml | \
  grep -A 20 env:

Prefix Delegation — 16x More IPs per ENI

By default, each ENI slot holds one secondary IP. With prefix delegation (available on Nitro instances), each slot holds a /28 CIDR (16 IPs). This dramatically increases pod density on expensive instance types like m5.4xlarge.

# Enable prefix delegation
kubectl set env daemonset aws-node \
  -n kube-system \
  ENABLE_PREFIX_DELEGATION=true \
  WARM_PREFIX_TARGET=1

# Verify pods per node increased
kubectl describe node <node-name> | grep "pods\|allocatable" -A 5

Custom Networking for More IPs

When your primary VPC CIDR is exhausted but you've added a secondary CIDR (e.g., 100.64.0.0/16), custom networking lets pods use IPs from that secondary CIDR while nodes keep their primary CIDR IPs:

# 1. Enable custom networking
kubectl set env daemonset aws-node \
  -n kube-system \
  AWS_VPC_K8S_CNI_CUSTOM_NETWORK_CFG=true \
  ENI_CONFIG_LABEL_DEF=topology.kubernetes.io/zone

# 2. Create an ENIConfig per AZ pointing to secondary subnets
apiVersion: crd.k8s.amazonaws.com/v1alpha1
kind: ENIConfig
metadata:
  name: us-east-1a
spec:
  subnet: subnet-0abc123def456789a   # secondary subnet in us-east-1a
  securityGroups:
    - sg-0abc123def456789b

---
apiVersion: crd.k8s.amazonaws.com/v1alpha1
kind: ENIConfig
metadata:
  name: us-east-1b
spec:
  subnet: subnet-0abc123def456789c
  securityGroups:
    - sg-0abc123def456789b

IPv6 Support

EKS supports IPv6 clusters where pods receive IPv6 addresses from the VPC IPv6 CIDR. Enable this at cluster creation time — you cannot switch an existing IPv4 cluster to IPv6:

# eksctl config for IPv6 cluster
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: ipv6-cluster
  region: us-east-1
  version: "1.30"
kubernetesNetworkConfig:
  ipFamily: IPv6
Note: IPv6 EKS clusters require VPC CNI v1.11.0+, CoreDNS v1.8.7+, and kube-proxy v1.24.0+. Not all add-ons (notably the EBS CSI Driver) support dual-stack yet — verify compatibility before enabling.

CoreDNS Configuration and Tuning

CoreDNS is the cluster DNS server, deployed as a Deployment (not a DaemonSet) in kube-system. Every pod's /etc/resolv.conf points to the CoreDNS ClusterIP. At scale, DNS becomes a bottleneck — hundreds of pods making lookup requests for every new connection creates a thundering herd on two CoreDNS replicas.

The Corefile

CoreDNS configuration lives in the coredns ConfigMap in kube-system. The default Corefile handles in-cluster names via the kubernetes plugin and forwards everything else to the VPC DNS resolver (169.254.169.253):

kubectl edit configmap coredns -n kube-system
.:53 {
    errors
    health {
      lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
      pods insecure
      fallthrough in-addr.arpa ip6.arpa
      ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
      max_concurrent 1000
    }
    cache 30
    loop
    reload
    loadbalance
}

Custom Domain Forwarding

If you need pods to resolve an on-premises domain (e.g., internal.corp) via a private DNS server, add a stub zone block:

.:53 {
    # ... existing config ...
}

internal.corp:53 {
    errors
    cache 30
    forward . 10.0.0.2 10.0.0.3   # on-premises DNS servers
}

Scaling CoreDNS for Large Clusters

The default 2 replicas are insufficient beyond ~500 nodes. Use the configuration_values JSON when managing via add-on API to set replica count and resource requests:

aws eks update-addon \
  --cluster-name prod-cluster \
  --addon-name coredns \
  --configuration-values '{"replicaCount":4,"resources":{"requests":{"cpu":"100m","memory":"256Mi"},"limits":{"cpu":"200m","memory":"512Mi"}}}' \
  --resolve-conflicts PRESERVE

NodeLocal DNSCache

NodeLocal DNSCache runs a DNS caching agent on every node using a link-local IP (169.254.20.10). Pods hit the local cache first, eliminating the network hop to CoreDNS for cached names. This reduces latency from ~1ms to ~0.1ms for cached lookups and dramatically reduces load on CoreDNS pods.

# Deploy NodeLocal DNSCache
# Replace CLUSTER_DNS with your CoreDNS ClusterIP
CLUSTER_DNS=$(kubectl get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}')

curl -s https://raw.githubusercontent.com/kubernetes/kubernetes/v1.30.0/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml \
  | sed "s/__PILLAR__DNS__SERVER__/${CLUSTER_DNS}/g" \
  | sed "s/__PILLAR__LOCAL__DNS__/169.254.20.10/g" \
  | sed "s/__PILLAR__DNS__DOMAIN__/cluster.local/g" \
  | kubectl apply -f -

# Verify the DaemonSet is running on all nodes
kubectl get daemonset node-local-dns -n kube-system
DNS Performance Tip: Set ndots:2 (instead of the default 5) in your pod's dnsConfig to reduce the number of search-domain retries for external names. With ndots:5, a lookup for api.example.com tries five fully-qualified permutations before going to the real DNS — that is 5x the queries.
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"
      - name: single-request-reopen

kube-proxy: iptables vs ipvs

kube-proxy is a DaemonSet that maintains network rules on each node so that Kubernetes Service ClusterIPs route to the correct pod endpoints. It watches the API server for Service and Endpoint changes and translates them into either iptables rules or IPVS rules depending on mode.

iptables Mode (default)

In iptables mode, kube-proxy appends DNAT rules to iptables chains. Traffic to a ClusterIP hits the KUBE-SVC chain, which randomly selects one of the endpoint DNAT rules. This is simple and battle-tested, but has O(n) lookup time — with 10,000 services, every packet traverses potentially thousands of rules before matching. At large scale you start to see measurable CPU overhead in the kernel.

ipvs Mode

IPVS (IP Virtual Server) uses a hash table for service lookups — O(1) regardless of the number of services. It also supports richer load-balancing algorithms (round-robin, least-connections, destination hashing) compared to iptables' random selection.

# Switch kube-proxy to ipvs mode via EKS add-on configuration_values
aws eks update-addon \
  --cluster-name prod-cluster \
  --addon-name kube-proxy \
  --configuration-values '{"mode":"ipvs"}' \
  --resolve-conflicts OVERWRITE

# Verify mode on a node
kubectl exec -n kube-system $(kubectl get pod -n kube-system -l k8s-app=kube-proxy -o name | head -1) \
  -- kube-proxy --version

# Check ipvs rules
kubectl exec -n kube-system $(kubectl get pod -n kube-system -l k8s-app=kube-proxy -o name | head -1) \
  -- ipvsadm -L -n | head -30

conntrack Tuning

Under high connection rates (e.g., short-lived HTTP/2 or gRPC connections at thousands per second), the conntrack table can fill up, causing nf_conntrack: table full, dropping packet errors. Tune the limits at the node level via a DaemonSet that sets sysctl values:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: sysctl-tuner
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: sysctl-tuner
  template:
    metadata:
      labels:
        app: sysctl-tuner
    spec:
      hostNetwork: true
      hostPID: true
      tolerations:
        - operator: Exists
      initContainers:
        - name: tuner
          image: busybox:1.36
          securityContext:
            privileged: true
          command:
            - sh
            - -c
            - |
              sysctl -w net.netfilter.nf_conntrack_max=1048576
              sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=86400
              sysctl -w net.core.somaxconn=65535
      containers:
        - name: pause
          image: k8s.gcr.io/pause:3.9
When to Replace kube-proxy with Cilium/eBPF: If your cluster runs 500+ services or you need network policies with sub-millisecond enforcement, consider replacing kube-proxy entirely with Cilium in eBPF mode (kubeProxyReplacement: true). Cilium bypasses iptables/ipvs entirely, hooks into the kernel at the socket level, and adds Hubble observability. This is a significant operational change but delivers 2-3x better connection throughput at scale.

EBS CSI Driver

The Amazon EBS CSI Driver replaces the deprecated in-tree EBS provisioner (removed in Kubernetes 1.27). It runs as a Deployment (controller) plus DaemonSet (node plugin) and requires an IAM role for the service account to make EBS API calls.

Prerequisites: IRSA Setup

# Create the IAM service account
eksctl create iamserviceaccount \
  --name ebs-csi-controller-sa \
  --namespace kube-system \
  --cluster prod-cluster \
  --attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy \
  --approve \
  --role-only \
  --role-name AmazonEKS_EBS_CSI_DriverRole

# Install the add-on
aws eks create-addon \
  --cluster-name prod-cluster \
  --addon-name aws-ebs-csi-driver \
  --service-account-role-arn arn:aws:iam::123456789012:role/AmazonEKS_EBS_CSI_DriverRole

gp3 StorageClass

gp3 is the preferred volume type — 20% cheaper than gp2 and you can independently configure IOPS and throughput without over-provisioning size:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
parameters:
  type: gp3
  iops: "6000"
  throughput: "250"
  encrypted: "true"
  kmsKeyId: arn:aws:kms:us-east-1:123456789012:key/mrk-abc123

PVC Example and Cross-AZ Considerations

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: production
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: gp3
  resources:
    requests:
      storage: 100Gi
Cross-AZ Warning: EBS volumes are AZ-locked. A pod on a node in us-east-1a cannot mount a volume created in us-east-1b. Use volumeBindingMode: WaitForFirstConsumer (as in the gp3 StorageClass above) so the PVC is provisioned in the same AZ as the pod that claims it. Never use Immediate binding with EBS.

Volume Snapshots

# Install VolumeSnapshot CRDs (required separately)
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v7.0.1/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v7.0.1/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v7.0.1/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml

# Create a VolumeSnapshotClass
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: ebs-vsc
driver: ebs.csi.aws.com
deletionPolicy: Retain

# Take a snapshot
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: postgres-snap-20260609
spec:
  volumeSnapshotClassName: ebs-vsc
  source:
    persistentVolumeClaimName: postgres-data

EFS CSI Driver

Amazon EFS provides a fully managed NFS filesystem that can be simultaneously mounted by pods across multiple AZs and multiple nodes — something EBS (ReadWriteOnce) cannot do. The EFS CSI Driver supports both static provisioning (you create the EFS filesystem manually) and dynamic provisioning (the driver creates access points on demand).

Installing the Add-On

# Create IRSA
eksctl create iamserviceaccount \
  --name efs-csi-controller-sa \
  --namespace kube-system \
  --cluster prod-cluster \
  --attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy \
  --approve \
  --role-name AmazonEKS_EFS_CSI_DriverRole

aws eks create-addon \
  --cluster-name prod-cluster \
  --addon-name aws-efs-csi-driver \
  --service-account-role-arn arn:aws:iam::123456789012:role/AmazonEKS_EFS_CSI_DriverRole

Dynamic Provisioning with Access Points

Access points enforce a specific POSIX user identity and a root directory path, making them ideal for multi-tenant scenarios where each namespace or team gets isolated storage on the same EFS filesystem:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: efs-sc
provisioner: efs.csi.aws.com
parameters:
  provisioningMode: efs-ap          # dynamic access point per PVC
  fileSystemId: fs-0abc12345def67890
  directoryPerms: "700"
  gidRangeStart: "1000"
  gidRangeEnd: "2000"
  basePath: "/dynamic"
  uid: "1000"
  gid: "1000"

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shared-logs
  namespace: team-a
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: efs-sc
  resources:
    requests:
      storage: 50Gi

Static Provisioning

apiVersion: v1
kind: PersistentVolume
metadata:
  name: efs-pv
spec:
  capacity:
    storage: 100Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: efs-sc
  csi:
    driver: efs.csi.aws.com
    volumeHandle: fs-0abc12345def67890::fsap-0xyz123
Performance Modes: EFS has two performance modes: General Purpose (default, <35,000 IOPS, lower latency) and Max I/O (higher aggregate throughput for parallel workloads, but higher latency). General Purpose handles 99% of use cases — only consider Max I/O for Hadoop-style workloads with hundreds of pods writing simultaneously.

AWS Load Balancer Controller

The AWS Load Balancer Controller is a Kubernetes controller that provisions Application Load Balancers (ALB) from Ingress objects and Network Load Balancers (NLB) from Service objects of type LoadBalancer. It replaced the older ALB Ingress Controller and the in-tree cloud provider load balancer logic.

Installation

eksctl create iamserviceaccount \
  --cluster prod-cluster \
  --namespace kube-system \
  --name aws-load-balancer-controller \
  --attach-policy-arn arn:aws:iam::123456789012:policy/AWSLoadBalancerControllerIAMPolicy \
  --approve

helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  -n kube-system \
  --set clusterName=prod-cluster \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller \
  --set enableShield=false \
  --set enableWaf=false \
  --set enableWafv2=false

IngressClass Configuration

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb
  annotations:
    ingressclass.kubernetes.io/is-default-class: "true"
spec:
  controller: ingress.k8s.aws/alb

ALB Ingress with HTTPS and WAF

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: production
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/abc-def
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: "15"
    alb.ingress.kubernetes.io/success-codes: "200,204"
    alb.ingress.kubernetes.io/wafv2-acl-arn: arn:aws:wafv2:us-east-1:123456789012:regional/webacl/prod-waf/abc
    alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=60
    alb.ingress.kubernetes.io/group.name: prod-alb   # share one ALB across Ingresses
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /v1
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 8080

NLB for TCP/UDP

apiVersion: v1
kind: Service
metadata:
  name: grpc-service
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: external
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
spec:
  type: LoadBalancer
  ports:
    - port: 443
      targetPort: 9090
      protocol: TCP
  selector:
    app: grpc-service

TargetGroupBinding for External Targets

TargetGroupBinding lets you register non-Kubernetes targets (e.g., Lambda functions, on-premises servers) into an ALB target group while still managing routing via Kubernetes Ingress:

apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: legacy-api-tgb
spec:
  serviceRef:
    name: legacy-api-proxy
    port: 80
  targetGroupARN: arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/legacy-api/abcdef

GuardDuty EKS Add-On

Amazon GuardDuty's EKS Runtime Monitoring feature deploys a security agent (the aws-guardduty-agent DaemonSet) onto every node. The agent uses eBPF to capture syscall events — process spawning, network connections, file operations — and streams them to GuardDuty for analysis. This provides runtime threat detection without requiring application changes.

Enabling the Add-On

# Enable EKS Runtime Monitoring in GuardDuty
aws guardduty update-detector \
  --detector-id $(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) \
  --features '[{"Name":"EKS_RUNTIME_MONITORING","Status":"ENABLED","AdditionalConfiguration":[{"Name":"EKS_ADDON_MANAGEMENT","Status":"ENABLED"}]}]'

# Verify the DaemonSet was deployed
kubectl get daemonset aws-guardduty-agent -n amazon-guardduty
kubectl get pods -n amazon-guardduty

Container-Specific Threat Findings

GuardDuty produces EKS-specific finding types that are distinct from EC2 findings:

  • CryptoCurrency:Runtime/BitcoinTool.B!DNS — a container queried a known cryptocurrency mining domain
  • Execution:Runtime/NewBinaryExecuted — a binary that was not in the original container image was executed (classic sign of container breakout or RCE)
  • PrivilegeEscalation:Runtime/CGroupsReleaseAgentFileModified — a pod attempted to write to the cgroup release_agent (a known container escape vector)
  • Discovery:Runtime/MaliciousFile — a process opened a file matching a known malware signature
  • Backdoor:Runtime/C&CActivity.B — a container established a connection to a known command-and-control server
Excluding Namespaces: The GuardDuty agent monitors all pods by default. To exclude monitoring for trusted namespaces (e.g., a benchmarking namespace that deliberately runs stress tools), add the label guardduty.amazonaws.com/agent-exclusion: "true" to the namespace. This prevents false positives from legitimate load testing.
# Exclude a namespace from monitoring
kubectl label namespace load-testing \
  guardduty.amazonaws.com/agent-exclusion=true

# Check GuardDuty findings via CLI
aws guardduty list-findings \
  --detector-id $(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) \
  --finding-criteria '{"Criterion":{"resource.resourceType":{"Equals":["EKSCluster"]}}}' \
  --query 'FindingIds'

Managing Add-Ons with Terraform

The aws_eks_addon Terraform resource manages the full lifecycle of EKS add-ons declaratively. Version pinning, configuration values, and IRSA associations are all expressed in HCL, making cluster state reproducible and auditable in version control.

# variables.tf
variable "cluster_name" {
  default = "prod-cluster"
}

variable "cluster_version" {
  default = "1.30"
}

# data sources
data "aws_eks_addon_version" "vpc_cni" {
  addon_name         = "vpc-cni"
  kubernetes_version = var.cluster_version
  most_recent        = true
}

data "aws_eks_addon_version" "coredns" {
  addon_name         = "coredns"
  kubernetes_version = var.cluster_version
  most_recent        = true
}

# add-ons.tf
resource "aws_eks_addon" "vpc_cni" {
  cluster_name             = var.cluster_name
  addon_name               = "vpc-cni"
  addon_version            = data.aws_eks_addon_version.vpc_cni.version
  resolve_conflicts_on_update = "PRESERVE"
  service_account_role_arn = aws_iam_role.vpc_cni.arn

  configuration_values = jsonencode({
    env = {
      ENABLE_PREFIX_DELEGATION = "true"
      WARM_PREFIX_TARGET       = "1"
    }
  })

  tags = {
    Environment = "prod"
    ManagedBy   = "terraform"
  }
}

resource "aws_eks_addon" "coredns" {
  cluster_name             = var.cluster_name
  addon_name               = "coredns"
  addon_version            = data.aws_eks_addon_version.coredns.version
  resolve_conflicts_on_update = "PRESERVE"

  configuration_values = jsonencode({
    replicaCount = 4
    resources = {
      requests = { cpu = "100m", memory = "256Mi" }
      limits   = { cpu = "200m", memory = "512Mi" }
    }
  })
}

resource "aws_eks_addon" "kube_proxy" {
  cluster_name             = var.cluster_name
  addon_name               = "kube-proxy"
  resolve_conflicts_on_update = "OVERWRITE"
}

resource "aws_eks_addon" "ebs_csi_driver" {
  cluster_name             = var.cluster_name
  addon_name               = "aws-ebs-csi-driver"
  service_account_role_arn = aws_iam_role.ebs_csi.arn
  resolve_conflicts_on_update = "OVERWRITE"
}

resource "aws_eks_addon" "efs_csi_driver" {
  cluster_name             = var.cluster_name
  addon_name               = "aws-efs-csi-driver"
  service_account_role_arn = aws_iam_role.efs_csi.arn
  resolve_conflicts_on_update = "OVERWRITE"
}

IAM Role for EBS CSI (IRSA)

data "aws_iam_policy_document" "ebs_csi_assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.eks.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub"
      values   = ["system:serviceaccount:kube-system:ebs-csi-controller-sa"]
    }
  }
}

resource "aws_iam_role" "ebs_csi" {
  name               = "AmazonEKS_EBS_CSI_DriverRole"
  assume_role_policy = data.aws_iam_policy_document.ebs_csi_assume.json
}

resource "aws_iam_role_policy_attachment" "ebs_csi" {
  role       = aws_iam_role.ebs_csi.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
}
Terraform Tip: Use most_recent = true in aws_eks_addon_version data sources only in development. In production, pin to a specific version (e.g., v1.30.0-eksbuild.1) and update it deliberately after testing in a staging cluster. This prevents automatic version bumps on a terraform apply from introducing unexpected behaviour.

Add-On Update Strategy

Updating add-ons is a two-phase process: control plane upgrade first, add-on updates second. Never upgrade add-ons before the control plane — some add-on versions require newer API server features that only exist in the newer Kubernetes version. Always upgrade add-ons before upgrading node groups, because the new kubelet on updated nodes may require newer CNI or kube-proxy versions to schedule pods correctly.

Conflict Resolution Policies

  • OVERWRITE: AWS replaces any field in the add-on's managed objects that differs from the desired add-on configuration. Use this for add-ons where you don't customise the managed DaemonSet/Deployment directly.
  • PRESERVE: AWS skips fields that have been manually modified. Your custom resource requests, replica counts, or environment variables are retained. Use this when you've tuned VPC CNI env vars or CoreDNS replicas.
  • NONE: The update fails if any conflict is detected. Useful in CI/CD pipelines to enforce that no manual edits have drifted from the managed configuration before proceeding.
# Safe add-on update sequence after a cluster version upgrade

# 1. Check current add-on versions
aws eks list-addons --cluster-name prod-cluster | jq '.addons[]' | \
  xargs -I{} aws eks describe-addon \
    --cluster-name prod-cluster \
    --addon-name {} \
    --query '{name: addon.addonName, version: addon.addonVersion, status: addon.status}'

# 2. Find the default version for the new k8s version
aws eks describe-addon-versions \
  --kubernetes-version 1.30 \
  --query 'addons[*].{name:addonName, default:addonVersions[?compatibilities[?defaultVersion==`true`]].addonVersion}' \
  --output table

# 3. Update each add-on
for ADDON in vpc-cni coredns kube-proxy aws-ebs-csi-driver; do
  echo "Updating $ADDON..."
  aws eks update-addon \
    --cluster-name prod-cluster \
    --addon-name $ADDON \
    --resolve-conflicts PRESERVE

  # Wait for the update to complete before moving to the next
  aws eks wait addon-active \
    --cluster-name prod-cluster \
    --addon-name $ADDON
  echo "$ADDON is active"
done

Testing Updates in Staging First

# Compare add-on versions between staging and production
diff \
  <(aws eks list-addons --cluster-name staging-cluster --output text | sort) \
  <(aws eks list-addons --cluster-name prod-cluster --output text | sort)

# Run a quick smoke test after add-on update
kubectl run dns-test --image=busybox:1.36 --rm -it --restart=Never -- \
  nslookup kubernetes.default.svc.cluster.local

kubectl run pvc-test --image=busybox:1.36 --rm -it --restart=Never \
  --overrides='{"spec":{"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"test-pvc"}}],"containers":[{"name":"c","image":"busybox:1.36","command":["sh","-c","echo OK > /data/test && cat /data/test"],"volumeMounts":[{"mountPath":"/data","name":"data"}]}]}}' \
  -- true

Rollback

If an add-on update causes problems, roll back by specifying the previous version explicitly:

# Roll back VPC CNI to previous version
aws eks update-addon \
  --cluster-name prod-cluster \
  --addon-name vpc-cni \
  --addon-version v1.18.0-eksbuild.1 \
  --resolve-conflicts OVERWRITE

# Monitor the DaemonSet rollout
kubectl rollout status daemonset aws-node -n kube-system --timeout=5m
Best Practice: Maintain a golden configuration document for each add-on's configuration_values. When you tune WARM_IP_TARGET or CoreDNS replicas, record the change in your IaC repo immediately. Drift between the running state and your Terraform causes update conflicts and makes rollbacks harder to reason about.