En la lección anterior actualizamos api-reservas sin cortar el servicio, pero para comprobar que respondía tuvimos que buscar a mano la IP de un pod concreto y esperar que siguiera existiendo cinco segundos después. Ese es el agujero que nos queda: cada despliegue, cada autorreparación y cada escalado cambian las IPs de los pods, así que ningún componente puede conocer a otro por su dirección. El Service es la respuesta de Kubernetes: una dirección virtual estable, con nombre propio, que reparte el tráfico entre todas las réplicas sanas de un componente y sobrevive a que los pods de detrás vayan y vengan. En esta lección entenderás el problema en profundidad, verás qué es exactamente un Service y cómo el controlador de endpoints mantiene actualizada la lista de destinos, leerás el manifiesto campo a campo distinguiendo port de targetPort, crearás los cuatro Servicios de Rutas Norte —tienda-web, api-reservas, postgres-reservas y redis-cache—, los verificarás desde dentro del clúster con pods efímeros y aprenderás a diagnosticar el fallo más frecuente de todo Kubernetes: un selector que no casa con las etiquetas de los pods.

Contenido

  1. El problema: IPs efímeras
  2. Qué es un Service y qué te da
  3. Endpoints y EndpointSlices: quién mantiene la lista
  4. Anatomía del manifiesto: port, targetPort, protocol
  5. Por qué ClusterIP es el tipo por defecto
  6. Los Servicios de Rutas Norte
  7. Verificación desde dentro del clúster
  8. El nombre DNS y los servicios headless
  9. El fallo más común: el selector que no casa

  1. El problema: IPs efímeras

Hagamos visible el problema con un experimento de treinta segundos. Anota las IPs actuales de api-reservas:

kubectl get pods -l app=api-reservas -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP            NODE
api-reservas-c5a9b47d8-h7kdn   1/1   Running   0    22m   10.244.0.48   rutas-norte
api-reservas-c5a9b47d8-w3pqx   1/1   Running   0    22m   10.244.0.49   rutas-norte

Ahora provoca un despliegue cualquiera y vuelve a mirar:

kubectl rollout restart deployment/api-reservas
kubectl rollout status deployment/api-reservas
kubectl get pods -l app=api-reservas -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP            NODE
api-reservas-7f1d3e942-b8mrs   1/1   Running   0    18s   10.244.0.52   rutas-norte
api-reservas-7f1d3e942-n4jvt   1/1   Running   0    12s   10.244.0.53   rutas-norte

Nombres nuevos, IPs nuevas. Si tienda-web tuviera escrita en su configuración la dirección 10.244.0.48, acabaría de perder la conexión con la API. Y los momentos en que esto ocurre son constantes:

Situación Efecto sobre las IPs
Despliegue de una versión nueva Todos los pods se sustituyen: todas las IPs cambian
Autorreparación tras una caída El pod sustituto tiene IP nueva
Escalado hacia arriba Aparecen IPs que nadie conocía
Escalado hacia abajo Desaparecen IPs que alguien tenía apuntadas
Desalojo por falta de memoria El pod renace en otro nodo con otra IP

Y hay un segundo problema, tan importante como el primero: el reparto de carga. Aunque congeláramos las IPs, tienda-web tendría que conocer las dos, tres o siete réplicas de api-reservas y repartir el tráfico entre ellas por su cuenta, comprobando cuáles están sanas. Eso es reinventar un balanceador dentro de cada cliente.

Rutas Norte necesita tres cosas de golpe:

  1. Una dirección que no cambie nunca, aunque los pods de detrás cambien todos.
  2. Reparto automático del tráfico entre las réplicas disponibles.
  3. Un nombre, no una IP, para no tener que configurar direcciones en ningún sitio.

  1. Qué es un Service y qué te da

Un Service es un objeto de Kubernetes que define un punto de acceso lógico y estable a un conjunto de pods, seleccionados por etiquetas.

flowchart LR
    TW["Pods de tienda-web"]
    SVC["<b>Service api-reservas</b><br/>ClusterIP 10.96.184.22<br/>nombre DNS: api-reservas"]
    P1["Pod api-reservas<br/>10.244.0.52"]
    P2["Pod api-reservas<br/>10.244.0.53"]
    P3["Pod api-reservas<br/>10.244.0.61<br/>(creado tras un escalado)"]
    TW -->|"http://api-reservas:3000"| SVC
    SVC --> P1
    SVC --> P2
    SVC --> P3

Lo que aporta, punto por punto:

  • Una IP virtual estable, la ClusterIP. Se asigna al crear el Service y no cambia mientras el Service exista, independientemente de cuántos pods haya detrás o de si hay cero.
  • Un nombre DNS, que es lo que realmente usarás: api-reservas desde el mismo namespace.
  • Balanceo de carga entre todos los pods sanos que casen con el selector, por defecto aleatorio.
  • Actualización automática de destinos: cuando un pod nace, entra; cuando muere o deja de estar listo, sale. Sin intervención humana.
  • Un puerto lógico que puede ser distinto del puerto real del contenedor.

