Wolfi vs Alpine vs distroless, medido con la misma app en Docker
Índice de contenidos
- Puntos clave
- Qué he comparado y cómo lo he medido
- Tamaño y CVE con un binario de Go estático
- Qué falló al ejecutar el mismo binario en cada base
- Tamaño y CVE con una app de Python
- Por qué Grype y Trivy dan cifras distintas
- Las dificultades de musl con las que me he topado en Alpine
- Cómo depurar una imagen sin shell
- Qué es gratis en Chainguard y qué exige cuenta
- Disponibilidad para arm64
- Qué base elegir según tu caso
- Preguntas frecuentes
- ¿Wolfi es más pequeño que Alpine?
- ¿Puedo usar las imágenes de Chainguard gratis en producción?
- ¿Por qué Grype encuentra CVE en Wolfi si Chainguard dice que no hay?
- Conclusión
- Fuentes
Construí la misma app en Go y en Python sobre Wolfi, Alpine, distroless y Debian slim. En Go, chainguard/static y distroless/static dejan 3,1 y 3,3 MB comprimidos sin CVE. En Python, Alpine da la imagen más pequeña (24,2 MB) si todas tus dependencias tienen rueda musl; Debian slim pesa el doble y Trivy le cuenta 204 avisos.
Para un binario de Go estático, cgr.dev/chainguard/static y gcr.io/distroless/static-debian13 dejan una imagen de 3,1 y 3,3 MB comprimidos sin ningún CVE; para una app de Python, Alpine da la imagen más pequeña siempre que todas tus dependencias tengan rueda para musl. Lo he medido construyendo la misma app en Go y la misma app Flask sobre Wolfi, Alpine 3.24, distroless y Debian 13 slim, en arm64, con Grype y Trivy del 30 de septiembre de 2026. Aquí están los tamaños, los paquetes, los avisos de seguridad, las dificultades con las que me he topado y qué imagen elegir en cada caso. También está la versión en inglés de esta comparativa.
Puntos clave
- App de Go en arm64: 3,1 MB comprimidos sobre chainguard/static, 3,3 sobre distroless/static, 6,7 sobre Alpine, 11,9 sobre wolfi-base y 32,7 sobre Debian slim.
- App Flask: 24,2 MB con Alpine, 28,4 con distroless, 34,2 con la imagen Python de Chainguard, 36,2 con wolfi-base y 49,8 con Debian slim.
- Trivy contó 204 avisos en la imagen Flask sobre Debian slim y 161 sobre distroless; en las basadas en Wolfi y Alpine, entre 0 y 3.
- Sobre Python 3.14 y arm64, torch 2.14.0, duckdb 1.5.6 y onnxruntime 1.30.0 no publican ruedas musllinux: en Alpine, duckdb intenta compilar y falla.
- Debian slim no trae certificados raíz y Alpine y wolfi-base no traen zona horaria: el mismo binario de Go falló en cada una por un motivo distinto.
- Chainguard solo sirve gratis
latestylatest-deven su catálogo público; para fijar versión tienes que anclar el digest.
Qué he comparado y cómo lo he medido
La prueba mide la misma aplicación sobre cada base, no las bases vacías, porque lo que despliegas es la aplicación. Si buscas qué es Wolfi y por qué existe, está en el artículo sobre Wolfi, la distribución base pensada para contenedores; aquí van los números.
Monté dos aplicaciones mínimas:
- App de Go: un servidor
net/httpcompilado conCGO_ENABLED=0engolang:1.27(Go 1.27.1) y copiado a cada base, con un modocheckque carga la zonaEurope/Madrid, hace un GET HTTPS acgr.deve imprime el UID - App de Python: Flask 3.1.3 con gunicorn 26.2.0, que responde
holaen el puerto 8000
Todo corrió en una VM Linux arm64 de 18 núcleos con Docker Engine 29.5.2, el 30 de septiembre de 2026. Estas son las bases, con la versión que había ese día:
| Base | Etiqueta | Versión |
|---|---|---|
| Debian slim | debian:trixie-slim |
Debian 13.7 |
| Alpine | alpine:3.24 |
3.24.2 |
| Wolfi | cgr.dev/chainguard/wolfi-base:latest |
rolling |
| Chainguard static | cgr.dev/chainguard/static:latest |
rolling |
| Distroless static | gcr.io/distroless/static-debian13 |
Debian 13 |
| Python Debian | python:3.14-slim-trixie |
3.14.7 |
| Python Alpine | python:3.14-alpine3.24 |
3.14.7 |
| Python Chainguard | cgr.dev/chainguard/python:latest |
3.14.7 |
| Python distroless | gcr.io/distroless/python3-debian13 |
3.13.5 |
El tamaño comprimido es la suma de las capas arm64 tal como quedan en el registro, lo que baja un docker pull. El tamaño en disco es la suma de esas capas descomprimidas. Uso megabytes decimales (106 bytes).
Los paquetes los cuenta Syft 1.52.0. Los avisos salen de Grype 0.119.0 y de Trivy 0.74.0. La base de datos de Grype es del 30 de septiembre a las 06:32 UTC, y la de Trivy, del mismo día a las 07:10 UTC.
Tamaño y CVE con un binario de Go estático
Con un binario estático, la base solo aporta certificados, zona horaria y usuario, y las dos imágenes mínimas ganan con claridad. El Dockerfile es el mismo para todas; solo cambia el argumento BASE:
ARG BASE=cgr.dev/chainguard/static:latest
FROM mirror.gcr.io/library/golang:1.27 AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hola .
FROM ${BASE}
COPY --from=build /hola /usr/local/bin/hola
ENTRYPOINT ["/usr/local/bin/hola"]
El ARG va antes del primer FROM a propósito: si lo declaras después, Docker falla con base name (${BASE}) should not be blank. Estos son los resultados:
El binario solo ocupa 2,5 MB comprimidos, así que chainguard/static añade 0,6 MB y distroless/static, 0,9. Debian slim multiplica la imagen por 10 y trae 78 paquetes que un binario estático no usa.
Qué falló al ejecutar el mismo binario en cada base
El modo check de la app de Go dio un resultado distinto en cada base, y solo las dos imágenes mínimas pasaron las tres pruebas. Esto es lo que imprimió:
| Base | Zona horaria | HTTPS | UID |
|---|---|---|---|
| chainguard/static | ok | ok | 65532 |
| distroless/static | ok | ok | 0 |
| alpine:3.24 | falla | ok | 0 |
| wolfi-base | falla | ok | 0 |
| debian:trixie-slim | ok | falla | 0 |
| scratch | falla | falla | 0 |
En Debian slim, el GET HTTPS devolvió x509: certificate signed by unknown authority, porque la imagen no trae el paquete ca-certificates. En Alpine y wolfi-base, time.LoadLocation devolvió unknown time zone Europe/Madrid, porque no incluyen tzdata. Las dos cosas se arreglan con una línea (apt-get install ca-certificates, apk add tzdata) o importando time/tzdata en Go, pero solo las ves si pruebas la imagen final.
La columna UID importa en Kubernetes con runAsNonRoot. chainguard/static arranca como nonroot (65532) sin tocar nada. distroless/static arranca como root en latest; su README publica la variante :nonroot para lo mismo.
Tamaño y CVE con una app de Python
En Python la base arrastra el intérprete y sus bibliotecas, y la mayor de las cinco imágenes solo dobla a la menor. Para las dos imágenes sin shell (Chainguard y distroless) usé una construcción multietapa: el entorno virtual se crea en la variante de desarrollo y se copia a la de ejecución. La de Chainguard queda así:
FROM cgr.dev/chainguard/python:latest-dev AS build
WORKDIR /app
RUN python -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM cgr.dev/chainguard/python:latest
WORKDIR /app
COPY --from=build /app/venv /app/venv
COPY app.py .
ENV PATH="/app/venv/bin:$PATH"
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
Si no has usado este patrón, la práctica de imágenes Docker multietapa lo explica paso a paso. Debian slim, Alpine y wolfi-base se construyeron en una sola etapa con pip install, como hace casi cualquier Dockerfile de ejemplo. Las cinco respondieron hola en el puerto 8000:
La imagen distroless de Python ejecuta el Python de Debian 13, que es el 3.13.5, no el 3.14.7 de las otras cuatro. No hay etiqueta distroless para 3.14: si necesitas un Python concreto, distroless no te deja elegirlo.
Por qué Grype y Trivy dan cifras distintas
Los dos escáneres leen fuentes distintas, y la diferencia es mayor justo en las imágenes pequeñas. En Alpine y en Wolfi, Trivy dio 0 avisos de sistema porque solo consulta los boletines de seguridad de cada distribución.
Grype encontró 4 en Alpine (zlib y busybox) y 6 en wolfi-base (todos en glibc 2.44). Su salida los marca con el espacio de nombres nvd:cpe: vienen de cruzar el nombre del paquete con la base de datos NVD, no del boletín de la distribución. Ninguno tenía versión corregida.
Los tres avisos de Trivy en las imágenes Python de Alpine y Chainguard no son de la base. Vienen de pip: su lista pip/_vendor/vendor.txt declara setuptools==70.3.0 y msgpack, y Trivy los trata como paquetes instalados. En distroless, los seis avisos corregibles de Grype son del pip 25.1.1 que Debian mete en el entorno virtual. En los tres casos pip viaja a producción sin hacer falta.
En Debian el volumen es otro. De los 176 avisos de Grype en debian:trixie-slim, 103 están marcados como wont-fix y 73 como not-fixed. Trivy marcó 28 como corregidos, y salen de tres paquetes con actualización en stable-security (libssl3t64, libpcre2-8-0 y openssl-provider-legacy): la imagen, creada el 18 de septiembre, ya tenía actualizaciones de seguridad pendientes el día 30. El artículo sobre Trivy y Grype para escanear imágenes explica cómo meter los dos en CI.
Las dificultades de musl con las que me he topado en Alpine
Alpine usa musl en lugar de glibc, y el problema práctico en Python son las ruedas precompiladas. La norma PEP 656 define la etiqueta musllinux para ellas, y en 2026 ya la publican casi todas las bibliotecas que probé. Pregunté a PyPI por 19 paquetes con rueda para Python 3.14 en arm64:
- Con rueda musllinux: numpy, pandas, pyarrow, scipy, polars, grpcio, cryptography, lxml, pillow, orjson, pydantic-core, psycopg-binary, uvloop, httptools, greenlet y tokenizers
- Sin rueda musllinux: torch 2.14.0, duckdb 1.5.6 y onnxruntime 1.30.0, que sí la tienen para glibc (manylinux)
Cuando no hay rueda, pip baja el código fuente e intenta compilarlo. Con duckdb==1.5.6 en python:3.14-alpine3.24, pip descargó un tar de 18,0 MB y falló así:
Building wheel for duckdb (pyproject.toml): finished with status 'error'
CMake Error: CMAKE_CXX_COMPILER not set, after EnableLanguage
× Failed to build installable wheels for some pyproject.toml based projects
╰─> duckdb
El mismo pip install en cgr.dev/chainguard/python:latest-dev, que usa glibc, instaló la rueda de duckdb 1.5.6 y ejecutó select 42 sin problema. Si tu requirements.txt incluye alguna de esas tres bibliotecas, Alpine te obliga a instalar un compilador y a compilar en cada construcción, y la ventaja de tamaño desaparece. El artículo de Wolfi citaba numpy, pandas y pyarrow como ejemplos de ruedas que faltaban en musl; para Python 3.14 en arm64 ya están publicadas.
Cómo depurar una imagen sin shell
Las imágenes Python de Chainguard y distroless no traen shell, y docker exec falla con exec: "sh": executable file not found in $PATH. Debian slim, Alpine y wolfi-base sí lo traen. El README de distroless lo resume en su primera frase: "Distroless images contain only your application and its runtime dependencies".
docker debug abre un shell en cualquier contenedor según la documentación de Docker, pero en Docker Engine 29.5.2 para Linux el comando no existe (docker: unknown command: docker debug). Lo que sí funcionó fue un contenedor auxiliar que comparte los espacios de nombres de procesos y red del contenedor que quieres inspeccionar:
docker run --rm -it --pid container:mi_app \
--network container:mi_app --cap-add SYS_PTRACE \
mirror.gcr.io/library/busybox:1.37 sh
Desde ese shell, wget -qO- 127.0.0.1:8000/ devolvió hola y ls /proc/1/root/app listó los ficheros de la app. Sin --cap-add SYS_PTRACE, el mismo ls devolvió Permission denied. Usé busybox porque wolfi-base no trae wget. Distroless también publica variantes :debug con un shell de busybox; Chainguard ofrece la variante latest-dev.
Qué es gratis en Chainguard y qué exige cuenta
Las imágenes del catálogo público de Chainguard se descargan sin cuenta, pero solo con las etiquetas latest y latest-dev. Lo comprobé: docker pull cgr.dev/chainguard/python:3.14 devuelve not found, y la lista de etiquetas del registro para chainguard/python solo contiene esas dos más las firmas, SBOM y atestaciones por digest. La página de precios lo dice así: unas 50 imágenes gratuitas "include only ‘:latest’ version tags and are not covered under our CVE remediation SLA".
La misma página ofrece también un plan gratuito de "up to five images per organization", con versiones anteriores incluidas, que exige registrar una organización. Para el catálogo anónimo, la forma de fijar versión es anclar el digest, como recomienda la guía de Chainguard para usuarios del catálogo público[1]:
docker buildx imagetools inspect cgr.dev/chainguard/python:latest \
--format '{{.Manifest.Digest}}'
El 30 de septiembre devolvió sha256:38ba1cbf71702bacc5f5be22ea41e3d4ad1bfb2565413b0caf4bafde38e831f2, que es el índice multiarquitectura. Sin digest, latest saltará de 3.14 a 3.15 cuando Chainguard lo mueva, y esa versión trae cambios que repasa el artículo de novedades de Python 3.15. Hay otro cambio que afecta a wolfi-base: su /etc/apk/repositories apunta ahora a https://apk.cgr.dev/chainguard, y según la documentación de Chainguard ese repositorio público conserva las versiones antiguas de cada paquete 12 meses.
Disponibilidad para arm64
Todas las bases de la prueba publican arm64, así que la arquitectura no decide la elección. Estas son las arquitecturas de cada índice el 30 de septiembre:
| Imagen | Arquitecturas |
|---|---|
| Chainguard (wolfi-base, static, python) | amd64, arm64 |
| distroless/static-debian13 | amd64, arm64, arm/v7, ppc64le, riscv64, s390x |
| distroless/python3-debian13 | amd64, arm64, riscv64 |
| alpine:3.24 y debian:trixie-slim | 8 cada una, incluidas 386, arm/v7 y riscv64 |
Si despliegas en una Raspberry Pi de 32 bits (arm/v7), Chainguard queda fuera y las demás sirven.
Qué base elegir según tu caso
La elección depende de si tu binario es estático y de cuánto necesitas depurar dentro del contenedor. Mi recomendación después de la prueba:
- Binario estático (Go con
CGO_ENABLED=0):cgr.dev/chainguard/staticogcr.io/distroless/static-debian13:nonroot. Unos 3 MB, cero avisos en los dos escáneres, certificados y zona horaria incluidos - Python con dependencias que tienen rueda musl: Alpine, la imagen más pequeña de la prueba (24,2 MB), con shell y apk
- Python con torch, duckdb, onnxruntime o cualquier dependencia sin rueda musl: la imagen Python de Chainguard en multietapa, anclada por digest, o wolfi-base si quieres shell y apk con glibc
- Necesitas Debian y apt: Debian slim, pero con
apt-get upgradeen la construcción y reconstruyendo cada semana; la imagen de la prueba ya tenía tres paquetes con parche pendiente
Distroless para Python solo lo elegiría si te basta el Python de Debian: en esta prueba cargó con 161 avisos de Trivy frente a 3 de la de Chainguard, con un tamaño parecido.
Preguntas frecuentes
¿Wolfi es más pequeño que Alpine?
No. En arm64, wolfi-base ocupa 9,4 MB comprimidos y alpine:3.24 4,2 MB. La ventaja de Wolfi es glibc, que instala ruedas manylinux sin compilar, y un boletín de seguridad con el que Trivy no encontró ningún aviso. Si quieres algo menor que Alpine, las imágenes mínimas de Chainguard (0,6 MB la static) son otra cosa: no llevan shell ni apk.
¿Puedo usar las imágenes de Chainguard gratis en producción?
Sí, las del catálogo público, pero solo con latest y latest-dev y sin el acuerdo de parcheo de CVE de la versión de pago. Para no depender de que latest cambie, ancla el digest y actualízalo tú cuando decidas cambiar de versión. El plan gratuito de hasta cinco imágenes por organización sí da acceso a versiones anteriores.
¿Por qué Grype encuentra CVE en Wolfi si Chainguard dice que no hay?
En esta prueba, los 6 avisos de Grype en wolfi-base venían de cruzar glibc 2.44 con la base de datos NVD (nvd:cpe), no del boletín de seguridad de Wolfi. Ninguno tenía corrección publicada. Trivy, que solo lee el boletín, dio 0. Antes de actuar, revisa el campo de origen de cada aviso.
Conclusión
Para un binario estático, chainguard/static y distroless/static empatan: unos 3 MB, cero avisos y las dos pruebas funcionales superadas; Chainguard añade el usuario no root por defecto y distroless, más arquitecturas. Para Python, Alpine es la imagen más pequeña mientras todas tus dependencias tengan rueda musl, y la de Chainguard es la alternativa con glibc que menos avisos acumula. Debian slim fue la mayor en las dos pruebas y la que más avisos arrastra, casi todos sin corrección publicada.
El siguiente paso es repetir las medidas con tu propia imagen. Construye la app sobre dos bases, pásale Grype y Trivy con la base de datos del día y ejecuta la imagen final antes de fiarte del tamaño. Si ya te has decidido por Chainguard, el artículo sobre imágenes mínimas y firmadas de Chainguard cubre la verificación de firmas con cosign.
Fuentes: [1] README de distroless[2], [2] Precios de Chainguard[3], [3] Cómo usar las imágenes de Chainguard[4], [4] Guía de Chainguard para el catálogo público[1], [5] Modelo de repositorios de paquetes de Chainguard[5], [6] Cambio de repositorio de wolfi-base[6], [7] PEP 656, etiqueta musllinux[7], [8] Documentación de docker debug[8], [9] Grype[9], [10] Trivy[10].
Fuentes
Código fuente
Accede a todo el código fuente de este artículo en GitHub.
Ver en GitHub