DEV Community

Cover image for Kubernetes Storage Playlist - Part 3: Implementing Amazon EBS Storage with Amazon EKS Using Terraform and Kubernetes Manifests

Kubernetes Storage Playlist - Part 3: Implementing Amazon EBS Storage with Amazon EKS Using Terraform and Kubernetes Manifests

In this blog, we’ll explore how to integrate Amazon Elastic Block Store (EBS) with Amazon Elastic Kubernetes Service (EKS). We’ll provision an EKS cluster with Terraform, configure the EBS CSI driver, and run an Nginx container that uses EBS storage to persist website files.

This is a practical guide for anyone building stateful workloads on EKS.

Understanding Amazon EBS for Kubernetes

What is Amazon EBS?

Amazon Elastic Block Store (EBS) provides persistent block-level storage volumes that can be attached to Amazon EC2 instances. Within Kubernetes, EBS volumes can be exposed to Pods through the EBS CSI (Container Storage Interface) driver, allowing workloads to persist data beyond pod lifecycles.

Architecture Diagram

When you use Amazon EBS with Amazon EKS, your application asks for storage through a PersistentVolumeClaim (PVC). Kubernetes then either connects this request to an existing EBS volume (static provisioning) or automatically creates a new one (dynamic provisioning) with the help of a StorageClass and the EBS CSI driver.

The CSI driver talks to AWS to create and attach the volume to the worker node where your Pod is running, and the volume gets mounted inside the container at the specified path (like /usr/share/nginx/html). One important thing to remember is that EBS volumes work only within a single Availability Zone (AZ). This means your Pod and the EBS volume must be in the same AZ. Dynamic provisioning usually takes care of this automatically, but with static provisioning, you need to make sure the volume is created in the right AZ.

Key Benefits for EKS

  • Durability: Data persists beyond pod restarts.
  • Performance: Multiple volume types (gp3, io2, st1, etc.) for optimized throughput or IOPS.
  • Elasticity: Volumes can be resized without downtime.
  • Cost-effectiveness: Pay only for the storage provisioned.

Important Considerations with EKS

  • EBS volumes are AZ-scoped – a Pod using EBS must run in the same Availability Zone as the volume.
  • Requires Kubernetes v1.17+ with CSI driver support.
  • Pods requiring EBS must use StatefulSets or carefully scheduled Deployments.
  • Resource quotas should be monitored to avoid exhausting storage or hitting API limits.

Step 1: Provisioning EKS Cluster with Terraform in a VPC

The first step in integrating Amazon EBS with EKS is to provision a Kubernetes cluster that runs securely inside a dedicated VPC. We will use the widely adopted AWS Terraform community modules for both the VPC and EKS setup. Please refer to main module of GitHub repo.

Step 2: Creating the EFS File System

EBS Storage: Encrypted EBS Storage with optional encryption

####################################################################################
# Static EBS Volume for Testing
####################################################################################
resource "aws_ebs_volume" "static_volume" {
  count = var.create_static_volume ? 1 : 0

  availability_zone = var.availability_zones[0]
  size              = var.static_volume_size
  type              = var.ebs_volume_type
  encrypted         = var.ebs_encrypted
  kms_key_id        = var.ebs_kms_key_id

  # Configure IOPS for gp3, io1, io2 volumes
  iops = var.ebs_volume_type == "gp3" || var.ebs_volume_type == "io1" || var.ebs_volume_type == "io2" ? var.ebs_volume_iops : null

  # Configure throughput for gp3 volumes
  throughput = var.ebs_volume_type == "gp3" ? var.ebs_volume_throughput : null

  tags = {
    Name        = "${var.cluster_name}-static-ebs-volume"
    Environment = var.environment
    Terraform   = "true"
    Purpose     = "Static EBS volume for Kubernetes testing"
  }
}