Un detalle que descoloca al principio: la ClusterIP no pertenece a ninguna máquina. No hay ninguna interfaz de red configurada con esa dirección, no responde a ping y no puedes hacerle ssh. Es una dirección virtual que existe únicamente como regla de reenvío instalada en cada nodo del clúster. Cuando un pod envía un paquete a 10.96.184.22, esa regla lo reescribe al vuelo hacia la IP real de uno de los pods de destino.

Quién instala esas reglas y cómo (iptables, IPVS, eBPF) es materia de Redes de Clúster. Para esta lección basta con el modelo mental: el Service es una regla de reenvío con nombre, no una máquina.

Un aviso sobre el balanceo, porque genera confusión: el reparto se hace por conexión, no por petición. Si un cliente abre una conexión HTTP persistente (keep-alive) o gRPC contra la ClusterIP, todas las peticiones de esa conexión acaban en el mismo pod. Es un comportamiento normal y previsible, pero conviene conocerlo antes de sorprenderse porque el tráfico "no se reparte".

  1. Endpoints y EndpointSlices: quién mantiene la lista

El Service, por sí solo, no sabe nada de pods. Solo declara un selector. Quien hace el trabajo sucio es otro controlador del kube-controller-manager: el controlador de endpoints.

Su bucle de reconciliación, que a estas alturas ya te resulta familiar:

  1. Lee el selector del Service.
  2. Busca los pods del namespace que casan con esas etiquetas.
  3. De ellos, selecciona los que están listos (Ready).
  4. Escribe la lista de sus IPs y puertos en un objeto EndpointSlice asociado al Service.
flowchart TD
    SVC["Service api-reservas<br/>selector: app=api-reservas, entorno=dev"]
    EC["Controlador de endpoints<br/>(kube-controller-manager)"]
    ES["EndpointSlice api-reservas-xk4p2<br/>10.244.0.52:3000 · ready<br/>10.244.0.53:3000 · ready"]
    KP["kube-proxy en cada nodo<br/>traduce a reglas de reenvío"]
    SVC --> EC
    EC -->|"observa pods con esas etiquetas"| EC
    EC --> ES
    ES --> KP

El punto crucial, y la razón de que existan las sondas: solo entran en la lista los pods listos. Un pod que arranca, uno que está terminando o uno cuya readinessProbe falla queda fuera y no recibe tráfico. Ese es el mecanismo exacto que hace posibles los despliegues sin corte de la lección anterior, y también la pieza que todavía nos falta hasta Verificaciones de Salud y Sondas.

Históricamente esta lista vivía en un objeto Endpoints (uno por Service, con todas las direcciones dentro). En clústeres grandes, un Service con miles de pods generaba un objeto enorme que se reenviaba entero a todos los nodos cada vez que cambiaba una sola IP. Por eso desde Kubernetes 1.21 el mecanismo real son los EndpointSlice: fragmentos de hasta 100 direcciones cada uno.

Aspecto Endpoints EndpointSlice
Objetos por Service 1 Tantos como hagan falta (100 direcciones por rebanada)
Escalabilidad Mala con miles de pods Diseñado para escalar
Estado Se mantiene por compatibilidad El mecanismo real desde 1.21
Comando kubectl get endpoints kubectl get endpointslices

En la práctica seguirás usando kubectl get endpoints para diagnosticar porque su salida es más compacta y legible, y Kubernetes lo mantiene sincronizado. Solo recuerda que por debajo son EndpointSlices.

  1. Anatomía del manifiesto: port, targetPort, protocol

Primer Service del proyecto, el de api-reservas:

# k8s/base/api-reservas-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-reservas
  namespace: rutas-norte-dev
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  type: ClusterIP
  selector:
    app: api-reservas
    entorno: dev
  ports:
    - name: http
      port: 3000
      targetPort: http
      protocol: TCP

Campo a campo:

  • apiVersion: v1: el Service pertenece al grupo core, como el Pod. No lleva apps/.
  • metadata.name: api-reservas: importantísimo, porque el nombre del Service es el nombre DNS. http://api-reservas:3000 funcionará gracias a esta línea.
  • spec.type: ClusterIP: es el valor por defecto; lo escribimos explícito por claridad. Los demás tipos, en Tipos de Servicios.
  • spec.selector: qué pods hay detrás. Aquí no hay matchLabels: el selector de un Service es un mapa plano de igualdad, más simple que el de un Deployment. Y una advertencia que desarrollaremos en el apartado 9: estas etiquetas deben coincidir con las de los pods, es decir, con spec.template.metadata.labels del Deployment, no con las del Deployment en sí.
  • ports[].name: http: obligatorio si hay más de un puerto, recomendable siempre.
  • ports[].port: 3000: el puerto del Service. Es al que se conectan los clientes: api-reservas:3000.
  • ports[].targetPort: http: el puerto del pod al que se reenvía. Aquí usamos el nombre del puerto declarado en el contenedor.
  • ports[].protocol: TCP: por defecto TCP. También admite UDP y SCTP.

port frente a targetPort

Es la confusión número uno con los Servicios. La regla es simple:

port es por donde entra el tráfico al Service. targetPort es por donde sale hacia el pod.

