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 sudo que pertenezca al grupo docker.
  • 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=0 genera 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.mod y descargar dependencias aprovecha la caché: si solo cambia el código, esa capa no se repite.
  • USER 65532:65532 con UID numérico permite que Kubernetes verifique runAsNonRoot sin 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

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-only monta el sistema de archivos raíz del contenedor en solo lectura; un atacante no puede modificar binarios ni dejar herramientas.
  • --cap-drop ALL elimina 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_SERVICE si tuviera que escuchar en un puerto inferior a 1024.
  • --security-opt no-new-privileges:true impide ganar privilegios mediante binarios setuid.
  • --memory y --pids-limit limitan 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.