Static provisioning using S3 Files
Now that we understand the S3 Files storage class for Kubernetes and why S3 Files uses static provisioning, let's statically provision a Persistent Volume for our pre-created S3 file system and modify the UI component to mount it.
An S3 file system has been provisioned for us, linked to an S3 bucket with versioning enabled. The file system includes mount targets and the required security group that includes an inbound rule allowing NFS traffic on port 2049. Let's get its ID which we'll need for the static PersistentVolume:
fs-0123456789abcdef0
With static provisioning we define three objects that work as a set:
- A
StorageClass(s3-files-sc) with no parameters, used only to bind the volume and claim - A
PersistentVolume(s3-files-pv) that points directly at our existing S3 file system - A
PersistentVolumeClaim(s3-files-claim) that binds to that specificPersistentVolume
The StorageClass for static provisioning acts only as a binding label between our PersistentVolume and PersistentVolumeClaim — it deliberately has no parameters, so the driver never tries to provision anything on demand:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: s3-files-sc
# No parameters and no dynamic provisioning: this StorageClass exists only to
# bind the statically-provisioned PersistentVolume to its PersistentVolumeClaim.
# The S3 file system is created ahead of time, so the EFS CSI driver does not
# provision anything on demand here.
provisioner: efs.csi.aws.com
Set the provisioner parameter to efs.csi.aws.com — the same provisioner used for EFS, since S3 Files uses the EFS CSI driver
Note that there are no parameters: unlike dynamic provisioning, we are not asking the driver to create a file system or access point
Now let's examine the PersistentVolume and PersistentVolumeClaim:
apiVersion: v1
kind: PersistentVolume
metadata:
name: s3-files-pv
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: s3-files-sc
csi:
driver: efs.csi.aws.com
volumeHandle: s3files:${S3_FILES_ID}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: s3-files-claim
namespace: ui
spec:
accessModes:
- ReadWriteMany
storageClassName: s3-files-sc
resources:
requests:
storage: 5Gi
volumeName: s3-files-pv
The csi.driver is efs.csi.aws.com, the EFS CSI driver that also serves S3 Files
The volumeHandle uses the s3files: prefix followed by our file system ID (for example s3files:fs-0123456789abcdef0). This prefix is what tells the driver to mount an S3 file system rather than an EFS file system. The $S3_FILES_ID environment variable is injected here.
The reclaim policy is Retain, so deleting the claim does not delete the underlying S3 file system or its data
The PersistentVolumeClaim sets volumeName: s3-files-pv so it binds to exactly the volume we defined, rather than triggering any dynamic provisioning.
Apply the kustomization, which creates the StorageClass, PersistentVolume, and PersistentVolumeClaim:
storageclass.storage.k8s.io/s3-files-sc created
persistentvolume/s3-files-pv created
persistentvolumeclaim/s3-files-claim created
Because the PersistentVolume was created ahead of time, it binds to our claim immediately:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
s3-files-pv 5Gi RWX Retain Bound ui/s3-files-claim s3-files-sc 10s
Let's examine the details of our PersistentVolumeClaim (PVC):
Name: s3-files-claim
Namespace: ui
StorageClass: s3-files-sc
Status: Bound
Volume: s3-files-pv
Labels: <none>
Annotations: pv.kubernetes.io/bind-completed: yes
Finalizers: [kubernetes.io/pvc-protection]
Capacity: 5Gi
Access Modes: RWX
VolumeMode: Filesystem
Used By: <none>
Events: <none>
Notice the claim is Bound to the s3-files-pv volume we statically defined — no external provisioner had to create anything.
Now we'll update the UI component to reference the S3 Files PVC:
- Kustomize Patch
- Deployment/ui
- Diff
apiVersion: apps/v1
kind: Deployment
metadata:
name: ui
spec:
replicas: 2
template:
spec:
initContainers:
- name: fix-permissions
image: public.ecr.aws/docker/library/busybox:1.37
command: ["sh", "-c", "chown 1000:1000 /s3files"]
volumeMounts:
- name: s3filesvolume
mountPath: /s3files
securityContext:
runAsUser: 0
containers:
- name: ui
volumeMounts:
- name: s3filesvolume
mountPath: /s3files
env:
- name: RETAIL_UI_PRODUCT_IMAGES_PATH
value: /s3files
volumes:
- name: s3filesvolume
persistentVolumeClaim:
claimName: s3-files-claim
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/created-by: eks-workshop
app.kubernetes.io/type: app
name: ui
namespace: ui
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/component: service
app.kubernetes.io/instance: ui
app.kubernetes.io/name: ui
template:
metadata:
annotations:
prometheus.io/path: /actuator/prometheus
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
labels:
app.kubernetes.io/component: service
app.kubernetes.io/created-by: eks-workshop
app.kubernetes.io/instance: ui
app.kubernetes.io/name: ui
spec:
containers:
- env:
- name: RETAIL_UI_PRODUCT_IMAGES_PATH
value: /s3files
- name: JAVA_OPTS
value: -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom
- name: METADATA_KUBERNETES_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: METADATA_KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: METADATA_KUBERNETES_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
envFrom:
- configMapRef:
name: ui
image: public.ecr.aws/aws-containers/retail-store-sample-ui:1.2.1
imagePullPolicy: IfNotPresent
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 45
periodSeconds: 20
name: ui
ports:
- containerPort: 8080
name: http
protocol: TCP
resources:
limits:
memory: 1.5Gi
requests:
cpu: 250m
memory: 1.5Gi
securityContext:
capabilities:
add:
- NET_BIND_SERVICE
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
volumeMounts:
- mountPath: /s3files
name: s3filesvolume
- mountPath: /tmp
name: tmp-volume
initContainers:
- command:
- sh
- -c
- chown 1000:1000 /s3files
image: public.ecr.aws/docker/library/busybox:1.37
name: fix-permissions
securityContext:
runAsUser: 0
volumeMounts:
- mountPath: /s3files
name: s3filesvolume
securityContext:
fsGroup: 1000
serviceAccountName: ui
volumes:
- name: s3filesvolume
persistentVolumeClaim:
claimName: s3-files-claim
- emptyDir:
medium: Memory
name: tmp-volume
app.kubernetes.io/type: app
name: ui
namespace: ui
spec:
- replicas: 1
+ replicas: 2
selector:
matchLabels:
app.kubernetes.io/component: service
app.kubernetes.io/instance: ui
[...]
app.kubernetes.io/name: ui
spec:
containers:
- env:
+ - name: RETAIL_UI_PRODUCT_IMAGES_PATH
+ value: /s3files
- name: JAVA_OPTS
value: -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom
- name: METADATA_KUBERNETES_POD_NAME
valueFrom:
[...]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
volumeMounts:
+ - mountPath: /s3files
+ name: s3filesvolume
- mountPath: /tmp
name: tmp-volume
+ initContainers:
+ - command:
+ - sh
+ - -c
+ - chown 1000:1000 /s3files
+ image: public.ecr.aws/docker/library/busybox:1.37
+ name: fix-permissions
+ securityContext:
+ runAsUser: 0
+ volumeMounts:
+ - mountPath: /s3files
+ name: s3filesvolume
securityContext:
fsGroup: 1000
serviceAccountName: ui
volumes:
+ - name: s3filesvolume
+ persistentVolumeClaim:
+ claimName: s3-files-claim
- emptyDir:
medium: Memory
name: tmp-volume
This patch also adds a small fix-permissions init container. The ui container runs as the non-root user 1000, but the S3 Files volume is mounted owned by root. Because S3 Files uses an NFS-based file system, the pod's fsGroup setting does not automatically change the ownership of the mounted volume the way it does for block storage like Amazon EBS. The init container runs as root and chowns the mount to user 1000 before the ui container starts, so the application can write to /s3files. Without it, writes such as the one later in this lab would fail with a permission error.
Apply these changes with the following command:
namespace/ui unchanged
serviceaccount/ui unchanged
configmap/ui unchanged
service/ui unchanged
deployment.apps/ui configured
Let's examine the volumeMounts in the deployment. Notice that our new volume named s3filesvolume is mounted at /s3files:
- mountPath: /s3files
name: s3filesvolume
- mountPath: /tmp
name: tmp-volume
At this point, the S3 file system is successfully mounted but currently empty:
Let's use a Kubernetes Job to populate the S3 Files volume with images:
Now let's demonstrate the shared storage functionality by listing the current files in /s3files through one of the UI component Pods:
1ca35e86-4b4c-4124-b6b5-076ba4134d0d.jpg
4f18544b-70a5-4352-8e19-0d070f46745d.jpg
631a3db5-ac07-492c-a994-8cd56923c112.jpg
79bce3f3-935f-4912-8c62-0d2f3e059405.jpg
8757729a-c518-4356-8694-9e795a9b3237.jpg
87e89b11-d319-446d-b9be-50adcca5224a.jpg
a1258cd2-176c-4507-ade6-746dab5ad625.jpg
cc789f85-1476-452a-8100-9e74502198e0.jpg
d27cf49f-b689-4a75-a249-d373e0330bb5.jpg
d3104128-1d14-4465-99d3-8ab9267c687b.jpg
d4edfedb-dbe9-4dd9-aae8-009489394955.jpg
d77f9ae6-e9a8-4a3e-86bd-b72af75cbc49.jpg
Because S3 Files automatically synchronizes data between the file system and the S3 bucket, we can verify that the images are also present in the underlying S3 bucket:
PRE /
2025-07-09 14:43:36 102950 1ca35e86-4b4c-4124-b6b5-076ba4134d0d.jpg
2025-07-09 14:43:36 118546 4f18544b-70a5-4352-8e19-0d070f46745d.jpg
[...]
The PRE / entry is a zero-byte object that S3 Files uses to represent the file system's root directory in the bucket — it is created when the fix-permissions init container sets ownership on the mount root. It is a normal part of how S3 Files maps a POSIX file system onto S3 objects, not an error, and it does not affect the image files. All of the image files appear alongside it at the top level of the bucket.
To further demonstrate the shared storage capabilities, let's create a new image called placeholder.jpg and add it to the S3 Files volume through the first Pod:
Now we'll verify that the second UI Pod can access this newly created file, demonstrating the shared nature of our S3 Files storage:
1ca35e86-4b4c-4124-b6b5-076ba4134d0d.jpg
4f18544b-70a5-4352-8e19-0d070f46745d.jpg
631a3db5-ac07-492c-a994-8cd56923c112.jpg
79bce3f3-935f-4912-8c62-0d2f3e059405.jpg
8757729a-c518-4356-8694-9e795a9b3237.jpg
87e89b11-d319-446d-b9be-50adcca5224a.jpg
a1258cd2-176c-4507-ade6-746dab5ad625.jpg
cc789f85-1476-452a-8100-9e74502198e0.jpg
d27cf49f-b689-4a75-a249-d373e0330bb5.jpg
d3104128-1d14-4465-99d3-8ab9267c687b.jpg
d4edfedb-dbe9-4dd9-aae8-009489394955.jpg
d77f9ae6-e9a8-4a3e-86bd-b72af75cbc49.jpg
placeholder.jpg <----------------
As you can see, even though we created the file through the first Pod, the second Pod has immediate access to it because they're both accessing the same shared S3 file system.
S3 Files will also automatically synchronize this new file back to the underlying S3 bucket, making it accessible through the S3 API as well.
Finally, let's confirm that the image is accessible through the UI service:
http://k8s-ui-uinlb-647e781087-6717c5049aa96bd9.elb.us-west-2.amazonaws.com/assets/img/products/placeholder.jpg
Visit the URL in your browser:
We've successfully demonstrated how Amazon S3 Files provides persistent shared storage for workloads running on Amazon EKS. This solution combines the simplicity and performance of a file system with the scalability and durability of Amazon S3, allowing multiple pods to read from and write to the same storage volume simultaneously while keeping data synchronized with your S3 bucket.