flowchart LR
    C["Cliente<br/>pod de tienda-web"] -->|"http://api-reservas:3000"| S["Service api-reservas<br/>port: 3000"]
    S -->|"reenvía a targetPort"| P["Pod api-reservas<br/>containerPort: 3000"]

No tienen por qué ser iguales, y a menudo no lo son. Un caso muy habitual en Rutas Norte:

ports:
  - name: http
    port: 80          # los clientes usan el puerto estandar
    targetPort: 3000  # pero la aplicacion Node escucha en 3000

Así tienda-web puede llamar a http://api-reservas sin especificar puerto, mientras la aplicación sigue escuchando donde le conviene.

targetPort con nombre

Compara las dos formas de apuntar al pod:

# Por numero
targetPort: 3000
# Por nombre, referenciando ports[].name del contenedor
targetPort: http

La segunda es claramente superior, y es la que adopta el proyecto. Ventajas:

  • Desacopla el Service del contenedor. Si mañana api-reservas pasa a escuchar en el 8080, basta cambiar el containerPort en el Deployment; el Service sigue siendo válido sin tocar una línea.
  • Permite un Service común a pods heterogéneos. Distintos pods pueden exponer el puerto http en números diferentes y el mismo Service los sirve a todos.
  • Se documenta solo. targetPort: metrics dice más que targetPort: 9090.

Requisito para poder usarla: el contenedor debe declarar el puerto con nombre. Nuestro Deployment ya lo hace:

ports:
  - name: http
    containerPort: 3000

Ojo con una asimetría que confunde: port no admite nombre, solo número. El nombre solo vale para targetPort.

  1. Por qué ClusterIP es el tipo por defecto

Kubernetes ofrece cuatro tipos de Service. Esta lección se centra en ClusterIP; el resto se tratan en detalle en Tipos de Servicios.

Tipo Alcance Uso típico
ClusterIP (por defecto) Solo dentro del clúster Comunicación entre componentes: la inmensa mayoría de los Servicios
NodePort Puerto en cada nodo Pruebas y entornos sin balanceador
LoadBalancer Balanceador externo del proveedor cloud Exposición pública, uno por servicio
ExternalName Alias DNS a un host externo Apuntar a un servicio de fuera del clúster

Que ClusterIP sea el defecto no es casualidad: es una decisión de diseño alineada con el principio de mínimo privilegio. Un componente no debería ser accesible desde fuera salvo que alguien lo decida explícitamente.

Aplicado a los seis componentes de Rutas Norte:

Componente Tipo de Service Por qué
tienda-web ClusterIP Se expondrá al exterior mediante Ingress, no con un Service público
api-reservas ClusterIP Igual: el Ingress enrutará api.rutasnorte.example hacia él
postgres-reservas ClusterIP Jamás debe ser accesible desde internet: contiene datos personales
redis-cache ClusterIP Solo lo consume api-reservas
worker-notificaciones Ninguno Nadie le habla; él sale a hablar con otros
informes-ocupacion Ninguno Tarea programada sin tráfico entrante

Fíjate en que incluso los componentes públicos usan ClusterIP. La exposición al exterior la hará un único Controlador de Ingress que enruta por dominio hacia estos Servicios internos: una sola puerta de entrada, un solo sitio donde terminar el TLS y aplicar reglas.

Y fíjate también en que dos componentes no llevan Service en absoluto. Un Service solo es necesario si alguien tiene que iniciar conexiones hacia el componente. worker-notificaciones consume una cola y habla con PostgreSQL, pero nadie le llama a él.

  1. Los Servicios de Rutas Norte

Vamos a dar dirección estable a la plataforma. Empezamos por el que ya tenemos escrito:

kubectl apply -f k8s/base/api-reservas-service.yaml
kubectl get svc api-reservas
service/api-reservas created

NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
api-reservas   ClusterIP   10.96.184.22    <none>        3000/TCP   4s

Ahí está la IP virtual: 10.96.184.22. Nunca cambiará mientras exista el Service.

tienda-web

# k8s/base/tienda-web-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: tienda-web
  namespace: rutas-norte-dev
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  type: ClusterIP
  selector:
    app: tienda-web
    entorno: dev
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP

postgres-reservas

Aún no hemos desplegado PostgreSQL —necesita almacenamiento persistente y llegará en el módulo 5—, pero podemos dejar preparado un Deployment provisional de una réplica para practicar la conectividad. El Service es lo que nos interesa:

# k8s/base/postgres-reservas-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres-reservas
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  type: ClusterIP
  selector:
    app: postgres-reservas
    entorno: dev
  ports:
    - name: postgres
      port: 5432
      targetPort: postgres
      protocol: TCP

Y el Deployment provisional, con una contraseña que por ahora va en claro. Es una mala práctica deliberada y temporal: se corrige en Secrets.

# k8s/base/postgres-reservas-deployment.yaml (PROVISIONAL: sin persistencia)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres-reservas
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: postgres-reservas
      entorno: dev
  template:
    metadata:
      labels:
        app: postgres-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: "reservas"
            - name: POSTGRES_USER
              value: "rutasnorte"
            - name: POSTGRES_PASSWORD
              value: "cambiame-en-modulo-3"   # provisional: ver leccion 03-02
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

