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 respetadocker stopmanual.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
alpinepara apps Linux genéricas (5 MB).distrolesspara apps Go/Java (sin shell siquiera).node:20-alpineen vez denode:20.python:3.12-slimen vez depython: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-stoppedconfigurado. - [ ] 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
/metricsexpuesto. - [ ] 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
- Guía Fedora 44: Novedades, Instalación y Upgrade para Sysadmins Linux
- Configura VLANs en Linux con nmcli: Guía paso a paso para sysadmins
- Protege tu SSH: Guía esencial para configurar Fail2ban en Linux
- Guía Definitiva: Configura Kubernetes con kubeadm en Ubuntu para Sysadmins Linux
- Configura Prometheus y Grafana: Guía DevOps para SysAdmins Linux

Pingback: Kubernetes para principiantes: tu primer cluster en 2026 - Soy Linux
Pingback: Imágenes endurecidas: reduce las alertas CVE de tus contenedores - Soy Linux