Salta al contenuto principale

02 - Architettura di una IDP Moderna: Componenti e Integrazione

Una IDP separa chi decide, chi esegue e chi registra. Esempi eseguiti: un piano di controllo, manifest Kubernetes validati, eventi CloudEvents.

Cosa ne pensi di questo articolo?

02 - Architettura di una IDP Moderna: Componenti e Integrazione
Platform EngineeringFoto:Brett SaylesPexels

In Breve

Una IDP separa chi decide, chi esegue e chi registra. Esempi eseguiti: un piano di controllo, manifest Kubernetes validati, eventi CloudEvents.

Tre piani, una responsabilità ciascuno

Una piattaforma interna regge se ogni richiesta segue lo stesso percorso: qualcuno la decide, qualcuno la esegue, qualcuno ne registra l'esito. In questo articolo chiamo i tre ruoli piano di controllo, piano di esecuzione e livello dei dati. Attenzione al nome: è una convenzione di lavoro, non uno standard, e «control plane» ha un significato preciso dentro Kubernetes. Qui lo uso per analogia, per ragionare su chi può modificare che cosa.

La regola che tiene insieme il disegno è che solo il piano di controllo riceve richieste dagli sviluppatori, e solo lui scrive lo stato desiderato verso il piano di esecuzione. Nessuno apre un terminale su un cluster per «sistemare al volo»: se serve un ambiente, passa da una richiesta, da una policy e da un manifest generato. Il resto dell'articolo mostra come si traduce in pratica, con esempi eseguiti: un piccolo piano di controllo che produce manifest Kubernetes, un validatore dei manifest e un evento CloudEvents con il suo instradamento.

Il piano di controllo: decidere prima di eseguire

Il piano di controllo contiene le parti con cui gli sviluppatori interagiscono e quelle che decidono se una richiesta è ammessa:

  • Portale e API: il punto d'ingresso, con autenticazione e limiti di frequenza; il portale (di solito Backstage, di cui parla l'articolo 4) è una delle interfacce possibili, accanto a riga di comando e API dirette.
  • Motore dei flussi: coordina i passi di una richiesta (validare, controllare le policy, creare le risorse, verificare) e ne ricorda lo stato; Temporal e Argo Workflows sono due scelte note.
  • Motore delle policy: decide se la richiesta rispetta le regole dell'organizzazione, per esempio con OPA o Kyverno.
  • Catalogo dei servizi: sa che cosa esiste e chi ne è proprietario, tema dell'articolo 6.

Le quattro voci non hanno lo stesso peso nel primo anno. Il catalogo e le policy sono ciò che rende la piattaforma governabile, mentre il motore dei flussi si giustifica solo quando una richiesta ha più passi che possono fallire a metà e vanno ripresi: con un solo passo, un job di CI basta. Per le policy, i due strumenti citati hanno percorsi diversi: OPA è un progetto CNCF graduated dal 2021-01-29 e Kyverno dal 2026-03-16 (pagine dei progetti, consultate il 2026-11-13). Quale usare dipende da dove la regola deve valere: lo approfondisce la serie sul DevSecOps nell'articolo su policy as code.

Per rendere concreto il confine fra decidere ed eseguire, lo script seguente è un piano di controllo in miniatura. Riceve una richiesta di ambiente in JSON, applica tre regole (nome del team, ambiente noto, tetto di CPU) e, se la richiesta passa, produce lo stato desiderato come manifest Kubernetes; altrimenti la rifiuta e non produce nulla. Le tre regole e la soglia di 16 core sono di esempio, scelte per il caso.

"""Piano di controllo in miniatura: valida una richiesta e produce lo stato desiderato."""
import json
import re
import sys

import yaml

AMBIENTI = {"dev", "staging", "production"}
NOME_VALIDO = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
CPU_MASSIMA = 16  # core richiedibili da un singolo namespace: soglia di esempio