redis-cache

# k8s/base/redis-cache-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: redis-cache
  namespace: rutas-norte-dev
  labels:
    app: redis-cache
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  type: ClusterIP
  selector:
    app: redis-cache
    entorno: dev
  ports:
    - name: redis
      port: 6379
      targetPort: redis
      protocol: TCP

Aplícalo todo y contempla el resultado:

kubectl apply -f k8s/base/
kubectl get svc
NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
api-reservas        ClusterIP   10.96.184.22    <none>        3000/TCP   3m
postgres-reservas   ClusterIP   10.96.201.140   <none>        5432/TCP   8s
redis-cache         ClusterIP   10.96.77.33     <none>        6379/TCP   8s
tienda-web          ClusterIP   10.96.12.209    <none>        80/TCP     8s

Cuatro direcciones estables para la plataforma Rutas Norte. A partir de ahora, ningún componente vuelve a conocer una IP de pod.

  1. Verificación desde dentro del clúster

Comprobar los endpoints

Antes de probar tráfico, verifica siempre que el Service ha encontrado pods:

kubectl get endpoints
NAME                ENDPOINTS                                       AGE
api-reservas        10.244.0.52:3000,10.244.0.53:3000              5m
postgres-reservas   10.244.0.58:5432                                2m
redis-cache         10.244.0.55:6379                                2m
tienda-web          10.244.0.44:80,10.244.0.45:80,10.244.0.46:80    2m

Esa columna ENDPOINTS es el dato más valioso del diagnóstico de Servicios: son las IPs reales a las que se reenviará el tráfico. Tres pods de tienda-web, dos de api-reservas, uno de cada base de datos. Exactamente lo esperado.

La vista moderna, en EndpointSlices:

kubectl get endpointslices -l kubernetes.io/service-name=api-reservas
NAME                 ADDRESSTYPE   PORTS   ENDPOINTS                 AGE
api-reservas-4x7km   IPv4          3000    10.244.0.52,10.244.0.53   5m

Probar el tráfico con un pod efímero

Usamos la técnica de la lección de Pods. Nota que ahora no necesitamos averiguar ninguna IP:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s http://api-reservas:3000/disponibilidad
{"servicio":"api-reservas","version":"2.4.0","pod":"api-reservas-7f1d3e942-b8mrs"}
pod "test" deleted

Hemos llamado a la API por su nombre. Ese http://api-reservas:3000 es exactamente la cadena de conexión que irá en la configuración de tienda-web, y no cambiará jamás.

Demostrar el balanceo de carga

Aquí es donde nos sirve haber incluido el nombre del pod en la respuesta de la API:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  sh -c 'for i in $(seq 1 8); do curl -s http://api-reservas:3000/ | grep -o "api-reservas-[a-z0-9-]*"; done'
api-reservas-7f1d3e942-b8mrs
api-reservas-7f1d3e942-n4jvt
api-reservas-7f1d3e942-n4jvt
api-reservas-7f1d3e942-b8mrs
api-reservas-7f1d3e942-b8mrs
api-reservas-7f1d3e942-n4jvt
api-reservas-7f1d3e942-b8mrs
api-reservas-7f1d3e942-n4jvt

Ocho peticiones repartidas entre las dos réplicas. El reparto es aleatorio, no estrictamente alterno: no esperes una distribución perfecta en pocas peticiones.

Comprobar la persistencia de la dirección

La prueba definitiva. Escala, redespliega y vuelve a llamar:

kubectl scale deployment api-reservas --replicas=4
kubectl rollout restart deployment/api-reservas
kubectl rollout status deployment/api-reservas
kubectl get svc api-reservas
kubectl get endpoints api-reservas
NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
api-reservas   ClusterIP   10.96.184.22   <none>        3000/TCP   12m

NAME           ENDPOINTS
api-reservas   10.244.0.64:3000,10.244.0.65:3000,10.244.0.66:3000,10.244.0.67:3000

La ClusterIP sigue siendo 10.96.184.22. Los cuatro endpoints son IPs completamente nuevas. Ese es el contrato del Service en una línea: la dirección de fuera no cambia nunca, la lista de dentro se actualiza sola.

Probar las bases de datos

kubectl run test-redis --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache -p 6379 ping
PONG
pod "test-redis" deleted
kubectl run test-pg --rm -it --image=postgres:16 --restart=Never --env="PGPASSWORD=cambiame-en-modulo-3" -- \
  psql -h postgres-reservas -U rutasnorte -d reservas -c "SELECT 'conexion correcta' AS estado;"
      estado
-------------------
 conexion correcta
(1 row)

pod "test-pg" deleted

Los cuatro componentes son alcanzables por nombre desde cualquier punto del clúster. La plataforma ya tiene sistema circulatorio.

Vuelve a 2 réplicas antes de seguir:

kubectl scale deployment api-reservas --replicas=2

  1. El nombre DNS y los servicios headless

El nombre completo

Cada Service obtiene automáticamente un registro DNS con esta forma:

<servicio>.<namespace>.svc.cluster.local

Para nuestra API: api-reservas.rutas-norte-dev.svc.cluster.local. Y como el DNS del clúster configura sufijos de búsqueda en cada pod, desde rutas-norte-dev puedes usar cualquiera de estas formas:

Forma Funciona desde Cuándo usarla
api-reservas El mismo namespace Lo habitual: configuración de componentes de Rutas Norte
api-reservas.rutas-norte-dev Cualquier namespace Cuando hay que cruzar entornos
api-reservas.rutas-norte-dev.svc.cluster.local Cualquier namespace Nombre completo, sin ambigüedad

Compruébalo:

kubectl run test-dns --rm -it --image=busybox:1.36 --restart=Never -- \
  nslookup api-reservas
Server:    10.96.0.10
Address:   10.96.0.10:53

Name:      api-reservas.rutas-norte-dev.svc.cluster.local
Address:   10.96.184.22

pod "test-dns" deleted

El nombre resuelve a la ClusterIP, no a las IPs de los pods. El funcionamiento interno de CoreDNS, los sufijos de búsqueda, los registros SRV y la resolución entre namespaces se estudian a fondo en DNS Interno y Descubrimiento de Servicios.

Servicios headless

Existe una variante que conviene conocer aunque no la usemos todavía: un Service con clusterIP: None, llamado headless.

spec:
  clusterIP: None       # servicio headless
  selector:
    app: postgres-reservas

Su comportamiento es distinto: no tiene IP virtual y no balancea nada. En su lugar, el DNS devuelve directamente las IPs de todos los pods que casan con el selector.

Aspecto Service normal Service headless
ClusterIP Sí, una IP virtual None
Qué devuelve el DNS La ClusterIP Todas las IPs de pod
Balanceo No: elige el cliente
Uso típico Cargas sin estado Cargas con estado, clientes que necesitan hablar con una réplica concreta

Para qué sirve: cuando un cliente necesita dirigirse a una réplica concreta en lugar de a "cualquiera". Es el caso de un clúster de PostgreSQL donde hay que escribir en el primario y leer de las réplicas, o de Kafka, o de cualquier sistema con quórum. Por eso los headless van casi siempre de la mano de los StatefulSets: la combinación da a cada réplica un nombre DNS propio y estable como postgres-reservas-0.postgres-reservas.rutas-norte-dev.svc.cluster.local.

Cuando en el módulo 6 convirtamos postgres-reservas en un StatefulSet, su Service pasará a ser headless. Los detalles, en DNS Interno y StatefulSets.

  1. El fallo más común: el selector que no casa

Si tuvieras que memorizar una sola cosa de esta lección, que sea esta. La avería número uno con Servicios en Kubernetes es un selector que no coincide con las etiquetas de los pods. Y es especialmente traicionera porque no produce ningún error: el Service se crea sin protestar, obtiene su ClusterIP, aparece en kubectl get svc con aspecto perfectamente sano... y no reenvía tráfico a ninguna parte.

Provocar la avería

# /tmp/servicio-roto.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-reservas-roto
  namespace: rutas-norte-dev
spec:
  type: ClusterIP
  selector:
    app: api-reserva        # falta la "s" final
    entorno: dev
  ports:
    - name: http
      port: 3000
      targetPort: 3000
kubectl apply -f /tmp/servicio-roto.yaml
kubectl get svc api-reservas-roto
service/api-reservas-roto created

NAME                TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
api-reservas-roto   ClusterIP   10.96.155.71   <none>        3000/TCP   3s

Aspecto impecable. Pero:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s --max-time 5 http://api-reservas-roto:3000/
curl: (7) Failed to connect to api-reservas-roto port 3000 after 2 ms: Could not connect to server

El procedimiento de diagnóstico

Cuatro pasos, siempre en este orden.

Paso 1: ¿tiene endpoints? Es la pregunta que resuelve el 80 % de los casos.

kubectl get endpoints api-reservas-roto
NAME                ENDPOINTS   AGE
api-reservas-roto   <none>      2m

<none>. El Service no ha encontrado ni un solo pod. Confirmado: el problema está en el selector o en las etiquetas, no en la red ni en la aplicación.

Paso 2: ¿qué selector tiene el Service?

kubectl describe svc api-reservas-roto | grep -i selector
Selector:  app=api-reserva,entorno=dev

Paso 3: ¿qué etiquetas tienen los pods?

kubectl get pods -l app=api-reservas --show-labels
NAME                           READY   STATUS    LABELS
api-reservas-7f1d3e942-b8mrs   1/1     Running   app=api-reservas,app.kubernetes.io/part-of=rutas-norte,entorno=dev,pod-template-hash=7f1d3e942

Paso 4: comparar. app=api-reserva frente a app=api-reservas. Ahí está la ese que falta.

La prueba definitiva es usar el selector del Service como filtro de kubectl: si no devuelve pods, el Service tampoco los encontrará.

kubectl get pods -l app=api-reserva,entorno=dev
No resources found in rutas-norte-dev namespace.
kubectl delete -f /tmp/servicio-roto.yaml