####################################################################################
# KMS Key for EBS Encryption (Optional)
####################################################################################
resource "aws_kms_key" "ebs_encryption" {
  count = var.ebs_kms_key_id == null ? 1 : 0

  description             = "KMS key for EBS volume encryption in ${var.cluster_name}"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  tags = {
    Name        = "${var.cluster_name}-ebs-encryption-key"
    Environment = var.environment
    Terraform   = "true"
  }
}

resource "aws_kms_alias" "ebs_encryption" {
  count = var.ebs_kms_key_id == null ? 1 : 0

  name          = "alias/${var.cluster_name}-ebs-encryption"
  target_key_id = aws_kms_key.ebs_encryption[0].key_id
}
Enter fullscreen mode Exit fullscreen mode

IAM Role: For EFS CSI driver with pod identity

####################################################################################
# IAM Role for EBS CSI Driver (Pod Identity)
####################################################################################
resource "aws_iam_role" "ebs_csi_driver_role" {
  name = "${var.cluster_name}-ebs-csi-driver-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "pods.eks.amazonaws.com"
        }
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
      }
    ]
  })

  tags = {
    Name        = "${var.cluster_name}-ebs-csi-driver-role"
    Environment = var.environment
    Terraform   = "true"
  }
}

####################################################################################
# Create Custom EBS CSI Driver Policy
####################################################################################
resource "aws_iam_policy" "ebs_csi_driver_policy" {
  name        = "${var.cluster_name}-ebs-csi-driver-policy"
  description = "Policy for EBS CSI driver"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ec2:CreateSnapshot",
          "ec2:AttachVolume",
          "ec2:DetachVolume",
          "ec2:ModifyVolume",
          "ec2:DescribeAvailabilityZones",
          "ec2:DescribeInstances",
          "ec2:DescribeSnapshots",
          "ec2:DescribeTags",
          "ec2:DescribeVolumes",
          "ec2:DescribeVolumesModifications"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "ec2:CreateTags"
        ]
        Resource = [
          "arn:aws:ec2:*:*:volume/*",
          "arn:aws:ec2:*:*:snapshot/*"
        ]
        Condition = {
          StringEquals = {
            "ec2:CreateAction" = [
              "CreateVolume",
              "CreateSnapshot"
            ]
          }
        }
      },
      {
        Effect = "Allow"
        Action = [
          "ec2:DeleteTags"
        ]
        Resource = [
          "arn:aws:ec2:*:*:volume/*",
          "arn:aws:ec2:*:*:snapshot/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "ec2:CreateVolume"
        ]
        Resource = "*"
        Condition = {
          StringLike = {
            "aws:RequestedRegion" = data.aws_region.current.name
          }
        }
      },
      {
        Effect = "Allow"
        Action = [
          "ec2:DeleteVolume"
        ]
        Resource = "*"
        Condition = {
          StringLike = {
            "ec2:ResourceTag/ebs.csi.aws.com/cluster" = "true"
          }
        }
      },
      {
        Effect = "Allow"
        Action = [
          "ec2:DeleteSnapshot"
        ]
        Resource = "*"
        Condition = {
          StringLike = {
            "ec2:ResourceTag/CSIVolumeSnapshotName" = "*"
          }
        }
      }
    ]
  })

  tags = {
    Name        = "${var.cluster_name}-ebs-csi-driver-policy"
    Environment = var.environment
    Terraform   = "true"
  }
}

####################################################################################
# Attach EBS CSI Driver Policy
####################################################################################
resource "aws_iam_role_policy_attachment" "ebs_csi_driver_policy" {
  role       = aws_iam_role.ebs_csi_driver_role.name
  policy_arn = aws_iam_policy.ebs_csi_driver_policy.arn
}

