Por dentro del motor: entendiendo la arquitectura de Docker

La primera vez que ejecuté docker run funcionó. Pero cuando algo falló, no tenía idea de dónde buscar. No entendía quién hacía qué, cómo se comunicaban las piezas ni por qué a veces el daemon parecía tener vida propia. Este artículo es lo que me hubiera gustado leer antes de ese momento.

Docker no es un solo programa: es un sistema de piezas

Uno de los errores conceptuales más comunes cuando arrancás con Docker es pensarlo como «el comando que corre contenedores». En realidad, Docker es una arquitectura cliente-servidor compuesta por varios componentes que trabajan juntos. Entenderlos hace que todo lo demás tenga sentido.

El flujo completo en un diagrama

┌─────────────────────────────────────────────────────────────┐
│                        TU TERMINAL                          │
│                                                             │
│   $ docker run nginx        ← Docker Client (CLI)          │
└──────────────────┬──────────────────────────────────────────┘
                   │  REST API (Unix socket o TCP)
                   ▼
┌─────────────────────────────────────────────────────────────┐
│                    DOCKER DAEMON (dockerd)                   │
│                                                             │
│   • Escucha comandos del cliente                            │
│   • Administra imágenes, contenedores, redes, volúmenes     │
│   • Delega la ejecución a containerd                        │
└──────────┬─────────────────────────┬────────────────────────┘
           │                         │
           ▼                         ▼
┌──────────────────┐      ┌──────────────────────────────────┐
│   containerd     │      │         Docker Registry           │
│                  │      │   (Docker Hub / privado)          │
│  • Gestiona      │      │                                   │
│    ciclo de vida │      │  • Almacena imágenes              │
│    del contenedor│      │  • docker pull baja de acá        │
│  • Usa runc para │      │  • docker push sube acá           │
│    crear procesos│      └──────────────────────────────────┘
└──────────────────┘

Docker Engine: el corazón del sistema

El Docker Engine es el conjunto completo: client + daemon + la API REST que los conecta. Cuando instalás Docker en un servidor, lo que instalás es el Engine. En mis nodos SUSE Linux, el daemon corre como servicio systemd y arranca automáticamente con el sistema.

# Ver estado del daemon
sudo systemctl status docker

# Ver logs del daemon en tiempo real
sudo journalctl -u docker -f

# Información completa del sistema Docker
docker info

Docker Client: lo que escribís en la terminal

El cliente es simplemente la CLI: el binario docker que usás en la terminal. Su único trabajo es traducir tus comandos a llamadas a la API REST del daemon. Lo que importa saber: el cliente y el daemon pueden estar en máquinas diferentes. Puedo controlar el daemon de un servidor remoto desde mi notebook sin ningún problema.

# Conectar el cliente a un daemon remoto
export DOCKER_HOST=tcp://192.168.1.100:2376
docker ps  # Lista contenedores del servidor remoto

# O con contextos (la forma moderna)
docker context create servidor-prod --docker "host=ssh://mbernal@192.168.1.100"
docker context use servidor-prod
docker ps  # Ahora habla con el servidor remoto

Docker Daemon (dockerd): quien realmente hace el trabajo

El daemon es el proceso que corre en background y gestiona todo: imágenes, contenedores, redes y volúmenes. Cuando ejecutás docker run nginx, es el daemon quien:

  1. Recibe el comando del cliente
  2. Verifica si la imagen nginx existe localmente
  3. Si no existe, la descarga del registry
  4. Crea el contenedor usando containerd y runc
  5. Configura la red y el sistema de archivos
  6. Arranca el proceso principal del contenedor

Imágenes Docker: plantillas inmutables

Una imagen es una plantilla de solo lectura que define el sistema de archivos y la configuración inicial de un contenedor. Está compuesta por capas (layers), donde cada instrucción del Dockerfile agrega una capa nueva. Esta arquitectura por capas es brillante: si dos imágenes comparten las mismas capas base, se almacenan una sola vez en disco.

# Ver imágenes locales
docker images

# Ver las capas de una imagen
docker history nginx:latest