El catálogo completo de causas

Cuando ENDPOINTS está vacío o incompleto, la causa está siempre en esta tabla:

Causa Cómo se detecta Solución
Etiqueta mal escrita en el selector kubectl get pods -l <selector> no devuelve nada Corregir el selector
El selector apunta a las etiquetas del Deployment, no del pod Las etiquetas de metadata y de template.metadata difieren El selector debe casar con spec.template.metadata.labels
Service y pods en namespaces distintos kubectl get pods -n <ns> -l <selector> en el namespace del Service Un Service solo selecciona pods de su propio namespace
Los pods existen pero no están Ready kubectl get pods muestra 0/1 Arreglar el pod: mirar describe y logs
targetPort con un nombre que el contenedor no declara Hay endpoints, pero con puerto incorrecto o sin puerto Declarar ports[].name en el contenedor
El contenedor no escucha realmente en ese puerto Hay endpoints, pero la conexión se rechaza kubectl exec y comprobar el puerto real

De todas ellas, la segunda es la más sutil y la que más tiempo hace perder. Mira este Deployment:

metadata:
  name: api-reservas
  labels:
    app: api-reservas-deploy     # etiqueta del DEPLOYMENT
spec:
  template:
    metadata:
      labels:
        app: api-reservas        # etiqueta de los PODS

Un Service con selector: {app: api-reservas-deploy} no encontrará nada, porque esa etiqueta la lleva el Deployment, que no es un pod. El Service selecciona pods, siempre. La convención de Rutas Norte —usar las mismas tres etiquetas en el Deployment y en el template— evita este error por construcción.

Cuando sí hay endpoints y aun así falla

Si ENDPOINTS tiene direcciones pero la conexión no funciona, el problema está más abajo:

# ¿Responde el pod directamente, saltandose el Service?
kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s --max-time 5 http://10.244.0.52:3000/

# ¿Escucha el proceso en el puerto que dice?
kubectl exec deploy/api-reservas -- netstat -tlnp 2>/dev/null || \
kubectl exec deploy/api-reservas -- wget -qO- http://localhost:3000/

Si el pod responde por IP directa pero no por el Service, mira el targetPort. Si no responde ni por IP directa, el problema es la aplicación, no Kubernetes.

Y un último aviso que ahorra horas: si tu aplicación escucha en 127.0.0.1 en lugar de en 0.0.0.0, solo se responde a sí misma. El Service tendrá endpoints correctos y aun así todas las conexiones fallarán. Es un fallo de configuración de la aplicación que parece un fallo de Kubernetes.

Errores Comunes y Consejos

  • Selector que no casa con las etiquetas de los pods. El fallo número uno. El Service se crea sin error y no sirve nada. Diagnóstico: kubectl get endpoints.
  • Apuntar el selector a las etiquetas del Deployment. Los Services seleccionan pods: las etiquetas relevantes son las de spec.template.metadata.labels.
  • Confundir port con targetPort. port es donde escucha el Service; targetPort, donde escucha el contenedor.
  • Usar un nombre en port. Solo targetPort admite nombres. port es siempre numérico.
  • Esperar que un Service llegue a pods de otro namespace. No lo hace: solo selecciona en el suyo. Para cruzar, se usa el nombre DNS completo, como veremos en Namespaces.
  • Que la aplicación escuche en 127.0.0.1. Debe escuchar en 0.0.0.0 para ser alcanzable desde fuera del contenedor.
  • Crear un Service para worker-notificaciones. No recibe conexiones entrantes; un Service sin clientes es ruido y superficie de ataque innecesaria.
  • Esperar un reparto perfectamente alterno. El balanceo es aleatorio por conexión, no por petición. Con keep-alive, todas las peticiones de una conexión van al mismo pod.
  • Hacer ping a una ClusterIP. No responde: es una regla de reenvío, no una interfaz. Usa curl o nc al puerto del Service.
  • Consejo: el diagnóstico de Servicios empieza siempre por kubectl get endpoints <nombre>. Vacío significa problema de selector o de etiquetas; con direcciones, problema de puertos o de aplicación.
  • Consejo: usa siempre targetPort por nombre. Desacopla el Service del contenedor y te ahorra un cambio cada vez que la aplicación mueva su puerto.
  • Consejo: para una prueba rápida sin escribir manifiestos, kubectl expose deployment api-reservas --port=3000 --target-port=http --dry-run=client -o yaml genera un Service correcto que puedes revisar y guardar.

Ejercicios

Ejercicio 1: Crear y verificar el Service de tienda-web

  1. Aplica el Service de tienda-web de esta lección y comprueba que tiene 3 endpoints.
  2. Desde un pod efímero, haz 6 peticiones a http://tienda-web/ y verifica que responde nginx.
  3. Anota la ClusterIP, escala el Deployment a 5 réplicas, redespliégalo con rollout restart y demuestra con dos comandos que la ClusterIP no ha cambiado y que los endpoints sí.
  4. Explica por qué el Service usa port: 80 y targetPort: http en lugar de targetPort: 80.

Ejercicio 2: Diagnosticar tres Servicios rotos

Un compañero ha aplicado estos tres Servicios en rutas-norte-dev y ninguno funciona. Para cada uno, identifica la causa exacta, indica el comando que la revela y escribe la corrección.

# Servicio A
apiVersion: v1
kind: Service
metadata:
  name: redis-cache-a
  namespace: rutas-norte-dev
spec:
  selector:
    app: redis
    entorno: dev
  ports:
    - port: 6379
      targetPort: redis
# Servicio B
apiVersion: v1
kind: Service
metadata:
  name: api-reservas-b
  namespace: rutas-norte-dev
spec:
  selector:
    app: api-reservas
    entorno: dev
  ports:
    - port: 3000
      targetPort: api      # el contenedor declara el puerto como "http"
# Servicio C
apiVersion: v1
kind: Service
metadata:
  name: tienda-web-c
  namespace: default        # ojo
spec:
  selector:
    app: tienda-web
    entorno: dev
  ports:
    - port: 80
      targetPort: http

Ejercicio 3: Conectar la plataforma de extremo a extremo

Objetivo: que api-reservas hable con redis-cache y con postgres-reservas solo por nombre, sin ninguna IP.

  1. Comprueba que los tres Servicios tienen endpoints.
  2. Desde un pod efímero de redis:7.2-alpine, escribe en la caché la disponibilidad de una expedición: la clave plazas:BIL-SAN:2026-08-14 con valor 37. Recupérala después desde otro pod efímero distinto.
  3. Desde un pod efímero de postgres:16, crea la tabla reservas con columnas id, cliente y expedicion, inserta dos reservas ficticias y consúltalas.
  4. Escribe el fragmento de variables de entorno que llevaría el Deployment de api-reservas para conectarse a ambos por nombre, y explica por qué esa configuración es idéntica en rutas-norte-dev, rutas-norte-pre y rutas-norte-pro.

Soluciones

Solución 1

kubectl apply -f k8s/base/tienda-web-service.yaml
kubectl get svc,endpoints tienda-web
service/tienda-web created

NAME                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/tienda-web   ClusterIP   10.96.12.209   <none>        80/TCP    5s

NAME                   ENDPOINTS                                      AGE
endpoints/tienda-web   10.244.0.44:80,10.244.0.45:80,10.244.0.46:80   5s
kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  sh -c 'for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code} " http://tienda-web/; done; echo'
200 200 200 200 200 200
pod "test" deleted
kubectl scale deployment tienda-web --replicas=5
kubectl rollout restart deployment/tienda-web
kubectl rollout status deployment/tienda-web
kubectl get svc tienda-web
kubectl get endpoints tienda-web
NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
tienda-web   ClusterIP   10.96.12.209   <none>        80/TCP    4m

NAME         ENDPOINTS
tienda-web   10.244.0.71:80,10.244.0.72:80,10.244.0.73:80,10.244.0.74:80,10.244.0.75:80

La ClusterIP sigue siendo 10.96.12.209; los cinco endpoints son IPs nuevas.

  1. targetPort: http referencia el nombre del puerto declarado en el contenedor (ports[].name: http) en lugar de su número. Así, si mañana tienda-web cambia su nginx para escuchar en el 8080, basta con actualizar el containerPort en el Deployment: el Service sigue siendo correcto sin tocarlo. Es un desacoplamiento entre la definición del servicio y el detalle de implementación del contenedor, y además hace el manifiesto autoexplicativo.
kubectl scale deployment tienda-web --replicas=3

Solución 2

Servicio Causa Comando que la revela Corrección
A El selector usa app: redis, pero los pods llevan app: redis-cache kubectl get endpoints redis-cache-a<none>, y kubectl get pods -l app=redis no devuelve nada app: redis-cache
B targetPort: api no existe: el contenedor declara el puerto como http Hay endpoints, pero la conexión falla; kubectl describe svc api-reservas-b muestra TargetPort: api/TCP y kubectl get pod -o jsonpath='{.spec.containers[0].ports}' muestra name: http targetPort: http
C El Service está en el namespace default y los pods en rutas-norte-dev. Un Service solo selecciona pods de su propio namespace kubectl get endpoints tienda-web-c -n default<none>, mientras que kubectl get pods -n default -l app=tienda-web no devuelve nada namespace: rutas-norte-dev
# Comprobacion del A
kubectl get endpoints redis-cache-a
kubectl get pods -l app=redis
NAME            ENDPOINTS   AGE
redis-cache-a   <none>      1m

No resources found in rutas-norte-dev namespace.
# Comprobacion del B: hay endpoints pero el puerto es un nombre inexistente
kubectl describe svc api-reservas-b | grep -E "TargetPort|Endpoints"
TargetPort:  api/TCP
Endpoints:   <none>
# Comprobacion del C
kubectl get endpoints tienda-web-c -n default
kubectl get pods -n default -l app=tienda-web
NAME           ENDPOINTS   AGE
tienda-web-c   <none>      1m

No resources found in default namespace.

Solución 3

