Migrando de Kubernetes Ingress a Gateway API con Envoy Gateway y observabilidad end-to-end
Nota: este artículo muestra una implementación anonimizada y generalizada. Los dominios, IPs, nombres de namespaces, nombres de aplicaciones y secretos fueron reemplazados por valores de ejemplo. La idea es explicar el enfoque técnico sin exponer información sensible de ningún entorno real.
Durante las últimas semanas estuve trabajando en una migración end-to-end desde Kubernetes Ingress hacia Gateway API para tres ambientes: internal, dev y testing.
A primera vista puede parecer un cambio simple: dejar de usar objetos Ingress y empezar a publicar servicios con HTTPRoute. Pero en la práctica la migración fue bastante más amplia.
No se trató solamente de cambiar un recurso de Kubernetes por otro. El objetivo fue diseñar una capa de entrada HTTP más ordenada, observable y operable, separando mejor las responsabilidades entre plataforma e implementación de aplicaciones.
La migración incluyó:
- Gateways separados por ambiente.
- IPs dedicadas mediante MetalLB.
GatewayyHTTPRouteusando Gateway API.EnvoyProxypara configurar el comportamiento del plano de datos de Envoy.- TLS termination en el Gateway.
- Redirección HTTP a HTTPS.
- Control de qué namespaces pueden publicar rutas.
ReferenceGrantpara permitir referencias cross-namespace a secretos TLS.ClientTrafficPolicyyBackendTrafficPolicypara timeouts, retries, circuit breaking y headers.- Access logs JSON.
- Métricas Envoy en Prometheus.
- Trazas con OpenTelemetry y Tempo.
- Logs en Loki.
- Alertas operativas.
- Guía para que los equipos de desarrollo puedan publicar rutas de manera estándar.
El resultado fue una capa de entrada que no solo enruta tráfico, sino que también permite responder preguntas operativas importantes:
- ¿La request llegó al Gateway?
- ¿Matcheó una ruta?
- ¿Qué
HTTPRoutela tomó? - ¿Qué upstream eligió Envoy?
- ¿Qué código HTTP respondió?
- ¿Hubo 4xx, 5xx, timeout, reset o fallo contra el backend?
- ¿Existe un
trace_idpara seguir la request? - ¿La aplicación está propagando el contexto de trazas?
1. Contexto: por qué migrar desde Ingress
Ingress resolvió durante años una necesidad muy concreta: exponer tráfico HTTP/HTTPS hacia servicios dentro de Kubernetes.
El problema es que, a medida que crece la plataforma, aparecen limitaciones prácticas:
- Mucha lógica queda escondida en annotations específicas del controller.
- Es difícil separar responsabilidades entre infraestructura y equipos de aplicación.
- Distintos Ingress Controllers implementan features de manera diferente.
- El modelo se vuelve poco expresivo para escenarios con múltiples listeners, múltiples dominios, políticas por ruta, gateways compartidos y control fino de exposición.
- La observabilidad muchas veces queda agregada después, no diseñada desde el inicio.
Gateway API propone un modelo más explícito:
GatewayClass: define el tipo de implementación disponible en el cluster.Gateway: representa el punto de entrada administrado por plataforma.Listener: define puertos, protocolos, certificados, hostnames y reglas de adjunción.HTTPRoute: define cómo una aplicación publica rutas HTTP.ReferenceGrant: permite referencias cross-namespace de forma explícita y controlada.
En esta implementación usé Envoy Gateway como controller, por lo que además aparecen recursos propios de Envoy Gateway:
EnvoyProxy.ClientTrafficPolicy.BackendTrafficPolicy.SecurityPolicy.
Una distinción importante:
| Recurso | API | Uso |
|---|---|---|
GatewayClass | Gateway API estándar | Define el controller disponible. |
Gateway | Gateway API estándar | Define el punto de entrada y sus listeners. |
HTTPRoute | Gateway API estándar | Publica rutas HTTP hacia Services. |
ReferenceGrant | Gateway API estándar | Autoriza referencias cross-namespace. |
EnvoyProxy | Extensión de Envoy Gateway | Configura deployment, service, HPA, PDB, telemetry y tracing de Envoy. |
ClientTrafficPolicy | Extensión de Envoy Gateway | Define comportamiento del tráfico cliente hacia el Gateway. |
BackendTrafficPolicy | Extensión de Envoy Gateway | Define timeouts, retries, circuit breaking y load balancing hacia backends. |
SecurityPolicy | Extensión de Envoy Gateway | Define CORS, auth u otras políticas de seguridad soportadas por Envoy Gateway. |
2. Objetivo de la arquitectura
La migración buscó resolver varios puntos al mismo tiempo:
-
Separar ambientes
Cada ambiente debía tener su propio Gateway, IP y reglas de publicación. -
Separar responsabilidades
El equipo de plataforma administraGateway,EnvoyProxy, certificados, políticas globales, observabilidad y permisos.
Los equipos de desarrollo publican sus aplicaciones medianteHTTPRoute. -
Evitar configuraciones manuales por aplicación
Una aplicación no debería tener que configurar Envoy directamente para obtener logs, métricas y trazas básicas. -
Estandarizar la publicación de rutas
Las aplicaciones exponen servicios usando un patrón común:HTTPRoute -> Service -> Pods. -
Mejorar la operación
La plataforma debe poder observar tráfico, errores, latencia, upstreams, certificados y trazas desde el Gateway. -
Preparar una base más cercana a Platform Engineering
La idea no es solamente exponer aplicaciones, sino construir una capacidad interna reusable.
3. Arquitectura general
La arquitectura simplificada queda así:
flowchart LR
Client[Cliente / navegador / API client]
DNS[DNS / Edge / Load Balancer externo]
MetalLB[MetalLB IP dedicada]
EG[Envoy Gateway]
GW[Gateway + Listeners]
HR[HTTPRoute]
SVC[Kubernetes Service]
POD[Pods de aplicación]
Prom[Prometheus]
Loki[Loki]
Tempo[Tempo]
OTel[OpenTelemetry Collector]
Client --> DNS --> MetalLB --> EG --> GW --> HR --> SVC --> POD
EG -->|métricas| Prom
EG -->|access logs JSON| Loki
EG -->|trazas OTLP| OTel --> Tempo
POD -->|spans opcionales| OTel
La ruta de tráfico es:
Cliente
-> DNS / Edge
-> IP LoadBalancer asignada por MetalLB
-> Envoy Gateway
-> Gateway Listener
-> HTTPRoute
-> Service
-> Pod
La ruta de observabilidad es:
Envoy Gateway
-> métricas Prometheus
-> access logs JSON recolectados por Loki
-> trazas OTLP hacia OpenTelemetry Collector / Tempo
Si la aplicación también está instrumentada con OpenTelemetry, entonces la traza no termina en el Gateway. Continúa dentro del backend y permite ver el flujo completo.
4. Layout lógico de ambientes
La implementación separa tres ambientes:
| Ambiente | Gateway | EnvoyProxy | Dominio ejemplo | IP ejemplo |
|---|---|---|---|---|
| internal | internal-gw | internal-proxy | *.apps.example.com | 192.0.2.10 |
| dev | dev-gw | dev-proxy | *.dev.apps.example.com | 192.0.2.11 |
| testing | testing-gw | testing-proxy | *.testing.apps.example.com | 192.0.2.12 |
192.0.2.0/24pertenece al rango reservado para documentación. En un entorno real se reemplaza por IPs de la red interna o del pool correspondiente.
5. Prerrequisitos
Antes de aplicar estos manifiestos asumí lo siguiente:
- Cluster Kubernetes operativo.
- Gateway API CRDs instalados.
- Envoy Gateway instalado y con un
GatewayClassllamadoeg. - MetalLB instalado en modo L2.
cert-managero secretos TLS ya creados.- Namespace
envoy-gateway-systemexistente. - Namespace
monitoringexistente. - Loki desplegado o algún agente recolectando logs de stdout de los pods.
- Tempo y OpenTelemetry Collector disponibles para recibir trazas OTLP.
- Prometheus o un Prometheus dedicado para scrapear métricas de Envoy.
Validaciones iniciales:
kubectl get gatewayclass
kubectl get crd | grep gateway
kubectl get pods -n envoy-gateway-system
kubectl get pods -n metallb-system
kubectl get pods -n monitoring
6. MetalLB: IPs dedicadas por Gateway
Para que cada Gateway tenga una IP estable, definí pools dedicados en MetalLB.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: envoy-gw-l2-internal
namespace: metallb-system
spec:
ipAddressPools:
- envoy-gw-pool-internal
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: envoy-gw-pool-internal
namespace: metallb-system
spec:
addresses:
- 192.0.2.10/32
autoAssign: false
avoidBuggyIPs: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: envoy-gw-l2-dev
namespace: metallb-system
spec:
ipAddressPools:
- envoy-gw-pool-dev
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: envoy-gw-pool-dev
namespace: metallb-system
spec:
addresses:
- 192.0.2.11/32
autoAssign: false
avoidBuggyIPs: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: envoy-gw-l2-testing
namespace: metallb-system
spec:
ipAddressPools:
- envoy-gw-pool-testing
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: envoy-gw-pool-testing
namespace: metallb-system
spec:
addresses:
- 192.0.2.12/32
autoAssign: false
avoidBuggyIPs: false
El punto importante es autoAssign: false. Eso evita que MetalLB asigne estas IPs a cualquier Service LoadBalancer. Las IPs quedan reservadas para los Gateways.
7. EnvoyProxy: configuración del plano de datos
EnvoyProxy es una extensión de Envoy Gateway. Me permitió controlar cómo se despliega Envoy y cómo se comporta a nivel operativo.
En esta capa configuré:
- Nivel de logging.
- Métricas Prometheus habilitadas.
- Access logs en JSON hacia stdout.
- Export de métricas/trazas hacia OpenTelemetry.
- Tags de trazas.
- HPA.
- PDB.
- Resources requests/limits.
- Service
LoadBalancer. - Labels para descubrimiento de Prometheus y separación por ambiente.
Ejemplo parametrizado para un ambiente:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: internal-proxy
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: internal
observability.platform.example.com/enabled: "true"
spec:
logging:
level:
default: warn
telemetry:
metrics:
prometheus:
disable: false
sinks:
- type: OpenTelemetry
openTelemetry:
host: otel-collector.monitoring.svc.cluster.local
port: 4317
accessLog:
settings:
- format:
type: JSON
json:
start_time: "%START_TIME%"
method: "%REQ(:METHOD)%"
scheme: "%REQ(:SCHEME)%"
authority: "%REQ(:AUTHORITY)%"
path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
protocol: "%PROTOCOL%"
response_code: "%RESPONSE_CODE%"
response_code_details: "%RESPONSE_CODE_DETAILS%"
response_flags: "%RESPONSE_FLAGS%"
connection_termination_details: "%CONNECTION_TERMINATION_DETAILS%"
upstream_transport_failure_reason: "%UPSTREAM_TRANSPORT_FAILURE_REASON%"
bytes_received: "%BYTES_RECEIVED%"
bytes_sent: "%BYTES_SENT%"
duration_ms: "%DURATION%"
upstream_service_time_ms: "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%"
downstream_remote_address: "%DOWNSTREAM_REMOTE_ADDRESS%"
downstream_direct_remote_address: "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%"
x_forwarded_for: "%REQ(X-FORWARDED-FOR)%"
user_agent: "%REQ(USER-AGENT)%"
request_id: "%REQ(X-REQUEST-ID)%"
trace_id: "%TRACE_ID%"
traceparent: "%REQ(TRACEPARENT)%"
requested_server_name: "%REQUESTED_SERVER_NAME%"
upstream_host: "%UPSTREAM_HOST%"
upstream_cluster: "%UPSTREAM_CLUSTER%"
route_name: "%ROUTE_NAME%"
virtual_cluster_name: "%VIRTUAL_CLUSTER_NAME%"
gateway_name: "internal-gw"
gateway_namespace: "envoy-gateway-system"
environment: "internal"
sinks:
- type: File
file:
path: /dev/stdout
tracing:
samplingRate: 100
provider:
type: OpenTelemetry
backendRefs:
- name: otel-collector
namespace: monitoring
port: 4317
spanName:
client: "%REQ(:METHOD)% %REQ(:AUTHORITY)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% -> %UPSTREAM_CLUSTER%"
server: "%REQ(:METHOD)% %REQ(:AUTHORITY)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% -> %UPSTREAM_CLUSTER%"
tags:
gateway.name: internal-gw
gateway.namespace: envoy-gateway-system
deployment.environment: internal
http.method: "%REQ(:METHOD)%"
http.authority: "%REQ(:AUTHORITY)%"
http.path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
http.protocol: "%PROTOCOL%"
http.status_code: "%RESPONSE_CODE%"
http.request_id: "%REQ(X-REQUEST-ID)%"
trace.id: "%TRACE_ID%"
route.name: "%ROUTE_NAME%"
upstream.cluster: "%UPSTREAM_CLUSTER%"
upstream.host: "%UPSTREAM_HOST%"
upstream.transport_failure_reason: "%UPSTREAM_TRANSPORT_FAILURE_REASON%"
response.flags: "%RESPONSE_FLAGS%"
response.code_details: "%RESPONSE_CODE_DETAILS%"
k8s.pod.name: "%ENVIRONMENT(ENVOY_POD_NAME)%"
k8s.namespace.name: "%ENVIRONMENT(ENVOY_POD_NAMESPACE)%"
provider:
type: Kubernetes
kubernetes:
envoyDeployment:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
pod:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
gateway.env: internal
gateway.type: internal
labels:
gateway.env: internal
gateway.type: internal
observability.platform.example.com/enabled: "true"
container:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
envoyHpa:
minReplicas: 2
maxReplicas: 5
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 2
periodSeconds: 60
selectPolicy: Max
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
envoyPDB:
minAvailable: 1
envoyService:
type: LoadBalancer
externalTrafficPolicy: Local
labels:
gateway.env: internal
gateway.type: internal
observability.platform.example.com/enabled: "true"
Para dev y testing usé el mismo patrón, cambiando:
metadata.name:dev-proxy,testing-proxy.gateway.env:dev,testing.gateway.type:dev,testing.gateway_name:dev-gw,testing-gw.deployment.environment:dev,testing.
8. Gateways por ambiente
Cada ambiente tiene su propio Gateway.
8.1 Gateway interno
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: internal-gw
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: internal
observability.platform.example.com/enabled: "true"
spec:
gatewayClassName: eg
infrastructure:
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: internal-proxy
addresses:
- type: IPAddress
value: 192.0.2.10
listeners:
- name: http-internal
protocol: HTTP
port: 80
hostname: "*.apps.example.com"
allowedRoutes:
namespaces:
from: Same
- name: https-internal
protocol: HTTPS
port: 443
hostname: "*.apps.example.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: internal-wildcard-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway.platform.example.com/internal: "true"
platform.example.com/gateway-access: internal
Redirección HTTP a HTTPS:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: internal-http-to-https-redirect
namespace: envoy-gateway-system
spec:
parentRefs:
- name: internal-gw
sectionName: http-internal
hostnames:
- "*.apps.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 443
statusCode: 301
8.2 Gateway dev
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: dev-gw
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: dev
spec:
gatewayClassName: eg
infrastructure:
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: dev-proxy
addresses:
- type: IPAddress
value: 192.0.2.11
listeners:
- name: http-dev
protocol: HTTP
port: 80
hostname: "*.dev.apps.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
platform.example.com/gateway-access: dev
- name: https-dev
protocol: HTTPS
port: 443
hostname: "*.dev.apps.example.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: dev-wildcard-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
platform.example.com/gateway-access: dev
8.3 Gateway testing
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: testing-gw
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: testing
spec:
gatewayClassName: eg
infrastructure:
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: testing-proxy
addresses:
- type: IPAddress
value: 192.0.2.12
listeners:
- name: http-testing
protocol: HTTP
port: 80
hostname: "*.testing.apps.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
platform.example.com/gateway-access: testing
- name: https-testing
protocol: HTTPS
port: 443
hostname: "*.testing.apps.example.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: testing-wildcard-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
platform.example.com/gateway-access: testing
9. Control de publicación por namespace
Uno de los puntos más importantes de Gateway API es que permite controlar qué namespaces pueden adjuntar rutas a un Gateway.
Ejemplo:
apiVersion: v1
kind: Namespace
metadata:
name: app-internal
labels:
gateway.platform.example.com/internal: "true"
platform.example.com/gateway-access: internal
environment: internal
owner: platform-team
---
apiVersion: v1
kind: Namespace
metadata:
name: app-dev
labels:
platform.example.com/gateway-access: dev
environment: dev
owner: backend-team
---
apiVersion: v1
kind: Namespace
metadata:
name: app-testing
labels:
platform.example.com/gateway-access: testing
environment: testing
owner: backend-team
Estas labels funcionan como un contrato de publicación.
Un namespace sin la label correcta puede crear un HTTPRoute, pero ese route no debería ser aceptado por el Gateway correspondiente.
Validación:
kubectl get httproute -A
kubectl describe httproute -n app-dev app-demo
kubectl get gateway -n envoy-gateway-system
kubectl describe gateway -n envoy-gateway-system dev-gw
En el status de HTTPRoute conviene revisar condiciones como:
Accepted=True.ResolvedRefs=True.Programmed=True, según versión/controller.
10. ReferenceGrant para certificados TLS cross-namespace
Si los certificados TLS están en el mismo namespace del Gateway, no hace falta ReferenceGrant.
Pero si el Gateway está en envoy-gateway-system y los Secrets TLS están en namespaces de aplicaciones, entonces Gateway API requiere una autorización explícita con ReferenceGrant.
Ejemplo:
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-dev-gateway-tls
namespace: app-dev
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: envoy-gateway-system
to:
- group: ""
kind: Secret
name: dev-wildcard-tls
- group: ""
kind: Secret
name: app-specific-tls
Para testing:
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-testing-gateway-tls
namespace: app-testing
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: envoy-gateway-system
to:
- group: ""
kind: Secret
name: testing-wildcard-tls
Esto es sano desde el punto de vista operativo: el namespace dueño del recurso referenciado decide quién puede consumirlo.
11. Políticas de tráfico
Separé dos tipos de políticas:
ClientTrafficPolicy: comportamiento desde el cliente hacia el Gateway.BackendTrafficPolicy: comportamiento desde Envoy hacia los backends.
11.1 ClientTrafficPolicy
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
name: internal-gw-client-policy
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: internal
platform.example.com/review-required: "true"
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: internal-gw
tls:
minVersion: "1.2"
maxVersion: "1.3"
alpnProtocols:
- h2
- http/1.1
headers:
requestID: PreserveOrGenerate
enableEnvoyHeaders: false
withUnderscoresAction: RejectRequest
timeout:
http:
requestReceivedTimeout: 30s
streamIdleTimeout: 5m
idleTimeout: 1h
connection:
bufferLimit: 1Mi
maxAcceptPerSocketEvent: 32
tcpKeepalive:
idleTime: 300s
interval: 30s
probes: 3
Puntos interesantes:
requestID: PreserveOrGeneratepermite mantener o generarx-request-id.enableEnvoyHeaders: falseevita exponer headers internos innecesariamente.withUnderscoresAction: RejectRequestayuda a endurecer el manejo de headers.- TLS mínimo 1.2 y máximo 1.3.
11.2 BackendTrafficPolicy global del Gateway
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: internal-gw-backend-policy
namespace: envoy-gateway-system
labels:
platform.example.com/gateway-role: internal
platform.example.com/review-required: "true"
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: internal-gw
timeout:
http:
requestTimeout: 60s
streamIdleTimeout: 5m
connectionIdleTimeout: 1h
retry:
numRetries: 2
perRetry:
timeout: 15s
retryOn:
triggers:
- connect-failure
- refused-stream
- unavailable
- cancelled
- retriable-status-codes
httpStatusCodes:
- 503
circuitBreaker:
maxConnections: 4096
maxPendingRequests: 1024
maxParallelRequests: 8192
maxParallelRetries: 256
maxRequestsPerConnection: 0
loadBalancer:
type: LeastRequest
Esta política define valores conservadores para el Gateway.
Para rutas específicas, un equipo puede proponer una política más puntual sobre su HTTPRoute, por ejemplo:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: app-demo-backend-policy
namespace: app-dev
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: app-demo
timeout:
http:
requestTimeout: 60s
streamIdleTimeout: 5m
connectionIdleTimeout: 1h
retry:
numRetries: 2
perRetry:
timeout: 15s
retryOn:
triggers:
- connect-failure
- refused-stream
- unavailable
- cancelled
- retriable-status-codes
httpStatusCodes:
- 503
circuitBreaker:
maxConnections: 1024
maxPendingRequests: 256
maxParallelRequests: 2048
maxParallelRetries: 64
loadBalancer:
type: LeastRequest
Para aplicaciones con WebSocket, SSE, streaming o uploads grandes, estos valores deben revisarse con cuidado. No conviene aplicar timeouts o buffering de forma global sin entender el patrón de tráfico.
12. Publicación de aplicaciones con HTTPRoute
El contrato para los equipos de desarrollo es simple:
- La aplicación tiene un
Deployment. - La aplicación expone un
Service. - El equipo publica un
HTTPRoutehacia el Gateway del ambiente. - El namespace debe tener la label que habilita adjuntar rutas a ese Gateway.
12.1 Ejemplo dev
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo
namespace: app-dev
labels:
app: app-demo
spec:
replicas: 2
selector:
matchLabels:
app: app-demo
template:
metadata:
labels:
app: app-demo
spec:
containers:
- name: app
image: nginx:alpine
ports:
- name: http
containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: app-demo
namespace: app-dev
labels:
app: app-demo
spec:
selector:
app: app-demo
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-demo
namespace: app-dev
labels:
app.kubernetes.io/name: app-demo
platform.example.com/gateway-role: dev
spec:
parentRefs:
- name: dev-gw
namespace: envoy-gateway-system
sectionName: https-dev
hostnames:
- app-demo.dev.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: app-demo
port: 80
12.2 Ejemplo testing
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-demo
namespace: app-testing
labels:
app.kubernetes.io/name: app-demo
platform.example.com/gateway-role: testing
spec:
parentRefs:
- name: testing-gw
namespace: envoy-gateway-system
sectionName: https-testing
hostnames:
- app-demo.testing.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: app-demo
port: 80
12.3 Ejemplo internal
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-demo
namespace: app-internal
labels:
app.kubernetes.io/name: app-demo
platform.example.com/gateway-role: internal
spec:
parentRefs:
- name: internal-gw
namespace: envoy-gateway-system
sectionName: https-internal
hostnames:
- app-demo.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: app-demo
port: 80
Con esta definición, Envoy ya puede generar información útil como:
authority.route_name.upstream_cluster.upstream_host.trace_id.duration_ms.response_code.response_flags.
13. SecurityPolicy por ruta
Para algunos casos puede ser útil aplicar una SecurityPolicy propia de Envoy Gateway.
Ejemplo con CORS:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: app-demo-security-policy
namespace: app-dev
labels:
app.kubernetes.io/name: app-demo
platform.example.com/gateway-role: dev
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: app-demo
cors:
allowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
allowHeaders:
- Authorization
- Content-Type
- X-Requested-With
exposeHeaders:
- X-Request-Id
maxAge: 24h
allowCredentials: true
En mi caso, la idea no fue mover toda la lógica de seguridad a las rutas, sino dejar preparada la capacidad para aplicar políticas por aplicación cuando tenga sentido.
14. Observabilidad: métricas, logs y trazas
La observabilidad fue una parte central de la migración.
El Gateway entrega visibilidad base para todo el tráfico que pasa por Envoy:
- Métricas de Envoy.
- Access logs JSON.
- Trazas del salto Gateway -> upstream.
- Correlación por
trace_idyx-request-id.
La aplicación no necesita configurar Envoy para obtener estos datos básicos. Solo necesita publicar su servicio con HTTPRoute.
Si además queremos ver spans internos del backend, entonces la aplicación sí debe estar instrumentada con OpenTelemetry y propagar el contexto.
15. Prometheus dedicado para Envoy Gateway
Una opción que usé fue tener un Prometheus dedicado para Envoy Gateway.
Esto permite separar métricas de Gateway de otras métricas generales del cluster, al menos en una primera etapa.
15.1 RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus-envoy
namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus-envoy
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/proxy
- services
- endpoints
- pods
verbs:
- get
- list
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus-envoy
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus-envoy
subjects:
- kind: ServiceAccount
name: prometheus-envoy
namespace: monitoring
15.2 Configuración de scrape
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-envoy-config
namespace: monitoring
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager-envoy.monitoring.svc.cluster.local:9093
scrape_configs:
- job_name: envoy-gateway-internal
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- envoy-gateway-system
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
- source_labels: [__meta_kubernetes_pod_label_gateway_env]
action: keep
regex: internal
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- source_labels: [__meta_kubernetes_namespace]
target_label: kubernetes_namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: kubernetes_pod_name
- source_labels: [__meta_kubernetes_pod_label_gateway_envoyproxy_io_owning_gateway_name]
target_label: gateway
- source_labels: [__meta_kubernetes_pod_label_gateway_type]
target_label: gateway_type
- job_name: envoy-gateway-dev
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- envoy-gateway-system
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
- source_labels: [__meta_kubernetes_pod_label_gateway_env]
action: keep
regex: dev
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- source_labels: [__meta_kubernetes_namespace]
target_label: kubernetes_namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: kubernetes_pod_name
- source_labels: [__meta_kubernetes_pod_label_gateway_envoyproxy_io_owning_gateway_name]
target_label: gateway
- source_labels: [__meta_kubernetes_pod_label_gateway_type]
target_label: gateway_type
- job_name: envoy-gateway-testing
kubernetes_sd_configs:
- role: pod
namespaces:
names:
- envoy-gateway-system
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
- source_labels: [__meta_kubernetes_pod_label_gateway_env]
action: keep
regex: testing
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- source_labels: [__meta_kubernetes_namespace]
target_label: kubernetes_namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: kubernetes_pod_name
- source_labels: [__meta_kubernetes_pod_label_gateway_envoyproxy_io_owning_gateway_name]
target_label: gateway
- source_labels: [__meta_kubernetes_pod_label_gateway_type]
target_label: gateway_type
- job_name: otel-servicegraph
metrics_path: /metrics
static_configs:
- targets:
- otel-collector.monitoring.svc.cluster.local:8889
labels:
kubernetes_namespace: monitoring
app: otel-servicegraph
15.3 Deployment y Service de Prometheus
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus-envoy
namespace: monitoring
labels:
app: prometheus-envoy
spec:
replicas: 1
selector:
matchLabels:
app: prometheus-envoy
template:
metadata:
labels:
app: prometheus-envoy
spec:
serviceAccountName: prometheus-envoy
containers:
- name: prometheus
image: quay.io/prometheus/prometheus:v3.5.3
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
ports:
- name: web
containerPort: 9090
volumeMounts:
- name: config
mountPath: /etc/prometheus
- name: rules
mountPath: /etc/prometheus/rules
- name: data
mountPath: /prometheus
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
volumes:
- name: config
configMap:
name: prometheus-envoy-config
- name: rules
configMap:
name: prometheus-envoy-rules
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: prometheus-envoy
namespace: monitoring
labels:
app: prometheus-envoy
spec:
selector:
app: prometheus-envoy
ports:
- name: web
port: 9090
targetPort: web
16. Alertas operativas
Algunas alertas útiles para operar Envoy Gateway:
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-envoy-rules
namespace: monitoring
data:
envoy-gateway-alerts.yml: |
groups:
- name: envoy-gateway-internal
rules:
- alert: EnvoyGatewayInternalTargetDown
expr: up{job="envoy-gateway-internal"} == 0
for: 2m
labels:
severity: critical
team: platform
annotations:
summary: "Envoy Gateway internal target down"
description: "Prometheus cannot scrape {{ $labels.kubernetes_pod_name }} in {{ $labels.kubernetes_namespace }}."
- alert: EnvoyGatewayInternalNoScrapeTargets
expr: absent(up{job="envoy-gateway-internal"})
for: 5m
labels:
severity: critical
team: platform
annotations:
summary: "No Envoy Gateway internal scrape targets"
description: "Prometheus is not discovering internal Envoy Gateway pods."
- alert: EnvoyGatewayInternalHigh5xx
expr: |
(
sum(rate(envoy_http_downstream_rq_xx{job="envoy-gateway-internal",envoy_response_code_class="5"}[5m]))
/
clamp_min(sum(rate(envoy_http_downstream_rq_total{job="envoy-gateway-internal"}[5m])), 1)
) > 0.05
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "High 5xx rate on internal Envoy Gateway"
description: "More than 5% of downstream requests are 5xx for 5 minutes."
- alert: EnvoyGatewayInternalHigh4xx
expr: |
(
sum(rate(envoy_http_downstream_rq_xx{job="envoy-gateway-internal",envoy_response_code_class="4"}[5m]))
/
clamp_min(sum(rate(envoy_http_downstream_rq_total{job="envoy-gateway-internal"}[5m])), 1)
) > 0.20
for: 10m
labels:
severity: warning
team: platform
annotations:
summary: "High 4xx rate on internal Envoy Gateway"
description: "More than 20% of downstream requests are 4xx for 10 minutes."
- alert: EnvoyGatewayInternalHighP95Latency
expr: |
histogram_quantile(
0.95,
sum by (le) (
rate(envoy_http_downstream_rq_time_bucket{job="envoy-gateway-internal"}[5m])
)
) > 1000
for: 10m
labels:
severity: warning
team: platform
annotations:
summary: "High p95 latency on internal Envoy Gateway"
description: "Downstream request p95 latency is above 1000ms for 10 minutes."
- alert: EnvoyGatewayInternalNoHealthyUpstream
expr: |
sum(rate(envoy_cluster_upstream_rq_503{job="envoy-gateway-internal"}[5m])) > 0
for: 5m
labels:
severity: critical
team: platform
annotations:
summary: "Envoy Gateway internal has upstream 503s"
description: "Envoy is returning upstream 503 responses. Check backend endpoints and HTTPRoute backendRefs."
- alert: EnvoyGatewayInternalUpstreamConnectionFailures
expr: |
sum(rate(envoy_cluster_upstream_cx_connect_fail{job="envoy-gateway-internal"}[5m])) > 0
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "Envoy Gateway internal upstream connection failures"
description: "Envoy is failing to connect to one or more upstream clusters."
- alert: EnvoyGatewayInternalCertificateExpiringSoon
expr: |
min(envoy_server_days_until_first_cert_expiring{job="envoy-gateway-internal"}) < 14
for: 1h
labels:
severity: warning
team: platform
annotations:
summary: "Envoy Gateway internal certificate expires soon"
description: "The first certificate loaded by Envoy expires in less than 14 days."
- alert: EnvoyGatewayInternalTracingDrops
expr: |
sum(rate(envoy_tracing_opentelemetry_spans_dropped{job="envoy-gateway-internal"}[5m])) > 0
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "Envoy Gateway internal is dropping traces"
description: "Envoy is dropping OpenTelemetry spans. Check the collector and Tempo pipeline."
- alert: EnvoyGatewayInternalNoRoute
expr: |
sum(rate(envoy_http_no_route{job="envoy-gateway-internal"}[5m])) > 1
for: 10m
labels:
severity: warning
team: platform
annotations:
summary: "Envoy Gateway internal requests without route"
description: "Envoy is receiving traffic that does not match any configured route."
17. Alertmanager mínimo
apiVersion: apps/v1
kind: Deployment
metadata:
name: alertmanager-envoy
namespace: monitoring
labels:
app: alertmanager-envoy
spec:
replicas: 1
selector:
matchLabels:
app: alertmanager-envoy
template:
metadata:
labels:
app: alertmanager-envoy
spec:
containers:
- name: alertmanager
image: quay.io/prometheus/alertmanager:v0.32.1
args:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
ports:
- name: web
containerPort: 9093
volumeMounts:
- name: config
mountPath: /etc/alertmanager
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
volumes:
- name: config
configMap:
name: alertmanager-envoy-config
---
apiVersion: v1
kind: ConfigMap
metadata:
name: alertmanager-envoy-config
namespace: monitoring
data:
alertmanager.yml: |
global:
resolve_timeout: 5m
route:
receiver: default
group_by:
- alertname
- gateway
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: default
---
apiVersion: v1
kind: Service
metadata:
name: alertmanager-envoy
namespace: monitoring
labels:
app: alertmanager-envoy
spec:
selector:
app: alertmanager-envoy
ports:
- name: web
port: 9093
targetPort: web
18. Queries PromQL útiles
Requests por segundo:
sum(rate(envoy_http_downstream_rq_total{job="envoy-gateway-internal"}[5m]))
5xx rate:
sum(rate(envoy_http_downstream_rq_xx{job="envoy-gateway-internal",envoy_response_code_class="5"}[5m]))
/
clamp_min(sum(rate(envoy_http_downstream_rq_total{job="envoy-gateway-internal"}[5m])), 1)
4xx rate:
sum(rate(envoy_http_downstream_rq_xx{job="envoy-gateway-internal",envoy_response_code_class="4"}[5m]))
/
clamp_min(sum(rate(envoy_http_downstream_rq_total{job="envoy-gateway-internal"}[5m])), 1)
Latencia p95:
histogram_quantile(
0.95,
sum by (le) (
rate(envoy_http_downstream_rq_time_bucket{job="envoy-gateway-internal"}[5m])
)
)
Conexiones activas:
sum(envoy_http_downstream_cx_active{job="envoy-gateway-internal"})
Memoria Envoy:
sum(envoy_server_memory_allocated{job="envoy-gateway-internal"})
Upstream 503:
sum(rate(envoy_cluster_upstream_rq_503{job="envoy-gateway-internal"}[5m]))
Fallas de conexión a upstream:
sum(rate(envoy_cluster_upstream_cx_connect_fail{job="envoy-gateway-internal"}[5m]))
Targets scrapeados:
up{job="envoy-gateway-internal"}
19. Queries LogQL útiles
Logs por hostname:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| authority="app-demo.apps.example.com"
Logs por route:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| route_name=~".*app-demo.*"
Logs por trace id:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| trace_id="TRACE_ID"
Errores 5xx:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| response_code >= 500
Rutas sin match:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| response_code_details="route_not_found"
Upstream elegido por Envoy:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| line_format "host={{.authority}} path={{.path}} route={{.route_name}} cluster={{.upstream_cluster}} upstream={{.upstream_host}} code={{.response_code}} trace={{.trace_id}}"
20. OpenTelemetry en las aplicaciones
Envoy puede generar el span del Gateway. Pero para ver la traza completa, la aplicación también debe instrumentarse.
Variables típicas:
env:
- name: OTEL_SERVICE_NAME
value: app-demo
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.monitoring.svc.cluster.local:4317
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: grpc
- name: OTEL_RESOURCE_ATTRIBUTES
value: deployment.environment=dev,k8s.namespace.name=app-dev,service.namespace=app-dev
- name: OTEL_TRACES_SAMPLER
value: parentbased_traceidratio
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.10"
Headers que la aplicación debería preservar si llama a otros servicios:
traceparent.tracestate.x-request-id.x-forwarded-for.x-forwarded-proto.x-forwarded-host.
Si la aplicación no propaga estos headers, Tempo va a mostrar el span del Gateway, pero no necesariamente la continuidad interna del backend.
21. Pruebas rápidas
21.1 Validar Gateway
kubectl get gateway -n envoy-gateway-system
kubectl describe gateway -n envoy-gateway-system internal-gw
kubectl get svc -n envoy-gateway-system
21.2 Validar HTTPRoute
kubectl get httproute -A
kubectl describe httproute -n app-dev app-demo
Revisar condiciones:
Accepted=True
ResolvedRefs=True
21.3 Validar Service y Endpoints
kubectl get svc -n app-dev app-demo
kubectl get endpoints -n app-dev app-demo
kubectl get pods -n app-dev -l app=app-demo
21.4 Probar sin tocar DNS usando curl —resolve
curl -k -sS -o /tmp/gateway-test.out -w '%{http_code} %{time_total}\n' \
--resolve app-demo.dev.apps.example.com:443:192.0.2.11 \
-H 'traceparent: 00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01' \
-H 'x-envoy-force-trace: true' \
https://app-demo.dev.apps.example.com/
21.5 Buscar el access log por trace id
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| trace_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
21.6 Buscar traza en Tempo
Buscar por:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
22. Plan de migración desde Ingress
El enfoque que usé fue migrar en paralelo, no hacer un corte grande de una sola vez.
Paso 1: Inventariar Ingress actuales
kubectl get ingress -A
kubectl get ingress -A -o wide
Relevar:
- Namespace.
- Host.
- Service destino.
- Puerto.
- TLS Secret.
- Annotations especiales.
- Rewrites.
- Timeouts.
- CORS.
- Whitelists.
- Dependencias con DNS o edge externo.
Paso 2: Clasificar por ambiente
Ejemplo:
| Host | Namespace | Ambiente | Gateway destino |
|---|---|---|---|
app1.apps.example.com | app-internal | internal | internal-gw |
app1.dev.apps.example.com | app-dev | dev | dev-gw |
app1.testing.apps.example.com | app-testing | testing | testing-gw |
Paso 3: Preparar Gateways en paralelo
Antes de mover tráfico real:
kubectl apply -f 01-metallb-pools.yaml
kubectl apply -f 03-envoyproxy.yaml
kubectl apply -f 04-gateways.yaml
kubectl apply -f 05-namespace-access.yaml
kubectl apply -f 06-reference-grants.yaml
kubectl apply -f 08-prometheus-alerts.yaml
Paso 4: Crear HTTPRoutes equivalentes
Por cada Ingress:
Ingress host/path/backend
-> HTTPRoute host/path/backendRef
Paso 5: Validar sin cambiar DNS
Usar curl --resolve apuntando directo a la IP del Gateway.
Paso 6: Revisar observabilidad
Antes de hacer el corte, validar:
- Log en Loki.
- Métrica en Prometheus.
- Traza en Tempo.
HTTPRouteaceptada.Servicecon endpoints.- TLS correcto.
Paso 7: Cambiar DNS o edge routing
Una vez validado, cambiar la resolución del host al nuevo Gateway o modificar la regla del balanceador/edge correspondiente.
Paso 8: Mantener rollback simple
Durante una ventana de convivencia:
- Ingress anterior sigue disponible.
- Gateway nuevo recibe solo hosts migrados.
- Si falla, se revierte DNS/edge al Ingress anterior.
Paso 9: Retirar Ingress
Cuando la ruta ya está estable:
kubectl delete ingress -n app-dev app-demo
23. Troubleshooting checklist
Cuando una app no responde a través de Gateway API, reviso la cadena completa.
23.1 ¿El DNS o edge apunta al Gateway correcto?
dig app-demo.dev.apps.example.com
O probar directo:
curl -vk --resolve app-demo.dev.apps.example.com:443:192.0.2.11 https://app-demo.dev.apps.example.com/
23.2 ¿El Gateway está programado?
kubectl get gateway -n envoy-gateway-system
kubectl describe gateway -n envoy-gateway-system dev-gw
23.3 ¿El HTTPRoute fue aceptado?
kubectl describe httproute -n app-dev app-demo
Buscar:
Accepted=True
ResolvedRefs=True
Si ResolvedRefs=False, revisar:
- Nombre del Service.
- Puerto.
- Namespace.
- ReferenceGrant si hay referencias cross-namespace.
23.4 ¿El namespace tiene las labels correctas?
kubectl get ns app-dev --show-labels
23.5 ¿El Service tiene endpoints?
kubectl get svc -n app-dev app-demo
kubectl get endpoints -n app-dev app-demo
Si no hay endpoints, el problema probablemente está en selectors, pods o readiness probes.
23.6 ¿Envoy está devolviendo 404 o route_not_found?
Buscar en Loki:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| response_code_details="route_not_found"
23.7 ¿Envoy está devolviendo 503?
Revisar Prometheus:
sum(rate(envoy_cluster_upstream_rq_503{job="envoy-gateway-internal"}[5m]))
Y logs:
{k8s_namespace_name="envoy-gateway-system", k8s_container_name="envoy"}
| json
| response_code = 503
23.8 ¿Hay fallas de conexión al upstream?
sum(rate(envoy_cluster_upstream_cx_connect_fail{job="envoy-gateway-internal"}[5m]))
23.9 ¿El certificado está cargado correctamente?
openssl s_client -connect app-demo.dev.apps.example.com:443 -servername app-demo.dev.apps.example.com
O directo a la IP:
openssl s_client -connect 192.0.2.11:443 -servername app-demo.dev.apps.example.com
24. Guía mínima para equipos de desarrollo
Para publicar una aplicación, un equipo necesita:
- Namespace aprobado con labels del Gateway.
Servicecreado y con endpoints listos.HTTPRouteapuntando al Gateway y listener correcto.- Host DNS apuntando al LB correspondiente.
- Aplicación preservando
traceparentyx-request-id. - Aplicación instrumentada con OpenTelemetry si se necesitan spans internos.
- Validación en Loki, Prometheus y Tempo con una request de prueba.
Ejemplo mínimo de HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-demo
namespace: app-dev
labels:
platform.example.com/gateway-role: dev
app.kubernetes.io/name: app-demo
spec:
parentRefs:
- name: dev-gw
namespace: envoy-gateway-system
sectionName: https-dev
hostnames:
- app-demo.dev.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: app-demo
port: 80
25. Aprendizajes de la migración
25.1 Gateway API no es solo un reemplazo de Ingress
El error sería pensar:
Ingress -> HTTPRoute
La migración real se parece más a:
Ingress Controller + annotations + configuración distribuida
-> Gateway API + Envoy Gateway + políticas + observabilidad + contrato de plataforma
25.2 La observabilidad tiene que formar parte del diseño
Agregar observabilidad después suele dejar huecos.
En este caso, la decisión fue que cada request que entra por el Gateway pueda generar datos útiles desde el inicio:
- Logs estructurados.
- Métricas.
- Trazas.
- Correlación.
- Upstream seleccionado.
- Route name.
- Response flags.
25.3 El control por namespace es clave
Permitir que cualquier namespace publique rutas hacia cualquier Gateway puede volverse peligroso.
Las labels de namespace y allowedRoutes permiten tener un modelo más claro:
Este namespace puede publicar en dev.
Este namespace puede publicar en testing.
Este namespace puede publicar en internal.
25.4 ReferenceGrant obliga a ser explícito
Cuando hay referencias cross-namespace, Gateway API obliga a declarar permisos.
Eso agrega algo de YAML, pero mejora el modelo de seguridad.
25.5 Los runbooks importan tanto como los manifiestos
La implementación no termina cuando kubectl apply funciona.
También hace falta documentar:
- Cómo publicar una ruta.
- Cómo validar una app.
- Cómo buscar logs.
- Cómo buscar una traza.
- Qué revisar cuando hay 503.
- Qué significa una ruta sin match.
- Qué headers debe preservar una aplicación.
25.6 La migración tiene que ser gradual
Gateway API puede convivir con Ingress durante la transición.
Eso permite migrar host por host, ambiente por ambiente y aplicación por aplicación, manteniendo rollback simple.
26. Conclusión
Migrar desde Kubernetes Ingress hacia Gateway API fue mucho más que cambiar la forma de declarar rutas.
Fue una oportunidad para ordenar la capa de entrada HTTP de la plataforma:
- Gateways por ambiente.
- Publicación estándar con
HTTPRoute. - Control de namespaces.
- TLS termination.
- Políticas de tráfico.
- Observabilidad integrada.
- Alertas operativas.
- Guías para desarrolladores.
La parte más valiosa fue pasar de una visión centrada en “exponer servicios” a una visión más cercana a Platform Engineering:
La plataforma no solo debe permitir desplegar aplicaciones.
También debe hacerlas publicables, observables, trazables y operables.
Gateway API, combinado con Envoy Gateway, Prometheus, Loki, Tempo y OpenTelemetry, permite construir una base bastante sólida para eso.
No porque agregue más YAML, sino porque obliga a pensar mejor las responsabilidades:
- Plataforma define los Gateways, políticas y observabilidad.
- Desarrollo publica rutas con un contrato claro.
- Operación puede diagnosticar problemas desde el punto de entrada hasta el backend.
Y para mí ese fue el mayor aprendizaje de la migración.