← Blog
·10 blog.minutes

Docker y Kubernetes: Una Guía Práctica para Desarrolladores Modernos

Una guía sin rodeos sobre contenerización, mejores prácticas de Dockerfile, fundamentos de Kubernetes y saber cuándo realmente necesitas un orquestador.

Todo desarrollador se topa eventualmente con el mismo muro. Escribes código en tu máquina, funciona perfectamente, lo subes a staging y explota con un error críptico sobre una dependencia del sistema faltante o una versión de librería diferente. El clásico problema "funciona en mi máquina" ha perseguido al desarrollo de software durante décadas, y Docker no solo lo resolvió, sino que hizo la solución tan simple que ahora no hay excusa para no usarlo.

Pero Docker resuelve solo el problema de empaquetado. Una vez que has contenerizado tu aplicación, aún necesitas ejecutarla en producción, potencialmente a través de múltiples servidores, con balanceo de carga, despliegues sin tiempo de inactividad, comprobaciones de salud y recuperación automática. Ahí es donde entra Kubernetes. Y ahí es también donde la mayoría de los desarrolladores se pierden en la complejidad, porque Kubernetes introduce un vocabulario completamente nuevo de abstracciones que lleva tiempo internalizar.

Esta guía corta a través del ruido. Cubre qué son realmente los contenedores bajo el capó, cómo escribir Dockerfiles que sean eficientes y seguros, los conceptos de Kubernetes que necesitas saber para desplegar aplicaciones reales, las compensaciones entre Docker Compose y Kubernetes para desarrollo local y, lo más importante, cuándo deberías recurrir a cada herramienta y cuándo deberías dejarla tranquila.

Qué son realmente los contenedores

Un contenedor no es una máquina virtual ligera. Este es el error más común y lleva a modelos mentales incorrectos. Una máquina virtual ejecuta un sistema operativo invitado completo sobre un hipervisor, con su propio kernel, su propia asignación de memoria y sus propios controladores de dispositivos. Un contenedor comparte el kernel del anfitrión y se ejecuta como un proceso de espacio de usuario aislado. El aislamiento lo proporcionan características del kernel de Linux: namespaces para el aislamiento de procesos, cgroups para los límites de recursos y overlay filesystems para las capas de imagen eficientes.

Esta distinción importa porque explica el comportamiento que observarás. Los contenedores se inician en milisegundos porque no hay kernel que arrancar. Usan menos memoria porque no hay kernel duplicado ni procesos de sistema redundantes. Pero también significan que un contenedor ejecutándose en Linux no puede ejecutar una versión de kernel diferente a la del anfitrión, y un contenedor de Windows requiere un anfitrión Windows (o una VM Hyper-V Linux en versiones anteriores). En macOS, Docker Desktop ejecuta contenedores Linux dentro de una VM ligera precisamente por esta razón.

Una imagen es la plantilla de solo lectura: una instantánea de un sistema de archivos más metadatos. Un contenedor es una instancia en ejecución de esa imagen, con una capa writable encima. Puedes construir una imagen una vez y ejecutar docenas de contenedores a partir de ella. Esta es la unidad fundamental de operación en el mundo Docker, y entenderla claramente hace que todo lo demás sea más fácil.

Mejores prácticas de Dockerfile

Un Dockerfile es una receta para construir una imagen. Cada instrucción crea una nueva capa, y las capas se almacenan en caché. Esto significa que el orden de las instrucciones afecta directamente la velocidad de construcción, el tamaño de la imagen y la seguridad. Aquí están los principios que más importan en proyectos reales.

Ordena las capas por frecuencia de cambio

Docker almacena en caché cada capa después de construirla. Si una capa no ha cambiado desde la última construcción, Docker reutiliza la versión en caché. Esto significa que debes poner las instrucciones que cambian raramente al principio y las instrucciones que cambian con frecuencia al final. Las dependencias del sistema (apt-get, apk add) casi nunca cambian. Las dependencias de la aplicación (npm install, pip install) cambian cuando actualizas tu lockfile. El código fuente de la aplicación cambia en cada commit.

# Bad: source code before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .                 # busts the cache for everything below
RUN npm ci                # runs on every build, even if package.json did not change
EXPOSE 3000
CMD ["node", "dist/index.js"]

# Good: stable-first layer ordering
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                # cached unless package.json changes
COPY . .                  # only the source changes bust this layer
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

La diferencia es dramática. El Dockerfile malo reconstruye todas las dependencias en cada commit. El bueno reconstruye las dependencias solo cuando el lockfile cambia, que es típicamente una vez por pull request en lugar de cada commit. En un proyecto Node.js con 500 dependencias, esto puede ahorrar dos minutos por construcción.

Construcciones multi-etapa

Las construcciones multi-etapa te permiten usar un Dockerfile para construir tu aplicación y producir una imagen de runtime mínima. La etapa de construcción contiene compiladores, dependencias de desarrollo y herramientas de construcción. La etapa de runtime copia solo la salida compilada. Esto mantiene las imágenes de producción pequeñas y reduce la superficie de ataque.

# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage — starts from a fresh, minimal base
FROM node:20-alpine AS runner
WORKDIR /app

# Only what is needed to run
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package.json ./
COPY --from=builder /build/node_modules ./node_modules

EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

La etapa de runtime no incluye el compilador de TypeScript, los archivos fuente ni las dependencias de desarrollo. Para una aplicación típica, esto reduce la imagen de 800 MB a menos de 200 MB. La sintaxis COPY --from=builder es la clave: extrae archivos de una etapa anterior sin arrastrar las capas de esa etapa.

Ejecutar como un usuario no root

Los contenedores se ejecutan como root por defecto. Esto es un riesgo de seguridad: si un atacante explota tu aplicación, tiene acceso root dentro del contenedor. La solución es una sola línea en tu Dockerfile que cambie a un usuario no root. La mayoría de las imágenes base incluyen un usuario node o nobody para este propósito.

Más allá de la seguridad, las construcciones multi-etapa y el ordenamiento adecuado de capas también mejoran la velocidad del pipeline CI/CD. Cada minuto ahorrado en una construcción de imagen es un minuto que tus desarrolladores no esperan por un despliegue. En un equipo de diez desarrolladores desplegando cinco veces al día, ahorrar dos minutos por construcción recupera más de sesenta horas de tiempo de desarrollador al año.

Fundamentos de Kubernetes

Kubernetes es un orquestador de contenedores. Toma un clúster de máquinas (nodos), programa contenedores en ellos, los mantiene ejecutándose, maneja la red y proporciona una API declarativa para describir el estado deseado de tu sistema. Le dices a Kubernetes lo que quieres (tres réplicas de tu servidor API, puerto 8080 expuesto, estrategia de actualización rolling) y él lo hace realidad.

La curva de aprendizaje es real porque Kubernetes introduce un conjunto de abstracciones en capas. Las tres con las que interactuarás más son Pods, Deployments y Services.

Pods

Un Pod es la unidad desplegable más pequeña en Kubernetes. Representa uno o más contenedores que comparten un namespace de red y volúmenes de almacenamiento. En la práctica, la mayoría de los Pods ejecutan un solo contenedor. Los patrones sidecar (un contenedor principal más un contenedor de logging o proxy) usan Pods multi-contenedor, pero para el despliegue diario de aplicaciones, usarás un contenedor por Pod.

Rara vez creas Pods directamente. Los Pods son efímeros: pueden ser terminados y reprogramados en cualquier momento. Si creas un Pod manualmente y el nodo en el que se ejecuta falla, el Pod se pierde para siempre. Aquí es donde entran los Deployments.

Deployments

Un Deployment gestiona un conjunto de Pods idénticos (un ReplicaSet). Maneja actualizaciones rolling, escalado, autocuración y reversiones. Este es el recurso que usarás para desplegar aplicaciones stateless.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
        - name: api
          image: myregistry/api-server:v1.2.3
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 250m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000

Este Deployment declara tres réplicas del servidor API. Kubernetes se asegurará de que siempre haya tres Pods ejecutándose. Si un Pod se cae, Kubernetes crea un reemplazo. Durante una actualización rolling (cambiando la etiqueta de la imagen), Kubernetes reemplaza los Pods uno por uno, asegurando cero tiempo de inactividad. La sonda de liveness le dice a Kubernetes cuándo un Pod está sano; la sonda de readiness le dice cuándo un Pod está listo para recibir tráfico.