# 1. Los tres Servicios con endpoints
kubectl get endpoints api-reservas redis-cache postgres-reservas
NAME                ENDPOINTS                            AGE
api-reservas        10.244.0.52:3000,10.244.0.53:3000    25m
redis-cache         10.244.0.55:6379                     20m
postgres-reservas   10.244.0.58:5432                     20m
# 2. Escribir en la cache desde un pod y leer desde otro
kubectl run redis-esc --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache SET plazas:BIL-SAN:2026-08-14 37
OK
pod "redis-esc" deleted
kubectl run redis-lec --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache GET plazas:BIL-SAN:2026-08-14
"37"
pod "redis-lec" deleted

Dos pods efímeros distintos, creados y destruidos, han compartido estado a través de redis-cache sin conocer ninguna IP.

# 3. PostgreSQL
kubectl run pg-cli --rm -it --image=postgres:16 --restart=Never \
  --env="PGPASSWORD=cambiame-en-modulo-3" -- \
  psql -h postgres-reservas -U rutasnorte -d reservas -c "
    CREATE TABLE IF NOT EXISTS reservas (
      id SERIAL PRIMARY KEY,
      cliente TEXT NOT NULL,
      expedicion TEXT NOT NULL
    );
    INSERT INTO reservas (cliente, expedicion) VALUES
      ('Marta Ibarra', 'BIL-SAN 2026-08-14 08:30'),
      ('Xabier Aroca', 'BIL-SAN 2026-08-14 08:30');
    SELECT * FROM reservas;"
 id |   cliente    |        expedicion
----+--------------+--------------------------
  1 | Marta Ibarra | BIL-SAN 2026-08-14 08:30
  2 | Xabier Aroca | BIL-SAN 2026-08-14 08:30
(2 rows)

pod "pg-cli" deleted
  1. Configuración de api-reservas por nombre:
env:
  - name: REDIS_HOST
    value: "redis-cache"
  - name: REDIS_PORT
    value: "6379"
  - name: DATABASE_HOST
    value: "postgres-reservas"
  - name: DATABASE_PORT
    value: "5432"
  - name: DATABASE_NAME
    value: "reservas"
  - name: DATABASE_USER
    value: "rutasnorte"
  # DATABASE_PASSWORD llegara desde un Secret en la leccion 03-02

Esa configuración es idéntica en los tres entornos porque el nombre corto redis-cache se resuelve dentro del namespace en el que corre el pod. Un pod de api-reservas en rutas-norte-pro resolverá redis-cache como redis-cache.rutas-norte-pro.svc.cluster.local, mientras que el mismo pod en rutas-norte-dev lo resolverá como redis-cache.rutas-norte-dev.svc.cluster.local. El mismo manifiesto sirve para los tres entornos sin cambiar una sola línea, y cada uno habla con su propia base de datos y su propia caché. Es exactamente el mecanismo que explota la lección siguiente, Namespaces.

Conclusión

La plataforma Rutas Norte ya tiene sistema circulatorio. Has visto en primera persona por qué las IPs de pod son inservibles como punto de conexión —cambian en cada despliegue, cada autorreparación y cada escalado— y cómo el Service resuelve el problema con una ClusterIP virtual que no cambia jamás, un nombre DNS y balanceo automático entre las réplicas sanas. Sabes que esa IP no es una máquina sino una regla de reenvío, y que la lista de destinos reales la mantiene al día el controlador de endpoints, escribiendo en EndpointSlices únicamente las direcciones de los pods listos.

Lees un manifiesto de Service sin dudar: port es por donde entra el tráfico y targetPort por donde sale hacia el contenedor, preferiblemente por nombre para desacoplar el Service de la implementación. Entiendes por qué ClusterIP es el tipo por defecto y por qué en Rutas Norte lo son todos los Servicios, incluidos los de los componentes públicos, que se expondrán mediante una única puerta de entrada. Y has dado dirección estable a cuatro componentes: tienda-web, api-reservas, postgres-reservas y redis-cache, mientras que worker-notificaciones e informes-ocupacion no llevan Service porque nadie inicia conexiones hacia ellos. Lo has verificado sin trampas: pods efímeros llamando por nombre, ocho peticiones repartidas entre dos réplicas, y la prueba definitiva de escalar y redesplegar viendo cómo la ClusterIP aguanta mientras todos los endpoints se renuevan.

Por último, te llevas el procedimiento de diagnóstico más rentable de todo Kubernetes: cuando un Service no responde, lo primero es kubectl get endpoints. Vacío significa que el selector no casa con las etiquetas de los pods, y de ahí a comparar describe svc con get pods --show-labels hay un paso. Con direcciones, el problema está en los puertos o en la aplicación.

Has notado que en toda la lección hemos escrito rutas-norte-dev una y otra vez, y que la configuración de api-reservas no menciona el entorno por ninguna parte porque el DNS lo resuelve solo. Eso no es casualidad: es la propiedad que hace posible desplegar la misma plataforma tres veces sin tocar los manifiestos. En la lección siguiente, Namespaces, montaremos los tres entornos de Rutas Norte —dev, pre y pro—, desplegaremos el mismo manifiesto en varios de ellos, veremos qué aísla realmente un namespace y, lo más importante, qué no aísla en absoluto.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados