DevOps

Construcción de una red zero-trust de microservicios en Kubernetes: políticas, mTLS e identidad sin Service Mesh

Ruslan Ismailov Publicado 12 min de lectura
C

Introducción: por qué zero-trust se convirtió en una necesidad en 2026

El modelo de seguridad zero-trust se basa en el principio de «no confiar en nadie por defecto» — ni en las solicitudes externas ni en los servicios internos del clúster. En 2026, esta filosofía dejó de ser una curiosidad arquitectónica y se convirtió en el estándar de facto para entornos de producción en Kubernetes. Las razones son simples: el escalado horizontal de microservicios amplía la superficie de ataque, y comprometer un solo pod sin el aislamiento adecuado abre el camino a toda la infraestructura.

Muchos equipos recurren de inmediato a Istio o Linkerd, asumiendo que zero-trust es imposible sin un Service Mesh completo. Esto es un error. Kubernetes ofrece suficientes primitivas — NetworkPolicy, RBAC, ServiceAccount — y el estándar abierto SPIFFE junto con su implementación SPIRE resuelven el problema de identidad sin un data plane adicional. La autenticación TLS mutua se implementa directamente en el código del servicio Go. En este artículo analizaremos cada una de estas capas en detalle, con ejemplos de YAML y código Go.

Problemas de la seguridad perimetral tradicional en Kubernetes

El enfoque clásico de «perímetro protegido» asume que todo lo que está dentro de la red es de confianza. En Kubernetes, esto significa que cualquier pod del clúster puede comunicarse con cualquier otro pod a través del DNS de Service por defecto. No existen restricciones a menos que se configuren explícitamente.

Las consecuencias son evidentes:

  • Un pod comprometido payment-service puede acceder directamente a user-db sin ninguna verificación.
  • La ausencia de mutual TLS significa que un servicio no puede estar seguro de con quién se está comunicando — si es un servicio legítimo o un atacante intermediario.
  • El movimiento lateral (lateral movement) tras comprometer un componente se vuelve trivial.

Zero-trust resuelve estos problemas mediante tres mecanismos ortogonales: aislamiento de red, identificación criptográfica y auditoría de cada interacción.

NetworkPolicy: aislamiento de tráfico a nivel de namespace y pod

NetworkPolicy es un recurso de Kubernetes que describe las reglas de tráfico entrante (ingress) y saliente (egress) para un conjunto de pods. Es importante entender que NetworkPolicy solo funciona si el clúster tiene instalado un plugin CNI con soporte de políticas (Calico, Cilium, Weave Net). Flannel en su configuración básica no aplica NetworkPolicy.

La estrategia «deny-all by default» es el primer paso hacia zero-trust. Creamos una política que bloquea todo el tráfico entrante para todos los pods en el namespace payments:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress

Luego permitimos explícitamente solo las rutas necesarias. Por ejemplo, order-service del namespace orders debe comunicarse con payment-service en el namespace payments:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-order-to-payment
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payment-service
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: orders
          podSelector:
            matchLabels:
              app: order-service
      ports:
        - protocol: TCP
          port: 8443

Nótese que usamos el puerto 8443 — lo que ya insinúa TLS. NetworkPolicy no inspecciona el contenido del tráfico, opera en L3/L4. Para control en L7 se necesita un CNI con soporte L7 (Cilium con Hubble) o verificación a nivel de aplicación mediante mTLS.

Recomendaciones para organizar NetworkPolicy en producción:

  • Crea políticas default-deny para ingress y egress en cada namespace.
  • Permite explícitamente las consultas DNS (kube-dns, puerto 53 UDP/TCP).
  • Usa labels separadas para diferentes roles de pods (role: api, role: worker).
  • Versiona las NetworkPolicy en Git como parte del IaC.

SPIFFE/SPIRE: emisión y rotación de identidades de servicios

SPIFFE (Secure Production Identity Framework for Everyone) es un estándar abierto que define el formato de identificadores criptográficos para cargas de trabajo. El identificador tiene forma de URI: spiffe://trust-domain/path. Por ejemplo: spiffe://cluster.local/ns/payments/sa/payment-service.

SPIRE es la implementación de referencia de SPIFFE, compuesta por dos componentes:

  • SPIRE Server — CA central que emite SVID (SPIFFE Verifiable Identity Document) en forma de certificados x.509.
  • SPIRE Agent — DaemonSet en cada nodo que atestigua los pods a través de la API de Kubernetes y entrega los SVID mediante un socket Unix.

El despliegue de SPIRE en el clúster comienza con la instalación del CRD y el deployment del Server. ConfigMap mínimo para SPIRE Server:

apiVersion: v1
kind: ConfigMap
metadata:
  name: spire-server
  namespace: spire
data:
  server.conf: |
    server {
      bind_address = "0.0.0.0"
      bind_port = "8081"
      trust_domain = "cluster.local"
      data_dir = "/run/spire/server/data"
      log_level = "INFO"
      ca_ttl = "24h"
      default_x509_svid_ttl = "1h"
    }
    plugins {
      DataStore "sql" {
        plugin_data {
          database_type = "sqlite3"
          connection_string = "/run/spire/server/data/datastore.sqlite3"
        }
      }
      NodeAttestor "k8s_psat" {
        plugin_data {
          clusters = {
            "my-cluster" = {
              service_account_allow_list = ["spire:spire-agent"]
            }
          }
        }
      }
      KeyManager "disk" {
        plugin_data {
          keys_path = "/run/spire/server/data/keys.json"
        }
      }
    }

El SPIRE Agent se registra en el nodo mediante Projected Service Account Token (PSAT) — un mecanismo de atestación integrado en Kubernetes. Tras una atestación exitosa, el agente obtiene acceso a la Workload API y distribuye los SVID a los pods a través del socket Unix /run/spire/sockets/agent.sock.

El registro de una carga de trabajo (workload registration entry) se realiza con el comando:

kubectl exec -n spire spire-server-0 -- \
  spire-server entry create \
  -spiffeID spiffe://cluster.local/ns/payments/sa/payment-service \
  -parentID spiffe://cluster.local/spire/agent/k8s_psat/my-cluster/NODE_UID \
  -selector k8s:ns:payments \
  -selector k8s:sa:payment-service

Los SVID se rotan automáticamente 5 minutos antes de que expire el TTL. El servicio recibe el nuevo certificado sin necesidad de reiniciarse — algo crítico para servicios Go de larga duración.

mTLS entre servicios Go: implementación sin Service Mesh

Una vez obtenido el SVID del SPIRE Agent a través de la Workload API, el servicio Go lo utiliza para establecer autenticación TLS mutua con otros servicios. La biblioteca github.com/spiffe/go-spiffe/v2 abstrae el trabajo con la Workload API y proporciona configuraciones tls.Config listas para usar.

Ejemplo de código Go para un servidor con mTLS mediante SPIFFE:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"github.com/spiffe/go-spiffe/v2/spiffeid"
	"github.com/spiffe/go-spiffe/v2/spiffetls"
	"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
	"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func main() {
	ctx := context.Background()

	// Conectamos con el SPIRE Agent a través de la Workload API
	client, err := workloadapi.New(ctx,
		workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
	)
	if err != nil {
		log.Fatalf("unable to create workload API client: %v", err)
	}
	defer client.Close()

	// Fuente X.509 SVID con rotación automática
	source, err := workloadapi.NewX509Source(ctx,
		workloadapi.WithClient(client),
	)
	if err != nil {
		log.Fatalf("unable to create X509Source: %v", err)
	}
	defer source.Close()

	// Permitimos solo order-service de nuestro trust domain
	allowedID := spiffeid.RequireFromString(
		"spiffe://cluster.local/ns/orders/sa/order-service",
	)

	tlsCfg := tlsconfig.MTLSServerConfig(
		source,
		source,
		tlsconfig.AuthorizeID(allowedID),
	)

	server := &http.Server{
		Addr:      ":8443",
		TLSConfig: tlsCfg,
		Handler:   http.HandlerFunc(paymentHandler),
	}

	log.Println("payment-service listening on :8443 with mTLS")
	if err := server.ListenAndServeTLS("", ""); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

func paymentHandler(w http.ResponseWriter, r *http.Request) {
	// El SPIFFE ID del cliente está disponible en los certificados TLS del peer
	if len(r.TLS.PeerCertificates) > 0 {
		log.Printf("authenticated caller SPIFFE ID: %s",
			r.TLS.PeerCertificates[0].URIs[0])
	}
	fmt.Fprintln(w, `{"status": "ok"}`)
}

Código del cliente para order-service que se comunica con payment-service:

package main