# Inspeccionar metadatos completos
docker inspect nginx:latest

Contenedores: instancias en ejecución de una imagen

Un contenedor es una imagen en ejecución. La diferencia clave: la imagen es inmutable (solo lectura), mientras que el contenedor agrega una capa de escritura encima donde los procesos pueden crear y modificar archivos. Cuando el contenedor se destruye, esa capa desaparece. Por eso los datos importantes van en volúmenes — pero eso lo vemos en otro artículo.

# Relación imagen → contenedor
docker images ls          # ver imágenes (plantillas)
docker ps -a              # ver contenedores (instancias)

# Crear contenedor sin arrancarlo
docker create --name mi-nginx nginx

# Arrancarlo
docker start mi-nginx

# O directamente: crear + arrancar
docker run -d --name mi-nginx -p 80:80 nginx

Docker Registry: el repositorio de imágenes

El registry es donde viven las imágenes. Docker Hub es el registry público por defecto, pero en producción muchas empresas usan registries privados. En mi entorno on-premise uso un registry privado para no depender de internet en los deploys.

# Levantar un registry privado local
docker run -d -p 5000:5000 --name registry-privado   -v /data/registry:/var/lib/registry   registry:2

# Tagear imagen para el registry privado
docker tag mi-api:latest localhost:5000/mi-api:latest

# Subir al registry privado
docker push localhost:5000/mi-api:latest

# Bajar desde el registry privado
docker pull localhost:5000/mi-api:latest

Docker Compose: orquestación local

Docker Compose es la herramienta para definir y ejecutar aplicaciones multi-contenedor. En lugar de ejecutar múltiples docker run, definís todos los servicios en un archivo YAML y los gestionás con un solo comando. Lo veremos en profundidad más adelante — te adelanto que cambia completamente la forma de trabajar.

El flujo completo: qué pasa cuando ejecutás docker pull nginx

# Esto es lo que pasa internamente:
$ docker pull nginx

# 1. Docker Client envía petición al daemon via /var/run/docker.sock
# 2. Daemon consulta: ¿tengo nginx:latest localmente?
# 3. Si no → contacta Docker Hub (registry.hub.docker.com)
# 4. Autentica (si la imagen es privada)
# 5. Descarga cada capa (layer) que no tenga en cache
# 6. Verifica integridad con el digest SHA256
# 7. Almacena las capas en /var/lib/docker/overlay2/

Using default tag: latest
latest: Pulling from library/nginx
a803e7c4b030: Pull complete   ← cada línea es una capa
8b625c47d697: Pull complete
4d3239651a63: Pull complete
Digest: sha256:bc5eac5eafc581aeda3008b4b1f07ebba230de2f27d47767129a6a905c84f470
Status: Downloaded newer image for nginx:latest

Por qué me importa entender esto

El día que tuve un contenedor que no arrancaba y no sabía por dónde empezar a debuggear, entender la arquitectura me salvó. Saber que el daemon escribe en /var/lib/docker/, que los logs del daemon están en journalctl, que el socket Unix es /var/run/docker.sock — esos detalles marcan la diferencia entre resolver el problema en 5 minutos o perder una hora.

# Cuando algo falla, estos son mis primeros comandos:
sudo journalctl -u docker --since "1 hour ago"
docker info
docker system df          # ver uso de disco
docker system events      # stream de eventos del daemon

Artículo anterior: Cómo Docker cambió la forma en que trabajo | Serie Docker Completo | Próximo: Mi guía para escribir Dockerfiles →


Artículo anterior: Cómo Docker cambió la forma en que trabajo (y por qué tardé en entenderlo) | Serie Docker Completo | Próximo: Mi guía para escribir Dockerfiles que no me den vergüenza →

Cómo Docker cambió la forma en que trabajo (y por qué tardé en entenderlo)

Durante años trabajé con máquinas virtuales y pensé que era suficiente. Cada nuevo proyecto significaba clonar una VM, esperar que levantara, rezar para que las dependencias fueran las correctas y luego descubrir, inevitablemente, que algo fallaba en producción que no fallaba en mi máquina. Docker cambió todo eso. Pero no fue amor a primera vista.

El problema que todos tenemos (y nadie quiere admitir)

Me acuerdo exactamente del momento. Eran las 11 de la noche, estábamos por hacer un deploy de una API .NET y el servidor de producción se negaba a arrancar la aplicación. El error era críptico: una biblioteca de dependencias con versión diferente a la que teníamos en desarrollo. Tres horas después, con el equipo exhausto, logramos resolver el problema manualmente. Al día siguiente alguien preguntó: «¿y si usamos Docker?»

En ese momento yo tenía mis reservas. «¿Para qué complicar las cosas si las VMs ya funcionan?», pensé. Tardé un tiempo en entender que no se trataba de complicar, sino exactamente de lo contrario.

¿Qué es Docker, realmente?

Docker es una plataforma de código abierto que permite empaquetar una aplicación junto con todas sus dependencias —bibliotecas, runtime, herramientas del sistema, configuración— en una unidad estandarizada llamada contenedor. Ese contenedor se puede ejecutar de forma idéntica en cualquier entorno que tenga Docker instalado: tu notebook, el servidor de staging, producción en cloud o un cluster on-premise como el que manejo yo en SUSE Linux HA.

La definición técnica es elegante: Docker comparte el kernel del sistema operativo anfitrión en lugar de virtualizar hardware completo. Eso lo hace liviano, rápido y eficiente. Pero lo que me convenció no fue la definición técnica sino lo que significa en la práctica: el contenedor que probé en mi máquina es exactamente el mismo que se ejecuta en producción. Sin sorpresas.

# Así de simple es levantar un servidor nginx con Docker
docker run -d -p 8080:80 --name mi-web nginx

# En segundos tenés un servidor web corriendo
# Sin instalar nginx, sin configurar nada en el sistema

Un poco de historia: Docker no inventó los contenedores

Algo que me resultó fascinante cuando profundicé en el tema es que la idea de contenedores no nació con Docker. La tecnología de aislamiento de procesos tiene décadas:

  • 1979 — chroot: El abuelo de todo. Permitía cambiar el directorio raíz de un proceso, aislándolo del sistema de archivos principal. Unix V7.
  • 2000 — FreeBSD Jails: Llevó el concepto más lejos: aislamiento de procesos, usuarios y red.
  • 2008 — Linux Containers (LXC): El kernel de Linux incorporó cgroups (para limitar uso de recursos) y namespaces (para aislar procesos, red, sistema de archivos). Esta es la base técnica sobre la que se construyó Docker.
  • 2013 — Docker: Solomon Hykes, trabajando en dotCloud (una empresa PaaS francesa), tomó toda esa tecnología del kernel y la envolvió en una herramienta con una CLI amigable y un ecosistema de distribución de imágenes. Presentado en PyCon 2013, liberado como open source ese mismo año.

La genialidad de Docker no fue inventar algo nuevo sino hacer accesible lo que ya existía. Y eso, en tecnología, suele ser la innovación más poderosa.

Por qué Docker importa: los 5 beneficios que cambiaron mi forma de trabajar

1. Consistencia entre entornos

Este es el grande. El que me hizo pasar de escéptico a evangelista. Con Docker, la frase «funciona en mi máquina» pierde sentido. Si el contenedor corre en dev, corre en prod. La imagen es la misma. Las dependencias son las mismas. El runtime es el mismo.

# Si esto funciona en tu máquina...
docker run --rm -v $(pwd):/app -w /app node:20 node app.js

# ...esto funcionará igual en el servidor de producción
docker run --rm -v /apps/miapp:/app -w /app node:20 node app.js

2. Arranque en segundos (no en minutos)

Venía de un mundo donde levantar una VM significaba esperar 2 o 3 minutos mínimo. Con Docker, un contenedor arranca en segundos o incluso milisegundos. Eso transforma completamente el flujo de trabajo: podés destruir y recrear entornos constantemente sin perder el hilo.