Services

Los Pods tienen direcciones IP dinámicas. Cada vez que un Pod se recrea, obtiene una nueva IP. Un Service proporciona un endpoint de red estable que balancea la carga del tráfico entre los Pods que coinciden con su selector. Así es como otras partes de tu sistema encuentran y se comunican con tu aplicación.

apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

Este Service mapea el puerto 80 en una IP estable interna del clúster al puerto 3000 en los Pods con la etiqueta app: api-server. Otros servicios dentro del clúster pueden alcanzarlo por el nombre DNS api-server. Para tráfico externo, usarías un recurso LoadBalancer o Ingress sobre el Service.

Kubernetes no es una plataforma en la que despliegas. Es una plataforma a la que describes tu despliegue. La diferencia entre imperativo y declarativo es el cambio mental más importante que puedes hacer.

Desarrollo local: Docker Compose vs Kubernetes

El error más grande que cometen los equipos es asumir que necesitan Kubernetes para el desarrollo local porque lo usan en producción. Docker Compose y Kubernetes sirven para diferentes propósitos, y elegir el equivocado para el trabajo local crea fricción innecesaria.

Docker Compose está diseñado para el desarrollo local. Se ejecuta en una sola máquina, inicia contenedores en segundos y tiene un formato YAML simple que se corresponde directamente con lo que necesitas: un servidor web, una base de datos, una instancia de Redis y quizás un trabajador de cola. Defines los servicios, y docker compose up pone todo en línea, con logs transmitidos a tu terminal, puertos mapeados a localhost y recarga en caliente funcionando de fábrica.

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Este archivo Compose te da un entorno de desarrollo funcional con recarga en caliente, una base de datos local con almacenamiento persistente y redes adecuadas entre servicios. Toma unas treinta líneas de YAML y se inicia en menos de diez segundos.

Minikube, Kind y k3s pueden ejecutar Kubernetes localmente, pero añaden una sobrecarga significativa. Requieren más memoria, tardan más en iniciarse e introducen complejidad (controladores de ingreso, service meshes, clases de almacenamiento) que simplemente no necesitas cuando estás iterando sobre una sola funcionalidad. Ejecutar Kubernetes localmente es útil para probar comportamientos específicos de Kubernetes (como políticas de desalojo de pods, autoescalado horizontal de pods o definiciones de recursos personalizados), pero no es un reemplazo de Compose en el desarrollo diario.

  • Usa Docker Compose para desarrollo local. Es rápido, simple y se corresponde directamente con los contenedores que ejecutas.
  • Usa Kubernetes (a través de Minikube o Kind) para pruebas de integración cuando tu infraestructura de producción use características de Kubernetes como ConfigMaps, Secrets o controladores personalizados.
  • Usa un clúster de desarrollo remoto solo cuando necesites acceso a GPU, hardware especializado o un entorno de staging compartido que refleje la producción exactamente.
  • No ejecutes dos clústeres de Kubernetes localmente solo porque tienes dos entornos. Compose maneja eso con un solo flag --profile.
  • Si tu equipo pasa más tiempo depurando configuraciones de Kubernetes que escribiendo código de aplicación, has ido más allá de lo que necesitas. Vuelve a Compose y añade complejidad solo cuando el dolor de no tenerla supere el dolor de mantenerla.

Errores comunes y cómo evitarlos

Incluso después de entender los conceptos, ciertos errores se repiten en todos los equipos. Aquí están los que vale la pena memorizar para que puedas saltarte las sesiones de depuración de dos días.

El caos de etiquetas de imagen es el problema de producción más común. Usar latest como etiqueta de imagen en un Deployment de Kubernetes significa que no puedes saber qué versión se está ejecutando en ningún nodo. Kubernetes solo extrae una imagen si no está presente en el nodo, así que latest en un nodo podría ser una versión diferente que latest en otro. Usa siempre etiquetas de versión semántica o SHAs de commit. Mejor aún, usa un digest de imagen completamente calificado: es lo único que se garantiza que sea inmutable.

Las solicitudes y límites de recursos se omiten con frecuencia o se establecen arbitrariamente. Si no estableces requests, Kubernetes no puede programar tus Pods de manera inteligente y los nodos se sobrecargan. Si no estableces limits, una fuga de memoria en un Pod puede colapsar otros Pods en el mismo nodo. Establece ambos y usa herramientas como el Vertical Pod Autoscaler en modo de recomendación para ajustarlos según el uso real.

Los ConfigMaps y Secrets se montan como variables de entorno o archivos. Las variables de entorno son convenientes, pero cualquier cambio requiere un reinicio del Pod para que surta efecto. Los montajes basados en archivos pueden actualizarse sin reiniciar (el Pod lee el nuevo contenido cuando se accede al archivo), pero muchas aplicaciones almacenan en caché la configuración al inicio. Conoce qué patrón usa tu aplicación y diseña tu enfoque de configuración en consecuencia.

Los volúmenes persistentes en Kubernetes no son mágicos. Un PersistentVolumeClaim solicita almacenamiento, pero la clase de almacenamiento subyacente debe estar configurada para tu proveedor de nube. Las clases de almacenamiento predeterminadas pueden usar almacenamiento conectado a la red que tiene diferentes características de rendimiento que los SSDs locales. Si el rendimiento de tu base de datos importa, compara tu clase de almacenamiento antes de ir a producción.

El logging y la depuración en Kubernetes es más difícil que en un solo servidor. Los Pods son efímeros, por lo que los logs desaparecen cuando se elimina un Pod. Usa kubectl logs --tail=50 -f nombre-del-pod para seguimiento en vivo, pero para depuración en producción, necesitas una solución de logging centralizada (Loki, Elasticsearch o un servicio de logging en la nube). Del mismo modo, ejecuta exec en un Pod con kubectl exec -it nombre-del-pod -- sh para inspeccionar un contenedor en ejecución, pero recuerda que cualquier cambio que hagas dentro de un contenedor en ejecución se pierde al reiniciar.

¿Realmente necesitas Kubernetes?

Esta es la pregunta que nadie quiere hacer porque Kubernetes se ve bien en un currículum de ingeniería y señala madurez operativa. Pero Kubernetes es una solución a un problema específico: ejecutar múltiples servicios contenerizados en múltiples máquinas con recuperación automática, escalado y despliegues rolling. Si tienes uno o dos servicios ejecutándose en un solo servidor, Kubernetes es excesivo.

Aquí hay un marco de decisión directo. Si estás desplegando una sola aplicación que sirve menos de diez mil solicitudes por segundo, un solo servidor con Docker Compose en producción (sí, Compose funciona bien en producción para muchas cargas de trabajo) te servirá bien. Añade un proxy inverso como Caddy o Nginx, configura copias de seguridad automatizadas y tienes un sistema de producción que un solo desarrollador puede entender y mantener en su totalidad.

Pasa a Kubernetes cuando tengas múltiples servicios que necesiten desplegarse de forma independiente, cuando necesites escalado por servicio (tu API necesita diez réplicas pero tu trabajador solo necesita dos), cuando necesites despliegues sin tiempo de inactividad como operación rutinaria, o cuando tu equipo tenga al menos una persona cuya responsabilidad principal sea la infraestructura. Antes de que se cumplan esas condiciones, el costo operativo de Kubernetes, tanto en gestión del clúster como en carga cognitiva del desarrollador, es un neto negativo.

Muchos equipos se benefician de un término medio. Usa Docker Compose para desarrollo local y una plataforma de contenedores gestionada como AWS App Runner, Google Cloud Run o Fly.io para producción. Estas plataformas te dan despliegue de contenedores, HTTPS automático y escalado sin requerir que gestiones un plano de control de Kubernetes. Obtienes la mayoría de los beneficios de la contenerización sin la curva de aprendizaje de Kubernetes.

La mejor estrategia de infraestructura es la que permite a tu equipo entregar funcionalidades. Docker y Kubernetes son herramientas, no identidades. Úsalos cuando ayuden y saltátelos cuando no.