Mejores Prácticas de Hardening de Imágenes de Contenedor
El hardening de imágenes de contenedor consiste en reducir al mínimo la superficie de ataque eliminando componentes innecesarios, aplicando el principio de mínimo privilegio y configurando los contenedores para que sean resistentes a compromisos. Una imagen bien endurecida tiene menos vulnerabilidades, no ejecuta procesos como root, tiene sistema de archivos de solo lectura y solo incluye los binarios estrictamente necesarios. Esta guía cubre las técnicas más efectivas para imágenes de producción seguras.
Requisitos Previos
- Docker 24+ o containerd instalado
- Conocimientos básicos de Dockerfile
- Trivy u otra herramienta de escaneo de vulnerabilidades (opcional pero recomendado)
Imágenes Base Mínimas
La elección de la imagen base es el factor más determinante en la superficie de ataque:
# MAL: imagen completa con muchas herramientas innecesarias
FROM ubuntu:22.04
# ~77 MB, cientos de paquetes, muchas posibles vulnerabilidades
# MEJOR: imagen Alpine (minimalista basada en musl libc)
FROM alpine:3.19
# ~7 MB, set mínimo de herramientas
# MEJOR AÚN: imagen distroless (sin shell, sin gestores de paquetes)
FROM gcr.io/distroless/base-debian12
# ~20 MB, sin shell, sin utilidades Unix
# ÓPTIMO para binarios compilados estáticamente: imagen scratch
FROM scratch
# 0 bytes, solo el binario de la aplicación
# Ejemplo real con Go (binario estático)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o servidor .
FROM scratch
COPY --from=builder /app/servidor /servidor
ENTRYPOINT ["/servidor"]
Comparativa de imágenes base por seguridad:
# Comparar el número de paquetes y vulnerabilidades
docker pull ubuntu:22.04 && trivy image ubuntu:22.04 | tail -5
docker pull alpine:3.19 && trivy image alpine:3.19 | tail -5
docker pull gcr.io/distroless/base-debian12 && trivy image gcr.io/distroless/base-debian12 | tail -5
# Ver el tamaño de cada imagen base
docker images --format "{{.Repository}}:{{.Tag}}\t{{.Size}}" | grep -E "ubuntu|alpine|distroless|scratch"
Builds Multi-Etapa
Los builds multi-etapa son la técnica más importante para reducir el tamaño y la superficie de ataque:
# Ejemplo con aplicación Node.js
# Etapa 1: Construcción (tiene todas las herramientas de desarrollo)
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar solo los archivos de dependencias primero (optimización de caché)
COPY package*.json ./
RUN npm ci --only=production
# Copiar el código fuente y compilar
COPY . .
RUN npm run build && \
# Eliminar archivos de test y desarrollo que no se necesitan en producción
find . -name "*.test.js" -delete && \
find . -name "*.spec.js" -delete
# Etapa 2: Imagen de producción (solo lo necesario para ejecutar)
FROM node:20-alpine AS runtime
# Crear usuario no root para la aplicación
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/false -D appuser
WORKDIR /app
# Copiar SOLO los artefactos necesarios de la etapa de construcción
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json .
# Usar el usuario no root
USER appuser
# Definir variables de entorno seguras (sin valores sensibles)
ENV NODE_ENV=production \
PORT=3000
EXPOSE 3000
ENTRYPOINT ["node", "dist/index.js"]
# Ejemplo con aplicación Python
FROM python:3.12-slim AS builder
WORKDIR /build
# Instalar dependencias en un directorio separado
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Imagen final: distroless para Python
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
# Copiar las dependencias instaladas
COPY --from=builder /root/.local /root/.local
# Copiar el código de la aplicación
COPY --chown=nonroot:nonroot app/ ./app/
USER nonroot
ENV PYTHONPATH=/root/.local/lib/python3.12/site-packages
ENTRYPOINT ["python3", "-m", "app"]
Usuario No Root
Ejecutar contenedores como root es la mala práctica más común:
FROM node:20-alpine
# CORRECTO: crear y usar un usuario dedicado sin privilegios
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# -S: sistema (no login), sin home, shell /bin/false
# Establecer propiedad correcta antes de cambiar el usuario
COPY --chown=appuser:appgroup . /app
WORKDIR /app
RUN npm ci --only=production
# IMPRESCINDIBLE: cambiar al usuario no root
USER appuser
CMD ["node", "index.js"]
Verificar que las imágenes no corren como root:
# Comprobar el usuario de una imagen
docker inspect nginx:latest | python3 -c "import sys,json; c=json.load(sys.stdin)[0]['Config']; print('Usuario:', c.get('User','root (sin definir)'))"
# Trivy detecta imágenes que corren como root
trivy image --security-checks config nginx:latest
# Dockle - herramienta de linting de Dockerfiles
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle:latest nginx:latest
Sistema de Archivos de Solo Lectura
# Configurar la imagen para sistema de archivos de solo lectura
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Crear solo los directorios que necesitan ser escribibles
RUN mkdir -p /app/tmp /app/logs && \
chown -R appuser:appgroup /app
WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
USER appuser
# Indicar qué rutas son temporales (para documentación y herramientas)
VOLUME ["/app/tmp", "/app/logs"]
CMD ["node", "index.js"]
Forzar sistema de archivos de solo lectura al ejecutar:
# Lanzar contenedor con rootfs de solo lectura
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
mi-app:latest
# En Kubernetes (SecurityContext)
# securityContext:
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1001
# allowPrivilegeEscalation: false
Gestión de Secretos en Imágenes
# MAL: secretos como ARG o ENV (quedan en el historial de capas)
ARG DB_PASSWORD
ENV DATABASE_URL=postgresql://user:${DB_PASSWORD}@db/app # ¡PELIGRO!
# MAL: copiar archivos de configuración con secretos
COPY config-with-secrets.env . # El secreto queda en la imagen
# BIEN: usar secretos de build (Docker BuildKit)
# RUN --mount=type=secret,id=db_password \
# export DB_PASS=$(cat /run/secrets/db_password) && \
# ./configure-app.sh
# El secreto NO queda en ninguna capa de la imagen
# Usar BuildKit para secrets:
# DOCKER_BUILDKIT=1 docker build \
# --secret id=db_password,src=./secrets/db_password.txt \
# -t mi-app .
# MEJOR: no manejar secretos en la imagen, inyectarlos en runtime
# Los secretos llegan como variables de entorno o archivos montados
# mediante Docker secrets, Kubernetes Secrets, o Vault Agent Injector
# Verificar que ninguna capa de la imagen contiene secretos
# Análisis de historial de capas
docker history --no-trunc mi-app:latest
# Trivy detecta secretos en las imágenes
trivy image --security-checks secret mi-app:latest
# Lista de patrones que busca Trivy (tokens API, contraseñas, claves privadas)
trivy image --security-checks secret --list-all-pkgs mi-app:latest
Namespaces y Capacidades
# Ejecutar sin capacidades adicionales (principio de mínimo privilegio)
docker run -d \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \ # Solo si necesita bind en puerto < 1024
--no-new-privileges \
--security-opt seccomp=/etc/docker/seccomp-default.json \
mi-app:latest
# Generar un perfil seccomp personalizado para la aplicación
# (más restrictivo que el perfil por defecto de Docker)
cat > /etc/docker/seccomp-app.json << 'EOF'
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "open", "close", "stat", "fstat", "lstat",
"poll", "lseek", "mmap", "mprotect", "munmap", "brk",
"rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "ioctl",
"pread64", "pwrite64", "readv", "writev", "access",
"pipe", "select", "sched_yield", "mremap", "msync", "mincore",
"madvise", "shmget", "shmat", "shmctl", "dup", "dup2",
"nanosleep", "getitimer", "alarm", "setitimer", "getpid",
"sendfile", "socket", "connect", "accept", "sendto", "recvfrom",
"sendmsg", "recvmsg", "shutdown", "bind", "listen",
"getsockname", "getpeername", "socketpair", "setsockopt",
"getsockopt", "clone", "fork", "vfork", "execve", "exit",
"wait4", "kill", "uname", "fcntl", "flock", "fsync",
"fdatasync", "truncate", "ftruncate", "getdents", "getcwd",
"chdir", "rename", "mkdir", "rmdir", "unlink", "readlink",
"chmod", "fchmod", "chown", "fchown", "lchown", "umask",
"gettimeofday", "getrlimit", "getrusage", "sysinfo", "times",
"getuid", "getgid", "geteuid", "getegid", "setuid", "setgid",
"gettid", "futex", "sched_getaffinity", "set_thread_area",
"exit_group", "epoll_create", "epoll_wait", "epoll_ctl",
"getdents64", "set_tid_address", "clock_gettime", "clock_getres",
"clock_nanosleep", "statfs", "openat", "mkdirat", "fstatat",
"unlinkat", "renameat", "linkat", "readlinkat", "fchmodat",
"faccessat", "pselect6", "ppoll", "splice", "tee",
"sync_file_range", "vmsplice", "move_pages", "accept4",
"epoll_create1", "dup3", "pipe2", "inotify_init1", "recvmmsg",
"prlimit64", "sendmmsg", "getrandom", "memfd_create"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
EOF
docker run --security-opt seccomp=/etc/docker/seccomp-app.json mi-app:latest
Verificación y Análisis
# Pipeline completo de verificación de seguridad de imagen
#!/bin/bash
IMAGE="mi-app:latest"
echo "=== 1. Escaneo de vulnerabilidades ==="
trivy image --severity CRITICAL,HIGH --ignore-unfixed "$IMAGE"
echo "=== 2. Verificación de configuración ==="
trivy image --security-checks config "$IMAGE"
echo "=== 3. Detección de secretos ==="
trivy image --security-checks secret "$IMAGE"
echo "=== 4. Análisis de capas ==="
docker history --no-trunc "$IMAGE"
echo "=== 5. Verificar usuario de ejecución ==="
USER=$(docker inspect "$IMAGE" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Config'].get('User','ROOT - SIN DEFINIR'))")
echo "Usuario: $USER"
[ "$USER" = "ROOT - SIN DEFINIR" ] && echo "ADVERTENCIA: La imagen corre como root"
echo "=== 6. Verificar uso de dockle ==="
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle:latest --exit-code 1 "$IMAGE"
Solución de Problemas
# La aplicación no tiene permisos para acceder a archivos
# Verificar la propiedad de los archivos dentro de la imagen
docker run --rm --entrypoint ls mi-app:latest -la /app
# Comprobar el UID/GID del usuario dentro del contenedor
docker run --rm --entrypoint id mi-app:latest
# El contenedor falla con rootfs de solo lectura
# Identificar qué archivos intenta escribir
docker run --rm mi-app:latest 2>&1 | grep "Read-only file system"
# Añadir tmpfs para esas rutas o usar volúmenes para datos persistentes
# La imagen es demasiado grande
# Analizar el tamaño de cada capa
docker history --format "{{.Size}}\t{{.CreatedBy}}" mi-app:latest | sort -rh | head -10
# Usar dive para análisis interactivo
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest mi-app:latest
Conclusión
El hardening de imágenes de contenedor es un proceso de múltiples capas que comienza con la elección de imágenes base mínimas, se refuerza con builds multi-etapa y el uso de usuarios no root, y se consolida con sistemas de archivos de solo lectura, perfiles seccomp y la eliminación de secretos de las capas de la imagen. La integración de herramientas de análisis como Trivy y Dockle en el pipeline CI/CD convierte el hardening en un proceso continuo y automatizado que se aplica a cada nueva versión de la imagen.


