Servidores

Docker en producción: 5 prácticas que separan junior de senior

Docker en producción: las 5 prácticas que separan junior de senior

Muchos devs usan Docker en su laptop y se sorprenden cuando las cosas fallan en producción. La diferencia entre junior y senior no es conocer comandos Docker: es saber qué hacer cuando un contenedor se cae a las 3 AM, o cuando una imagen tiene una vulnerabilidad crítica, o cuando el disco se llena por logs.

—

TL;DR — Lo que vas a tener al final

  • Tiempo de setup: 1-2 horas para el primer multi-stage, 30 min para los siguientes.
  • Requisitos: Docker 24+ con BuildKit habilitado.
  • Resultado: Imágenes 80-95% más pequeñas, builds 50-70% más rápidos.
  • Troubleshooting: DOCKER_BUILDKIT=1, dive para analizar layers, troubleshooting sin shell con stage debug.

Práctica 1: Healthchecks que reflejen realidad

Lo que hace un junior


services:
  app:
    image: mi-app:latest
    ports:
      - "8080:8080"

Sin healthcheck. Docker considera el contenedor “vivo” mientras el proceso principal corra. Si la app se cuelga pero el proceso no muere, Docker no se entera.

Lo que hace un senior


services:
  app:
    image: mi-app:latest
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

El healthcheck le pregunta a la app “¿estás bien?” en lugar de asumirlo. Si la app responde “vivo” → healthy. Si falla 3 veces seguidas → unhealthy.

Docker hace 3 cosas con esto:

1. Reinicia el contenedor si está unhealthy (con restart: always).

2. Espera a que esté healthy antes de marcar el stack como “ready”.

3. Permite load balancers identificar contenedores caídos.

Healthchecks realistas según servicio


# PostgreSQL
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 10s
  timeout: 5s
  retries: 5
# Redis
healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 10s
# Nginx
healthcheck:
  test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
  interval: 30s
# Nextcloud
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost/status.php"]
  interval: 30s
# App propia (sin curl disponible)
healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health | grep -q '"status":"ok"'"]
  interval: 30s

No hagas healthcheck con echo: cualquier contenedor con un shell va a pasar.

—

Práctica 2: Multi-stage builds para imágenes pequeñas

Lo que hace un junior


FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

Imagen final: 1.2 GB. Incluye todo el toolchain de Node, archivos de tests, dev dependencies, código fuente.

Lo que hace un senior


# Etapa de construcción
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Etapa final (solo runtime)
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Imagen final: 150 MB. Sin herramientas de build, sin código fuente, sin dev dependencies. Solo lo necesario para correr.

Por qué importa

  • Arranque más rápido: imágenes pequeñas se bajan en segundos.
  • Menos superficie de ataque: sin compiladores, sin shells innecesarios.
  • Menos espacio en disco: en servidores con muchas imágenes, importa.

Multi-stage para Python


FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . .
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app .
ENV PATH=/root/.local/bin:$PATH
USER nobody
CMD ["python", "-m", "app"]

Multi-stage para Go


FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
USER nobody
CMD ["/server"]

Imagen final Go: 15 MB. Sí, mega-chica.

—

Práctica 3: Restart policies y límites de recursos

Restart policies


services:
  app:
    image: mi-app:latest
    restart: unless-stopped  # Reinicia siempre, excepto si lo detienes tú

Opciones:

  • no (default): nunca reinicia.
  • always: siempre reinicia, incluso después de reboot.
  • unless-stopped: igual que always pero respeta docker stop manual.
  • on-failure[:max-retries]: solo si el exit code != 0.

Para producción: unless-stopped. Si tú mismo lo detuviste, no quieres que se reinicie solo.

Límites de recursos


services:
  app:
    image: mi-app:latest
    deploy:
      resources:
        limits:
          cpus: '2.0'      # máximo 2 cores
          memory: 1G        # máximo 1 GB RAM
        reservations:
          cpus: '0.5'      # mínimo garantizado
          memory: 256M
# NOTA: deploy.resources SOLO aplica en Swarm mode (docker stack deploy).
# Con docker compose up regular, estos límites se IGNORAN silenciosamente.
# Para Compose v2 usa top-level: cpus:, mem_limit:, mem_reservation:, pids_limit:

