Three Layers of Container Security on Amazon EKS: Scan the Image, Lock the Pod, Scope the Identity
A hands-on guide to securing a Go service on Amazon EKS: build a minimal non-root image, scan it continuously with Amazon ECR and Amazon Inspector, enforce the Kubernetes "restricted" Pod Security Standard, and give pods least-privilege AWS access with EKS Pod Identity instead of access keys.
Introduction
I am doing undergraduate research on security in Kubernetes and containers. One lesson came up again and again: most container incidents are not clever zero-days. They are known CVEs in old base images, pods running as root, and credentials with far more permissions than needed.
None of these needs an expensive tool to fix. In this post I secure a small Go service on Amazon EKS using only native AWS and Kubernetes features.
The three layers
Layer | Risk | Control used here |
|---|---|---|
Image | Known vulnerabilities, extra tools for attackers | Distroless non-root image + ECR enhanced scanning (Amazon Inspector) |
Pod | Container escape, privilege escalation | Pod Security Standards, "restricted" level |
Identity | Leaked or over-privileged AWS credentials | EKS Pod Identity with a least-privilege IAM role |
Each layer assumes the previous one can fail. A vulnerable image in a locked-down pod with a narrow role does much less damage.
Step 1: Build a minimal non-root image
The smallest attack surface is the one with nothing in it. A static Go binary on a distroless base has no shell, no package manager and far fewer packages to carry CVEs.
FROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/order-api
FROM gcr.io/distroless/static-debian12:nonrootCOPY --from=build /app /appUSER 65532:65532EXPOSE 8080ENTRYPOINT ["/app"]Use the numeric UID (65532 is
nonroot in distroless). Kubernetes can verify runAsNonRoot only when the user is a number, not a name.Push it to Amazon ECR:
aws ecr create-repository --repository-name order-api \ --image-tag-mutability IMMUTABLE
aws ecr get-login-password | docker login --username AWS --password-stdin \ 111122223333.dkr.ecr.us-east-1.amazonaws.com
docker build -t 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1 .docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1Immutable tags stop anyone from silently replacing
v1 with a different image.Step 2: Scan images continuously with Amazon Inspector
ECR basic scanning checks an image once, at push. Enhanced scanning uses Amazon Inspector to scan OS and language packages (including Go modules) and rescans automatically when new CVEs are published.
aws inspector2 enable --resource-types ECR
aws ecr put-registry-scanning-configuration \ --scan-type ENHANCED \ --rules '[{"scanFrequency":"CONTINUOUS_SCAN", "repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]'Block vulnerable images in CI
Add this after
docker push in your pipeline. It waits for the scan and fails the build on any CRITICAL or HIGH finding:REPO=order-apiTAG=v1
aws ecr wait image-scan-complete --repository-name $REPO --image-id imageTag=$TAG
COUNTS=$(aws ecr describe-image-scan-findings \ --repository-name $REPO --image-id imageTag=$TAG \ --query 'imageScanFindings.findingSeverityCounts' --output json)
echo "Findings: $COUNTS"CRIT=$(echo "$COUNTS" | jq '(.CRITICAL // 0) + (.HIGH // 0)')
if [ "$CRIT" -gt 0 ]; then echo "Blocking deploy: $CRIT critical/high vulnerabilities" exit 1fiGet alerted about new CVEs in running images
Continuous scanning only helps if someone sees the result. An EventBridge rule can forward new critical findings to an SNS topic (email, Slack via Chatbot):
{ "source": ["aws.inspector2"], "detail-type": ["Inspector2 Finding"], "detail": { "severity": ["CRITICAL"] }}Step 3: Enforce the "restricted" Pod Security Standard
Kubernetes ships with Pod Security Admission, enabled by default on EKS. A namespace label is enough to reject pods that run as root, request privileged mode or keep dangerous Linux capabilities.
apiVersion: v1kind: Namespacemetadata: name: comanda labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: latest pod-security.kubernetes.io/warn: restrictedA Deployment that passes the restricted level:
apiVersion: apps/v1kind: Deploymentmetadata: name: order-api namespace: comandaspec: replicas: 2 selector: matchLabels: { app: order-api } template: metadata: labels: { app: order-api } spec: serviceAccountName: order-api securityContext: runAsNonRoot: true runAsUser: 65532 seccompProfile: { type: RuntimeDefault } containers: - name: order-api image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1 ports: [{ containerPort: 8080 }] securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: ["ALL"] } resources: requests: { cpu: 100m, memory: 64Mi } limits: { memory: 128Mi }readOnlyRootFilesystem is not required by the standard, but costs nothing for a Go binary that writes nothing to disk.To adopt this in an existing cluster without breaking workloads, start with only the
warn and audit labels, fix what they report, then add enforce .Step 4: Least-privilege AWS access with EKS Pod Identity
The worst way to give a pod AWS access is an access key in a Kubernetes Secret. The second worst is the node's instance role, because then every pod on the node shares it. EKS Pod Identity maps one Kubernetes service account to one IAM role, with short-lived credentials.
Install the agent add-on once per cluster:
aws eks create-addon --cluster-name demo --addon-name eks-pod-identity-agentCreate a role that only EKS pods can assume (
trust.json ):{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Service": "pods.eks.amazonaws.com" }, "Action": ["sts:AssumeRole", "sts:TagSession"] }]}Give it only what order-api needs, here reading one secret:
aws iam create-role --role-name order-api-pod \ --assume-role-policy-document file://trust.json
aws iam put-role-policy --role-name order-api-pod --policy-name read-secret \ --policy-document '{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:comanda/order-api-*" }] }'Link the role to the service account:
kubectl create serviceaccount order-api -n comanda
aws eks create-pod-identity-association \ --cluster-name demo --namespace comanda --service-account order-api \ --role-arn arn:aws:iam::111122223333:role/order-api-podNo code change is needed: current AWS SDKs (including the Go SDK v2) find the credentials through the default chain.
Pod Identity or IRSA?
| EKS Pod Identity | IRSA |
|---|---|---|
Setup | Add-on + association API | OIDC provider per cluster + annotated service account |
Trust policy | Same for every cluster | Contains the cluster's OIDC URL |
Reuse role in many clusters | Yes, without editing it | Must add each cluster's OIDC provider |
Runs on | EKS on EC2 nodes | EKS, also Fargate and self-managed clusters |
For new EKS clusters on EC2 nodes, Pod Identity is simpler. IRSA is still the choice on Fargate.
Step 5: Prove it works
Security controls you never tested are assumptions. Three quick checks:
1. A privileged pod is rejected.
kubectl run bad --image=nginx --privileged -n comandaError from server (Forbidden): pods "bad" is forbidden:violates PodSecurity "restricted:latest": privileged (container "bad" must not set securityContext.privileged=true), ...2. There is no shell to abuse.
kubectl exec -it deploy/order-api -n comanda -- sh# fails: executable file not found3. The pod gets scoped, short-lived credentials. The Pod Identity webhook injects the credential endpoint into the container:
kubectl get pod -n comanda -l app=order-api \ -o jsonpath='{.items[0].spec.containers[0].env[*].name}'# AWS_CONTAINER_CREDENTIALS_FULL_URI AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE ...If the app tries anything outside its policy (for example
s3:ListBuckets ), the call returns AccessDenied and CloudTrail records which role tried it.Cost
The security controls are almost free; the cluster is what costs money (approximate list prices, check the AWS pricing pages).
Item | Approx. cost |
|---|---|
Pod Security Admission | Free (built into Kubernetes) |
EKS Pod Identity | Free |
Inspector ECR scanning | ~USD 0.09 per image first scan, ~USD 0.01 per rescan |
EKS control plane | ~USD 0.10 per hour (~USD 73/month) |
Accounts new to Inspector get a free trial period. For a lab, delete the cluster when you finish (
eksctl delete cluster --name demo ).Conclusion
With a distroless image, continuous scanning, the restricted Pod Security Standard and Pod Identity, the common container attack paths get much narrower: fewer CVEs to exploit, no root or privilege escalation, and no long-lived keys to steal.
Next layers to explore:
- Network policies to limit which pods can talk to each other.
- Image signing with AWS Signer and a policy that admits only signed images.
- Runtime detection with Amazon GuardDuty EKS Runtime Monitoring.
- Policy as code with Kyverno or OPA Gatekeeper for rules beyond Pod Security Standards.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article