← maurobernal.com.ar

Etiqueta: devops

  • Microservicios con Docker: lo que aprendí armando mi primera arquitectura

    Arrancé con un monolito .NET en un solo contenedor. Funcionaba bien, hasta que el equipo creció y todos tocábamos el mismo código. Desplegar un cambio en la pantalla de login requería redeployar toda la aplicación. Fue entonces cuando empecé a explorar microservicios con Docker.

    Monolito vs microservicios: cuándo tiene sentido el cambio

    AspectoMonolito en DockerMicroservicios en Docker
    Complejidad inicialBajaAlta
    Deploy independienteNo — todo o nadaSí — servicio por servicio
    Escalabilidad selectivaNoSí — escalar solo lo que lo necesita
    Fallo aisladoUn bug afecta todoUn servicio caído no baja todo
    Equipos independientesDifícilCada equipo dueño de su servicio
    Overhead operacionalBajoAlto — más servicios que monitorear

    Mi recomendación: empezá con el monolito. Cuando los puntos de dolor de la tabla de arriba se vuelvan reales en tu día a día, ahí es el momento de dividir.

    Mi primera arquitectura de microservicios: auth + api + frontend

    # docker-compose.yml — tres servicios independientes
    
    version: '3.8'
    
    services:
      # Servicio de autenticación (JWT, usuarios)
      auth-service:
        build: ./services/auth
        environment:
          - DB_CONNECTION=Host=postgres;Database=auth;Username=auth;Password=${AUTH_DB_PASS}
          - JWT_SECRET=${JWT_SECRET}
          - JWT_EXPIRY=1h
        depends_on:
          postgres:
            condition: service_healthy
        restart: unless-stopped
        # Sin puerto expuesto - solo accesible internamente
    
      # API principal de negocio
      api-service:
        build: ./services/api
        environment:
          - DB_CONNECTION=Host=postgres;Database=apidb;Username=api;Password=${API_DB_PASS}
          - AUTH_SERVICE_URL=http://auth-service:8080
          - CACHE_URL=redis:6379
        depends_on:
          - auth-service
          - redis
        restart: unless-stopped
    
      # Frontend React
      frontend:
        build: ./services/frontend
        environment:
          - REACT_APP_API_URL=http://api-service:8080
        restart: unless-stopped
    
      # Proxy - único punto de entrada externo
      nginx:
        image: nginx:1.25-alpine
        ports:
          - "80:80"
        volumes:
          - ./nginx/microservices.conf:/etc/nginx/nginx.conf:ro
        depends_on:
          - frontend
          - api-service
    
      # Infraestructura compartida
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_MULTIPLE_DATABASES: auth,apidb  # extensión para múltiples DBs
          POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASS}
        volumes:
          - postgres-data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready"]
          interval: 10s
          retries: 5
    
      redis:
        image: redis:7-alpine
        volumes:
          - redis-data:/data
    
    volumes:
      postgres-data:
      redis-data:

    Comunicación entre servicios

    Dentro de la red Docker, los servicios se llaman por nombre. La API valida tokens llamando al servicio de auth en cada request:

    # En el código de api-service (.NET):
    // Validar token contra auth-service
    var authResponse = await _httpClient.GetAsync(
        $"{_authServiceUrl}/validate?token={token}"
    );
    
    // En el docker-compose, AUTH_SERVICE_URL = http://auth-service:8080
    // Docker resuelve "auth-service" al contenedor correcto automáticamente

    Deploy independiente: la ventaja real

    # Actualizar solo el servicio de auth sin tocar nada más
    docker compose up -d --build auth-service
    
    # Escalar solo la API (recibe más carga)
    docker compose up -d --scale api-service=3
    
    # Rollback solo del frontend
    docker compose stop frontend
    docker compose rm -f frontend
    TAG=anterior docker compose up -d frontend

    Lo que aprendí en el proceso

    La transición de monolito a microservicios no es solo técnica: es organizacional. Cada servicio necesita su propio repositorio (o al menos su propia carpeta), su propio pipeline de CI/CD y su propio dueño. La complejidad operacional sube. Por eso Docker Compose no es suficiente para microservicios en producción a escala — ese es el camino hacia Kubernetes, que vemos en el próximo artículo.


    Artículo anterior: Docker en CI/CD | Serie Docker Completo | Próximo: Seguridad en Docker →


    Artículo anterior: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas | Serie Docker Completo | Próximo: Seguridad en Docker: errores que cometí y cómo los corregí →

  • Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas

    Antes de Docker, nuestro pipeline de CI era una caja negra. «Funcionó en mi máquina pero el build del CI falló». Después de adoptar Docker en el pipeline, los builds fallidos por diferencias de entorno desaparecieron. Si buildea en CI, buildea en producción. Siempre.

    Por qué Docker transforma el CI/CD

    El problema fundamental del CI tradicional: el servidor de CI tiene su propio entorno — versiones de runtime, librerías del sistema, variables — que puede diferir del de desarrollo y producción. Docker elimina ese problema: el build ocurre dentro de un contenedor con el entorno exacto que vos definís en el Dockerfile.

    GitHub Actions: build, test y push a registry

    # .github/workflows/ci-cd.yml
    name: Build, Test and Deploy
    
    on:
      push:
        branches: [ main, develop ]
      pull_request:
        branches: [ main ]
    
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
    
    jobs:
      build-and-test:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v4
    
          - name: Set up Docker Buildx
            uses: docker/setup-buildx-action@v3
    
          - name: Login to Container Registry
            if: github.event_name != 'pull_request'
            uses: docker/login-action@v3
            with:
              registry: ${{ env.REGISTRY }}
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
    
          - name: Extract metadata
            id: meta
            uses: docker/metadata-action@v5
            with:
              images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
              tags: |
                type=ref,event=branch
                type=semver,pattern={{version}}
                type=sha,prefix=sha-
    
          - name: Build and push
            uses: docker/build-push-action@v5
            with:
              context: .
              push: ${{ github.event_name != 'pull_request' }}
              tags: ${{ steps.meta.outputs.tags }}
              labels: ${{ steps.meta.outputs.labels }}
              cache-from: type=gha    # cache de GitHub Actions
              cache-to: type=gha,mode=max
    
      deploy:
        needs: build-and-test
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main'
        steps:
          - name: Deploy to production
            uses: appleboy/ssh-action@v1
            with:
              host: ${{ secrets.PROD_HOST }}
              username: ${{ secrets.PROD_USER }}
              key: ${{ secrets.PROD_SSH_KEY }}
              script: |
                cd /apps/miapp
                docker compose pull
                docker compose up -d --no-build
                docker image prune -f

    GitLab CI: Docker-in-Docker

    # .gitlab-ci.yml
    stages:
      - build
      - test
      - push
      - deploy
    
    variables:
      DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
      DOCKER_TLS_CERTDIR: "/certs"
    
    # Build de la imagen
    build:
      stage: build
      image: docker:24
      services:
        - docker:24-dind
      before_script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
      script:
        - docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $DOCKER_IMAGE .
        - docker push $DOCKER_IMAGE
    
    # Tests dentro de contenedor
    test:
      stage: test
      image: $DOCKER_IMAGE
      script:
        - dotnet test --no-build --verbosity normal
    
    # Tag como latest en main
    push-latest:
      stage: push
      image: docker:24
      services:
        - docker:24-dind
      only:
        - main
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - docker pull $DOCKER_IMAGE
        - docker tag $DOCKER_IMAGE $CI_REGISTRY_IMAGE:latest
        - docker push $CI_REGISTRY_IMAGE:latest
    
    # Deploy al servidor
    deploy-prod:
      stage: deploy
      only:
        - main
      script:
        - ssh -i $SSH_KEY deploy@$PROD_HOST
            "cd /apps/miapp &&
             docker compose pull &&
             docker compose up -d --no-build"

    Optimizar el tiempo de build con cache

    El mayor costo de tiempo en un pipeline Docker es el build. Con la caché bien configurada, un build que tardaba 8 minutos puede bajar a 40 segundos si solo cambió el código de la aplicación.

    # La clave: copiar el .csproj ANTES que el código
    # Así el restore se cachea hasta que cambien las dependencias
    
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    
    # Esta capa se cachea hasta que cambie el .csproj
    COPY ["MiApi.csproj", "."]
    RUN dotnet restore
    
    # Esta capa se invalida con cada commit
    COPY . .
    RUN dotnet publish -c Release -o /app/publish

    El resultado en números

    Antes de implementar este pipeline: builds inconsistentes, ~20 minutos por build, deploys manuales con SSH y comandos a mano. Después: builds reproducibles, 2-4 minutos con cache (8 min cold), deploy automático en cada push a main, rollback trivial con docker compose up -d apuntando a la imagen anterior. Menos stress, más confianza en cada deploy.


    Artículo anterior: Entornos consistentes | Serie Docker Completo | Próximo: Microservicios →


    Artículo anterior: Cómo uso Docker para tener el mismo entorno en dev, test y producción | Serie Docker Completo | Próximo: Microservicios con Docker: lo que aprendí armando mi primera arquitectura →

  • Cómo uso Docker para tener el mismo entorno en dev, test y producción

    El mismo código, el mismo docker-compose.yml, la misma imagen. Pero en desarrollo quiero hot reload, logs verbosos y la base de datos con datos de prueba. En producción quiero imágenes optimizadas, variables reales y sin herramientas de debug. Docker tiene una forma elegante de manejar esto sin duplicar archivos.

    El problema clásico: dev funciona, prod falla

    Antes de adoptar este patrón, tenía dos docker-compose.yml separados: uno para dev y otro para prod. Se desincronizaban constantemente. Cambiaba algo en dev, me olvidaba de replicarlo en prod, y el deploy fallaba. La solución es usar un archivo base más overrides por entorno.

    El patrón: base + override

    # docker-compose.yml (base - lo que es igual en todos los entornos)
    version: '3.8'
    
    services:
      api:
        image: ${REGISTRY:-localhost}/mi-api:${TAG:-latest}
        environment:
          - ASPNETCORE_ENVIRONMENT=${APP_ENV:-Development}
          - ConnectionStrings__Default=${DB_CONNECTION}
        depends_on:
          postgres:
            condition: service_healthy
        restart: unless-stopped
    
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_DB: miapp
          POSTGRES_USER: app
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes:
          - postgres-data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U app"]
          interval: 10s
          retries: 5
    
    volumes:
      postgres-data:
    # docker-compose.override.yml (desarrollo - se aplica automáticamente)
    version: '3.8'
    
    services:
      api:
        build:
          context: .
          dockerfile: Dockerfile.dev    # SDK completo con hot reload
        volumes:
          - .:/app                      # bind mount para hot reload
        ports:
          - "5000:80"                   # expuesto para debuggear
        environment:
          - ASPNETCORE_ENVIRONMENT=Development
          - Logging__LogLevel__Default=Debug
    
      postgres:
        ports:
          - "5432:5432"                 # accesible desde el host para pgAdmin
        volumes:
          - ./sql/seed-dev.sql:/docker-entrypoint-initdb.d/seed.sql:ro
    # docker-compose.prod.yml (producción - aplicar explícitamente)
    version: '3.8'
    
    services:
      api:
        image: miregistry.local/mi-api:${TAG}   # imagen pre-buildeada
        deploy:
          resources:
            limits:
              memory: 512m
              cpus: '0.5'
        environment:
          - ASPNETCORE_ENVIRONMENT=Production
          - Logging__LogLevel__Default=Warning
        # Sin ports expuestos - solo accesible via nginx
    
      nginx:
        image: nginx:1.25-alpine
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro

    Cómo usarlos

    # Desarrollo (usa base + override automáticamente)
    docker compose up -d
    
    # Producción (base + prod explícito)
    docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
    
    # Staging (si tuviera un docker-compose.staging.yml)
    docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d

    Variables de entorno por entorno: los archivos .env

    # .env.dev (en el repo - no tiene secretos reales)
    APP_ENV=Development
    DB_PASSWORD=dev-password
    DB_CONNECTION=Host=postgres;Database=miapp;Username=app;Password=dev-password
    REGISTRY=localhost
    TAG=latest
    
    # .env.prod (NUNCA en el repo - en el servidor o secrets manager)
    APP_ENV=Production
    DB_PASSWORD=password-super-seguro-generado
    DB_CONNECTION=Host=postgres;Database=miapp;Username=app;Password=password-super-seguro-generado
    REGISTRY=miregistry.intranet.empresa.com
    TAG=1.4.2
    # Desarrollo
    docker compose --env-file .env.dev up -d
    
    # Producción
    docker compose --env-file .env.prod   -f docker-compose.yml   -f docker-compose.prod.yml   up -d

    Resultado: el mismo repo, comportamiento correcto en cada entorno

    Con este patrón, un desarrollador nuevo puede clonar el repo y levantar el entorno de desarrollo con docker compose up -d. El pipeline de CI/CD usa el archivo de producción con las variables correctas. No hay dos versiones del mismo archivo que se desincronicen. La infraestructura está en el código, versionada junto con la aplicación.

    En mi cluster SUSE, el deploy de producción es un script de 4 líneas: pull del repo, pull de la imagen nueva, docker compose up -d con el .env.prod, y verificación de health. Predecible, repetible, auditable.


    Artículo anterior: Docker Compose | Serie Docker Completo | Próximo: Docker en CI/CD →


    Artículo anterior: Docker Compose: el día que dejé de levantar contenedores a mano | Serie Docker Completo | Próximo: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas →

  • Docker Compose: el día que dejé de levantar contenedores a mano

    Tenía un script Bash con 8 comandos docker run. Cada vez que alguien del equipo necesitaba levantar el entorno de desarrollo, le mandaba el script por Slack y rezaba para que no hubiera cambiado nada desde la última vez. Un día un compañero me mostró su docker-compose.yml. Nunca más volví al script.

    ¿Qué es Docker Compose?

    Docker Compose es una herramienta para definir y ejecutar aplicaciones multi-contenedor usando un archivo YAML. En lugar de recordar 8 comandos docker run con todos sus flags, definís todos los servicios, redes y volúmenes en un solo archivo versionado. Un comando levanta todo; otro lo baja.

    El docker-compose.yml completo: .NET + PostgreSQL + Redis + Nginx

    Este es el stack que uso como base en mis proyectos. Cada servicio tiene su rol claro:

    version: '3.8'
    
    services:
      # Proxy inverso - único punto de entrada
      nginx:
        image: nginx:1.25-alpine
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
          - ./nginx/certs:/etc/nginx/certs:ro
        depends_on:
          - api
        restart: unless-stopped
    
      # API .NET 8
      api:
        build:
          context: .
          dockerfile: Dockerfile
        environment:
          - ASPNETCORE_ENVIRONMENT=Production
          - ConnectionStrings__Default=Host=postgres;Database=miapp;Username=app;Password=${DB_PASSWORD}
          - Redis__ConnectionString=redis:6379
        depends_on:
          postgres:
            condition: service_healthy
          redis:
            condition: service_started
        restart: unless-stopped
        # Sin -p: solo accesible internamente a través de nginx
    
      # Base de datos PostgreSQL
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_DB: miapp
          POSTGRES_USER: app
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes:
          - postgres-data:/var/lib/postgresql/data
          - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U app -d miapp"]
          interval: 10s
          timeout: 5s
          retries: 5
        restart: unless-stopped
    
      # Cache Redis
      redis:
        image: redis:7-alpine
        command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
        volumes:
          - redis-data:/data
        restart: unless-stopped
    
    volumes:
      postgres-data:
      redis-data:
    
    networks:
      default:
        name: miapp-network

    El archivo .env: secretos fuera del YAML

    # .env (en .gitignore - nunca en el repo)
    DB_PASSWORD=password-super-seguro-aqui
    REDIS_PASSWORD=otro-password-seguro

    Los comandos que uso todos los días

    # Levantar todo en background
    docker compose up -d
    
    # Levantar y ver los logs mientras arranca
    docker compose up
    
    # Levantar solo un servicio (y sus dependencias)
    docker compose up -d api
    
    # Ver estado de los servicios
    docker compose ps
    
    # Logs de todos los servicios
    docker compose logs -f
    
    # Logs de un servicio específico
    docker compose logs -f api
    
    # Ejecutar comando en un servicio
    docker compose exec api bash
    docker compose exec postgres psql -U app -d miapp
    
    # Bajar todo (mantiene volúmenes)
    docker compose down
    
    # Bajar y eliminar volúmenes (¡CUIDADO en producción!)
    docker compose down -v
    
    # Rebuild y restart de un servicio
    docker compose up -d --build api
    
    # Escalar un servicio (múltiples instancias)
    docker compose up -d --scale api=3

    Health checks: que Compose espere a que los servicios estén listos

    Uno de los problemas clásicos: la API arranca antes que la base de datos y falla al conectar. La solución está en los healthcheck y depends_on con condición, como hice en el ejemplo de Postgres. Compose espera hasta que el healthcheck pase antes de arrancar los servicios dependientes.

    # Verificar el healthcheck de un servicio
    docker compose ps
    # NAME              STATUS
    # miapp-postgres-1  healthy   ← Postgres superó el healthcheck
    # miapp-api-1       running   ← API arrancó después

    El antes y el después

    Mi script Bash antes:

    # ❌ Lo que tenía antes (8 líneas que siempre tenía que recordar actualizar)
    docker network create miapp
    docker run -d --name postgres --network miapp -e POSTGRES_PASSWORD=... ...
    docker run -d --name redis --network miapp ...
    docker run -d --name api --network miapp -e DB_HOST=postgres ...
    docker run -d --name nginx --network miapp -p 80:80 ...
    # etc...

    Ahora:

    # ✅ Todo el stack en un comando
    docker compose up -d

    El archivo está en el repo. Cualquier miembro del equipo puede clonar y levantar el entorno completo en un comando. Sin documentación de «cómo levantar el entorno». Sin scripts que se desactualizan. El docker-compose.yml es la documentación.


    Artículo anterior: Redes en Docker | Serie Docker Completo | Próximo: Entornos consistentes →


    Artículo anterior: Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad | Serie Docker Completo | Próximo: Cómo uso Docker para tener el mismo entorno en dev, test y producción →

  • Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad

    Pasé casi tres horas intentando que un contenedor de frontend se comunicara con un contenedor de backend. Hacía curl http://localhost:3000 desde dentro del frontend y no llegaba nada. El problema no era el código: era que no entendía cómo funcionan las redes en Docker.

    El modelo de red de Docker

    Cada contenedor tiene su propia interfaz de red virtual y su propia dirección IP dentro de la red Docker. El localhost dentro de un contenedor es ese contenedor, no el host ni otro contenedor. Ese fue mi error: intentar llegar a otro contenedor como si fuera mi máquina.

    Los tres drivers de red nativos

    ┌─────────────────────────────────────────────────────────────────┐
    │  BRIDGE (default)                                               │
    │                                                                 │
    │  Host ──── docker0 ──── contenedor1 (172.17.0.2)               │
    │                    └─── contenedor2 (172.17.0.3)               │
    │                                                                 │
    │  • Red virtual privada                                          │
    │  • Contenedores aislados entre sí por defecto                  │
    │  • Se exponen puertos explícitamente con -p                     │
    ├─────────────────────────────────────────────────────────────────┤
    │  HOST                                                           │
    │                                                                 │
    │  Host ──── contenedor (comparte la red del host)               │
    │                                                                 │
    │  • Sin aislamiento de red                                       │
    │  • Máximo rendimiento (sin NAT)                                 │
    │  • Solo disponible en Linux                                     │
    ├─────────────────────────────────────────────────────────────────┤
    │  NONE                                                           │
    │                                                                 │
    │  contenedor (sin red - solo loopback)                          │
    │                                                                 │
    │  • Aislamiento total                                            │
    │  • Para procesos que no necesitan red                           │
    └─────────────────────────────────────────────────────────────────┘

    La solución: redes definidas por el usuario

    La red bridge por defecto tiene una limitación importante: los contenedores no se pueden resolver por nombre, solo por IP. Las redes personalizadas solucionan esto con DNS automático. Esta es la forma correcta de conectar contenedores.

    # Crear una red personalizada
    docker network create mi-app-network
    
    # Levantar backend
    docker run -d   --name backend   --network mi-app-network   -e DB_HOST=postgres   mi-api:latest
    
    # Levantar frontend - puede resolver "backend" por nombre
    docker run -d   --name frontend   --network mi-app-network   -p 80:3000   -e API_URL=http://backend:8080   mi-frontend:latest
    
    # Levantar base de datos en la misma red
    docker run -d   --name postgres   --network mi-app-network   -v postgres-data:/var/lib/postgresql/data   -e POSTGRES_PASSWORD=secreto   postgres:16-alpine
    
    # Ahora desde frontend podés hacer:
    # curl http://backend:8080/api/health  ✅
    # La base de datos NO está expuesta al exterior (sin -p)

    Publicación de puertos: qué exponés y qué no

    Un error común es publicar todos los puertos de todos los servicios. La buena práctica: solo el punto de entrada de tu aplicación (el frontend o la API pública) se expone al host. La base de datos, el cache, los servicios internos — solo accesibles dentro de la red Docker.

    # ❌ Exponer todo - superficie de ataque innecesaria
    docker run -p 5432:5432 postgres    # DB expuesta al mundo
    docker run -p 6379:6379 redis       # Cache expuesta al mundo
    docker run -p 8080:8080 mi-api      # API interna expuesta
    
    # ✅ Solo exponer el punto de entrada
    docker run -p 80:80 mi-nginx        # Solo el proxy/frontend al exterior
    # El resto: en red interna, sin -p

    Comandos de diagnóstico de red

    # Ver redes existentes
    docker network ls
    
    # Inspeccionar una red (ver qué contenedores están conectados)
    docker network inspect mi-app-network
    
    # Conectar/desconectar un contenedor de una red en caliente
    docker network connect mi-app-network contenedor-existente
    docker network disconnect mi-app-network contenedor-existente
    
    # Diagnóstico de conectividad desde dentro de un contenedor
    docker exec -it frontend ping backend
    docker exec -it frontend curl http://backend:8080/health
    docker exec -it frontend nslookup backend  # resolución DNS

    Lo que debería haber hecho desde el principio

    La solución a mis tres horas de frustración era simple: crear una red personalizada y usar los nombres de contenedor como hostnames. Desde que lo entendí, la comunicación entre servicios es trivial. La clave mental: dentro de una red Docker personalizada, el nombre del contenedor es el hostname. http://backend:8080 funciona igual que http://192.168.1.100:8080, pero sin tener que saber IPs que cambian.


    Artículo anterior: Volúmenes y persistencia | Serie Docker Completo | Próximo: Docker Compose →


    Artículo anterior: Cuando perdí datos de producción por no usar volúmenes (y cómo no repetirlo) | Serie Docker Completo | Próximo: Docker Compose: el día que dejé de levantar contenedores a mano →

  • Cuando perdí datos de producción por no usar volúmenes (y cómo no repetirlo)

    Era viernes por la tarde. Reinicié un contenedor de PostgreSQL que habíamos levantado «temporalmente» hacía tres meses. En segundos entendí el error: los datos vivían dentro del contenedor, no en un volumen. Tres meses de datos del cliente, gone. Esa tarde aprendí para siempre qué es la persistencia en Docker.

    El problema: los contenedores son efímeros por diseño

    Cuando Docker crea un contenedor, agrega una capa de escritura sobre la imagen base. Todo lo que el proceso escribe va ahí. Cuando el contenedor se destruye, esa capa desaparece con él. Es intencional: los contenedores son desechables. El problema surge cuando guardamos datos importantes en esa capa temporal.

    # Demostración del problema:
    docker run -d --name mi-postgres postgres:16
    docker exec -it mi-postgres psql -U postgres -c "CREATE TABLE clientes (id serial, nombre text);"
    docker exec -it mi-postgres psql -U postgres -c "INSERT INTO clientes VALUES (1, 'Empresa ABC');"
    
    # Destruir el contenedor...
    docker rm -f mi-postgres
    
    # Volver a crearlo...
    docker run -d --name mi-postgres postgres:16
    docker exec -it mi-postgres psql -U postgres -c "SELECT * FROM clientes;"
    # ERROR: relation "clientes" does not exist
    # Los datos desaparecieron.

    Las tres formas de manejar datos en Docker

    TipoDónde viveGestionado porCaso de uso
    Volumes/var/lib/docker/volumes/DockerBases de datos, datos persistentes en producción
    Bind MountsCualquier path del hostVosDesarrollo local, compartir código
    tmpfs mountsRAM del hostDockerDatos sensibles temporales, caché efímera

    Volúmenes nombrados: la forma correcta para producción

    Los volúmenes son el mecanismo preferido para datos persistentes. Docker los gestiona completamente: sabe dónde están, los protege de eliminaciones accidentales y los puede mover entre contenedores fácilmente.

    # Crear un volumen nombrado
    docker volume create postgres-data
    
    # Usarlo con PostgreSQL
    docker run -d   --name mi-postgres   -e POSTGRES_PASSWORD=secreto   -e POSTGRES_DB=miapp   -v postgres-data:/var/lib/postgresql/data   -p 5432:5432   --restart=unless-stopped   postgres:16-alpine
    
    # Ahora los datos sobreviven al contenedor:
    docker rm -f mi-postgres
    docker run -d   --name mi-postgres   -e POSTGRES_PASSWORD=secreto   -v postgres-data:/var/lib/postgresql/data   postgres:16-alpine
    # ✅ Los datos siguen ahí

    Comandos de gestión de volúmenes

    docker volume ls                          # listar volúmenes
    docker volume inspect postgres-data       # detalle de un volumen
    docker volume rm postgres-data            # eliminar (solo si no está en uso)
    docker volume prune                       # eliminar todos los no usados

    Bind Mounts: para desarrollo local

    Los bind mounts montan un directorio del host directamente en el contenedor. Los uso mucho en desarrollo: edito el código en mi máquina y el contenedor lo ve en tiempo real, sin necesidad de hacer rebuild.

    # Desarrollo de API .NET con hot reload
    docker run -d   --name mi-api-dev   -v $(pwd):/app   -w /app   -p 5000:80   -e ASPNETCORE_ENVIRONMENT=Development   mcr.microsoft.com/dotnet/sdk:8.0   dotnet watch run
    
    # Desarrollo Node.js con nodemon
    docker run -d   --name mi-node-dev   -v $(pwd):/app   -w /app   -p 3000:3000   node:20-alpine   sh -c "npm install && npm run dev"

    Backup y restauración de volúmenes

    Después del incidente del viernes, implementé backups automáticos de todos los volúmenes de producción. La estrategia más confiable que encontré:

    # Backup de un volumen a un archivo tar
    docker run --rm   -v postgres-data:/data   -v $(pwd)/backups:/backups   alpine   tar czf /backups/postgres-$(date +%Y%m%d_%H%M%S).tar.gz -C /data .
    
    # Restaurar desde backup
    docker run --rm   -v postgres-data:/data   -v $(pwd)/backups:/backups   alpine   tar xzf /backups/postgres-20260311_030000.tar.gz -C /data
    
    # Backup directo de PostgreSQL (más limpio para DBs)
    docker exec mi-postgres   pg_dump -U postgres miapp > backup_$(date +%Y%m%d).sql

    La regla que aplico siempre

    Si el dato importa, va en un volumen. Sin excepción. Bases de datos, archivos subidos por usuarios, certificados, configuraciones que cambian en runtime. Todo lo que no quiero perder cuando hago docker rm -f va en un volumen nombrado. El viernes que perdí esos datos fue la última vez que cometí ese error.


    Artículo anterior: Ciclo de vida de un contenedor | Serie Docker Completo | Próximo: Redes en Docker →


    Artículo anterior: docker run y todo lo que nadie te explica del ciclo de vida de un contenedor | Serie Docker Completo | Próximo: Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad →

  • docker run y todo lo que nadie te explica del ciclo de vida de un contenedor

    Son las 3 de la mañana. Me llega una alerta: un contenedor en producción está en estado Exited (137). No arranca. No hay logs visibles. Y yo no entiendo qué pasó. Esa noche aprendí más sobre el ciclo de vida de un contenedor que en semanas de tutoriales.

    Los estados de un contenedor Docker

    Un contenedor no está simplemente «prendido o apagado». Tiene un ciclo de vida con varios estados bien definidos, y entenderlos es fundamental para diagnosticar problemas.

    EstadoSignificadoCómo llegar
    createdCreado pero nunca iniciadodocker create
    runningEn ejecucióndocker start / docker run
    pausedProcesos suspendidos (SIGSTOP)docker pause
    restartingReiniciándose (restart policy activa)Fallo del proceso principal
    exitedDetenido — con código de salidadocker stop o fallo
    deadFallo en la eliminaciónError interno del daemon

    Ese Exited (137) de las 3am significaba: el proceso fue terminado por una señal 9 (SIGKILL). En mi caso, el OOMKiller del kernel mató el contenedor porque superó el límite de memoria. El código de salida 137 = 128 + 9. Una vez que entendés la convención, el diagnóstico es inmediato.

    docker run: el comando que más usás y menos conocés

    El 80% de las veces empezamos con docker run nombre-imagen y nos conformamos con eso. Pero docker run tiene decenas de flags que cambian completamente el comportamiento del contenedor. Acá están los que realmente uso:

    Flags de ejecución básicos

    # -d: modo detached (background) - casi siempre lo quiero en producción
    docker run -d nginx
    
    # --name: nombre legible - SIEMPRE lo pongo
    docker run -d --name mi-nginx nginx
    
    # -p: mapeo de puertos host:contenedor
    docker run -d --name mi-nginx -p 8080:80 nginx
    
    # -e: variables de entorno
    docker run -d --name mi-api   -e ASPNETCORE_ENVIRONMENT=Production   -e ConnectionStrings__Default="Server=db;Database=miapp"   mi-api:latest
    
    # --env-file: variables desde archivo (no las expongo en bash history)
    docker run -d --name mi-api --env-file .env.prod mi-api:latest

    Políticas de reinicio: la diferencia entre prod y dev

    # no (default): no reinicia nunca
    docker run --restart=no mi-app
    
    # always: siempre reinicia, incluso al reboot del host
    # (úsalo para servicios críticos en producción)
    docker run -d --restart=always --name mi-api mi-api:latest
    
    # unless-stopped: como always, pero respeta docker stop manual
    docker run -d --restart=unless-stopped --name mi-api mi-api:latest
    
    # on-failure:N: reinicia solo si falla, máximo N veces
    docker run -d --restart=on-failure:3 mi-worker:latest

    Límites de recursos: lo que me hubiera evitado la alerta de las 3am

    # Limitar memoria y CPU
    docker run -d   --name mi-api   --memory="512m" \          # máximo 512MB de RAM
      --memory-swap="1g" \       # swap incluido
      --cpus="0.5" \             # máximo 50% de un CPU
      --restart=unless-stopped   mi-api:latest
    
    # Ver uso de recursos en tiempo real
    docker stats
    docker stats mi-api           # solo ese contenedor

    Los comandos de gestión que uso todos los días

    # Listar contenedores
    docker ps                    # en ejecución
    docker ps -a                 # todos, incluso detenidos
    docker ps -a --format "table {{.Names}}	{{.Status}}	{{.Ports}}"
    
    # Logs - mi herramienta principal de diagnóstico
    docker logs mi-api
    docker logs -f mi-api        # follow (como tail -f)
    docker logs --tail 100 mi-api
    docker logs --since 1h mi-api   # última hora
    docker logs --since "2026-03-11T03:00:00" mi-api
    
    # Ejecutar comandos dentro del contenedor
    docker exec mi-api ls /app
    docker exec -it mi-api bash  # shell interactivo
    docker exec -it mi-api sh    # para contenedores Alpine (sin bash)
    
    # Copiar archivos entre host y contenedor
    docker cp mi-api:/app/logs/error.log ./error.log
    docker cp ./config.json mi-api:/app/config.json

    Diagnosticar el problema de las 3am: mi checklist

    Cuando me llega una alerta de contenedor caído, sigo siempre el mismo proceso:

    # 1. Ver el estado y el código de salida
    docker ps -a | grep mi-api
    # CONTAINER ID   IMAGE       STATUS                      NAMES
    # a1b2c3d4e5f6   mi-api     Exited (137) 2 minutes ago  mi-api
    
    # 2. Ver los últimos logs ANTES de que muriera
    docker logs --tail 50 mi-api
    
    # 3. Inspeccionar el contenedor completo
    docker inspect mi-api | python3 -m json.tool | grep -A5 "State"
    
    # 4. Ver eventos del daemon
    docker system events --since 1h --filter container=mi-api
    
    # Códigos de salida comunes:
    # 0   → salida normal (proceso terminó correctamente)
    # 1   → error genérico de la aplicación
    # 137 → SIGKILL (OOM Killer o docker kill)
    # 143 → SIGTERM (docker stop - salida limpia)
    # 126 → permisos insuficientes para ejecutar el comando
    # 127 → comando no encontrado

    Detener y eliminar contenedores correctamente

    # Detener con gracia (envía SIGTERM, espera 10s, luego SIGKILL)
    docker stop mi-api
    
    # Detener más rápido (menos tiempo de espera)
    docker stop --time=5 mi-api
    
    # Forzar eliminación inmediata (SIGKILL directo - para emergencias)
    docker kill mi-api
    
    # Eliminar contenedor detenido
    docker rm mi-api
    
    # Detener Y eliminar en uno
    docker rm -f mi-api
    
    # Limpiar todos los contenedores detenidos
    docker container prune

    Lo que aprendí esa noche

    El contenedor que falló a las 3am no tenía límites de memoria configurados, y el proceso .NET tenía una fuga de memoria. El OOMKiller del kernel lo mató antes de que pudiera afectar al nodo completo. En cierta forma, funcionó como debía. Desde entonces, todos mis contenedores de producción tienen --memory configurado y política --restart=unless-stopped. Una mala noche bien aprovechada.


    Artículo anterior: Cómo escribir Dockerfiles | Serie Docker Completo | Próximo: Volúmenes y persistencia →


    Artículo anterior: Mi guía para escribir Dockerfiles que no me den vergüenza | Serie Docker Completo | Próximo: Cuando perdí datos de producción por no usar volúmenes (y cómo no repetirlo) →

  • Mi guía para escribir Dockerfiles que no me den vergüenza

    Mi primer Dockerfile ocupaba 2.1 GB. No exagero. Usaba una imagen base de Ubuntu completa, instalaba todo lo que se me ocurría por las dudas, y copiaba el código sin pensar en las capas. Hoy mis imágenes de producción pesan entre 60 y 120 MB. Este artículo es ese camino condensado.

    El Dockerfile: instrucciones para construir una imagen

    Un Dockerfile es un archivo de texto con instrucciones que Docker ejecuta en orden para construir una imagen. Cada instrucción crea una nueva capa. Las capas se cachean, lo que hace que los builds sucesivos sean rápidos — si sabés ordenarlas bien.

    Las instrucciones esenciales

    # Instrucciones que uso en casi todos mis Dockerfiles:
    
    FROM        # imagen base (siempre es la primera instrucción)
    WORKDIR     # directorio de trabajo dentro del contenedor
    COPY        # copia archivos del host al contenedor
    RUN         # ejecuta un comando durante el build
    ENV         # variables de entorno
    EXPOSE      # documenta el puerto que usa el contenedor
    ENTRYPOINT  # comando principal del contenedor
    CMD         # argumentos por defecto del entrypoint

    Mi primer Dockerfile real: una API .NET 8

    Cuando tuve que dockerizar mi primera API .NET en el trabajo, empecé con algo que funcionaba pero era terrible. Luego aprendí a hacerlo bien.

    La versión ingenua (no hagas esto)

    # ❌ Dockerfile ingenuo - imagen de 2GB+
    FROM ubuntu:22.04
    
    RUN apt-get update && apt-get install -y     wget curl git vim     dotnet-sdk-8.0
    
    WORKDIR /app
    COPY . .
    RUN dotnet build -c Release
    
    EXPOSE 80
    CMD ["dotnet", "run", "--project", "MiApi"]

    Problemas de esta versión: imagen base enorme, herramientas de desarrollo en producción, SDK completo en runtime, dotnet run en lugar de un ejecutable publicado, y sin usuario no-root.

    La versión con multi-stage build (la forma correcta)

    # ✅ Dockerfile con multi-stage build - imagen de ~80MB
    # STAGE 1: Build
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    
    # Copiar solo el .csproj primero (aprovecha cache de capas)
    COPY ["src/MiApi/MiApi.csproj", "src/MiApi/"]
    RUN dotnet restore "src/MiApi/MiApi.csproj"
    
    # Ahora copiar el resto del código
    COPY . .
    WORKDIR "/src/src/MiApi"
    RUN dotnet build "MiApi.csproj" -c Release -o /app/build
    
    # STAGE 2: Publish
    FROM build AS publish
    RUN dotnet publish "MiApi.csproj" -c Release -o /app/publish     /p:UseAppHost=false
    
    # STAGE 3: Runtime (imagen final - solo lo necesario)
    FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
    WORKDIR /app
    
    # Usuario no-root
    RUN adduser --disabled-password --gecos "" appuser
    USER appuser
    
    COPY --from=publish /app/publish .
    EXPOSE 80
    ENTRYPOINT ["dotnet", "MiApi.dll"]

    La magia del multi-stage: la imagen final solo contiene el runtime de ASP.NET y el binario publicado. Todo el SDK, las herramientas de build y el código fuente quedan afuera. El resultado: de 2.1 GB a 82 MB.

    Cómo funcionan las capas (y por qué importa el orden)

    Cada instrucción RUN, COPY o ADD crea una capa nueva. Docker cachea cada capa y solo reconstruye las que cambiaron y todas las que vienen después. Esta regla cambia completamente cómo ordenás las instrucciones.

    # ❌ Orden incorrecto: cualquier cambio en el código invalida el restore
    COPY . .                              # capa 1 - se invalida con cada cambio
    RUN dotnet restore                    # capa 2 - se repite innecesariamente
    
    # ✅ Orden correcto: restore se cachea hasta que cambie el .csproj
    COPY ["MiApi.csproj", "."]           # capa 1 - solo cambia si el proyecto cambia
    RUN dotnet restore                    # capa 2 - se cachea
    COPY . .                              # capa 3 - cambia con el código
    RUN dotnet build -c Release           # capa 4 - solo se repite si hay cambios

    Buenas prácticas que aplico en todos mis Dockerfiles

    1. Siempre usar imágenes base específicas, nunca :latest

    # ❌ Impredecible en producción
    FROM node:latest
    FROM python:latest
    
    # ✅ Reproducible y auditable
    FROM node:20.11.0-alpine3.19
    FROM python:3.12.2-slim-bookworm

    2. Preferir imágenes Alpine o Slim

    # node:20-alpine:   ~55 MB
    # node:20-slim:    ~180 MB
    # node:20:         ~1.1 GB
    FROM node:20-alpine

    3. Combinar comandos RUN para reducir capas

    # ❌ Tres capas separadas
    RUN apt-get update
    RUN apt-get install -y curl
    RUN rm -rf /var/lib/apt/lists/*
    
    # ✅ Una sola capa, sin cache de apt
    RUN apt-get update && apt-get install -y     curl     && rm -rf /var/lib/apt/lists/*

    4. El .dockerignore es tan importante como el .gitignore

    # .dockerignore
    **/bin/
    **/obj/
    **/.git/
    **/node_modules/
    **/*.log
    .env
    .env.local
    docker-compose*.yml
    README.md

    5. Nunca hardcodear secretos en el Dockerfile

    # ❌ NUNCA - queda grabado en la capa para siempre
    ENV DB_PASSWORD=mipassword123
    RUN curl -H "Authorization: Bearer TOKEN_SECRETO" https://api.ejemplo.com
    
    # ✅ Pasar en runtime como variables de entorno
    docker run -e DB_PASSWORD=$DB_PASSWORD mi-imagen

    Comandos esenciales para construir y gestionar imágenes

    # Construir imagen
    docker build -t mi-api:1.0.0 .
    docker build -t mi-api:1.0.0 -f Dockerfile.prod .  # Dockerfile específico
    docker build --no-cache -t mi-api:latest .           # Forzar rebuild completo
    
    # Etiquetar
    docker tag mi-api:1.0.0 mi-api:latest
    docker tag mi-api:1.0.0 miregistry.local/mi-api:1.0.0
    
    # Ver capas y tamaño
    docker history mi-api:1.0.0
    docker images mi-api
    
    # Limpiar imágenes no usadas
    docker image prune -a  # elimina todas las imágenes sin contenedor activo

    El resultado: de 2.1 GB a 82 MB

    Con el multi-stage build, una imagen base correcta, el orden de capas optimizado y el .dockerignore configurado, pasé de imágenes que tardaban 8 minutos en buildearse y ocupaban gigabytes, a imágenes que buildean en 90 segundos (o 20 segundos con cache) y pesan menos de 100 MB. En producción, eso se traduce en deploys más rápidos, menos espacio en el registry y menor superficie de ataque.


    Artículo anterior: Arquitectura interna de Docker | Serie Docker Completo | Próximo: Ciclo de vida de contenedores →


    Artículo anterior: Por dentro del motor: entendiendo la arquitectura de Docker | Serie Docker Completo | Próximo: docker run y todo lo que nadie te explica del ciclo de vida de un contenedor →

  • 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 →

Tags

tsql (27)mssql (26)sql (20)devops (20)dotnet (18)docker (15)performance (14)contenedores (11)dotnet10 (10)linux (9)csharp (8)microservicios (7)angular (7)angular21 (7)sql server (6)issabel (6)docker-compose (6)typescript (6)mysql (5).NET (5)