Por qué importa:

  • Previene el “noisy neighbor”: una app con memory leak no mata al resto.
  • Planificación de capacidad: sabes exactamente cuánto consume cada servicio.
  • Estabilidad: el kernel OOM killer no decide por ti.

La regla del 80%

Si tu servidor tiene 16 GB de RAM, asigna como límite la suma de todos los servicios + 20% de holgura para el host.

Ejemplo con 16 GB:

  • Nextcloud: 2 GB
  • PostgreSQL: 2 GB
  • Redis: 1 GB
  • Prometheus: 2 GB
  • Grafana: 1 GB
  • Otros 4 servicios: 1 GB c/u = 4 GB
  • Total límites: 12 GB (75% del servidor)
  • Holgura para host: 4 GB (25%)

—

Práctica 4: Secrets, no variables de entorno

Lo que hace un junior


services:
  db:
    environment:
      POSTGRES_PASSWORD: micontraseña123

El password queda en texto plano en docker-compose.yml. Si subes esto a Git (y mucha gente lo hace sin querer), tu DB queda expuesta al mundo.

Lo que hace un senior

Opción A: Variables de entorno desde archivo externo


services:
  db:
    env_file:
      - .env  # archivo en .gitignore

.env:


POSTGRES_PASSWORD=micontraseña123

Opción B: Docker secrets (Swarm mode)


services:
  db:
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
  db_password:
    external: true
    name: mi-db-password  # creado con: echo "micontraseña123" | docker secret create mi-db-password -

Opción C: Vault, SOPS, age, Doppler, Infisical

Para setups serios, usa un gestor de secretos. Nunca hardcodees contraseñas en YAML.

Logs también pueden filtrar secretos


services:
  app:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Esto rota los logs automáticamente. Si no lo configuras, el contenedor puede llenar el disco.

Para no loguear secretos:


# Python: sanitizar logs
import logging
class SecretFilter(logging.Filter):
    def filter(self, record):
        secrets = ['password', 'token', 'api_key']
        for s in secrets:
            if s in record.getMessage().lower():
                record.msg = "[REDACTED]"
        return True
logging.getLogger().addFilter(SecretFilter())

—

Práctica 5: Observabilidad (logs, métricas, traces)

Logs estructurados


services:
  app:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
        labels: "service,environment"

Y desde la app, emite logs en JSON:


import logging
import json
logger = logging.getLogger()
handler = logging.StreamHandler()
class JsonFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
            "module": record.module,
        })
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)

Por qué JSON: herramientas como Loki, Elasticsearch o CloudWatch pueden parsear y buscar en logs estructurados. Texto plano es buscable solo con regex frágil.

Métricas: expón un endpoint /metrics

Prometheus espera que tu app exponga métricas en formato estándar:


from prometheus_client import start_http_server, Counter, Histogram
REQUESTS = Counter('app_requests_total', 'Total requests', ['method', 'endpoint'])
LATENCY = Histogram('app_request_duration_seconds', 'Request latency')
@app.route('/api/users')
def users():
    with LATENCY.time():
        REQUESTS.labels(method='GET', endpoint='/users').inc()
        return get_users()
# En tu docker-compose:
# expose el puerto 9090 para Prometheus

Traces: corre OpenTelemetry

Para apps complejas, los traces te dicen qué pidió cada request y dónde se demora:


from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
tracer = trace.get_tracer(__name__)
FlaskInstrumentor().instrument_app(app)
@app.route('/api/users')
def users():
    with tracer.start_as_current_span("get_users"):
        # tu código
        pass

El trace va a un backend como Jaeger o Tempo. Cuando algo está lento, ves exactamente qué función es la culpable.

—

Bonus: seguridad

No correr como root


# Crea usuario no-root
RUN adduser --disabled-password --gecos '' appuser
USER appuser

Imagen base mínima

  • alpine para apps Linux genéricas (5 MB).
  • distroless para apps Go/Java (sin shell siquiera).
  • node:20-alpine en vez de node:20.
  • python:3.12-slim en vez de python:3.12.

Escaneo de vulnerabilidades