####################################################################################
# Pod Identity Association for EBS CSI Driver
####################################################################################
resource "aws_eks_pod_identity_association" "ebs_csi_driver" {
  cluster_name    = var.cluster_name
  namespace       = "kube-system"
  service_account = "ebs-csi-controller-sa"
  role_arn        = aws_iam_role.ebs_csi_driver_role.arn

  tags = {
    Name        = "${var.cluster_name}-ebs-csi-pod-identity"
    Environment = var.environment
    Terraform   = "true"
  }
}
Enter fullscreen mode Exit fullscreen mode

EKS Add-on: AWS EFS CSI driver for Kubernetes integration

####################################################################################
###  EBS CSI Driver Addon (deployed after EBS module)
####################################################################################
resource "aws_eks_addon" "ebs_csi_driver" {
  cluster_name = module.eks.cluster_name
  addon_name   = "aws-ebs-csi-driver"

  # Ensure this addon is created after the EBS module creates the IAM role and pod identity association
  depends_on = [module.ebs]

  tags = {
    Name        = "${var.cluster_name}-ebs-csi-driver"
    Environment = var.environment
    Terraform   = "true"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: EBS Storage Implementation Patterns

Static Provisioning

In static provisioning, the PersistentVolume (PV) is created manually by the administrator. The PV explicitly points to an existing EBS volume ID and directory.

How it works:

  1. You first provision an EBS Volume using Terraform/CLI.
  2. You then define a Kubernetes PersistentVolume (PV) resource that references the EBS Volume ID.
  3. Then youcreate a PersistentVolumeClaim (PVC) that requests storage matching the PV’s specifications.
  4. Deploy your Pod (e.g., Nginx) and mount the PVC as a volume to store files.
  • static-storage-class.yaml - StorageClass for static EBS volumes (no parameters needed)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: ebs-static-sc
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  fsType: ext4
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
Enter fullscreen mode Exit fullscreen mode
  • static-persistent-volume.yaml - PersistentVolume pointing to existing EBS volume (requires ${EBS_VOLUME_ID})
apiVersion: v1
kind: PersistentVolume
metadata:
  name: ebs-static-pv
spec:
  capacity:
    storage: 10Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ebs-static-sc
  csi:
    driver: ebs.csi.aws.com
    volumeHandle: ${EBS_VOLUME_ID}
    fsType: ext4
Enter fullscreen mode Exit fullscreen mode
  • static-persistent-volume-claim.yaml - PersistentVolumeClaim for static volume
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ebs-static-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ebs-static-sc
  volumeName: ebs-static-pv
  resources:
    requests:
      storage: 10Gi
Enter fullscreen mode Exit fullscreen mode
  • static-nginx-pod.yaml - Test pod using static EBS volume
apiVersion: v1
kind: Pod
metadata:
  name: nginx-ebs-static-pod
  namespace: default
  labels:
    app: nginx-ebs-static
spec:
  containers:
  - name: nginx
    image: nginx:latest
    ports:
    - containerPort: 80
    volumeMounts:
    - name: ebs-storage
      mountPath: /usr/share/nginx/html
    env:
    - name: POD_NAME
      valueFrom:
        fieldRef:
          fieldPath: metadata.name
    command: ["/bin/sh"]
    args: ["-c", "echo '<h1>Hello from EBS Static Volume!</h1><p><b>Pod:</b> '$POD_NAME'</p>' > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'"]
  volumes:
  - name: ebs-storage
    persistentVolumeClaim:
      claimName: ebs-static-pvc
Enter fullscreen mode Exit fullscreen mode
  • static-nginx-service.yaml - Service to expose the static test pod
apiVersion: v1
kind: Service
metadata:
  name: nginx-ebs-static-service
  namespace: default
  labels:
    app: nginx-ebs-static
spec:
  selector:
    app: nginx-ebs-static
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
Enter fullscreen mode Exit fullscreen mode

Deployment Steps For Static Provisioning:

  1. Get EBS values from Terraform:
cd infrastructure
EBS_VOLUME_ID=$(terraform output -raw ebs_volume_id 2>/dev/null || echo "")
Enter fullscreen mode Exit fullscreen mode
  1. Update manifests with EFS values:
sed "s/\${EBS_VOLUME_ID}/$EBS_VOLUME_ID/g" static-persistent-volume.yaml > static-persistent-volume-final.yaml
Enter fullscreen mode Exit fullscreen mode
  1. Apply static manifests:
kubectl apply -f static-storage-class.yaml
kubectl apply -f static-persistent-volume-final.yaml
kubectl apply -f static-persistent-volume-claim.yaml
kubectl apply -f static-nginx-pod.yaml
kubectl apply -f static-nginx-service.yaml
Enter fullscreen mode Exit fullscreen mode

Refer to static-deploy.sh for deployment script for static provisioning

$ kubectl get sc,pv,pvc
NAME                                        PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
storageclass.storage.k8s.io/ebs-static-sc   ebs.csi.aws.com         Delete          WaitForFirstConsumer   true                   6m39s

NAME                             CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                    STORAGECLASS    VOLUMEATTRIBUTESCLASS   REASON   AGE
persistentvolume/ebs-static-pv   10Gi       RWO            Retain           Bound    default/ebs-static-pvc   ebs-static-sc   <unset>                          6m31s

NAME                                   STATUS   VOLUME          CAPACITY   ACCESS MODES   STORAGECLASS    VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/ebs-static-pvc   Bound    ebs-static-pv   10Gi       RWO            ebs-static-sc   <unset>                 6m28s
Enter fullscreen mode Exit fullscreen mode

EBS Volume:

Nginx pod accessing EBS for index.html

Dynamic Provisioning (Recommended)

In dynamic provisioning, Kubernetes automatically creates EBS volumes on demand using the EBS CSI driver and a StorageClass.

How it works:

  1. Define a StorageClass that specifies the EBS Volume (e.g., gp3 type, retention policy, binding mode)..
  2. When an application requests storage using a PVC, Kubernetes dynamically creates:
    • A new PV backed by a new EBS volume
    • Pods mount the dynamically provisioned PV through the PVC.
  • dynamic-storage-class.yaml - StorageClass with gp3 volume configuration
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: ebs-dynamic-sc
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  fsType: ext4
  encrypted: "true"
  iops: "3000"
  throughput: "125"
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
reclaimPolicy: Delete
Enter fullscreen mode Exit fullscreen mode
  • dynamic-persistent-volume-claim.yaml - PVC for dynamic volume creation
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ebs-dynamic-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ebs-dynamic-sc
  resources:
    requests:
      storage: 10Gi
Enter fullscreen mode Exit fullscreen mode
  • dynamic-nginx-pod.yaml - Test pod using dynamic EBS volume
apiVersion: v1
kind: Pod
metadata:
  name: nginx-ebs-dynamic-pod
  namespace: default
  labels:
    app: nginx-ebs-dynamic
spec:
  containers:
  - name: nginx
    image: nginx:latest
    ports:
    - containerPort: 80
    volumeMounts:
    - name: ebs-storage
      mountPath: /usr/share/nginx/html
    env:
    - name: POD_NAME
      valueFrom:
        fieldRef:
          fieldPath: metadata.name
    lifecycle:
      postStart:
        exec:
          command: ["/bin/sh", "-c", "echo '<h1>Hello from EBS Dynamic Volume!</h1><p><b>Pod:</b> '$POD_NAME'</p>' > /usr/share/nginx/html/index.html"]
  volumes:
  - name: ebs-storage
    persistentVolumeClaim:
      claimName: ebs-dynamic-pvc
Enter fullscreen mode Exit fullscreen mode
  • dynamic-nginx-service.yaml - Service to expose the dynamic test pod
apiVersion: v1
kind: Service
metadata:
  name: nginx-ebs-dynamic-service
  namespace: default
  labels:
    app: nginx-ebs-dynamic
spec:
  selector:
    app: nginx-ebs-dynamic
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
Enter fullscreen mode Exit fullscreen mode

Deployment Steps For Dynamic Provisioning:

  1. Apply static manifests:
kubectl apply -f dynamic-storage-class.yaml
kubectl apply -f dynamic-persistent-volume-claim.yaml
kubectl apply -f dynamic-nginx-pod.yaml
kubectl apply -f dynamic-nginx-service.yaml
Enter fullscreen mode Exit fullscreen mode

Refer to dynamic-deploy.sh for deployment script for dynamic provisioning

$ kubectl get sc,pv,pvc
NAME                                         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
storageclass.storage.k8s.io/ebs-dynamic-sc   ebs.csi.aws.com         Delete          WaitForFirstConsumer   true                   3m31s

NAME                                                        CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                     STORAGECLASS     VOLUMEATTRIBUTESCLASS   REASON   AGE
persistentvolume/pvc-7057dfad-6961-49cd-bf4f-db2f0bd9727f   10Gi       RWO            Delete           Bound    default/ebs-dynamic-pvc   ebs-dynamic-sc   <unset>                          3m22s

NAME                                    STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS     VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/ebs-dynamic-pvc   Bound    pvc-7057dfad-6961-49cd-bf4f-db2f0bd9727f   10Gi       RWO            ebs-dynamic-sc   <unset>                 3m29s
Enter fullscreen mode Exit fullscreen mode

EBS Volume:

Nginx pod accessing EBS for index.html

Verification

Check that everything is working:

# Check EFS CSI driver pods
kubectl get pods -n kube-system -l app=efs-csi-controller

# Check storage classes
kubectl get storageclass ebs-sc

# For Static Provisioning:
kubectl get pv ebs-static-pv
kubectl get pvc ebs-static-pvc
kubectl get pod nginx-ebs-static
kubectl get service nginx-ebs-static-service

# For Dynamic Provisioning:
kubectl get pvc ebs-dynamic-pvc
kubectl get pod nginx-ebs-dynamic
kubectl get service nginx-ebs-dynamic-service

# Check volume mount
kubectl exec nginx-ebs-static-pod -- df -h /usr/share/nginx/html
kubectl exec nginx-ebs-dynamic-pod -- df -h /usr/share/nginx/html

# View content
kubectl exec nginx-ebs-static-pod -- cat /usr/share/nginx/html/index.html
kubectl exec nginx-ebs-dynamic-pod -- cat /usr/share/nginx/html/index.html

# Test nginx web servers
kubectl port-forward service/nginx-ebs-static-service 8082:80   # Static
# Then visit http://localhost:8082

kubectl port-forward service/nginx-ebs-dynamic-service 8083:80  # Dynamic
# Then visit http://localhost:8083
Enter fullscreen mode Exit fullscreen mode

Cleanup

  • Static Provisioning Cleanup
kubectl delete -f static-storage-class.yaml
kubectl delete -f static-persistent-volume-final.yaml
kubectl delete -f static-persistent-volume-claim.yaml
kubectl delete -f static-nginx-pod.yaml
kubectl delete -f static-nginx-service.yaml
Enter fullscreen mode Exit fullscreen mode
  • Dynamic Provisioning Cleanup
kubectl delete -f dynamic-nginx-service.yaml
kubectl delete -f dynamic-nginx-pod.yaml
kubectl delete -f dynamic-persistent-volume-claim.yaml
kubectl delete -f dynamic-storage-class.yaml
Enter fullscreen mode Exit fullscreen mode

And then terraform destroy the EKS infrastructure if you are not using it to save costs.

Conclusion

Amazon EBS provides reliable block storage for stateful workloads on Amazon EKS. Using Terraform for infrastructure provisioning and Kubernetes manifests for storage configuration gives you both automation and flexibility. With proper setup of IAM roles, CSI drivers, and best practices, you can run workloads like Nginx with persistent storage on EKS confidently.

References

Top comments (0)