Hai Kubernetes in produzione. I tuoi deployment girano, i pod scalano, il cluster regge. Eppure ogni volta che uno sviluppatore vuole deployare una nuova app, apre una PR sul repo delle configurazioni, aspetta che qualcuno del team platform la riveda, e nel frattempo ti scrive su Slack per chiederti "ma quando è che arriva il mio namespace?".

Suona familiare?

Oggi questo modello non è più sostenibile. Le aziende medio-grandi che hanno abbracciato DevOps negli anni scorsi si trovano ora di fronte a un problema di scala: il team platform è diventato un collo di bottiglia umano. La risposta dell'industria si chiama Platform Engineering, e il suo strumento centrale è l'Internal Developer Platform (IDP).

In questo articolo vedremo come costruire una IDP concreta con Kubernetes e Backstage: dai golden path ai template Software Templates, fino alle policy di governance con OPA e Kyverno. Niente teoria — solo roba che puoi applicare da subito.

Cos'è una Internal Developer Platform (e perché non è "solo un portale")

Prima di sporcarci le mani, chiarisco un equivoco comune: un'IDP non è un bel frontend sopra kubectl. È un sistema che astrae la complessità dell'infrastruttura e offre ai developer un'esperienza self-service con guardrail integrati.

I tre pilastri di un'IDP matura sono:

  • Self-service: il developer ottiene ciò di cui ha bisogno senza aprire ticket
  • Golden path: percorsi "opinionated" che incorporano best practice di sicurezza, osservabilità e governance
  • Ownership chiara: ogni servizio ha un owner, una documentazione e un contesto raggiungibili dallo stesso posto

Backstage — il progetto open source di Spotify, ora parte della CNCF — è diventato lo standard de facto per la UI e il catalogo dell'IDP. Kubernetes è il runtime su cui tutto gira. Messi insieme, danno vita a una piattaforma che scala con la tua organizzazione.

Architettura di riferimento

Prima di entrare nei dettagli, ecco lo stack che costruiremo:

Backstage gestisce l'esperienza developer (catalogo, template, doc). Crossplane o ArgoCD gestiscono il provisioning e il delivery. OPA/Kyverno garantiscono che nessun team bypasso le policy.

Step 1: installare Backstage e configurare il Software Catalog

Partiamo dall'installazione di Backstage. Avrai bisogno di Node.js 20+ e npx:

npx @backstage/create-app@latest
cd my-backstage-app
yarn install
yarn dev

Il Software Catalog è il cuore di Backstage. Ogni servizio, API, libreria o risorsa viene registrata tramite un file catalog-info.yaml nel repo del progetto:

# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: order-service
  description: Microservizio gestione ordini
  annotations:
    backstage.io/techdocs-ref: dir:.
    github.com/project-slug: myorg/order-service
    prometheus.io/alert: "AlertManager"
  tags:
    - java
    - spring-boot
    - production
  links:
    - url: https://grafana.mycompany.it/d/order-service
      title: Dashboard Grafana
      icon: dashboard
spec:
  type: service
  lifecycle: production
  owner: team-backend
  system: order-management
  providesApis:
    - order-api
  consumesApis:
    - inventory-api
    - payment-api

Puoi configurare Backstage per auto-scoprire questi file via GitHub/GitLab integration. In app-config.yaml:

catalog:
  providers:
    github:
      myorg:
        organization: 'myorg'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
  rules:
    - allow: [Component, System, API, Resource, Location]

Risultato: in pochi minuti hai un catalogo unificato di tutti i tuoi servizi, con owner, dipendenze e link alle risorse operative — tutto in un posto.

Step 2: creare Golden Path con i Software Templates

I Software Templates (o "Scaffolder") sono il meccanismo con cui un developer crea una nuova app seguendo il golden path della tua organizzazione: struttura del repo, Dockerfile, pipeline CI/CD, configurazione Kubernetes — tutto pre-cablato.

Ecco un template per un microservizio Spring Boot:

# templates/spring-boot-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: spring-boot-microservice
  title: Spring Boot Microservice
  description: Crea un nuovo microservizio Spring Boot con CI/CD e monitoring inclusi
  tags:
    - java
    - spring-boot
    - recommended