def controlla(r):
    """Restituisce l'elenco delle violazioni di policy: vuoto = richiesta ammessa."""
    violazioni = []
    if not NOME_VALIDO.match(r["team"]):
        violazioni.append(f"nome team non valido: {r['team']!r}")
    if r["ambiente"] not in AMBIENTI:
        violazioni.append(f"ambiente sconosciuto: {r['ambiente']!r}")
    if r["cpu"] > CPU_MASSIMA:
        violazioni.append(f"cpu richiesta {r['cpu']} oltre il massimo {CPU_MASSIMA}")
    return violazioni


def stato_desiderato(r):
    ns = f"team-{r['team']}"
    etichette = {"platform.example/team": r["team"], "platform.example/env": r["ambiente"]}
    return [
        {"apiVersion": "v1", "kind": "Namespace",
         "metadata": {"name": ns, "labels": etichette}},
        {"apiVersion": "v1", "kind": "ResourceQuota",
         "metadata": {"name": "quota", "namespace": ns},
         "spec": {"hard": {"requests.cpu": str(r["cpu"] // 2), "limits.cpu": str(r["cpu"]),
                           "requests.memory": f"{r['cpu'] * 1}Gi", "limits.memory": f"{r['cpu'] * 2}Gi",
                           "pods": "50"}}},
        {"apiVersion": "v1", "kind": "LimitRange",
         "metadata": {"name": "default-contenitori", "namespace": ns},
         "spec": {"limits": [{"type": "Container",
                              "defaultRequest": {"cpu": "100m", "memory": "128Mi"},
                              "default": {"cpu": "500m", "memory": "512Mi"}}]}},
        {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
         "metadata": {"name": "default-deny-ingress", "namespace": ns},
         "spec": {"podSelector": {}, "policyTypes": ["Ingress"]}},
        {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
         "metadata": {"name": "allow-same-team", "namespace": ns},
         "spec": {"podSelector": {}, "policyTypes": ["Ingress"],
                  "ingress": [{"from": [{"namespaceSelector": {
                      "matchLabels": {"platform.example/team": r["team"]}}}]}]}},
    ]


richiesta = json.loads(sys.argv[1])
problemi = controlla(richiesta)
if problemi:
    print("RIFIUTATA:", "; ".join(problemi), file=sys.stderr)
    sys.exit(1)
print(yaml.safe_dump_all(stato_desiderato(richiesta), sort_keys=False), end="")

Due scelte del codice contano più del resto. La funzione controlla restituisce l'elenco di tutte le violazioni invece di fermarsi alla prima, perché chi riceve un rifiuto deve poterlo correggere in un colpo solo. E stato_desiderato non applica nulla: restituisce dati, che un altro componente (il piano di esecuzione) porterà sul cluster. Separarle permette di testare le regole senza un cluster e di rigenerare i manifest quando cambia il template.

Lo script stampa il YAML su standard output e, in caso di rifiuto, esce con codice 1 e scrive l'errore su standard error. Con la richiesta valida (team checkout, ambiente production, 16 core) ho ottenuto i cinque manifest riportati più sotto; con {"team":"Checkout_Team","ambiente":"prod","cpu":64} l'uscita è stata:

# python3 richiesta_ambiente.py '{"team":"Checkout_Team","ambiente":"prod","cpu":64}'
# RIFIUTATA: nome team non valido: 'Checkout_Team'; ambiente sconosciuto: 'prod'; cpu richiesta 64 oltre il massimo 16
# (codice di uscita: 1)

Il piano di esecuzione: dove le cose accadono davvero

Il piano di esecuzione sono i cluster Kubernetes con i loro strumenti di build e di rilascio. Un riconciliatore GitOps come Argo CD o Flux legge lo stato desiderato (qui, i manifest generati dallo script) e lo porta sul cluster, correggendo le differenze nel tempo. Il vantaggio architetturale è che il piano di controllo può fermarsi senza fermare i servizi: i cluster continuano a eseguire l'ultimo stato noto, e si bloccano solo le nuove richieste. È una proprietà del disegno a riconciliazione, non una misura che ho fatto.

Ecco i manifest prodotti dallo script per la richiesta valida. Contengono un namespace con etichette, una quota di risorse, un limite predefinito per i contenitori e due policy di rete. Li ho validati con kubeconform 0.8.0 contro gli schemi di Kubernetes 1.37.0 in modalità -strict, che rifiuta anche i campi sconosciuti.

apiVersion: v1
kind: Namespace
metadata:
  name: team-checkout
  labels:
    platform.example/team: checkout
    platform.example/env: production
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: quota
  namespace: team-checkout
spec:
  hard:
    requests.cpu: '8'
    limits.cpu: '16'
    requests.memory: 16Gi
    limits.memory: 32Gi
    pods: '50'
---
apiVersion: v1
kind: LimitRange
metadata:
  name: default-contenitori
  namespace: team-checkout
spec:
  limits:
  - type: Container
    defaultRequest:
      cpu: 100m
      memory: 128Mi
    default:
      cpu: 500m
      memory: 512Mi
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: team-checkout
spec:
  podSelector: {}
  policyTypes:
  - Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-same-team
  namespace: team-checkout
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          platform.example/team: checkout

# Output (kubeconform -strict -summary -kubernetes-version 1.37.0):
# Summary: 5 resources found in 1 file - Valid: 5, Invalid: 0, Errors: 0, Skipped: 0

Quattro dettagli di questi manifest vengono dalla documentazione di Kubernetes e sono quelli su cui si inciampa. Primo: una ResourceQuota su CPU e memoria fa rifiutare i pod che non dichiarano richieste e limiti, e per questo accanto compare una LimitRange con valori predefiniti. Secondo: una NetworkPolicy ha effetto solo se la rete del cluster (il plugin CNI) la applica, altrimenti viene accettata e ignorata. Terzo: le policy di rete non sono in conflitto ma si sommano, quindi default-deny-ingress (seleziona tutti i pod e non ammette nulla) insieme ad allow-same-team (ammette il traffico dai namespace con l'etichetta del team) producono «solo il team entra». Quarto: l'ordine delle policy non conta.

Per sapere se il validatore vede davvero gli errori, ho rovinato il file sostituendo ingress: con ingres: in una policy. L'uscita:

# kubeconform -strict -summary -kubernetes-version 1.37.0 sbagliato.yaml
# sbagliato.yaml - NetworkPolicy allow-same-team is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.37.0-standalone-strict/networkpolicy-networking-v1.json#' - at '/spec': additional properties 'ingres' not allowed
# Summary: 5 resources found in 1 file - Valid: 4, Invalid: 1, Errors: 0, Skipped: 0
# (codice di uscita: 1)

Il controllo di schema trova i refusi e i campi sbagliati, non gli errori di intenzione: un tetto di CPU troppo alto o una policy di rete troppo permissiva passano la validazione. Per quelle serve il motore delle policy, che è l'altra metà del piano di controllo. Nota anche che kubeconform scarica gli schemi da un repository terzo (yannh/kubernetes-json-schema): in una pipeline chiusa si usa una copia locale, con l'opzione -schema-location.

Il livello dei dati: che cosa è successo

Il livello dei dati raccoglie ciò che serve a spiegare lo stato della piattaforma: metriche, log, tracce e un registro degli eventi (chi ha chiesto che cosa, con quale esito). Per le prime tre la serie sull'Observability è il riferimento, a partire dai fondamenti e da OpenTelemetry. Qui conta il registro degli eventi, perché è ciò che permette a più componenti di reagire senza chiamarsi fra loro.

CloudEvents è la specifica del CNCF per descrivere un evento in modo uniforme. Nella versione 1.0.2 gli attributi obbligatori sono quattro: id, source, specversion e type, tutte stringhe non vuote. Il type serve a instradare, e la specifica raccomanda (SHOULD) di prefissarlo con un nome di dominio invertito. Lo script costruisce un evento di rilascio completato, lo valida e lo instrada a chi si è iscritto con uno schema.

import hashlib
import json
from datetime import datetime
from fnmatch import fnmatch

OBBLIGATORI = ("id", "source", "specversion", "type")


def nuovo_evento(tipo, sorgente, soggetto, dati, quando):
    corpo = json.dumps([tipo, sorgente, soggetto, dati, quando], sort_keys=True)
    return {
        "specversion": "1.0",
        "id": hashlib.sha256(corpo.encode()).hexdigest()[:12],  # stesso evento, stesso id
        "source": sorgente,
        "type": tipo,
        "subject": soggetto,
        "time": quando,
        "datacontenttype": "application/json",
        "data": dati,
    }


def valida(e):
    errori = [f"manca l'attributo obbligatorio {a!r}" for a in OBBLIGATORI if not e.get(a)]
    if e.get("specversion") not in (None, "1.0"):
        errori.append("specversion deve essere '1.0'")
    if "." not in e.get("type", "."):
        errori.append("type: la specifica raccomanda un prefisso DNS inverso")
    if "time" in e:
        try:
            datetime.fromisoformat(e["time"])
        except ValueError:
            errori.append("time non è un timestamp RFC 3339")
    return errori


def destinatari(e, iscrizioni):
    return sorted(nome for nome, schema in iscrizioni.items() if fnmatch(e["type"], schema))


ok = nuovo_evento(
    "io.example.platform.deployment.completed", "/ci/checkout", "checkout@v2.3.1",
    {"environment": "production", "durata_s": 312}, "2026-11-13T09:30:00Z")
print(json.dumps(ok, indent=2, sort_keys=True))
print("valido:", valida(ok) == [])

rotto = {"type": "deployment-completed", "time": "ieri"}
print("evento rotto:", valida(rotto))

iscrizioni = {
    "audit": "io.example.platform.*",
    "metriche-dora": "io.example.platform.deployment.*",
    "allarmi": "io.example.platform.incident.*",
}
print("destinatari:", destinatari(ok, iscrizioni))

# Output (python3 evento.py):
# {
#   "data": {
#     "durata_s": 312,
#     "environment": "production"
#   },
#   "datacontenttype": "application/json",
#   "id": "790ece950cf4",
#   "source": "/ci/checkout",
#   "specversion": "1.0",
#   "subject": "checkout@v2.3.1",
#   "time": "2026-11-13T09:30:00Z",
#   "type": "io.example.platform.deployment.completed"
# }
# valido: True
# evento rotto: ["manca l'attributo obbligatorio 'id'", "manca l'attributo obbligatorio 'source'", "manca l'attributo obbligatorio 'specversion'", 'type: la specifica raccomanda un prefisso DNS inverso', 'time non è un timestamp RFC 3339']
# destinatari: ['audit', 'metriche-dora']

L'id è calcolato dal contenuto, quindi lo stesso evento ha sempre lo stesso identificatore e un consumatore può scartare i duplicati: la specifica chiede che la coppia source più id sia unica per ogni evento distinto, e sulle code con consegna «almeno una volta» i duplicati arrivano davvero. Il secondo evento, volutamente rotto, mostra l'elenco completo degli errori; il terzo risultato mostra che l'evento di rilascio arriva ad audit e a metriche-dora ma non ad allarmi. Il valore di time con la «Z» finale richiede Python 3.11 o successivo per essere letto da fromisoformat.

Il broker che trasporta gli eventi (Kafka, NATS o altro) non compare nel listato, perché la scelta dipende dal volume e da chi lo gestisce, e perché lo script fa il solo abbinamento fra tipo e iscrizione. Se i consumatori sono pochi e il volume è basso, un webhook diretto basta: il broker si aggiunge quando un consumatore lento non deve rallentare chi produce.

Service mesh: quando serve davvero

Un service mesh aggiunge a ogni comunicazione fra servizi cifratura, identità e metriche senza modificare il codice. La documentazione di Linkerd descrive l'mTLS automatico fra i pod inseriti nella mesh, e quella di Istio tratta l'autenticazione reciproca come parte del modello di sicurezza. Per questo sono i due nomi che si incontrano per primi.

Un fatto da controllare prima di sceglierli riguarda la licenza e la distribuzione. Istio è un progetto CNCF graduated dal 2023-07-12 (pagina del progetto, consultata il 2026-11-13). Linkerd resta open source, ma la sua pagina delle release dichiara che dal febbraio 2024 il progetto non pubblica più artefatti di release stabili: i rilasci stabili sono responsabilità delle distribuzioni dei fornitori (per esempio Buoyant Enterprise for Linkerd), mentre il progetto pubblica le release edge. Chi sceglie Linkerd per «una cosa leggera da gestire» deve quindi decidere se usare le edge, pagare una distribuzione o compilare da sé. Il costo che un mesh aggiunge è sempre uno: un componente in più su ogni percorso di rete, da aggiornare e da capire quando qualcosa non risponde. Se l'unico obiettivo è cifrare il traffico fra pochi servizi, valuta prima se bastano i certificati gestiti dal tuo ingress e dalle librerie dei servizi.

Scalare senza moltiplicare i piani di controllo

Con più cluster (per regione, per ambiente, per carichi diversi) il piano di controllo resta uno solo e i piani di esecuzione crescono. Tre scelte pesano più delle altre:

  • Isolamento fra team: namespace, quote e policy di rete, come nei manifest sopra, prima di pensare a cluster separati per team.
  • Un solo catalogo: l'elenco dei cluster e di ciò che ospitano sta in un posto, non nella memoria di chi li ha creati.
  • Stato desiderato in Git: se il piano di controllo si ferma, i cluster hanno comunque l'ultimo stato verificato.

La vera domanda di scala non è «quanti cluster» ma «quante cose devo cambiare quando ne aggiungo uno»: ogni cluster nuovo deve nascere da un modello (lo stesso che ha prodotto i manifest di sopra), altrimenti la frammentazione dell'articolo 1 si riproduce un livello più in basso. Sul multi-cloud come scelta strategica torna l'articolo 8.

Che cosa costruire per primo

Parti dal piano di controllo più piccolo che dia un risultato: una richiesta, una policy, un manifest, un solo cluster. Aggiungi il motore dei flussi quando una richiesta ha più passi che possono fallire a metà, il broker quando i consumatori diventano più di due, il mesh quando hai un requisito di sicurezza di rete che i certificati dell'ingress non coprono. Questa è un'indicazione mia, non un dato di ricerca, e vale per team piccoli: in organizzazioni grandi alcune di queste parti esistono già per altri motivi.

Che cosa sai fare adesso

Sai distinguere chi decide, chi esegue e chi registra, e perché separarli rende testabile la parte che decide. Sai generare e validare manifest Kubernetes con una policy davanti, e riconosci le quattro trappole di quota, rete, somma delle policy e ordine. Sai costruire un evento CloudEvents valido e instradarlo, e sai quali domande porre a un service mesh prima di adottarlo. Il prossimo articolo scende sul percorso dello sviluppatore: i golden path.

Domande frequenti

Quali sono i tre livelli di una Internal Developer Platform?

In questa serie: il piano di controllo, che riceve le richieste e decide con le policy; il piano di esecuzione, che porta sui cluster lo stato desiderato; il livello dei dati, che registra metriche, log, tracce ed eventi. È una convenzione di lavoro, non uno standard.

Che cosa fa il piano di controllo?

Riceve le richieste degli sviluppatori, controlla le policy dell'organizzazione e produce lo stato desiderato, per esempio i manifest Kubernetes di un ambiente. Non applica nulla da sé: lo fa il piano di esecuzione.

Perché separare il piano di controllo da quello di esecuzione?

Perché le regole si possono testare senza un cluster e perché, con un riconciliatore GitOps, i cluster continuano a eseguire l'ultimo stato noto anche se il piano di controllo si ferma: si bloccano solo le nuove richieste.

Serve un service mesh in una IDP?

Non all'inizio. Serve quando hai un requisito di sicurezza o di traffico fra servizi che i certificati dell'ingress non coprono. Dal febbraio 2024 il progetto Linkerd non pubblica più release stabili: vanno scelte le edge o una distribuzione di un fornitore.

Nella stessa serie trovi anche l'infrastructure as code con Terraform e Pulumi e il confronto fra AI on-prem e cloud.

Fonti

Gli script sono stati eseguiti due volte con Python 3.14 e PyYAML 6, con uscita identica; la validazione con kubeconform 0.8.0 contro gli schemi 1.37.0. Gli esempi non descrivono nessuna infrastruttura reale.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti