Día 56 - Deployment + Service NodePort para nginx
Problema / Desafío
Los devs de Nautilus quieren desplegar un sitio estático con alta disponibilidad y escalabilidad. El equipo de DevOps decide usar un Deployment con múltiples replicas, y exponerlo con un Service de tipo NodePort.
- Deployment:
nginx-deployment, imagennginx:latest, containernginx-container, 3 replicas - Service:
nginx-service, tipoNodePort, nodePort = 30011
Conceptos clave
Pod vs Deployment: ¿por qué casi nunca creamos Pods directamente?
Esta es probablemente la distinción más importante para entender Kubernetes.
| Característica | Pod (stand-alone) | Deployment |
|---|---|---|
| Tiene lifecycle | Sí (Pending → Running → Succeeded/Failed) |
Sí, pero a través de los Pods que controla |
| Gestiona su propio lifecycle | No: si el Pod se cae, queda muerto | Sí: si un Pod se cae, crea uno nuevo (self-healing) |
| Tiene número de replicas | No — es uno solo | Sí — spec.replicas define cuántas instancias quiero |
| Rolling updates | No — para cambiar imagen hay que delete + apply |
Sí — kubectl set image dispara rolling update |
| Rollback | No — el Pod viejo no se guarda | Sí — historial de ReplicaSets permite rollout undo |
| Campos inmutables | Muchísimos (volumeMounts, image, resources, etc.) | Casi todos los del template son mutables |
| Para qué sirve en producción | Casi nada — labs, debugging, jobs one-shot | El 90% de los workloads productivos |
Resumen: el Pod es el ladrillo, el Deployment es el albañil. El Pod es la unidad atómica que K8s sabe correr; el Deployment es lo que mantiene viva una flota de Pods.
Una analogía útil: un Pod es como un proceso en Linux. Si crashea, no resucita solo — alguien tiene que reiniciarlo (
systemd,supervisor, etc.). El Deployment es ese "supervisor": observa los Pods, y si nota que faltan replicas, crea más.
Self-healing: ¿qué pasa cuando se mata un Pod del Deployment?
- K8s borra el Pod
- El ReplicaSet controller nota que tiene 2 Pods donde debería tener 3
- Crea un Pod nuevo con el template del Deployment
- El Service automáticamente lo incluye (porque el nuevo Pod tiene el mismo label)
- Total: ~5-10 segundos de "downtime parcial" (2/3 capacidad), pero el sistema se cura solo
Con un Pod stand-alone (sin Deployment), borrar el Pod = downtime indefinido hasta que alguien recree.
La jerarquía: Deployment → ReplicaSet → Pod
Deployment: nginx-deployment (spec.replicas=3, declara qué quiero)
│
└── owns ──► ReplicaSet: nginx-deployment-7d9c4b8f (3 replicas reales)
│
├── owns ──► Pod: nginx-deployment-7d9c4b8f-xj2pl
├── owns ──► Pod: nginx-deployment-7d9c4b8f-q7s97
└── owns ──► Pod: nginx-deployment-7d9c4b8f-9846v
- Deployment declara qué estado deseo: 3 replicas de la imagen X
- ReplicaSet controla cuántos Pods existen: crea/borra Pods para llegar al número
- Pod es la unidad ejecutable: un grupo de containers corriendo en un nodo
La relación es por ownerReferences. Al borrar el Deployment, K8s borra en cascada los ReplicaSets, y esos borran sus Pods. Al borrar solo el ReplicaSet, sus Pods se borran (pero el Deployment crea uno nuevo).
¿Por qué existe la capa ReplicaSet? Para que el rolling update funcione. Cada cambio en el Pod Template (ej:
set image) hace que el Deployment cree un ReplicaSet nuevo y escale el viejo a 0. Así se puede hacer rollback rápido (ver Día 51 y Día 52).
Anatomía de un container — campos importantes
Solo declaramos los mínimos en este lab (name + image), pero un container "serio" puede tener muchos más:
| Campo | Para qué sirve |
|---|---|
name |
Identificador del container dentro del Pod (único). Se usa en kubectl logs/exec -c |
image |
Imagen de OCI/Docker (repo:tag o repo@sha256:...). Usar tag fijo, no latest, en prod |
imagePullPolicy |
Always / IfNotPresent / Never. Default IfNotPresent salvo si el tag es latest |
command |
Sobrescribe el ENTRYPOINT de la imagen. Lista de strings (un argv por elemento) |
args |
Sobrescribe el CMD de la imagen. Combinado con command |
ports |
Lista de containerPort (informacional — kubelet no abre puertos) |
env |
Variables de entorno hardcodeadas (name/value) o de fuente (valueFrom: secretKeyRef) |
envFrom |
Importar TODO un ConfigMap o Secret como variables de entorno |
volumeMounts |
Qué volúmenes del Pod monta y en qué path |
resources |
requests (para scheduler) y limits (enforced en runtime). Ver Día 50 |
livenessProbe |
Cómo K8s sabe si el container está "vivo". Si falla, K8s lo mata y reinicia |
readinessProbe |
Cómo K8s sabe si el container está listo para recibir tráfico. Si falla, el Service lo excluye |
startupProbe |
Para apps de arranque lento — pospone las otras probes hasta que esta pase |
securityContext |
UID/GID que corre el proceso, capabilities, readOnlyRootFilesystem, etc. |
lifecycle |
Hooks postStart (post-arranque) y preStop (pre-shutdown). Útil para graceful shutdown |
Ejemplo de un container "completo" para referencia:
containers:
- name: nginx-container
image: nginx:1.27.0 # tag fijo, no latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
name: http
protocol: TCP
env:
- name: TZ
value: "America/Argentina/Buenos_Aires"
- name: API_KEY
valueFrom:
secretKeyRef: { name: api-secrets, key: key }
resources:
requests: { cpu: 100m, memory: 64Mi }
limits: { cpu: 500m, memory: 128Mi }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet: { path: /, port: 80 }
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 101
readOnlyRootFilesystem: true
Qué es un Service y por qué se necesita
Problema fundamental: los Pods son efímeros. Sus IPs cambian cuando se recrean (rolling update, eviction, crash). Si una app cliente apunta a 10.42.0.5 y ese Pod muere y se recrea como 10.42.0.7, el cliente queda apuntando al vacío.
Solución — Service: un objeto K8s que da una IP estable y un DNS estable que ruteam tráfico al conjunto cambiante de Pods (identificados por label). El Service:
- Tiene una ClusterIP virtual fija dentro del cluster (
10.43.x.xen k3s,10.96.x.xen kubeadm default) - Tiene un DNS interno:
<service-name>.<namespace>.svc.cluster.local - Mantiene una lista de endpoints (los Pods que matchean su selector) que actualiza automáticamente cada vez que un Pod aparece/desaparece
- El kube-proxy corriendo en cada nodo instala reglas iptables/IPVS que hacen el ruteo desde la ClusterIP hacia los Pods reales
Cliente intra-cluster
│
│ request a nginx-service:80 (DNS resuelve a 10.43.110.17)
▼
Service ClusterIP: 10.43.110.17:80
│ kube-proxy intercepta y elige un Pod (round-robin)
│
├──► Pod 10.42.1.5:80 (nginx-deployment-xj2pl)
├──► Pod 10.42.2.7:80 (nginx-deployment-q7s97)
└──► Pod 10.42.0.3:80 (nginx-deployment-9846v)
Tipos de Service (spec.type)
| Tipo | Alcance | Cómo se accede | Cuándo usarlo |
|---|---|---|---|
ClusterIP |
Solo dentro del cluster | <svc-name>.<ns>.svc.cluster.local o la ClusterIP |
Default. Tráfico interno (app → DB, app → cache) |
NodePort |
Externo al cluster | <IP-de-cualquier-nodo>:<nodePort> (rango default: 30000-32767) |
Exposición rápida en labs, on-prem sin load balancer |
LoadBalancer |
Externo (cloud) | IP pública asignada por el proveedor cloud (AWS ELB, GCP LB, etc.) | Producción en cloud. Internamente crea NodePort + LB externo |
ExternalName |
Alias de DNS externo | DNS interno mapea a un nombre externo (db.example.com) |
Apuntar a servicios fuera del cluster sin proxy |
Headless (ClusterIP: None) |
DNS directo a pods | DNS devuelve las IPs de los pods individuales, sin balanceo | StatefulSets, descubrimiento peer-to-peer |
Anatomía de un Service NodePort — los 3 puertos
Un Service de tipo NodePort involucra tres puertos distintos que se confunden todo el tiempo:
ports:
- port: 80 # ← Puerto del Service (ClusterIP)
targetPort: 80 # ← Puerto del Pod
nodePort: 30011 # ← Puerto expuesto en CADA nodo del cluster
| Campo | Rol | Visible desde |
|---|---|---|
port |
Puerto donde el Service escucha en la ClusterIP virtual | Solo intra-cluster |
targetPort |
Puerto del proceso adentro del Pod al que se redirige | Solo el Service lo usa |
nodePort |
Puerto que cada nodo del cluster abre hacia afuera. Tráfico llega ahí → entra al cluster → al Service | Externo al cluster |
Flujo de tráfico para acceso externo:
Cliente externo (mi laptop)
│
│ HTTP GET http://<IP-del-nodo>:30011/
▼
Nodo del cluster (kube-proxy escucha 30011 en cada nodo)
│ kube-proxy NAT-ea hacia la ClusterIP
▼
Service ClusterIP (10.43.110.17:80)
│ kube-proxy elige un endpoint
▼
Pod (10.42.1.5:80) — nginx escuchando
Importante:
nodePortse abre en TODOS los nodos del cluster, incluso los que no corren ningún Pod del Service. kube-proxy en cada nodo hace el routing. Por eso se puede pegarle a cualquierIP-de-nodo:30011y va a funcionar.
El selector como pegamento de los 3 niveles
Esta es la parte que mucha gente no internaliza al principio:
# Deployment
spec:
selector:
matchLabels:
app: nginx-deployment # ← (1) busca pods con este label
template:
metadata:
labels:
app: nginx-deployment # ← (2) PONE este label en los pods que crea
# Service
spec:
selector:
app: nginx-deployment # ← (3) rutea tráfico a pods con este label
Los tres app: nginx-deployment DEBEN coincidir. No hay un check formal — son solo strings — pero si difieren, todo se desconecta silenciosamente:
- Si el
selectordel Deployment no matchea lostemplate.labels: el Deployment crea pods pero los considera "huérfanos" y crea más → loop infinito - Si el
selectordel Service no matchea: el Service queda con 0 endpoints → 503/timeout al acceder - Si los
template.labelscambian pero elselectorno: el Deployment no reconoce sus propios pods como suyos
Comprobar la conexión Service → Pods:
Si ENDPOINTS está vacío, el selector no matchea ningún Pod.
Pasos
- Escribir
deployment.ymlcon 3 replicas kubectl apply -f deployment.ymly verificar que aparezcan 3 Pods Running- Escribir
service.ymlcon tipo NodePort ynodePort: 30011 kubectl apply -f service.ymly verificar que tenga endpoints- Probar acceso por la ClusterIP (intra) y por el NodePort (extra)
Comandos / Código
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
selector:
matchLabels:
app: nginx-deployment
template:
metadata:
labels:
app: nginx-deployment
spec:
containers:
- name: nginx-container
image: nginx:latest
3/3 confirma que las 3 replicas están corriendo y listas.
Service NodePort
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: NodePort
selector:
app: nginx-deployment
ports:
- port: 80
targetPort: 80
nodePort: 30011
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-service NodePort 10.43.110.17 <none> 80:30011/TCP 8s
Lectura clave de la columna PORT(S):
80→ elportdel Service (en la ClusterIP10.43.110.17)30011→ elnodePort(expuesto en cada nodo)- (
targetPort: 80no aparece acá — está implícito en la config interna)
Verificar que el Service tiene endpoints
Tres endpoints = tres pods listos detrás del Service. Si la lista estuviera vacía o tuviera menos de 3, el selector del Service no estaría matcheando todos los Pods.
Alternativa moderna:
kubectl get endpointslice -l kubernetes.io/service-name=nginx-service. Desde K8s 1.21 se usanEndpointSlice(más escalables) perokubectl get endpointssigue funcionando por compatibilidad.
Verificar la conexión (las 3 vías reales)
Vía 1 — Intra-cluster con FQDN completo (<svc>.<ns>.svc.cluster.local)
Crear un pod ephemeral (con --rm se borra al terminar) en el namespace default y pegarle al Service del namespace test-system usando el FQDN:
kubectl run test-curl --image=curlimages/curl -it --rm --restart=Never \
-- curl -I http://nginx-service.test-system.svc.cluster.local:80/
HTTP/1.1 200 OK
Server: nginx/1.31.0
Date: Sat, 16 May 2026 12:38:24 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Wed, 13 May 2026 12:43:09 GMT
Connection: keep-alive
ETag: "6a0471dd-380"
Accept-Ranges: bytes
El FQDN tiene la forma <service>.<namespace>.svc.cluster.local. Esta es la forma portable — funciona desde cualquier namespace, ideal para apps cross-namespace.
Vía 2 — Externa al cluster, vía NodePort
HTTP/1.1 200 OK
Server: nginx/1.31.0
Date: Sat, 16 May 2026 12:39:29 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Wed, 13 May 2026 12:43:09 GMT
Connection: keep-alive
ETag: "6a0471dd-380"
Accept-Ranges: bytes
10.0.0.1 es la IP de un nodo del cluster. El puerto 30011 es el nodePort configurado — abierto en TODOS los nodos, así que cualquier IP de nodo del cluster + :30011 funciona. Lo confirmás con kubectl get nodes -o wide.
Vía 3 — Intra-cluster con nombre corto (mismo namespace)
kubectl run test-curl -n test-system --image=curlimages/curl -it --rm --restart=Never \
-- curl -I http://nginx-service:80
HTTP/1.1 200 OK
Server: nginx/1.31.0
Date: Sat, 16 May 2026 12:40:40 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Wed, 13 May 2026 12:43:09 GMT
Connection: keep-alive
ETag: "6a0471dd-380"
Accept-Ranges: bytes
Notá el -n test-system en kubectl run: el pod cliente vive en el mismo namespace que el Service. Eso es lo que permite resolver el nombre corto nginx-service sin FQDN — el DNS de cluster (CoreDNS) auto-completa el sufijo .test-system.svc.cluster.local cuando el pod hace la query.
¿Por qué
--rmy--restart=Never?--restart=Neverle dice akubectl runque cree un Pod stand-alone (no un Deployment).--rmborra el Pod cuando termina el comando. Combinado con-itda un "container ephemeral" perfecto para troubleshooting: corre, hace lo suyo, desaparece.
Las 3 vías compradas
| Vía | Comando | Cuándo funciona |
|---|---|---|
| FQDN completo | curl http://nginx-service.test-system.svc.cluster.local |
Desde cualquier pod del cluster, en cualquier namespace |
| Nombre corto | curl http://nginx-service |
Solo si el pod cliente está en el mismo namespace que el Service |
| NodePort externo | curl http://<IP-de-cualquier-nodo>:30011 |
Desde afuera del cluster (cualquier IP de nodo sirve) |
| ClusterIP directo (sin DNS) | curl http://10.43.110.17 |
Sirve pero es frágil — la IP cambia si el Service se recrea |
Confirmación de que las 3 vías llegan al mismo contenido: el header ETag: "6a0471dd-380" es idéntico en las tres respuestas. El ETag es un hash del contenido + mtime del archivo servido. Si las 3 replicas tuvieran versiones distintas de la imagen, podrías ver ETags distintos. Acá las 3 corren nginx:1.31.0 con el mismo index.html default → mismo ETag.
Confirmar el balanceo entre replicas
Por qué kubectl exec deployment/<name> NO sirve para esto
nginx-deployment-699d747d58-k98pp
nginx-deployment-699d747d58-k98pp
nginx-deployment-699d747d58-k98pp
... (20 veces el mismo pod)
No es un bug del Service — es que kubectl exec deployment/X resuelve internamente al primer Pod que matchea el selector y exec-ea directo a ese, sin pasar nunca por kube-proxy. Equivale a:
# Lo que kubectl hace por debajo:
POD=$(kubectl get pods -l app=nginx-deployment -o jsonpath='{.items[0].metadata.name}')
kubectl exec -it $POD -- hostname
Para validar balanceo necesitamos: (a) requests HTTP que sí pasen por el Service, y (b) que cada Pod devuelva contenido distinto.
Test real: inyectar hostname y curl al Service
# Paso 1: en cada pod, sobrescribir el index.html con su propio hostname
for pod in $(kubectl get pods -n test-system -l app=nginx-deployment -o jsonpath='{.items[*].metadata.name}'); do
kubectl exec -n test-system $pod -- sh -c "echo $pod > /usr/share/nginx/html/index.html"
done
# Paso 2: pegarle 30 veces al Service desde un pod ephemeral
kubectl run test-curl -n test-system --image=curlimages/curl -it --rm --restart=Never \
-- sh -c 'for i in $(seq 1 30); do curl -s http://nginx-service/; done' \
| sort | uniq -c
Output real:
15 nginx-deployment-699d747d58-k98pp
6 nginx-deployment-699d747d58-tf2h8
9 nginx-deployment-699d747d58-v8z8g
1 pod "test-curl" deleted from test-system namespace
Lectura:
- Las 3 replicas respondieron — el balanceo funciona, los 3 pods están detrás del Service
- 15 + 6 + 9 = 30 requests = todas las del loop
- La distribución es desbalanceada (50%/30%/20%) — esto NO es un bug, es variancia con muestra chica. kube-proxy en iptables usa selección random independiente por request (probabilidad 1/3 cada uno). Con 30 muestras la desviación esperada es alta; con 300 converge a ~100 cada uno, con 3000 a ~1000.
- La última línea
1 pod "test-curl" deleted ...viene del--rm(kubectl avisa por stderr que borró el pod). Es ruido — para limpiarlo, agregar2>/dev/nullal final del comando.
Cómo funciona kube-proxy por dentro (a nivel iptables)
# Reglas iptables que instala kube-proxy para el Service (simplificado):
KUBE-SVC-NGINX:
-m statistic --mode random --probability 0.33333 -j KUBE-SEP-POD1
-m statistic --mode random --probability 0.50000 -j KUBE-SEP-POD2 ← prob 0.5 sobre lo que sobra
-j KUBE-SEP-POD3 ← el resto cae acá
Cada packet que llega al ClusterIP atraviesa esa chain. Las probabilidades 0.33 / 0.5 / 1.0 están calibradas para dar ~1/3 cada una sobre el total. Pero la decisión es por-connection (gracias a conntrack), no por-packet — una vez que una TCP connection se establece con un endpoint, todos sus packets van ahí. Cada curl del loop abre una connection nueva → decisión nueva.
Implicación práctica: si tu cliente usa HTTP keep-alive (mantiene la connection abierta entre requests), todas esas requests van al mismo pod. Lo mismo aplica a WebSockets, gRPC sin re-resolución de DNS, conexiones JDBC con pool fijo, etc. Para distribuir carga real con clientes long-lived, hace falta un Service mesh (Istio/Linkerd) o un LB L7 (Ingress con nginx/Envoy) que haga balanceo por-request en vez de por-connection.
Modos de kube-proxy
kube-proxy tiene varios modos seleccionables al instalar el cluster:
| Modo | Algoritmo | Default en | Performance | Algoritmos disponibles |
|---|---|---|---|---|
iptables |
Random con weights (lo que vimos) | kubeadm, k3s | O(N) reglas | Solo random |
IPVS |
Configurable por algoritmo | Cluster grandes (1000+) | O(1) lookup | rr, lc, dh, sh, wlc, etc. |
nftables |
Equivalente a iptables pero mejor | K8s 1.31+ (alpha→beta) | Mejor que iptables | Random |
kernelspace |
Windows-only | Windows nodes | - | - |
Lo ves con: kubectl get cm kube-proxy -n kube-system -o yaml | grep mode:. La mayoría de clusters chicos usan iptables y para los efectos prácticos del balanceo se siente igual que round-robin.
Cuándo NO usar NodePort
- En producción cloud: usar
LoadBalancer. El cloud provider crea un LB externo (con DNS, TLS termination, etc.) y los nodos no quedan expuestos directamente. - Para HTTPS / paths múltiples / virtual hosts: usar un Ingress controller. Un solo LoadBalancer por delante del Ingress, y el Ingress hace ruteo L7 (por path, por host).
- Si el cluster está en una red corporativa con firewalls estrictos: NodePort abre puertos en cada nodo (rango 30000-32767), lo cual choca con políticas de firewall corporativas comunes.
Troubleshooting
| Problema | Causa y solución |
|---|---|
kubectl get svc muestra el Service pero ENDPOINTS está vacío |
El selector del Service no matchea los labels de ningún Pod. Verificar con kubectl get pods --show-labels |
Deployment crea pods pero READY 0/3 |
Imagen no se puede pull, falta config, app crashea al arrancar. kubectl describe pod <pod> y kubectl logs <pod> |
| Service responde 503 / connection refused desde afuera | Pod no escucha en targetPort. Verificar con kubectl exec <pod> -- ss -tlnp o cambiar targetPort para que matchee el puerto real |
| Pego al NodePort y obtengo timeout | Firewall del nodo bloquea el puerto. En cloud, abrir el security group para el rango 30000-32767 desde la IP que necesite |
nodePort: 30011 ya está en uso |
Otro Service ya tomó ese puerto. Cambiar a otro valor en el rango 30000-32767, o dejar nodePort sin especificar para que K8s lo asigne |
| El Deployment crea pods nuevos en loop, infinitamente | selector.matchLabels no matchea template.metadata.labels. Resultado: los pods no son reconocidos como del Deployment → se crean más |
| Borré el pod y no se recrea | El pod no estaba siendo controlado por un Deployment (era stand-alone). Verificar con kubectl get pod <name> -o jsonpath='{.metadata.ownerReferences}' |
Tag :latest causa comportamiento inesperado en rolling updates |
Con :latest, K8s no sabe si la "imagen actual" es la misma que la "imagen nueva". Usar tags inmutables (nginx:1.27.0) en prod |
Recursos
- Deployments (oficial)
- Service (oficial)
- Service types — NodePort/LoadBalancer/etc (oficial)
- Container spec reference (oficial)
kubectl explainpara descubrir campos — ej:kubectl explain pod.spec.containers --recursive- Pod Lifecycle (oficial)
- Connecting Applications with Services (tutorial)