spec:
  owner: team-platform
  type: service

  parameters:
    - title: Informazioni servizio
      required:
        - name
        - description
        - owner
      properties:
        name:
          title: Nome servizio
          type: string
          pattern: '^[a-z][a-z0-9-]*$'
          description: Nome in kebab-case (es. order-service)
        description:
          title: Descrizione
          type: string
        owner:
          title: Team owner
          type: string
          ui:field: OwnerPicker
          ui:options:
            allowedKinds: ['Group']
        
    - title: Infrastruttura
      properties:
        replicas:
          title: Numero di repliche iniziali
          type: integer
          default: 2
          enum: [1, 2, 3, 5]
        resourceProfile:
          title: Profilo risorse
          type: string
          default: small
          enum: [small, medium, large]
          enumNames:
            - 'Small (256m CPU, 256Mi RAM)'
            - 'Medium (500m CPU, 512Mi RAM)'
            - 'Large (1 CPU, 1Gi RAM)'

  steps:
    - id: fetch-base
      name: Fetch base template
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          description: ${{ parameters.description }}
          owner: ${{ parameters.owner }}
          replicas: ${{ parameters.replicas }}
          resourceProfile: ${{ parameters.resourceProfile }}

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        allowedHosts: ['github.com']
        description: ${{ parameters.description }}
        repoUrl: github.com?owner=myorg&repo=${{ parameters.name }}
        defaultBranch: main
        repoVisibility: private

    - id: register
      name: Register in Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
        catalogInfoPath: '/catalog-info.yaml'

    - id: create-argocd-app
      name: Crea applicazione ArgoCD
      action: http:backstage:request
      input:
        method: POST
        path: /api/proxy/argocd/api/v1/applications
        body:
          metadata:
            name: ${{ parameters.name }}
            namespace: argocd
          spec:
            project: default
            source:
              repoURL: https://github.com/myorg/${{ parameters.name }}
              targetRevision: HEAD
              path: k8s/overlays/staging
            destination:
              server: https://kubernetes.default.svc
              namespace: ${{ parameters.name }}

  output:
    links:
      - title: Repository GitHub
        url: ${{ steps['publish'].output.remoteUrl }}
      - title: Catalogo Backstage
        url: ${{ steps['register'].output.catalogInfoUrl }}

La cartella skeleton/ contiene la struttura del progetto con variabili Nunjucks. Esempio per il catalog-info.yaml generato:

# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: ${{ values.name }}
  description: ${{ values.description }}
spec:
  type: service
  lifecycle: experimental
  owner: ${{ values.owner }}

Con questo template, un developer compila un form in Backstage e in 2 minuti ha: repo GitHub con struttura standard, pipeline GitHub Actions pre-configurata, Deployment Kubernetes con le risorse giuste, e app registrata nel catalogo. Zero Slack, zero PR sul repo platform.

Step 3: Namespace e RBAC automatizzati con Crossplane

Ogni nuovo servizio ha bisogno di un namespace Kubernetes con RBAC e network policy corrette. Farlo a mano è il classico collo di bottiglia. Con Crossplane puoi definire un Composite Resource (XR) che incapsula tutta la logica:

# platform/compositions/app-namespace.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: app-namespace
spec:
  compositeTypeRef:
    apiVersion: platform.mycompany.it/v1alpha1
    kind: AppNamespace
  resources:
    - name: namespace
      base:
        apiVersion: kubernetes.crossplane.io/v1alpha1
        kind: Object
        spec:
          forProvider:
            manifest:
              apiVersion: v1
              kind: Namespace
              metadata:
                labels:
                  managed-by: crossplane
                  team: ""  # patch dal claim
      patches:
        - fromFieldPath: spec.parameters.team
          toFieldPath: spec.forProvider.manifest.metadata.labels.team

    - name: resource-quota
      base:
        apiVersion: kubernetes.crossplane.io/v1alpha1
        kind: Object
        spec:
          forProvider:
            manifest:
              apiVersion: v1
              kind: ResourceQuota
              metadata:
                name: default-quota
              spec:
                hard:
                  requests.cpu: "4"
                  requests.memory: 4Gi
                  limits.cpu: "8"
                  limits.memory: 8Gi
                  pods: "20"
      patches:
        - fromFieldPath: spec.parameters.namespace
          toFieldPath: spec.forProvider.manifest.metadata.namespace
        - fromFieldPath: spec.parameters.resourceProfile
          toFieldPath: spec.forProvider.manifest.spec.hard
          transforms:
            - type: map
              map:
                small:
                  requests.cpu: "2"
                  limits.cpu: "4"
                  requests.memory: 2Gi
                  limits.memory: 4Gi
                medium:
                  requests.cpu: "4"
                  limits.cpu: "8"
                  requests.memory: 4Gi
                  limits.memory: 8Gi

    - name: network-policy-default-deny
      base:
        apiVersion: kubernetes.crossplane.io/v1alpha1
        kind: Object
        spec:
          forProvider:
            manifest:
              apiVersion: networking.k8s.io/v1
              kind: NetworkPolicy
              metadata:
                name: default-deny-ingress
              spec:
                podSelector: {}
                policyTypes:
                  - Ingress

Il developer (o il template Backstage) crea semplicemente un claim:

# Claim: l'unica cosa che vede il developer
apiVersion: platform.mycompany.it/v1alpha1
kind: AppNamespace
metadata:
  name: order-service-ns
spec:
  parameters:
    namespace: order-service
    team: team-backend
    resourceProfile: medium

Crossplane crea namespace, ResourceQuota e NetworkPolicy in automatico. Il developer non tocca mai le risorse Kubernetes direttamente.

Step 4: Policy di Governance con Kyverno

Il self-service funziona solo se hai guardrail solidi. Kyverno ti permette di definire policy Kubernetes che vengono valutate a ogni richiesta all'API server — in modalità enforce (blocca) o audit (logga senza bloccare).

Ecco alcune policy fondamentali:

Require resource limits su ogni container

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
  annotations:
    policies.kyverno.io/title: Require Resource Limits
    policies.kyverno.io/description: >
      Ogni container deve dichiarare CPU e memory limits.
      Senza limiti un pod può esaurire le risorse del nodo.
spec:
  validationFailureAction: enforce
  background: true
  rules:
    - name: validate-limits
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaceSelector:
                matchLabels:
                  managed-by: crossplane  # Solo nei namespace gestiti dalla piattaforma
      validate:
        message: "CPU e memory limits sono obbligatori per ogni container."
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    cpu: "?*"
                    memory: "?*"

Blocca immagini senza tag (no latest)

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  validationFailureAction: enforce
  rules:
    - name: require-image-tag
      match:
        any:
          - resources:
              kinds: [Pod]
      validate:
        message: "Usare il tag ':latest' è vietato. Specifica una versione esplicita."
        pattern:
          spec:
            containers:
              - image: "!*:latest"
            initContainers:
              - image: "!*:latest"

Mutate: aggiungi label standard automaticamente

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-standard-labels
spec:
  rules:
    - name: add-team-label
      match:
        any:
          - resources:
              kinds: [Pod, Deployment, Service]
      mutate:
        patchStrategicMerge:
          metadata:
            labels:
              +(managed-by): crossplane
              +(platform-version: "1.0"

Con Kyverno in audit mode puoi capire quante violazioni esistono prima di passare a enforce. Il comando per vedere il report:

kubectl get policyreport -A
kubectl describe clusterpolicyreport -n order-service

Step 5: Osservabilità integrata nel Golden Path

Un golden path serio include l'osservabilità di default. Ogni app generata dal template deve avere metriche Prometheus e log strutturati senza che il developer debba configurare nulla.

Nel template Backstage, includi già nella skeleton un ServiceMonitor:

# skeleton/k8s/base/service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: ${{ values.name }}
  labels:
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app: ${{ values.name }}
  endpoints:
    - port: http
      path: /actuator/prometheus
      interval: 30s
      scrapeTimeout: 10s

E un dashboard Grafana pre-configurato tramite ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: ${{ values.name }}-dashboard
  labels:
    grafana_dashboard: "1"
data:
  dashboard.json: |
    {
      "title": "${{ values.name }} - Overview",
      "panels": [
        {
          "title": "Request Rate",
          "targets": [{ "expr": "rate(http_requests_total{app=\"${{ values.name }}\"}[5m])" }]
        },
        {
          "title": "Error Rate",
          "targets": [{ "expr": "rate(http_requests_total{app=\"${{ values.name }}\",status=~\"5..\"}[5m])" }]
        },
        {
          "title": "P99 Latency",
          "targets": [{ "expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{app=\"${{ values.name }}\"}[5m]))" }]
        }
      ]
    }

Il developer ottiene il suo Grafana dashboard dal giorno zero. Senza configurare nulla.

Governance e adozione: i soft skills del Platform Engineering

La tecnologia è la parte facile. Il vero lavoro è l'adozione.

Alcune lezioni pratiche:

Tratta i developer come clienti. Il tuo "prodotto" è la piattaforma. Se i golden path sono scomodi, i team li bypasseranno. Raccogli feedback regolarmente, misura il tempo medio per deployare un nuovo servizio (Time to First Deploy), e traccia le pull request "fuori standard".

Parti dall'audit mode. Non passare a enforce su Kyverno finché non hai misurato quante violazioni esistono nell'attuale parco applicativo. Cominciare con il blocco crea ostilità.

Documenta nel catalogo, non su Confluence. TechDocs in Backstage permette di tenere la documentazione vicina al codice. Se la doc sta nel repo, viene aggiornata. Se sta su Confluence, invecchia.

Versiona la tua piattaforma. Tratta l'IDP come un prodotto con release notes. Quando aggiorni un template o una policy, comunicalo.

Conclusione

Il Platform Engineering non è una moda: è la risposta strutturale ai limiti del DevOps tradizionale quando la scala aumenta. Kubernetes e Backstage, messi insieme con Crossplane e Kyverno, ti danno tutti gli strumenti per costruire una IDP che scala con la tua organizzazione.

Il punto di partenza è sempre lo stesso: individua il collo di bottiglia più grande (spesso è il provisioning dei namespace o il setup iniziale di un repo), costruisci il golden path per quel caso, e misura il prima e il dopo. Poi iteri.

La piattaforma non finisce mai — e questa è la cosa più bella del Platform Engineering.

Hai già una IDP in produzione? Raccontami la tua esperienza nei commenti — o scrivimi direttamente. Ogni architettura è diversa, e confrontarsi con chi è già sulla strada è sempre il modo più veloce per migliorare.