# Levantar PostgreSQL 16 completo en segundos:
docker run -d   --name postgres-dev   -e POSTGRES_PASSWORD=mipassword   -e POSTGRES_DB=miapp   -p 5432:5432   postgres:16-alpine

# Listo. Base de datos funcionando sin instalar nada en el sistema.

3. Eficiencia de recursos

En mi cluster on-premise de dos nodos SUSE Linux, cada recurso cuenta. Antes, cada servicio necesitaba su propia VM con un SO completo. Hoy tengo decenas de contenedores corriendo en los mismos nodos que antes apenas soportaban cinco VMs. Los contenedores comparten el kernel del host: no replican el sistema operativo, solo empaquetan lo que la aplicación necesita.

4. Portabilidad real

Construís la imagen una vez y la ejecutás en cualquier lado: tu notebook con WSL2, un servidor Ubuntu on-premise, AWS EC2, Azure Container Instances, Google Cloud Run. La misma imagen. Sin recompilar, sin reconfigurar. Esta portabilidad es lo que después abre la puerta a Kubernetes, que orquesta esos contenedores a escala.

5. Aislamiento y seguridad

Cada contenedor vive en su propio mundo: sus procesos, su sistema de archivos, su red. Si un contenedor tiene un problema, los demás no se enteran. Puedo tener dos versiones de Python corriendo en paralelo sin conflictos. Puedo experimentar con una nueva versión de una librería sin tocar el sistema. Para un entorno de producción, eso es tranquilidad.

Docker vs. Máquinas Virtuales: la comparación que aclara todo

Cuando empecé con Docker, la pregunta que más me hacían era: «¿y entonces las VMs mueren?». La respuesta corta es no. La respuesta larga es más interesante.

CaracterísticaDocker (Contenedor)Máquina Virtual
AbstracciónNivel de SONivel de hardware
KernelComparte el kernel del hostKernel propio por VM
Tiempo de arranqueSegundos / milisegundosMinutos
Tamaño de imagenMBsGBs
Consumo de recursosMuy bajoAlto (OS completo por VM)
Densidad en el hostAlta (decenas/cientos)Baja (pocas VMs)
AislamientoA nivel de procesoAislamiento completo de SO
PortabilidadMuy altaMedia (imágenes pesadas)

¿Cuándo uso Docker?

  • Microservicios y APIs independientes
  • Entornos de desarrollo locales (base de datos, cache, message broker)
  • Pipelines de CI/CD donde necesito reproducibilidad
  • Cuando necesito escalar rápido y aprovechar recursos
  • Como base para orquestar con Kubernetes

¿Cuándo sigo usando VMs?

  • Cuando necesito aislar completamente a nivel de SO (seguridad máxima)
  • Aplicaciones legacy que dependen de un SO específico
  • Cuando el equipo no está listo para el cambio (la convivencia es válida)
  • Workloads que requieren acceso directo a hardware específico

En muchos de mis entornos actuales uso ambos: contenedores Docker corriendo dentro de VMs. Así combino el aislamiento fuerte de las VMs con la eficiencia y portabilidad de los contenedores.

Un ejemplo real: de «funciona en mi máquina» a «funciona en todos lados»

Hace poco tuve que desplegar una aplicación .NET 8 que en desarrollo corría perfecto y en producción fallaba por diferencias en las librerías del sistema. Con Docker, la solución fue crear una imagen que empaquetara exactamente el runtime y las dependencias necesarias:

# Dockerfile simple para una API .NET 8
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MiApi.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /app/build

FROM build AS publish
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MiApi.dll"]

Desde ese día, el deploy es siempre el mismo comando:

docker build -t miapi:latest .
docker run -d -p 80:80 --name miapi miapi:latest

Sin sorpresas. Sin diferencias entre entornos. Sin las 11 de la noche arreglando dependencias.

Conclusión: vale la curva de aprendizaje

Si estás evaluando si meterte con Docker, mi respuesta es sí, sin dudar. La curva de aprendizaje existe, pero no es empinada. En pocos días podés tener tus primeros contenedores funcionando. Y una vez que lo incorporás a tu flujo de trabajo, no entendés cómo trabajabas sin él.

En los próximos artículos de esta serie voy a ir a fondo con la arquitectura interna de Docker, los Dockerfiles, la gestión de redes, volúmenes y finalmente Docker Compose —el salto que te lleva de un contenedor a un entorno completo con múltiples servicios coordinados.

Si tenés preguntas o querés compartir tu propia experiencia con Docker (o con las VMs que todavía no pudiste abandonar), dejá tu comentario abajo. 👇


Este artículo forma parte de la serie Docker Completo, donde recorro desde los conceptos básicos hasta casos de uso avanzados en entornos on-premise y cloud.


Serie Docker Completo | Próximo: Por dentro del motor: entendiendo la arquitectura de Docker →

El parche perfecto: Cómo domé una fuga de memoria en Kubernetes con un CronJob inteligente

El Problema: Una API con hambre de RAM

No importa cuánta experiencia tengas, a veces el código te juega una mala pasada. Recientemente, me encontré con un desafío clásico pero persistente: una de nuestras APIs críticas desarrollada en .NET presentaba una fuga de memoria (memory leak).

En nuestro entorno On-Premise, corriendo sobre un cluster de SUSE Linux Enterprise (HA) con solo dos nodos, no podíamos permitir que un pod consumiera recursos hasta asfixiar al nodo o provocar un reinicio descontrolado por el OOMKiller. Si el pod llegaba al límite del Deployment (1500 Mi), la latencia subía y la experiencia del usuario se degradaba.

La Idea: Si no puedes curarlo (aún), manténlo limpio

Mientras el equipo de desarrollo investigaba el root cause en el código, necesitaba una solución operativa. Podría haber configurado un auto-reinicio simple, pero quería algo más «quirúrgico».

Mi premisa fue:

  1. No quiero reiniciar todos los pods a la vez (evitar downtime).
  2. Solo quiero matar al pod que esté realmente en peligro (umbral del 75-80%).
  3. Debo asegurar que el nuevo pod esté saludable antes de pasar al siguiente.

Como mi día a día es en la terminal con Zsh y mi fiel alias kub para kubectl, decidí automatizar mi propio flujo de trabajo.

La Implementación: RBAC y un Script de Bash

La solución no podía ser «toscamente» manual. Implementé un CronJob dentro del cluster que actúa como un recolector de basura inteligente.

1. Seguridad ante todo (RBAC)

No quería que el script corriera con permisos de administrador. Creé una ServiceAccount específica y un Role con los permisos mínimos necesarios: get, list y watch para pods y despliegues. Sin el permiso de watch, el script no podría esperar de forma segura a que la nueva réplica estuviera lista.

2. El «Cerebro» del Script

El script realiza un baile preciso:

  • Consulta las métricas reales mediante kubectl top pods.
  • Filtra los pods de mi API y calcula el porcentaje de uso basado en el límite real configurado en el Deployment.
  • Si un pod supera el 75%, lo elimina.
  • Inmediatamente ejecuta un kubectl rollout status. Esto es vital: el script se pausa hasta que Kubernetes confirma que el nuevo pod pasó sus Health Checks.

3. El Toque On-Premise

Al estar en un cluster con OCFS2 (Oracle Cluster File System 2) y almacenamiento compartido RWX, la conmutación de pods es extremadamente fluida. El nuevo pod monta los volúmenes en segundos, ya sea en el nodea o en el nodeb, sin errores de bloqueo.

El Resultado: Estabilidad 24/7

Programé el CronJob para ejecutarse dos veces al día: a las 3 AM y a las 3 PM.

¿El resultado? El sistema ahora se «limpia» solo. En los logs de la última prueba, pude ver cómo el script identificaba un pod al 80% (1202 Mi), lo eliminaba y esperaba a que la nueva réplica estuviera al 100% antes de seguir. El resto de los pods, que estaban en niveles normales, no fueron tocados.

Esta solución me dio la tranquilidad de que, mientras llega el parche definitivo en el código, mi infraestructura sigue siendo robusta, predecible y, sobre todo, altamente disponible.


Lo que aprendí:

  • Observabilidad: kubectl top es tu mejor amigo cuando el Metrics Server está bien configurado.
  • RBAC es clave: No escatimes en configurar los verbos correctos (watch me salvó la vida).
  • Automatiza tus parches: Un «parche» bien automatizado es una herramienta de ingeniería, no una chapuza.

 kub create job --from=cronjob/memory-leak-patch test-mem-clean -n gag
job.batch/test-mem-clean created
❯ kub logs -f -l job-name=test-mem-clean -n gag
--- Iniciando chequeo de memoria ---
Umbral: 75% de 1500 Mi
>> Pod deploy-gag-api-prod-849d5cb498-z6trv excedido: 80% (1202 Mi). Matando...
pod "deploy-gag-api-prod-849d5cb498-z6trv" deleted from gag namespace
>> Esperando a que el reemplazo esté Ready...
Waiting for deployment "deploy-gag-api-prod" rollout to finish: 4 of 5 updated replicas are available...
deployment "deploy-gag-api-prod" successfully rolled out
>> Pod deploy-gag-api-prod-849d5cb498-xzxtb OK: 21% (326 Mi).
>> Pod deploy-gag-api-prod-849d5cb498-x8krt OK: 15% (235 Mi).
>> Pod deploy-gag-api-prod-849d5cb498-d22w4 OK: 10% (161 Mi).
>> Pod deploy-gag-api-prod-849d5cb498-wtlpr OK: 9% (144 Mi).
--- Saneamiento finalizado ---

Este es el yaml

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: pod-memory-cleaner
  namespace: gag
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-memory-cleaner-role
  namespace: gag
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "delete", "watch"]
  - apiGroups: ["metrics.k8s.io"]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: pod-memory-cleaner-binding
  namespace: gag
subjects:
  - kind: ServiceAccount
    name: pod-memory-cleaner
    namespace: gag
roleRef:
  kind: Role
  name: pod-memory-cleaner-role
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: memory-leak-patch
  namespace: gag
spec:
  # Se ejecuta a las 3:00 AM y 3:00 PM (15:00)
  schedule: "0 3,15 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: pod-memory-cleaner
          restartPolicy: OnFailure
          containers:
          - name: patch-script
            image: bitnami/kubectl:latest
            command:
            - /bin/sh
            - -c
            - |
              NAMESPACE="gag"
              DEPLOYMENT="deploy-gag-api-prod"
              THRESHOLD=75
              
              # Obtener límite de memoria del deployment
              LIMIT=$(kubectl get deploy $DEPLOYMENT -n $NAMESPACE -o jsonpath='{.spec.template.spec.containers[0].resources.limits.memory}' | sed 's/Mi//')
              
              echo "--- Iniciando saneamiento de memoria ---"
              echo "Umbral configurado: $THRESHOLD% de $LIMIT Mi"

              # Listar pods del deployment ordenados por mayor consumo de memoria
              kubectl top pods -n $NAMESPACE --no-headers | grep "$DEPLOYMENT" | sort -k3 -rn | while read line; do
                POD_NAME=$(echo $line | awk '{print $1}')
                USAGE=$(echo $line | awk '{print $3}' | sed 's/Mi//')
                
                # Cálculo de porcentaje
                PERCENT=$(( USAGE * 100 / LIMIT ))
                
                if [ "$PERCENT" -gt "$THRESHOLD" ]; then
                  echo ">> Pod $POD_NAME excedido: $PERCENT% ($USAGE Mi). Eliminando..."
                  kubectl delete pod $POD_NAME -n $NAMESPACE
                  
                  echo ">> Esperando que la nueva réplica esté Ready (Rollout Status)..."
                  kubectl rollout status deployment/$DEPLOYMENT -n $NAMESPACE
                  
                  # Margen para convergencia de red (MetalLB)
                  sleep 15
                else
                  echo ">> Pod $POD_NAME OK: $PERCENT% ($USAGE Mi)."
                fi
              done
              echo "--- Saneamiento finalizado ---"