Configura un cluster EKS privato senza endpoint pubblico. Setup VPN con OpenVPN, networking sicuro e stack observability per ambienti enterprise.
Securing Your EKS Cluster: Guida Pratica al Private Networking con OpenVPN Access
La maggior parte dei tutorial su EKS ti mostra il percorso felice: cluster con endpoint pubblico, kubectl che funziona magicamente dal tuo laptop, tutto rose e fiori. Poi arriva il security audit e scopri che il tuo API server Kubernetes è esposto su Internet, raggiungibile da chiunque conosca l’URL.
Ho visto troppi team passare settimane a fare “hardening” di cluster che erano fondamentalmente aperti al mondo. La verità è che un cluster EKS sicuro richiede decisioni architetturali prese prima del provisioning, non dopo. Questo articolo ti guida attraverso la configurazione completa di un cluster EKS privato — zero esposizione pubblica dell’API server — con accesso VPN per gli sviluppatori e uno stack di observability che funziona anche in ambienti air-gapped.
Non troverai workaround o mezze misure. Alla fine avrai un’infrastruttura che passa audit di sicurezza enterprise senza compromettere la produttività del team.
Prerequisiti
Prima di iniziare, assicurati di avere:
- AWS CLI v2 configurata con credenziali che hanno permessi per creare VPC, EKS, EC2, IAM
- eksctl versione 0.165+ (alcune flag sono cambiate nelle versioni recenti)
- kubectl compatibile con la versione EKS che deployerai
- Helm 3.x per il deployment dello stack di monitoring
- Terraform 1.5+ (opzionale ma consigliato per IaC)
- Un dominio su Route53 per i certificati OpenVPN (può essere anche un sottodominio)
- Budget AWS: questa configurazione costa circa $300-400/mese nella region us-east-1
💡 Se non hai mai lavorato con VPC peering o Transit Gateway, dedica 30 minuti a rivedere la documentazione AWS. Alcuni concetti li darò per assodati.
Architettura e Concetti Chiave
L’architettura che implementeremo separa nettamente tre zone di rete:
flowchart TD
DEV[Developer Laptop] -->|VPN Tunnel UDP 1194| VPN
ATK[Potential Attacker] -->|✗ BLOCKED| BLOCK[ ]
style BLOCK fill:none,stroke:none
subgraph AWS["AWS Region — VPC 10.0.0.0/16"]
subgraph PUB["Public Subnets"]
NAT[NAT Gateway]
VPN[OpenVPN Access Server]
end
subgraph PRIV["Private Subnets"]
subgraph EKS["EKS Cluster"]
API[API Server Endpoint]
WRK[Worker Nodes]
OBS[Prometheus / Grafana]
end
end
NAT -->|egress only| INTERNET((Internet))
VPN -->|kubectl 443| API
WRK --> NAT
end
Decisioni architetturali chiave:
API Server solo privato: L’endpoint EKS non avrà un indirizzo IP pubblico. Punto. Nessun security group al mondo rende sicuro un endpoint pubblico quanto la sua assenza.
OpenVPN in subnet pubblica: Unico punto di ingresso, con autenticazione a due fattori e logging completo. Ogni connessione è tracciata.
NAT Gateway per egress: I worker nodes possono raggiungere Internet (per pull di immagini, ad esempio) ma non sono raggiungibili dall’esterno.
Observability interna: Prometheus e Grafana girano dentro il cluster, accessibili solo via VPN. Niente SaaS esterni che richiedono esposizione di metriche.
Implementazione Passo-Passo
Sezione 1: VPC e Networking Privato
Iniziamo dalle fondamenta. Una VPC mal configurata compromette tutto il resto.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
| # vpc-config.tf - Configurazione Terraform per la VPC
# Questo file definisce l'intera topologia di rete
locals {
# Usiamo /16 per avere spazio per crescere
# Mai sottodimensionare il CIDR - aggiungere range dopo è doloroso
vpc_cidr = "10.0.0.0/16"
# Tre AZ per alta disponibilità
azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
# Subnet pubbliche: solo per NAT Gateway e VPN
# Teniamo il range piccolo (/24 = 251 IP utilizzabili)
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
# Subnet private: qui vivranno i worker nodes
# Range più ampio (/19 = 8190 IP) per supportare scaling
private_subnets = ["10.0.32.0/19", "10.0.64.0/19", "10.0.96.0/19"]
# Subnet isolate: per database e risorse che non devono
# MAI raggiungere Internet, neanche via NAT
isolated_subnets = ["10.0.128.0/24", "10.0.129.0/24", "10.0.130.0/24"]
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "eks-private-vpc"
cidr = local.vpc_cidr
azs = local.azs
public_subnets = local.public_subnets
private_subnets = local.private_subnets
database_subnets = local.isolated_subnets
# Un NAT Gateway per AZ garantisce che il fallimento
# di una AZ non blocchi l'egress delle altre
enable_nat_gateway = true
single_nat_gateway = false # MAI in produzione
one_nat_gateway_per_az = true
# DNS resolution necessaria per endpoint privati EKS
enable_dns_hostnames = true
enable_dns_support = true
# Tag richiesti da EKS per auto-discovery delle subnet
public_subnet_tags = {
"kubernetes.io/role/elb" = 1
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1
# Questo tag è CRITICO - senza, EKS non trova le subnet
"kubernetes.io/cluster/eks-private" = "shared"
}
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
# VPC Endpoints per evitare traffico via Internet
# Questo riduce latenza e costi di NAT Gateway
resource "aws_vpc_endpoint" "s3" {
vpc_id = module.vpc.vpc_id
service_name = "com.amazonaws.eu-west-1.s3"
# Gateway endpoint per S3 - gratuito
vpc_endpoint_type = "Gateway"
route_table_ids = module.vpc.private_route_table_ids
}
resource "aws_vpc_endpoint" "ecr_api" {
vpc_id = module.vpc.vpc_id
service_name = "com.amazonaws.eu-west-1.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = module.vpc.private_subnets
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "ecr_dkr" {
vpc_id = module.vpc.vpc_id
service_name = "com.amazonaws.eu-west-1.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = module.vpc.private_subnets
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# Security group per VPC endpoints
resource "aws_security_group" "vpc_endpoints" {
name_prefix = "vpc-endpoints-"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
# Solo traffico dalla VPC
cidr_blocks = [local.vpc_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
|
⚠️ Errore comune: dimenticare i VPC endpoints per ECR. Senza di essi, ogni pull di immagine passa dal NAT Gateway, che costa $0.045/GB. Con cluster attivi, questo può aggiungere centinaia di dollari al mese.
Ora creiamo il cluster EKS con endpoint privato:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
| # eks-cluster.yaml - eksctl configuration
# Questo file configura un cluster completamente privato
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: eks-private
region: eu-west-1
version: "1.28"
# VPC esistente - non lasciare che eksctl ne crei una nuova
vpc:
id: "vpc-xxxxxxxxx" # Sostituisci con l'ID della VPC creata sopra
subnets:
private:
eu-west-1a:
id: "subnet-private-1a"
eu-west-1b:
id: "subnet-private-1b"
eu-west-1c:
id: "subnet-private-1c"
# QUESTA È LA CONFIGURAZIONE CRITICA
# publicAccessCIDRs vuoto + privateAccess true = endpoint solo privato
clusterEndpoints:
publicAccess: false # Nessun accesso pubblico all'API server
privateAccess: true # Solo accesso dalla VPC
# IAM OIDC provider per service account IAM roles
iam:
withOIDC: true
# Node groups in subnet private
managedNodeGroups:
- name: workers-general
instanceType: m5.large
desiredCapacity: 3
minSize: 2
maxSize: 10
# Nodes solo in subnet private
subnets:
- subnet-private-1a
- subnet-private-1b
- subnet-private-1c
privateNetworking: true # Nessun IP pubblico sui nodes
# Labels per scheduling
labels:
workload-type: general
# Taints se vuoi dedicare node groups
# taints:
# - key: dedicated
# value: monitoring
# effect: NoSchedule
iam:
attachPolicyARNs:
- arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy
- arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy
- arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # Per debug via SSM
# SSH disabilitato - usa SSM Session Manager
ssh:
allow: false
# Addons gestiti da AWS
addons:
- name: vpc-cni
version: latest
attachPolicyARNs:
- arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy
- name: coredns
version: latest
- name: kube-proxy
version: latest
# CloudWatch logging per audit
cloudWatch:
clusterLogging:
enableTypes:
- api
- audit
- authenticator
- controllerManager
- scheduler
|
Esegui il provisioning:
1
2
3
4
5
6
7
| # Crea il cluster (richiede 15-20 minuti)
eksctl create cluster -f eks-cluster.yaml
# Verifica che l'endpoint sia effettivamente privato
aws eks describe-cluster --name eks-private \
--query 'cluster.resourcesVpcConfig.endpointPublicAccess'
# Deve restituire: false
|
📝 Nota: Dopo questo punto, kubectl dal tuo laptop non funzionerà. È esattamente quello che vogliamo. Configureremo l’accesso VPN nella prossima sezione.
Sezione 2: OpenVPN Access Server come Gateway Sicuro
OpenVPN Access Server offre un buon equilibrio tra sicurezza e usabilità. L’alternativa sarebbe AWS Client VPN, ma costa di più e ha meno flessibilità nella configurazione.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
| # openvpn-instance.tf - Deploy OpenVPN Access Server
# AMI ufficiale OpenVPN Access Server (BYOL)
data "aws_ami" "openvpn" {
most_recent = true
owners = ["679593333241"] # OpenVPN Inc.
filter {
name = "name"
values = ["OpenVPN Access Server*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Security group per OpenVPN
resource "aws_security_group" "openvpn" {
name_prefix = "openvpn-"
vpc_id = module.vpc.vpc_id
# Porta admin HTTPS - limitata a IP noti durante setup
ingress {
from_port = 943
to_port = 943
protocol = "tcp"
cidr_blocks = ["YOUR_OFFICE_IP/32"] # Cambia con il tuo IP
description = "Admin interface"
}
# Porta client HTTPS
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Sviluppatori da ovunque
description = "Client web interface"
}
# OpenVPN UDP - preferibile per performance
ingress {
from_port = 1194
to_port = 1194
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
description = "OpenVPN UDP tunnel"
}
# Egress verso la VPC per raggiungere EKS
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [local.vpc_cidr]
description = "Access to VPC resources"
}
# Egress verso Internet per updates
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS outbound"
}
tags = {
Name = "openvpn-sg"
}
}
# Elastic IP per indirizzo stabile
resource "aws_eip" "openvpn" {
domain = "vpc"
tags = {
Name = "openvpn-eip"
}
}
# Instance OpenVPN
resource "aws_instance" "openvpn" {
ami = data.aws_ami.openvpn.id
instance_type = "t3.small" # Sufficiente per ~50 connessioni simultanee
subnet_id = module.vpc.public_subnets[0]
vpc_security_group_ids = [aws_security_group.openvpn.id]
associate_public_ip_address = true
# Key pair per accesso SSH iniziale (poi disabilitare)
key_name = "your-key-pair"
# Script di inizializzazione
user_data = <<-EOF
#!/bin/bash
# Attendi che OpenVPN AS sia pronto
sleep 60
# Configura admin password iniziale
echo "openvpn:${random_password.openvpn_admin.result}" | chpasswd
# Abilita routing verso subnet private
/usr/local/openvpn_as/scripts/sacli --key "vpn.server.routing.private_network.0" \
--value "10.0.0.0/16" ConfigPut
# Riavvia per applicare
/usr/local/openvpn_as/scripts/sacli start
EOF
root_block_device {
volume_size = 30
volume_type = "gp3"
encrypted = true
}
tags = {
Name = "openvpn-access-server"
}
lifecycle {
ignore_changes = [ami] # Non ricreare per nuove AMI
}
}
resource "aws_eip_association" "openvpn" {
instance_id = aws_instance.openvpn.id
allocation_id = aws_eip.openvpn.id
}
# Password admin generata
resource "random_password" "openvpn_admin" {
length = 16
special = false
}
# Output per setup iniziale
output "openvpn_admin_url" {
value = "https://${aws_eip.openvpn.public_ip}:943/admin"
}
output "openvpn_client_url" {
value = "https://${aws_eip.openvpn.public_ip}/"
}
output "openvpn_admin_password" {
value = random_password.openvpn_admin.result
sensitive = true
}
|
Configurazione post-deploy di OpenVPN:
Accedi all’interfaccia admin e configura:
Network Settings → VPN Server:
- Protocol: UDP (migliore performance)
- Port: 1194
- Routing: “Yes, using NAT” per le subnet private
VPN Settings:
# Split tunnel - instrada solo traffico VPC via VPN
# Questo evita di rallentare la navigazione normale degli sviluppatori
Should client Internet traffic be routed through the VPN? → No
# Aggiungi route specifiche
Specify the private subnets: 10.0.0.0/16
# DNS push per risolvere nomi interni
DNS Server 1: 10.0.0.2 # VPC DNS resolver
Certificati - genera certificati validi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Sul server OpenVPN, genera CA e certificati
cd /usr/local/openvpn_as/scripts
# Rigenera con nome dominio corretto
./sacli --key "cs.ca_bundle" GetActiveWebCerts
./sacli --key "host.name" --value "vpn.yourdomain.com" ConfigPut
# Installa certificato Let's Encrypt (se hai un dominio)
apt install certbot
certbot certonly --standalone -d vpn.yourdomain.com
# Importa in OpenVPN AS
./sacli --key "cs.priv_key" --value_file "/etc/letsencrypt/live/vpn.yourdomain.com/privkey.pem" ConfigPut
./sacli --key "cs.cert" --value_file "/etc/letsencrypt/live/vpn.yourdomain.com/cert.pem" ConfigPut
|