Sécuriser un cluster AWS EKS de production
Ceci durcit un cluster EKS par défaut avec trois contrôles : IAM scopé au ServiceAccount via IRSA plutôt qu'un rôle partagé au niveau du node, NetworkPolicies default-deny plutôt que l'allow-all par défaut de Kubernetes, et contraintes OPA Gatekeeper qui bloquent les manifestes non conformes à l'admission.
IRSA : scoper l'IAM au ServiceAccount, pas au node
IAM Roles for Service Accounts permet à un ServiceAccount Kubernetes d'assumer un rôle IAM précis via fédération OIDC : sans identifiants statiques, sans permissions portées par l'ensemble du node. EKS expose déjà un fournisseur OIDC par cluster : on l'associe à IAM, puis on écrit une trust policy qui ne fait confiance qu'aux tokens émis pour une paire namespace/ServiceAccount donnée.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.eu-west-1.amazonaws.com/id/ABCDEF1234567890"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.eu-west-1.amazonaws.com/id/ABCDEF1234567890:sub": "system:serviceaccount:payments:invoice-worker",
"oidc.eks.eu-west-1.amazonaws.com/id/ABCDEF1234567890:aud": "sts.amazonaws.com"
}
}
}
]
}La condition sub est le contrôle à elle seule : sans elle, n'importe quel ServiceAccount du cluster annoté avec l'ARN de ce rôle pourrait l'assumer. Annotez le ServiceAccount avec l'ARN du rôle, attachez une permissions policy scopée (les actions S3 et les ARN de ressources dont le workload a besoin, jamais s3:*), et retirez entièrement les permissions correspondantes de l'instance profile du node ; c'est l'étape que les équipes sautent, et la sauter annule toute la migration.
Réduire le rôle du node ne suffit pas. N'importe quel pod peut encore interroger le service de métadonnées de l'instance (IMDS) sur 169.254.169.254 et récupérer les identifiants qu'il reste au rôle du node. Exigez IMDSv2 et fixez la limite de sauts à 1 dans le launch template du node group. Les réponses IMDSv2 ne peuvent alors plus franchir le saut réseau supplémentaire jusqu'au namespace réseau d'un pod, et le node lui-même (kubelet, le VPC CNI qui tourne sur le réseau de l'hôte) continue de fonctionner :
resource "aws_launch_template" "eks_nodes" {
name_prefix = "eks-nodes-"
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 uniquement
http_put_response_hop_limit = 1 # les pods n'atteignent plus IMDS
}
}Les pods qui utilisent IRSA n'ont pas besoin d'IMDS pour obtenir leurs identifiants. Certains add-ons y lisent en revanche la région ou l'ID du VPC (l'AWS Load Balancer Controller, par exemple). Passez-leur ces valeurs explicitement en flags ou en values Helm avant le déploiement.
Un rôle IAM de node trop large est un multiplicateur de mouvement latéral dans un cluster multi-tenant : il signifie que le rayon d'impact de la compromission de n'importe quel pod sur ce node équivaut au rayon d'impact de la compromission du node lui-même. Vérifiez ce point spécifiquement lors de vos audits.
NetworkPolicies default-deny, puis un allow scopé
Les NetworkPolicies sont additives et scopées au namespace : en l'absence de définition, tout est ouvert. La correction consiste à poser une policy deny-all par namespace, puis à ajouter des allows explicites pour le trafic réellement légitime.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-invoice-worker-egress
namespace: payments
spec:
podSelector:
matchLabels:
app: invoice-worker
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/16 # CIDR du VPC
- 172.20.0.0/16 # CIDR des services (ClusterIP kubernetes.default)
ports:
- protocol: TCP
port: 443podSelector: {} sur la policy deny-all signifie « tous les pods de ce namespace », et c'est ce qui en fait une policy default-deny. Avec un sélecteur de labels, elle ne couvrirait que les pods qu'on a pensé à labelliser. La seconde policy autorise ensuite invoice-worker à résoudre le DNS via kube-system et à sortir en HTTPS, ce dont il a besoin pour joindre les endpoints S3 et STS avec le rôle assumé via IRSA. Tout le reste est bloqué, y compris la base de données paiements dans un autre namespace, le namespace marketing et le serveur API Kubernetes, à condition que la règle HTTPS exclue les adresses internes au cluster et au VPC.
Faites attention à la règle HTTPS. Un simple to: [] sur le port 443 autorise n'importe quelle destination sur le 443, et le serveur API Kubernetes écoute lui aussi sur le 443, à la fois derrière la ClusterIP kubernetes.default et sur les ENI de l'endpoint privé dans votre VPC. Le ipBlock avec except laisse le HTTPS public ouvert tout en excluant le CIDR du VPC et le CIDR des services (remplacez les deux par les valeurs de votre cluster, obtenues avec aws eks describe-cluster --query cluster.kubernetesNetworkConfig). Si le workload atteint STS ou S3 via des VPC endpoints de type interface, ceux-ci se trouvent dans la plage du VPC, donc ajoutez un allow ipBlock séparé pour les sous-réseaux des endpoints. Si l'endpoint API du cluster est public, restreignez aussi ses CIDR sources autorisés, car une règle basée sur les IP ne distingue pas l'endpoint API public des autres adresses AWS.
Les règles réseau ne couvrent qu'une partie du problème. Un pod qui ne parle pas à l'API Kubernetes ne devrait pas non plus porter de token pour elle :
apiVersion: v1
kind: ServiceAccount
metadata:
name: invoice-worker
namespace: payments
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/invoice-worker
automountServiceAccountToken: falseIRSA fonctionne toujours avec ce réglage. Le webhook pod identity d'EKS injecte son propre token projeté, avec l'audience sts.amazonaws.com, et ce token n'est valable que pour STS.
Aucune de ces policies n'a d'effet tant que rien n'applique les NetworkPolicies. L'AWS VPC CNI sait le faire nativement depuis la v1.14 (gardez l'add-on sur une version à jour), via un agent de network policy qui tourne à côté d'aws-node, mais la fonctionnalité est désactivée par défaut. Activez-la dans la configuration de l'add-on managé. Calico n'est pas nécessaire :
resource "aws_eks_addon" "vpc_cni" {
cluster_name = aws_eks_cluster.main.name
addon_name = "vpc-cni"
configuration_values = jsonencode({
enableNetworkPolicy = "true"
})
}L'équivalent en CLI est aws eks update-addon --cluster-name <cluster> --addon-name vpc-cni --configuration-values '{"enableNetworkPolicy":"true"}'. Une fois la configuration appliquée, les pods aws-node affichent deux conteneurs au lieu d'un. Avant cela, le serveur API accepte toutes les policies ci-dessus sans que rien ne les applique.
OPA Gatekeeper : bloquer les manifestes défaillants dès l'admission
Gatekeeper est le framework de contraintes d'OPA branché comme webhook d'admission de validation : un ConstraintTemplate définit la logique Rego, une Constraint l'applique avec des paramètres précis.
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredresources
spec:
crd:
spec:
names:
kind: K8sRequiredResources
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredresources
# Find the pod spec, whatever kind of object wraps it
pod_spec = spec {
input.review.object.kind == "Pod"
spec := input.review.object.spec
}
pod_spec = spec {
{"Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job"}[input.review.object.kind]
spec := input.review.object.spec.template.spec
}
pod_spec = spec {
input.review.object.kind == "CronJob"
spec := input.review.object.spec.jobTemplate.spec.template.spec
}
containers[c] {
c := pod_spec.containers[_]
}
containers[c] {
c := pod_spec.initContainers[_]
}
violation[{"msg": msg}] {
c := containers[_]
not c.resources.requests.cpu
msg := sprintf("%v: container <%v> is missing a cpu request", [input.review.object.kind, c.name])
}
violation[{"msg": msg}] {
c := containers[_]
not c.resources.requests.memory
msg := sprintf("%v: container <%v> is missing a memory request", [input.review.object.kind, c.name])
}
violation[{"msg": msg}] {
c := containers[_]
not c.resources.limits.memory
msg := sprintf("%v: container <%v> is missing a memory limit", [input.review.object.kind, c.name])
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredResources
metadata:
name: require-container-resources
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
- apiGroups: ["batch"]
kinds: ["Job", "CronJob"]
excludedNamespaces: ["kube-system", "gatekeeper-system"]Deux détails rendent cette contrainte utile. D'abord, elle cible les workloads en plus des Pod. Une contrainte sur les seuls Pod bloque bien les pods, mais le Deployment lui-même est accepté. kubectl apply réussit, le contrôleur de ReplicaSet n'arrive pas à créer les pods, et la seule trace est un événement FailedCreate que personne ne surveille. En ciblant les Deployments, StatefulSets, DaemonSets, Jobs et CronJobs, le manifeste est rejeté dès le kubectl apply, et la personne qui déploie voit l'erreur. Ensuite, elle vérifie aussi les initContainers, puisqu'ils sont planifiés sur les mêmes ressources du node.
Elle exige des requests CPU et mémoire sur chaque conteneur ainsi qu'une limite mémoire, et laisse volontairement de côté la limite CPU. Ce sont les requests CPU qui vous protègent d'un noisy neighbor. Le scheduler les réserve, et en cas de contention le noyau répartit le CPU au prorata de ces requests, si bien qu'un pod très actif ne peut pas affamer les autres pods du node. Une limite CPU ajoute du throttling même quand le node a des cœurs inactifs, ce qui dégrade généralement la latence plus qu'il n'aide. La mémoire, elle, ne se throttle pas. Sans limite, un seul conteneur qui fuit met tout le node en pression mémoire et déclenche des évictions.
Le même modèle de template s'applique pour interdire les tags :latest, exiger readOnlyRootFilesystem, ou bloquer hostNetwork: true en dehors d'une courte allowlist.
Déployez toujours une nouvelle contrainte en dryrun pendant au moins un cycle de déploiement complet. Elle journalise les violations sans rien bloquer, de quoi découvrir que quatorze Deployments existants échoueraient à la policy avant qu'un passage en deny ne fasse tomber une mise en production un vendredi. Corrigez les cas en violation, puis basculez.
Pod Security Standards comme socle sous-jacent
Les contraintes Gatekeeper sont sur mesure et peuvent dériver de l'intention initiale, mais les Pod Security Standards (PSS), appliqués via le contrôleur d'admission Pod Security natif, offrent un socle qui ne dépend pas d'avoir écrit le bon Rego. Labelliser un namespace avec le profil restricted bloque les conteneurs privilégiés, le partage des namespaces hôtes et les capabilities non par défaut, sans écrire une seule policy custom :
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restrictedJe considère PSS restricted comme le socle minimal. Il couvre gratuitement les pièges bien connus au niveau Kubernetes. Je mets dans Gatekeeper les règles propres à l'organisation, comme les requests et limites mémoire obligatoires, la provenance des images ou les conventions de labels.
Le chemin durci, de bout en bout
Chaque étape restreint un peu plus ce que la précédente a laissé passer, et retirer n'importe laquelle des trois couches laisse les deux autres continuer d'avoir un rôle réel à jouer. Mais un cluster qui les combine toutes les trois est une cible fondamentalement différente du cluster par défaut, en allow-all et rôle de node universel, sur lequel démarrent la plupart des équipes.
IRSA, les NetworkPolicies et les contraintes Gatekeeper ne sont pas difficiles à mettre en place isolément : les clusters partent en production sans eux parce qu'une échéance ne se soucie pas du rayon d'impact, et que rien ne force la conversation avant un audit ou un incident. Les rétrofitter coûte plus cher que de les construire dès le premier namespace, mais le travail reste identique, que vous l'écriviez au jour un ou après qu'un incident vous y ait forcé.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez
Tutoriels similaires
FinOps Kubernetes sur EKS avec Karpenter
Remplacez les node groups statiques d'EKS par le provisioning just-in-time de Karpenter pour réduire le compute.
Sécuriser les secrets CI/CD avec HashiCorp Vault
Sortir les secrets des variables CI/CD et fichiers .env pour les stocker dans HashiCorp Vault, avec des identifiants à courte durée de vie.
Site statique sécurisé sur AWS S3 & CloudFront
Hébergez un site statique sur S3 derrière CloudFront avec Origin Access Control, TLS ACM et invalidation de cache.