import (
	"context"
	"io"
	"log"
	"net/http"

	"github.com/spiffe/go-spiffe/v2/spiffeid"
	"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
	"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func main() {
	ctx := context.Background()

	source, err := workloadapi.NewX509Source(ctx,
		workloadapi.WithClientOptions(
			workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
		),
	)
	if err != nil {
		log.Fatalf("X509Source error: %v", err)
	}
	defer source.Close()

	serverID := spiffeid.RequireFromString(
		"spiffe://cluster.local/ns/payments/sa/payment-service",
	)

	tlsCfg := tlsconfig.MTLSClientConfig(
		source,
		source,
		tlsconfig.AuthorizeID(serverID),
	)

	client := &http.Client{
		Transport: &http.Transport{TLSClientConfig: tlsCfg},
	}

	resp, err := client.Get("https://payment-service.payments.svc.cluster.local:8443/charge")
	if err != nil {
		log.Fatalf("request failed: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	log.Printf("response: %s", body)
}

Puntos clave de la implementación:

  • Las funciones tlsconfig.AuthorizeID verifican el SPIFFE ID del interlocutor — si el ID no coincide con el esperado, el handshake TLS finalizará con error.
  • X509Source actualiza automáticamente los certificados en segundo plano mediante un watch sobre la Workload API.
  • El socket del SPIRE Agent se monta en el pod a través de un volumen hostPath o un CSI driver.

Montaje del volumen para el pod:

volumes:
  - name: spire-agent-socket
    hostPath:
      path: /run/spire/sockets
      type: Directory
containers:
  - name: payment-service
    image: payment-service:latest
    volumeMounts:
      - name: spire-agent-socket
        mountPath: /run/spire/sockets
        readOnly: true

Auditoría y registro de eventos de seguridad

Zero-trust sin auditoría es solo la mitad del trabajo. Es necesario registrar cada evento relevante: quién accedió a qué, cuándo y con qué resultado.

A nivel de Kubernetes, habilita la Audit Policy para el API Server. Ejemplo de política que registra cambios en NetworkPolicy y secretos:

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: RequestResponse
    resources:
      - group: "networking.k8s.io"
        resources: ["networkpolicies"]
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets", "serviceaccounts"]
  - level: None
    users: ["system:nodes"]
    verbs: ["get", "list", "watch"]

A nivel de aplicación, cada servicio Go debe registrar el SPIFFE ID de las conexiones entrantes. Usa logging estructurado (slog o zap) y envía los logs a un almacenamiento centralizado (Loki, Elasticsearch). Ejemplo de middleware para servidor HTTP:

func auditMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		callerID := "unknown"
		if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
			for _, uri := range r.TLS.PeerCertificates[0].URIs {
				callerID = uri.String()
				break
			}
		}
		log.Printf(`{"event":"request","caller":"%s","path":"%s","method":"%s"}`,
			callerID, r.URL.Path, r.Method)
		next.ServeHTTP(w, r)
	})
}

Considera también la integración con Falco — una herramienta de seguridad en tiempo de ejecución capaz de detectar patrones anómalos de syscalls y conexiones de red directamente en los pods.

Ejemplo práctico: comunicación segura entre tres microservicios

Consideremos el siguiente escenario: api-gateway (namespace gateway) → order-service (namespace orders) → payment-service (namespace payments). Cada salto debe estar protegido con mTLS y verificación del SPIFFE ID.

Arquitectura de identidades:

  • spiffe://cluster.local/ns/gateway/sa/api-gateway
  • spiffe://cluster.local/ns/orders/sa/order-service
  • spiffe://cluster.local/ns/payments/sa/payment-service

La NetworkPolicy para order-service permite tráfico entrante solo desde api-gateway:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-gateway-to-orders
  namespace: orders
spec:
  podSelector:
    matchLabels:
      app: order-service
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: gateway
          podSelector:
            matchLabels:
              app: api-gateway
      ports:
        - protocol: TCP
          port: 8443
  policyTypes:
    - Ingress

A nivel de aplicación, order-service verifica adicionalmente que el SPIFFE ID del cliente sea exactamente el de api-gateway. Incluso si se elude la NetworkPolicy (por ejemplo, a través de un nodo comprometido), el mTLS con verificación de identidad impedirá que un servicio no autorizado establezca la conexión.

La cadena de protección es la siguiente:

  1. NetworkPolicy bloquea el tráfico L3/L4 no deseado antes de que el paquete llegue al pod.
  2. El handshake TLS con verificación de certificado proporciona cifrado y autenticación en L4.
  3. La verificación del SPIFFE ID en el código Go garantiza la autorización a nivel de aplicación.
  4. El log de auditoría registra cada interacción exitosa y fallida.

Esta protección multicapa implementa el principio de defense-in-depth sin un único punto de fallo.

Conclusiones

La seguridad zero-trust en Kubernetes no es un producto que se pueda «instalar y olvidar». Es un enfoque arquitectónico que se implementa mediante una combinación de mecanismos:

  • NetworkPolicy proporciona aislamiento de red en L3/L4 sin modificar el código de los servicios.
  • SPIFFE/SPIRE otorgan a cada servicio un identificador verificado criptográficamente con rotación automática.
  • mTLS en Go con verificación del SPIFFE ID cubre la autorización a nivel de aplicación.
  • Auditoría y logging garantizan visibilidad y capacidad de análisis forense.

El stack descrito no requiere Istio ni Linkerd y se integra bien en clústeres Kubernetes existentes. La ausencia de Service Mesh implica menos complejidad operativa, menos componentes que puedan fallar y control directo sobre cada aspecto de la seguridad. Para los equipos que desean comprender completamente su modelo de seguridad, esta suele ser una mejor opción que un sidecar-proxy opaco.

Empieza poco a poco: introduce una NetworkPolicy default-deny en un namespace, despliega SPIRE y añade mTLS a un servicio Go. Este camino incremental permitirá al equipo acumular experiencia sin arriesgar la estabilidad del entorno de producción.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →