EKS Architecture
Amazon EKS (Elastic Kubernetes Service) runs Kubernetes with AWS managing the control plane. Understanding where the boundary is between what AWS owns and what you own is critical for networking, security, and debugging.
EKS vs Vanilla Kubernetes
| Aspect | Vanilla K8s | EKS |
|---|---|---|
| Control plane | You run and manage | AWS runs, fully managed, multi-AZ HA |
| etcd | You manage, backup | AWS managed, encrypted at rest |
| API Server endpoint | You expose | https://<hash>.gr7.<region>.eks.amazonaws.com |
| Upgrades | Manual | Managed (you trigger, AWS applies) |
| Worker nodes | Any machine | EC2 (managed node groups, self-managed, or Fargate) |
| Node IAM | Manual | Instance profile (managed node group) or IRSA (pod-level) |
| Cluster auth | kubeconfig + certs |
AWS IAM → aws eks get-token → K8s RBAC |
| Networking | CNI of your choice | AWS VPC CNI (pods get real VPC IPs) |
| Load balancers | Cloud controller manager | AWS Load Balancer Controller (ALB/NLB) |
Architecture: AWS-Managed vs Customer-Managed
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph AWSAccount["AWS Managed Account (opaque to you)"]
subgraph CPVPC["EKS Control Plane VPC (AWS-owned, not visible)"]
APISERVER["API Server: Multi-AZ HA, auto-scaled by AWS"]:::dark
ETCD["etcd: AWS managed, KMS encrypted, auto-backed-up"]:::k8s
SCHEDULER["kube-scheduler: managed by AWS"]:::purple
CM["kube-controller-manager: managed by AWS"]:::purple
CCM["AWS Cloud Controller Manager: provisions ELBs and routes"]:::blue
end
end
subgraph YourAccount["Your AWS Account"]
subgraph YourVPC["Your VPC (10.0.0.0/16)"]
subgraph ENIBridge["Cross-Account ENI Bridge"]
ENI_CP["AWS-injected ENI in your subnet (10.0.1.50): how API Server reaches kubelets"]:::teal
end
subgraph PubSubnet["Public Subnets (10.0.0.0/24, 10.0.1.0/24)"]
ALB["AWS Load Balancer (ALB/NLB): terminates external traffic"]:::red
NAT["NAT Gateway: outbound internet for private nodes"]:::orange
end
subgraph PrivSubnet["Private Subnets (10.0.10.0/24, 10.0.11.0/24)"]
subgraph Node1["EC2 Worker Node 1 (10.0.10.15)"]
KUBELET1["kubelet: registers with API Server via cross-account ENI"]:::dark
KPROXY1["kube-proxy: iptables/IPVS rules"]:::blue
VPCCNI1["aws-node VPC CNI DaemonSet: assigns VPC IPs to pods"]:::teal
POD1["Pod: my-app IP 10.0.10.20 (real VPC IP)"]:::teal
POD2["Pod: my-app IP 10.0.10.21"]:::blue
COREDNS["Pod: coredns IP 10.0.10.22"]:::teal
end
subgraph Node2["EC2 Worker Node 2 (10.0.11.30)"]
KUBELET2["kubelet"]:::k8s
VPCCNI2["aws-node VPC CNI"]:::teal
POD3["Pod: my-app IP 10.0.11.40 (real VPC IP)"]:::teal
POD4["Pod: aws-load-balancer-controller"]:::purple
end
end
end
IAM["AWS IAM OIDC Provider: IRSA pod-to-role mapping"]:::blue
ECR["Amazon ECR: container image registry"]:::yellow
CW["CloudWatch: Container Insights and control plane logs"]:::yellow
end
APISERVER -->|"HTTPS via cross-account ENI"| ENI_CP
ENI_CP -->|"in-VPC traffic"| KUBELET1
ENI_CP -->|"in-VPC traffic"| KUBELET2
ALB -->|"routes to pod IPs"| POD1
ALB --> POD3
POD1 -->|"outbound via NAT"| NAT
VPCCNI1 -->|"assigns secondary ENI IPs"| POD1
IAM -.->|"OIDC: pod assumes role via projected SA token"| POD4
ECR -.->|"image pull"| Node1
The Cross-Account ENI — How Control Plane Reaches Nodes
This is the most important networking concept in EKS. The API Server lives in AWS's VPC, but it needs to reach kubelet on your nodes (for exec, logs, port-forward). AWS solves this by injecting an ENI (Elastic Network Interface) directly into your subnet. This ENI has an IP in your private subnet and is managed entirely by AWS — you can see it in your EC2 console but should not modify it.
When the API Server needs to reach kubelet on 10.0.10.15:10250, it goes through its side of the ENI → your subnet → node's private IP. The traffic never leaves AWS's network, but it crosses account/VPC boundaries via this ENI.
Cluster endpoint access modes:
| Mode | API Server reachable from | Use case |
|---|---|---|
| Public | Internet (filtered by CIDR allow-list) | Dev, CI from outside VPC |
| Private | Within your VPC only (via ENI) | Production — never expose to internet |
| Public + Private | Both | Transition/hybrid |
VPC CNI — Why EKS Pods Get Real VPC IPs
Vanilla Kubernetes uses an overlay network (VXLAN/IPIP) where pod IPs are in a separate CIDR that's NAT'd at the node. EKS uses the AWS VPC CNI plugin (aws-node DaemonSet) which assigns real VPC subnet IPs to pods.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph VanillaOverlay["Vanilla K8s: overlay network"]
N1["Node: 192.168.1.10"]:::dark --> P1["Pod: 10.244.1.2 (overlay CIDR)"]:::blue
N2["Node: 192.168.1.11"]:::dark --> P2["Pod: 10.244.2.3 (overlay CIDR)"]:::blue
P1 -->|"VXLAN tunnel encapsulation"| P2
end
subgraph EKSVPCCNI["EKS: VPC CNI - real subnet IPs"]
EN1["Node: 10.0.10.15 with secondary ENI IPs .20 .21 .22"]:::dark --> EP1["Pod: 10.0.10.20 (real VPC IP)"]:::teal
EN1 --> EP2["Pod: 10.0.10.21 (real VPC IP)"]:::teal
EN2["Node: 10.0.11.30 with secondary ENI IPs .40 .41"]:::dark --> EP3["Pod: 10.0.11.40 (real VPC IP)"]:::teal
EP1 -->|"native VPC routing, no encapsulation"| EP3
end
How VPC CNI works:
aws-nodeDaemonSet attaches secondary ENIs to each EC2 node (up tomax_ENIsper instance type)- Each secondary ENI gets multiple secondary private IPs
- When a pod is created,
aws-nodeassigns one of these secondary IPs directly to the pod'seth0 - Because these are real VPC IPs, pod-to-pod traffic across nodes uses normal VPC routing — no overlay, no encapsulation
Implication: Pod IP exhaustion is a real concern. If your subnet has 256 IPs and nodes pre-allocate secondary IPs, you can run out. Solution: use /16 or /18 subnets for worker nodes, enable custom networking to use a secondary CIDR (100.64.0.0/16), or enable prefix delegation (each ENI IP prefix = /28 = 16 IPs, massively increasing pod density).
IAM Authentication & IRSA
Cluster Authentication Flow
sequenceDiagram
participant Dev as Developer / CI
participant AWS as AWS STS
participant K8s as K8s API Server
participant RBAC as RBAC Authorizer
Dev->>AWS: aws eks get-token --cluster-name my-cluster
AWS-->>Dev: Presigned STS URL token (expires 15 min)
Dev->>K8s: kubectl get pods with Bearer token
K8s->>AWS: Validate token via aws-iam-authenticator webhook
AWS-->>K8s: IAM identity: arn:aws:iam::123:user/deepanshu
K8s->>RBAC: Does this identity have permission?
Note over K8s: aws-auth ConfigMap maps IAM ARN to K8s username/groups
RBAC-->>K8s: Allowed (bound to ClusterRole developer)
K8s-->>Dev: Pod list response
The aws-auth ConfigMap (legacy) or EKS Access Entries (new, recommended) maps IAM principals to Kubernetes RBAC subjects:
# aws-auth ConfigMap (legacy approach)
apiVersion: v1
kind: ConfigMap
metadata:
name: aws-auth
namespace: kube-system
data:
mapRoles: |
- rolearn: arn:aws:iam::123456789:role/eks-node-group-role
username: system:node:{{EC2PrivateDNSName}}
groups:
- system:bootstrappers
- system:nodes
- rolearn: arn:aws:iam::123456789:role/ci-deploy-role
username: ci-deployer
groups:
- deployers
IRSA — IAM Roles for Service Accounts
IRSA lets individual pods assume IAM roles without putting AWS credentials in the pod or sharing node IAM permissions across all pods on a node.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
POD["Pod with ServiceAccount s3-reader and projected OIDC token"]:::blue -->|"1. read projected token"| SDK["AWS SDK in app"]:::blue
SDK -->|"2. AssumeRoleWithWebIdentity: token + role ARN"| STS["AWS STS"]:::blue
STS -->|"3. validate token"| OIDC["EKS OIDC Provider"]:::blue
OIDC -->|"token valid"| STS
STS -->|"4. temporary credentials (15min-1hr)"| SDK
SDK -->|"5. access S3 with temp creds"| S3["Amazon S3"]:::yellow
apiVersion: v1
kind: ServiceAccount
metadata:
name: s3-reader
namespace: default
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/my-app-s3-read-role
---
spec:
serviceAccountName: s3-reader
containers:
- name: app
env:
- name: AWS_REGION
value: us-east-1
The IAM role's trust policy must allow the cluster's OIDC provider to assume it:
{
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/XXXX"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/XXXX:sub": "system:serviceaccount:default:s3-reader"
}
}
}]
}
Node Groups: Managed vs Self-Managed vs Fargate
| Feature | Managed Node Group | Self-Managed | Fargate |
|---|---|---|---|
| AMI updates | AWS handles, you trigger | You manage | AWS handles |
| Node visibility | EC2 instances in your account | EC2 instances | No nodes (serverless) |
| Draining on upgrade | Automatic (respects PDB) | Manual | N/A |
| GPU support | Yes | Yes | No |
| DaemonSets | Yes | Yes | No (sidecar injection only) |
| Spot instances | Yes | Yes | Yes (Fargate Spot) |
| Cost model | EC2 pricing | EC2 pricing | Per-pod vCPU+memory |
| Best for | Most workloads | Custom AMI, kernel config | Batch, small isolated pods |
Fargate limitation: No DaemonSets. Since there are no nodes (pods run on AWS micro-VMs), aws-node CNI DaemonSet, kube-proxy DaemonSet, and monitoring DaemonSets don't run. AWS handles pod networking separately. CloudWatch Container Insights uses sidecar injection via Fluent Bit.
EKS Add-ons
| Add-on | What it does | Why it matters |
|---|---|---|
vpc-cni (aws-node) |
Assigns VPC IPs to pods | Core networking — don't let it get out of date |
kube-proxy |
iptables/IPVS service routing | Keeps node service rules in sync |
coredns |
In-cluster DNS | my-svc.my-ns.svc.cluster.local resolution |
aws-ebs-csi-driver |
Provisions EBS volumes for PVCs | Required since K8s 1.23 (in-tree deprecated) |
aws-efs-csi-driver |
Shared EFS mounts | Multi-AZ shared storage |
adot |
Metrics/traces collection | Feeds X-Ray and CloudWatch |
EKS Control Plane Logs
Enable in cluster config. Logged to CloudWatch log group /aws/eks/<cluster>/cluster:
| Log type | What it contains | Use for |
|---|---|---|
api |
All API Server requests | Audit trail, who-did-what |
audit |
K8s audit log (create/delete/patch) | Security investigation |
authenticator |
IAM auth webhook decisions | Debugging auth failures |
controllerManager |
Controller reconciliation events | Debugging object not created |
scheduler |
Scheduling decisions and failures | Debugging Pending pods |
aws logs filter-log-events \
--log-group-name /aws/eks/my-cluster/cluster \
--log-stream-name-prefix kube-scheduler \
--filter-pattern "my-pending-pod" \
--region us-east-1