Estos dos comandos analizan las capas de la imagen contra la base de datos CVEs (Common Vulnerabilities and Exposures). Docker Scout viene integrado en Docker Desktop y Docker Engine 25+; Trivy es open source de Aqua Security y funciona en cualquier Docker engine (incluso versiones viejas). Ambos bajan una DB de vulnerabilidades actualizada, así que la primera ejecución tarda unos minutos.


# Con Docker Scout (built-in)
docker scout cves mi-app:latest
# Con Trivy (open source)
docker run --rm aquasec/trivy image mi-app:latest

Si docker scout te tira command not found, estás en una versión vieja de Docker Engine: actualizá a 25+ o cambiá a Trivy, que es independiente. Trivy muestra las vulnerabilidades agrupadas por severidad (CRITICAL, HIGH, MEDIUM, LOW); filtrá con --severity HIGH,CRITICAL si solo quieres ver las importantes. Para que el escaneo sea útil, corrélo contra la imagen final (la que sale del multi-stage), no contra la intermedia, porque la final es la que va a producción.

Integra esto en CI:


# .github/workflows/docker.yml
- name: Scan image
  uses: aquasec/[email protected]  # Pinneado para builds reproducibles
  with:
    image-ref: 'mi-app:latest'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'  # Falla el build si hay vulnerabilidades críticas

—

Checklist antes de pasar a producción

  • [ ] Healthcheck definido y realista.
  • [ ] restart: unless-stopped configurado.
  • [ ] Límites de CPU/RAM definidos.
  • [ ] Multi-stage build aplicado (imagen < 300 MB).
  • [ ] Secrets fuera del docker-compose.
  • [ ] No corre como root dentro del contenedor.
  • [ ] Logs estructurados en JSON.
  • [ ] Endpoint /metrics expuesto.
  • [ ] Rotación de logs configurada.
  • [ ] Escaneo de vulnerabilidades en CI.
  • [ ] Backup de volúmenes documentado.
  • [ ] Documentado qué hacer si X servicio se cae.

—

Preguntas frecuentes

¿Por qué Docker en producción: 5 prácticas que separan junior de senior es relevante para sysadmins Linux?

Forma parte del flujo diario de administración. Los conceptos explicados se aplican directamente a servidores en producción y resuelven problemas reales que enfrentan sysadmins hispanohablantes.

¿Qué necesito para empezar con Docker en producción: 5 prácticas?

Distribución Linux actualizada (Debian, Ubuntu, RHEL, Fedora o Arch), acceso root, y conexión a internet. Para producción: entorno de staging separado antes de aplicar cambios.

¿Cuáles son los errores más comunes con Docker en producción: 5 prácticas?

Los tres más frecuentes: (1) saltar la lectura de logs, (2) aplicar cambios directos en producción sin probar, (3) olvidar permisos y ownership. La documentación y un plan de rollback siempre.

¿Cuándo usar Docker en producción: 5 prácticas y cuándo una alternativa?

Esta solución es ideal para el caso descrito. Alternativas como Docker, Ansible o configuración manual pueden ser mejores según contexto: portabilidad, escala y reproducibilidad son los factores clave.

¿Cómo migro desde una configuración previa a Docker en producción: 5 prácticas?

Documentar el estado actual, probar la nueva configuración en staging, validar funcionalmente, y aplicar en producción con rollback plan listo. Los posts de SoyLinux incluyen troubleshooting para los problemas más comunes.

Lee también

Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux para empresas y proyectos personales. Me especializo en infraestructura, redes, virtualización, monitoring, automation y self-hosting. Sobre SoyLinux.com SoyLinux.com es un blog técnico en español sobre Linux, software libre y self-hosting. Lo mantengo en mi tiempo libre. Áreas de expertise - Linux server administration: RHEL, CentOS, Rocky, Alma, Fedora, Debian, Ubuntu - Network services: Postfix, BIND, Samba, NFS, Squid - Virtualización y containers: KVM, Proxmox, Docker, Podman - Monitoring: Prometheus, Grafana, Zabbix - Automation: Ansible, Bash scripting - Self-hosting: Vaultwarden, Nextcloud, Jellyfin, Immich, Homelab Contacto - Email: [email protected]

2 thoughts on “Docker en producción: 5 prácticas que separan junior de senior”

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *