Endurecer una imagen de contenedor significa dejar dentro solo lo que la aplicación necesita para ejecutarse y lanzarla con los mínimos privilegios. Cada paquete que sobra es una posible vulnerabilidad y cada herramienta presente (una shell, un gestor de paquetes, curl) es algo que un atacante puede aprovechar si compromete el contenedor. En este tutorial partirás de un Dockerfile típico, lo convertirás en una imagen mínima con build multi-etapa, base distroless y usuario no root, y la ejecutarás con sistema de archivos de solo lectura y sin capacidades, comprobando la mejora con Trivy.
Requisitos previos
Para seguir esta guía necesitas:
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con un usuario no root con privilegios
sudoque pertenezca al grupodocker. - Docker Engine 24 o superior con BuildKit (activado por defecto en Docker 23 y posteriores).
- Trivy instalado, para medir las vulnerabilidades antes y después.
Paso 1: Crear la aplicación de ejemplo
Usarás un pequeño servidor HTTP en Go, porque produce un binario estático que permite llegar a la imagen más pequeña posible. Los mismos principios se aplican a cualquier lenguaje; al final verás un ejemplo en Python.
Crea el directorio del proyecto:
mkdir -p ~/hola && cd ~/hola
Crea el archivo go.mod:
nano go.mod
module example.com/hola
go 1.25
Crea el código de la aplicación:
nano main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hola desde un contenedor endurecido")
})
log.Println("Escuchando en :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Paso 2: Medir el punto de partida
Este es el Dockerfile que se suele escribir primero: una sola etapa con la imagen completa de Go, que incluye compilador, shell, git y cientos de paquetes de Debian.
nano Dockerfile.inicial
FROM golang:1.25
WORKDIR /app
COPY . .
RUN go build -o hola .
EXPOSE 8080
CMD ["./hola"]
Constrúyela y anota tamaño, usuario y vulnerabilidades:
docker build -f Dockerfile.inicial -t hola:inicial .
docker images hola:inicial --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker inspect --format 'Usuario: "{{.Config.User}}"' hola:inicial
trivy image --severity HIGH,CRITICAL --quiet hola:inicial | grep Total
hola:inicial 850MB
Usuario: ""
Total: 25 (HIGH: 22, CRITICAL: 3)
Las cifras exactas varían según la fecha, pero el patrón se repite: una imagen de cientos de megas, que se ejecuta como root (usuario vacío) y arrastra vulnerabilidades de paquetes que la aplicación nunca usa.
Paso 3: Excluir archivos del contexto de build
COPY . . copia todo el directorio, incluidos .git, archivos .env con credenciales o artefactos locales. Un archivo .dockerignore lo evita y además acelera el build:
nano .dockerignore
.git
.env
*.env
*.pem
*.key
Dockerfile*
.dockerignore
Paso 4: Build multi-etapa con base distroless y usuario no root
Un build multi-etapa compila en una imagen con todas las herramientas y copia solo el resultado a una imagen final mínima. Para un binario Go estático, la base gcr.io/distroless/static-debian12 contiene únicamente certificados raíz, datos de zona horaria y los archivos /etc/passwd y /etc/group: no tiene shell ni gestor de paquetes. Su variante :nonroot define el usuario nonroot con UID 65532.
nano Dockerfile
# syntax=docker/dockerfile:1
# Etapa de compilación
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola .
# Imagen final
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hola /hola
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/hola"]
Qué aporta cada decisión:
CGO_ENABLED=0genera un binario estático que no depende de la libc de la imagen.-trimpath -ldflags="-s -w"elimina rutas locales y símbolos de depuración del binario.- Copiar primero
go.mody descargar dependencias aprovecha la caché: si solo cambia el código, esa capa no se repite. USER 65532:65532con UID numérico permite que Kubernetes verifiquerunAsNonRootsin tener que resolver el nombre.- La forma exec de
ENTRYPOINT(con corchetes) hace que la aplicación sea el PID 1 y reciba las señales de parada.
Construye la imagen:
docker build -t hola:hardened .
Paso 5: Comparar el resultado
Repite las mismas mediciones del paso 2:
docker images hola:hardened --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker inspect --format 'Usuario: "{{.Config.User}}"' hola:hardened
trivy image --severity HIGH,CRITICAL --quiet hola:hardened | grep Total
hola:hardened 9MB
Usuario: "65532:65532"
Total: 0 (HIGH: 0, CRITICAL: 0)
La imagen ha pasado de cientos de megas a unos pocos, ya no se ejecuta como root y Trivy no encuentra paquetes del sistema vulnerables. Revisa también el Dockerfile con el analizador de configuración de Trivy:
trivy config .
Para el Dockerfile endurecido no debe reportar fallos; para Dockerfile.inicial marcará la ausencia de USER.
Paso 6: Fijar la imagen base por digest
Una etiqueta como :nonroot puede apuntar a otra imagen mañana. Para builds reproducibles y auditables, fija la base por su digest SHA-256. Obténlo así:
docker buildx imagetools inspect gcr.io/distroless/static-debian12:nonroot | grep Digest
Digest: sha256:...
Y úsalo en el FROM, conservando la etiqueta para que sea legible:
FROM gcr.io/distroless/static-debian12:nonroot@sha256:el_digest_obtenido
Notafijar por digest implica que tú decides cuándo actualizar. Automatízalo con una herramienta como Renovate o Dependabot para no quedarte con una base desactualizada.
Paso 7: Usar secretos de build sin dejarlos en la imagen
Cualquier valor pasado con ARG o ENV, o copiado con COPY, queda guardado en las capas de la imagen y se puede leer con docker history o extrayendo la capa, aunque lo borres en una instrucción posterior. Si el build necesita una credencial (por ejemplo, un token para descargar dependencias privadas), usa un secreto de BuildKit, que se monta solo durante esa instrucción RUN.
Si tu proyecto descarga módulos de repositorios Git privados, en la etapa de compilación del Dockerfile sustituye la línea RUN go mod download por esta, que monta un archivo .netrc con credenciales de Git solo mientras se descargan las dependencias:
RUN --mount=type=secret,id=netrc,target=/root/.netrc go mod download
Y pasa el secreto al construir:
docker build --secret id=netrc,src="$HOME/.netrc" -t hola:hardened .
Comprueba que no queda rastro en el historial de la imagen:
docker history --no-trunc hola:hardened | grep -i netrc
No debe devolver nada: la etapa de compilación no forma parte de la imagen final y, aunque lo fuera, BuildKit no guarda el contenido del secreto en ninguna capa. Los secretos que la aplicación necesita en ejecución (contraseñas de base de datos, claves de API) no deben estar en la imagen: inyéctalos al arrancar con variables de entorno, Docker secrets o Kubernetes Secrets.
Paso 8: Ejecutar el contenedor con privilegios mínimos
Una imagen endurecida se complementa con opciones de ejecución restrictivas:
docker run -d --name hola \
--read-only \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--memory 128m --pids-limit 100 \
-p 8080:8080 \
hola:hardened
--read-onlymonta el sistema de archivos raíz del contenedor en solo lectura; un atacante no puede modificar binarios ni dejar herramientas.--cap-drop ALLelimina todas las capacidades de Linux. Esta aplicación escucha en el puerto 8080, así que no necesita ninguna; solo añadirías--cap-add NET_BIND_SERVICEsi tuviera que escuchar en un puerto inferior a 1024.--security-opt no-new-privileges:trueimpide ganar privilegios mediante binarios setuid.--memoryy--pids-limitlimitan el daño de una fuga de memoria o de una bomba de procesos.
Comprueba que la aplicación responde:
curl http://localhost:8080
Hola desde un contenedor endurecido
Comprueba que no hay shell a la que entrar:
docker exec -it hola sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown
Si tu aplicación necesita escribir archivos temporales, no quites --read-only: monta un tmpfs solo en esa ruta, por ejemplo --tmpfs /tmp:rw,noexec,nosuid,size=64m. En Kubernetes, el equivalente es este securityContext del contenedor:
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Docker aplica además su perfil seccomp por defecto, que bloquea decenas de llamadas al sistema peligrosas. No lo desactives con --security-opt seccomp=unconfined.
Ejemplo con Python
Los lenguajes interpretados necesitan el intérprete en la imagen final, pero se aplican los mismos principios: una etapa para instalar dependencias en un entorno virtual y una imagen slim final con usuario no root, sin compiladores ni cachés de pip:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home app
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY app/ ./app/
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app.main:app"]
Este ejemplo supone que requirements.txt incluye gunicorn y que la aplicación WSGI está en app/main.py. El código queda propiedad de root y el usuario app solo puede leerlo, no modificarlo.
Solución de problemas
permission denied al arrancar como usuario no root: la aplicación intenta escribir en un directorio que pertenece a root. Identifica la ruta en los logs (docker logs hola) y monta un tmpfs o un volumen en ella, en lugar de volver a root.
Read-only file system: igual que el caso anterior con --read-only. Las rutas habituales son /tmp, directorios de caché y archivos PID.
No puedo depurar una imagen distroless porque no tiene shell: usa la variante :debug-nonroot, que incluye una shell de BusyBox, solo en entornos de pruebas; o adjunta un contenedor auxiliar que comparta el espacio de procesos: docker run -it --rm --pid container:hola --network container:hola busybox.
Errores de TLS o de zona horaria en scratch: si usas FROM scratch en lugar de distroless, faltan los certificados raíz y los datos de zona horaria. Por eso esta guía usa distroless/static, que ya los incluye.
Conclusión
Has pasado de una imagen de cientos de megas que se ejecutaba como root a una imagen mínima, sin shell, con usuario no root, base fijada por digest, sin secretos en sus capas y ejecutada en solo lectura y sin capacidades. Como siguientes pasos, puedes añadir trivy image --exit-code 1 a tu pipeline para bloquear imágenes vulnerables, firmar las imágenes con Cosign, y vigilar su comportamiento en ejecución con Falco.
