BuildKit Docker: secretos, cache y multi-arch explicados

En pocas palabras: BuildKit es el motor de build de Docker, backend por defecto desde la versión 23.0. Con --mount=type=secret los tokens no quedan en docker history, con --mount=type=cache se reutilizan dependencias entre builds (de 2m21s a 8s) y docker buildx permite compilar para varias arquitecturas desde una sola máquina.

Si laburás con Docker hace rato, seguro te pasó: cada build vuelve a descargar exactamente los mismos paquetes, aunque el lockfile no cambió nada. BuildKit, el motor de build de Docker activado por defecto desde la versión 23.0, soluciona esto con cache mounts, secretos que no quedan en el historial de la imagen y builds multi-plataforma desde una sola máquina.

BuildKit es el motor de construcción de imágenes de Docker, disponible desde la versión 18.09 y backend por defecto desde la 23.0 según la guía técnica publicada por FJ Palacios. Permite montar secretos temporales, reusar cache de dependencias entre builds y compilar para varias arquitecturas de CPU en paralelo, todo con sintaxis nativa del Dockerfile. Lo interesante no es que exista —lleva años ahí— sino decidir en qué casos conviene activar cada pieza y en cuáles es esfuerzo de más.

En 30 segundos

  • BuildKit es el backend por defecto de Docker desde la versión 23.0, cualquier instalación de 2023 en adelante ya lo tiene activo.
  • El flag --mount=type=secret hace que un token o clave viva solo durante ese RUN, sin quedar en docker history.
  • Con --mount=type=cache, un build de npm pasó de 2m 21s la primera vez a 8 segundos la segunda, según el mismo benchmark de la fuente.
  • El cache de BuildKit vive en la máquina de build, no en la imagen, por eso en runners efímeros de CI no rinde igual que en local.
  • Con docker buildx build --platform linux/amd64,linux/arm64 se generan imágenes para dos arquitecturas desde una sola máquina, aunque QEMU puede convertir un build de Go de 45 segundos en uno de 8 minutos.

¿Cómo se activa BuildKit y qué es el pragma de sintaxis?

BuildKit ya viene activado si tenés Docker 23.0 o superior, que es cualquier instalación hecha desde 2023 en adelante. No hay que prender nada. Si por algún motivo estás en una versión más vieja, podés forzarlo por sesión con DOCKER_BUILDKIT=1 docker build.

En Docker Desktop, tanto en Mac como en Windows, docker buildx viene incluido de fábrica. En Linux instalado desde paquetes del sistema (Arch, Ubuntu, Debian) el plugin es opcional: se instala con apt install docker-buildx-plugin, pacman -S docker-buildx o, vía Homebrew, brew install docker-buildx. Confirmás que quedó bien con docker buildx version.

Ahora bien, hay un detalle que la mayoría se salta: agregar # syntax=docker/dockerfile:1 como primera línea del Dockerfile. Parece un comentario boludo (nunca mejor dicho, sin usar la palabra prohibida) pero no lo es. BuildKit usa “frontends”, parsers de Dockerfile versionados con ciclo de release propio. Ese pragma fija el frontend al canal estable más reciente y es lo que te da acceso a --mount dentro de RUN. Sin él, Docker usa la versión que vino empaquetada con tu instalación, y esa puede no tener nada de lo que sigue en esta nota.

¿Cómo evitar que los secretos queden expuestos en docker history?

Se evita montando el secreto solo dentro del RUN que lo necesita, con --mount=type=secret, en vez de pasarlo por ARG. La diferencia es que ARG deja el valor grabado en el historial de la imagen, visible para cualquiera que corra docker history; el mount de BuildKit no.

La sintaxis tiene dos partes. En el Dockerfile:

  • En el Dockerfile: RUN --mount=type=secret,id=npm_token npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token) && npm ci
  • En el comando de build, desde archivo: docker build --secret id=npm_token,src=.npmtoken .
  • En el comando de build, desde variable de entorno: docker build --secret id=npm_token,env=NPM_TOKEN .

El secreto se monta en /run/secrets/<id> solo mientras corre ese RUN. Cuando termina, desaparece. Si corrés docker history my-image vas a ver el comando (algo como RUN --mount=type=secret,id=npm_token npm config set...) pero nunca el valor real del token. Exactamente lo que faltaba resolver desde que ARG quedó marcado como mala práctica de seguridad.

Caso hipotético (a modo de ejemplo, no un hecho reportado): imaginemos un equipo que hace dos años armó un Dockerfile con ARG NPM_TOKEN para instalar un paquete privado, y esa imagen se reconstruyó decenas de veces desde entonces. Migrar el RUN a --mount=type=secret arregla los builds nuevos, pero no borra el token de las imágenes viejas que ya circularon por el registry: si alguna de esas capas quedó expuesta, el token sigue comprometido hasta que se rota. El criterio de decisión ahí es simple: migrar a secrets es obligatorio hacia adelante, pero si hubo un ARG con datos sensibles en producción, la rotación de esas credenciales no es opcional, es el paso que realmente cierra el agujero.

¿Cómo clonar un repositorio privado sin copiar claves SSH a la imagen?

Se hace con RUN --mount=type=ssh combinado con docker build --ssh default, que reenvía el agente SSH del host sin que ninguna clave toque el filesystem del contenedor. Es el mismo mecanismo que los secretos, pero pensado para clonar repos privados durante el build.

El Dockerfile queda así: instalás git y openssh-client, después corrés RUN --mount=type=ssh git clone [email protected]:tu-org/libreria-privada.git /deps. En el comando de build agregás --ssh default. Las claves nunca quedan en ninguna capa y, apenas termina ese RUN, el acceso se corta.

¿Cómo evitar que docker build descargue las mismas dependencias en cada build?

Se evita con un cache mount, RUN --mount=type=cache,target=/ruta, que persiste la carpeta de cache del gestor de paquetes entre builds distintos, incluso si borraste la imagen anterior. El primer build descarga todo de internet; el segundo encuentra el cache y no descarga nada.

La diferencia de tiempo es inmediata en proyectos con dependencias reales: la primera corrida tardó 2 minutos 21 segundos con descarga completa desde npm, y la segunda corrida bajó a 8 segundos con cache hit y cero descargas, según el benchmark documentado en la guía original. El patrón se repite en distintos gestores, solo cambia la ruta de cache y el comando puntual, como se ve en la tabla:

Gestor de paquetesRuta de cacheComando de ejemplo
npm/root/.npmRUN --mount=type=cache,target=/root/.npm npm ci
pip/root/.cache/pipRUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
Cargo (Rust)/usr/local/cargo/registryRUN --mount=type=cache,target=/usr/local/cargo/registry cargo build --release
apt (Debian/Ubuntu)/var/cache/apt y /var/lib/aptRUN --mount=type=cache,target=/var/cache/apt,sharing=locked ...
buildkit docker diagrama explicativo

Con apt, el parámetro sharing=locked no es cosmético: sin él, dos builds en paralelo pueden corromper la base de datos de apt al escribir sobre el mismo cache al mismo tiempo.

Criterio práctico para decidir si vale la pena agregarlo: si tu proyecto tiene pocas dependencias y reconstruís la imagen una vez cada tanto, el cache mount no cambia mucho tu vida y agrega una línea más para mantener. Donde sí paga es en proyectos con árboles de dependencias grandes (monorepos de Node, proyectos Python con librerías científicas pesadas, cualquier cosa con Cargo) y en flujos donde reconstruís seguido durante el desarrollo. Ahí la diferencia entre 2 minutos y 8 segundos por iteración se siente en la jornada completa, no en una corrida aislada.

¿Por qué el cache de Docker no acelera los builds en CI/CD?

No acelera porque el cache vive en la máquina donde corrió el build, no dentro de la imagen, y la mayoría de los runners de CI arrancan en una máquina limpia en cada job. Agregás el cache mount, te funciona perfecto en local, subís el mismo Dockerfile a GitHub Actions o GitLab CI y el build tarda exactamente lo mismo que antes: no es que hiciste algo mal, es que no hay cache previo para encontrar.

¿Existe solución? Sí, pero es específica de cada plataforma. GitHub Actions, GitLab CI y la mayoría de los proveedores modernos soportan persistir el estado de BuildKit entre corridas, aunque el mecanismo exacto varía según el runner que uses. Vale la pena revisar la documentación de tu CI antes de asumir que el cache mount no sirve, porque en local sí funciona (y bastante bien).

¿Cómo construir imágenes Docker para múltiples arquitecturas con Buildx?

Se construyen con docker buildx build --platform linux/amd64,linux/arm64, que genera la imagen para ambas arquitecturas desde una sola máquina y la publica bajo el mismo tag. Docker elige automáticamente la versión correcta en docker pull según la arquitectura del host que hace el pull.

Primero creás un builder con soporte multi-plataforma: docker buildx create --use --name multi-arch-builder. Después corrés el build apuntando al registro, algo como docker buildx build --platform linux/amd64,linux/arm64 -t tu-registro/tu-app:latest --push . Un detalle que conviene tener claro: los builds multi-plataforma necesitan --push o --output, no pueden quedar solo en cache local.

Acá viene el problema real. Para arquitecturas distintas a la nativa del host, BuildKit usa emulación QEMU, que funciona pero convierte tu CPU en un actor haciendo de otra CPU, con todo lo que eso implica para el rendimiento. Un build de Go que en nativo tarda 45 segundos puede pasar a 8 minutos cuando QEMU traduce cada instrucción sobre la marcha. Node.js, Python y otros lenguajes interpretados sufren mucho menos: el Dockerfile instala un runtime que ya existe como imagen multi-arquitectura, y el código fuente no necesita compilarse.

Criterio de decisión, resumido:

  • Si tu stack es interpretado (Node, Python, Ruby) y solo instala un runtime multi-arch, buildx con QEMU es perfectamente razonable: la penalidad de emulación es baja porque no hay compilación pesada de por medio.
  • Si tu stack es compilado (Go, Rust, C++, C#) y el build ya tarda lo suyo en nativo, evaluá runners nativos por arquitectura (por ejemplo, corriendo el job de arm64 en un runner arm64 real) antes de asumir que emular siempre sale gratis. La diferencia entre 45 segundos y 8 minutos no es un caso extremo, es el comportamiento esperado.
  • Si vas a desplegar en un VPS o servidor propio con una arquitectura fija, no necesitás multi-arch en absoluto: alcanza con buildear nativo para esa arquitectura y ahorrarte toda la complejidad de Buildx.

Errores comunes al usar BuildKit

  • Olvidar el pragma de sintaxis. Sin # syntax=docker/dockerfile:1 como primera línea, --mount puede fallar o comportarse distinto según qué versión de Docker tenga instalada cada máquina del equipo.
  • Seguir usando ARG para secretos. Si el token ya está en ARG, migrarlo a --mount=type=secret no sirve de nada si no reconstruís la imagen desde cero: el historial viejo con el secreto expuesto sigue circulando. Y si ese token estuvo en producción, migrarlo sin rotarlo deja la puerta igual de abierta.
  • Esperar que el cache mount funcione igual en CI que en local. El cache vive en el disco de la máquina de build. Si tu runner es efímero, necesitás configurar persistencia de cache aparte, no alcanza con agregar el flag.
  • Usar QEMU para todo sin medir el impacto. Para proyectos compilados (Go, Rust, C++) armar dos pipelines nativos por arquitectura suele salir más barato en tiempo que esperar minutos de emulación en cada build.

Preguntas Frecuentes

¿Qué es BuildKit en Docker?

BuildKit es el motor de construcción de imágenes de Docker, disponible desde la versión 18.09 y backend por defecto desde la 23.0. Agrega funciones como secretos temporales, cache mounts persistentes y builds multi-plataforma en paralelo, todo con sintaxis nativa del Dockerfile.

¿Desde qué versión de Docker viene BuildKit activado por defecto?

Desde Docker 23.0, es decir, cualquier instalación hecha desde 2023 en adelante. Si tenés una versión anterior, podés forzarlo por sesión con la variable de entorno DOCKER_BUILDKIT=1.

Esto se conecta con BuildKit en Docker, donde explicamos cómo aprovecharlo para armar imágenes más rápido.

¿Cómo evito que un secreto quede visible en docker history?

Usando RUN --mount=type=secret,id=nombre en el Dockerfile junto con docker build --secret id=nombre,src=archivo en el comando de build. El secreto se monta solo durante ese RUN y desaparece al terminar, sin quedar en ninguna capa de la imagen. Si el secreto ya estuvo expuesto en un ARG anterior, además de migrar la sintaxis conviene rotar esa credencial.

¿Por qué el cache de Docker no funciona en GitHub Actions o GitLab CI?

Porque el cache de BuildKit vive en la máquina donde corrió el build, no en la imagen, y la mayoría de los runners de CI arrancan en una máquina limpia en cada ejecución. La solución es persistir el estado de BuildKit entre corridas, con un método que depende de cada plataforma de CI.

¿Cómo construyo una imagen Docker para amd64 y arm64 al mismo tiempo?

Con docker buildx build --platform linux/amd64,linux/arm64 -t tu-registro/tu-app:latest --push ., después de crear un builder multi-plataforma con docker buildx create --use --name multi-arch-builder. El build necesita --push o --output porque no puede quedar solo en cache local. Si tu stack es compilado y el build nativo ya tarda, conviene medir cuánto suma la emulación QEMU antes de dejarlo así en producción.

Conclusión

BuildKit no es una función nueva ni experimental, es el backend estándar de Docker desde 2023 y la mayoría de los equipos usa una fracción mínima de lo que ofrece. Migrar los secretos de ARG a --mount=type=secret es un cambio de una tarde que cierra un agujero de seguridad concreto, siempre que además rotes cualquier credencial que haya circulado antes en texto plano. Agregar cache mounts según el gestor de paquetes que uses recorta minutos reales de build en proyectos con dependencias pesadas, con el límite claro de que en CI necesitás persistencia aparte. Y si tu stack ya corre en más de una arquitectura, Buildx resuelve el problema de mantener dos pipelines separados, aunque conviene medir el costo de QEMU en builds compilados antes de asumir que emular siempre sale gratis: la decisión cambia bastante según si tu código se compila o se interpreta.

Fuentes

Te puede interesar...