Il Contesto
Ho un mini cluster K3s composto da due VM Debian su Proxmox. Le VM sono state sottoposte a hardening di base: accesso SSH solo con chiave, servizi non necessari disabilitati, firewall nftables con policy drop di default. Il cluster si trova dietro una LAN privata, raggiungibile dall’esterno tramite port forwarding configurato sul router.
Il problema concreto: sulla stessa rete locale girano diversi servizi containerizzati su host separati dal cluster. Questi servizi hanno bisogno di TLS, di un dominio pubblico e di un minimo di protezione a livello di header e rate limiting. Installarli uno per uno su ogni host non ha senso. Serve un punto di ingresso unico.
La soluzione adottata consiste nell’usare il cluster K3s come reverse proxy centralizzato. Traefik, l’ingress controller integrato in K3s, gestisce la terminazione TLS e l’applicazione dei middleware di sicurezza. I servizi esterni vengono esposti tramite Service headless e EndpointSlice che puntano all’IP LAN del container.
Quando Ha Senso Usare K3s Come Reverse Proxy
Esistono scenari in cui questo approccio risulta ragionevole, e altri in cui aggiunge complessita senza un ritorno reale.
Ha senso quando:
- Sulla rete locale esistono piu servizi che richiedono TLS e un dominio pubblico. Gestire certificati e rinnovi su ogni singolo host diventa rapidamente un problema di manutenzione.
- Si vuole applicare una policy di sicurezza uniforme (HSTS, CSP, rate limiting) senza replicare la configurazione su ogni servizio.
- Si ha già un cluster K3s attivo per altri scopi. Aggiungere un ingress per un servizio esterno costa poche righe di YAML.
- Il servizio esterno non ha un reverse proxy proprio o ne ha uno limitato.
Non ha senso quando:
- Si ha un singolo servizio da esporre. Un reverse proxy dedicato come Caddy o un semplice Nginx bastano e sono piu semplici da mantenere.
- Il servizio è già dietro un proprio reverse proxy con TLS. Aggiungere un secondo layer di proxy introduce latenza e complessita di debug senza benefici evidenti.
- Non si ha familiarità con Kubernetes. Il costo di apprendimento di K3s, Traefik e cert-manager non si giustifica solo per fare reverse proxy.
I Problemi Tipici della Configurazione
Configurare un reverse proxy in Kubernetes non è lineare come farlo con Nginx o Caddy. Ci sono diversi punti in cui la configurazione può rompersi senza messaggi di errore chiari.
Middleware inesistenti o mal referenziati
Traefik referenzia i middleware con il formato <namespace>-<nome>@kubernetescrd. Se il nome o il namespace non corrispondono esattamente a quanto dichiarato nel manifest del Middleware, Traefik non trova la risorsa e restituisce 404 sull’intera route, non un errore esplicito, ma una pagina vuota. Spesso il log è meno evidente e bisogna abilitare un verbosing approfondito di Traefik.
Endpoints deprecati
Kubernetes ha deprecato la risorsa v1 Endpoints a partire dalla versione 1.33. La sostituzione e EndpointSlice (discovery.k8s.io/v1). Se il cluster è aggiornato l’uso di Endpoints genera warning e potrebbe non funzionare in futuro. Il passaggio richiede l’aggiunta della label kubernetes.io/service-name sull’EndpointSlice per collegarlo al Service.
Content Security Policy troppo restrittiva
Una CSP che blocca blob: o data: negli img-src e media-src puo impedire il funzionamento di interfacce web che caricano contenuti dinamici. Un media server, ad esempio, genera URL blob: tramite MediaSource Extensions per la riproduzione video nel browser. Se la CSP non li consente, il player non funziona e non esiste nessun errore visibile nella pagina.
Compressione di stream compressi
Abilitare la compressione Traefik con compress: {} senza esclusioni significa che anche stream video e audio codificati con codec lossy come H.264 o AAC vengono processati da gzip o brotli. Il risultato è un aumento del consumo CPU senza riduzione apprezzabile della dimensione dei dati. Per i MIME types da escludere, usare sempre tipi completi (video/mp4, video/x-matroska) e mai prefissi troncati come video/, che alcune versioni di Traefik non gestiscono correttamente.
Implementazione: Esporre un Servizio Esterno
Prendiamo come esempio un servizio che gira su un container con IP 10.0.0.20 sulla porta 3000, da esporre sul dominio git.example.org.
1. Namespace
Ogni servizio ha il proprio namespace. Questo isola le risorse e permette di applicare middleware con scope per-app.
apiVersion: v1
kind: Namespace
metadata:
name: myservice
2. Service headless
Il Service dichiara le porte ma non ha selector — non punta a Pod nel cluster, ma verra collegato manualmente a un IP esterno tramite EndpointSlice.
apiVersion: v1
kind: Service
metadata:
name: myservice
namespace: myservice
spec:
ports:
- name: http
port: 3000
targetPort: 3000
3. EndpointSlice
Questa risorsa collega il Service all’IP del container esterno. La label kubernetes.io/service-name e obbligatoria per il binding.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: myservice-1
namespace: myservice
labels:
kubernetes.io/service-name: myservice
addressType: IPv4
ports:
- name: http
port: 3000
endpoints:
- addresses:
- "10.0.0.20"
4. Certificato TLS
cert-manager gestisce il rinnovo automatico tramite Let’s Encrypt con challenge DNS-01 via Cloudflare.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: myservice-tls
namespace: myservice
spec:
secretName: myservice-tls
issuerRef:
name: letsencrypt-cloudflare
kind: ClusterIssuer
dnsNames:
- git.example.org
5. Middleware di sicurezza
Un middleware che applica HSTS, CSP, XSS filter e Permissions-Policy. Questo vive nel namespace del servizio e viene referenziato come myservice-myservice-headers@kubernetescrd.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: myservice-headers
namespace: myservice
spec:
headers:
contentSecurityPolicy: >-
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
contentTypeNosniff: true
customFrameOptionsValue: "DENY"
forceSTSHeader: true
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
browserXssFilter: true
6. Ingress
L’ingress collega tutto: host, TLS, backend e catena di middleware.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myservice
namespace: myservice
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: >-
myservice-myservice-headers@kubernetescrd,
kube-system-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- git.example.org
secretName: myservice-tls
rules:
- host: git.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myservice
port:
number: 3000
Perché Questo Stack
K3s fornisce Traefik di default. cert-manager automatizza i certificati. I middleware Traefik applicano header di sicurezza. L’insieme funziona come un reverse proxy centralizzato con TLS e hardening, senza installare software aggiuntivo sui singoli host.
Il vantaggio pratico è la centralizzazione: una modifica al middleware di rate limiting si propaga a tutti i servizi che lo referenziano. Un rinnovo certificato gestito da cert-manager non richiede intervento manuale. Un nuovo servizio esterno si aggiunge con cinque o sei manifest YAML e un kubectl apply.
Il costo è la complessità intrinseca di Kubernetes. Per due o tre servizi potrebbe non valere la pena. Nel momento in cui i servizi diventano cinque, dieci, quindici, il costo iniziale di setup viene ammortizzato dall’uniformità della gestione.
Nota: Questa configurazione espone servizi tramite un tunnel Cloudflare, non tramite porte aperte direttamente sulla macchina. Il port forwarding sul router punta al tunnel, non ai servizi. Questo aggiunge un layer di protezione contro la scansione diretta degli IP.