Descripción general
Si construyes el mismo Dockerfile dos veces y obtienes imágenes diferentes, no puedes verificar la integridad del build. Un build no reproducible significa que no tienes forma de confirmar que el artefacto que se ejecuta en producción fue realmente producido a partir del código fuente que auditaste. Los atacantes pueden explotar esta ambigüedad para inyectar código malicioso durante el proceso de build sin ser detectados.
Este laboratorio te guía a través de las fuentes de no reproducibilidad en los builds de contenedores, demuestra técnicas para eliminar cada una y muestra cómo verificar la reproducibilidad automáticamente en pipelines de CI/CD. Al finalizar, tendrás un Dockerfile completamente reproducible y un workflow de GitHub Actions que lo demuestra en cada commit.
Requisitos previos
- Docker con BuildKit — Docker Desktop 23.0+ tiene BuildKit habilitado por defecto. Verifica con
docker buildx version. - diffoscope — Instala con
pip install diffoscope. Esta herramienta realiza comparaciones profundas y recursivas de archivos y archivos comprimidos. - crane — Instala desde go-containerregistry. Se utiliza para inspeccionar y manipular imágenes de contenedores y registros.
- Cosign — Instala desde Sigstore. Se utiliza para la firma y verificación de imágenes de contenedores.
- Un repositorio de prueba con un Dockerfile (lo crearemos en el paso de configuración).
- Go 1.22+ instalado localmente (opcional, para pruebas locales fuera de Docker).
Configuración del entorno
Crea un repositorio de prueba nuevo con una aplicación Go simple. Esto nos proporciona un proyecto realista y mínimo con el que trabajar a lo largo del laboratorio.
Paso 1: Inicializar el proyecto
mkdir repro-build-lab && cd repro-build-lab
git init
go mod init github.com/example/repro-build-lab
Paso 2: Crear la aplicación Go
Crea cmd/app/main.go:
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from repro-build-lab v1\n")
})
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "ok\n")
})
fmt.Printf("Listening on :%s\n", port)
http.ListenAndServe(":"+port, nil)
}
Paso 3: Crear el Dockerfile intencionalmente no reproducible
Este Dockerfile contiene todos los errores comunes que conducen a builds no reproducibles:
# Intentionally non-reproducible Dockerfile
FROM golang:latest
WORKDIR /src
# Floating package versions
RUN apt-get update && apt-get install -y curl
# Embeds current timestamp into the image
RUN echo "Built at $(date)" > /build-info
COPY . .
RUN go build -o /app ./cmd/app
EXPOSE 8080
CMD ["/app"]
Observa los problemas:
FROM golang:latest— la imagen base cambia sin previo aviso.apt-get install -y curl— sin pinning de versión, por lo que la versión instalada varía.echo "Built at $(date)"— inyecta una marca de tiempo que es diferente en cada build.- Sin
.dockerignore— archivos locales como.git/se filtran en el contexto del build, cambiando los hashes de las capas.
Haz commit del proyecto inicial:
git add -A
git commit -m "Initial non-reproducible project"
Ejercicio 1: Demostrar la no reproducibilidad
Antes de corregir nada, demostremos que el Dockerfile actual produce imágenes diferentes en cada build.
Paso 1: Construir la imagen dos veces
# First build
docker build --no-cache -t myapp:build1 .
# Wait a moment so the timestamp differs
sleep 2
# Second build
docker build --no-cache -t myapp:build2 .
El flag --no-cache obliga a Docker a ejecutar cada capa desde cero, lo cual es esencial para esta comparación. En un entorno real de CI/CD, los builds a menudo se ejecutan en runners nuevos sin caché.
Paso 2: Comparar los digests de las imágenes
docker inspect --format='{{.Id}}' myapp:build1
# sha256:a1b2c3d4e5f6... (example)
docker inspect --format='{{.Id}}' myapp:build2
# sha256:f6e5d4c3b2a1... (different!)
Los digests son diferentes aunque nada en el código fuente cambió. Esto significa que no puedes verificar que una imagen dada fue producida a partir de un commit específico.
Paso 3: Usar diffoscope para identificar qué difiere
# Export both images as tarballs
docker save myapp:build1 -o build1.tar
docker save myapp:build2 -o build2.tar
# Run diffoscope
diffoscope build1.tar build2.tar --html-dir diff-report
Abre diff-report/index.html en un navegador. El informe revela exactamente qué difiere entre los dos builds:
- Marcas de tiempo — el archivo
/build-infocontiene fechas diferentes. - Metadatos de paquetes apt — las listas de paquetes y archivos de caché contienen marcas de tiempo y pueden obtener diferentes micro-versiones.
- Binario Go — el binario compilado contiene rutas de build embebidas e IDs de build.
- Orden de capas y metadatos — Docker embebe marcas de tiempo de creación en los metadatos de las capas.
Cada una de estas es una fuente de no reproducibilidad que eliminaremos en los siguientes ejercicios.
Ejercicio 2: Pinning de la imagen base por digest
La mayor fuente de variación es la imagen base. golang:latest es un objetivo móvil — puede cambiar entre builds, entre ejecuciones de CI, o incluso entre regiones si un registro es eventualmente consistente.
Paso 1: Encontrar el digest actual
crane digest golang:1.22
# sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b
Paso 2: Fijar la imagen base
Actualiza la línea FROM en el Dockerfile:
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b
El formato es image:tag@sha256:digest. Docker descargará por digest, ignorando el tag. El tag se mantiene para legibilidad humana.
Paso 3: Reconstruir y comparar
docker build --no-cache -t myapp:pinned1 .
sleep 2
docker build --no-cache -t myapp:pinned2 .
docker inspect --format='{{.Id}}' myapp:pinned1
docker inspect --format='{{.Id}}' myapp:pinned2
Los digests aún son diferentes — otras fuentes de no reproducibilidad permanecen. Pero si comparas las capas, la capa de la imagen base ahora es idéntica entre builds. Has eliminado la mayor fuente de variación.
Por qué esto importa
Sin pinning por digest, un tag comprometido o secuestrado puede reemplazar silenciosamente tu imagen base con una maliciosa. El pinning por digest es una garantía criptográfica: obtienes exactamente los bytes que esperas, o el build falla.
Ejercicio 3: Pinning de versiones de paquetes
Las versiones flotantes de paquetes introducen no determinismo en la capa de dependencias. Cada vez que se ejecuta apt-get update, obtiene el índice actual del repositorio, que puede listar versiones diferentes de paquetes.
Opción A: Pinning de versiones de paquetes Debian
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u8 && \
rm -rf /var/lib/apt/lists/*
Para encontrar la versión actual disponible en tu imagen base:
docker run --rm golang:1.22 apt-cache policy curl
Opción B: Usar Alpine con paquetes fijados
Los paquetes de Alpine tienen cadenas de versión más simples e imágenes más pequeñas:
FROM golang:1.22-alpine@sha256:<alpine-digest>
RUN apk add --no-cache curl=8.5.0-r0
Opción C: Build multi-stage (preferido)
El mejor enfoque es evitar instalar paquetes en la imagen final por completo. Usa un build multi-stage donde la etapa de build tiene las herramientas y la etapa de runtime es mínima:
# Build stage — tools are only needed here
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app ./cmd/app
# Runtime stage — no apt-get, no floating packages
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
CMD ["/app"]
Con este enfoque, la imagen de runtime tiene cero llamadas al gestor de paquetes, lo que elimina toda una clase de no reproducibilidad.
Reconstruir y comparar
docker build --no-cache -t myapp:pinpkg1 .
sleep 2
docker build --no-cache -t myapp:pinpkg2 .
docker inspect --format='{{.Id}}' myapp:pinpkg1
docker inspect --format='{{.Id}}' myapp:pinpkg2
Las capas de paquetes ahora son idénticas entre builds. Las diferencias restantes provienen de las marcas de tiempo y del binario Go en sí.
Ejercicio 4: Eliminar marcas de tiempo y contenido no determinista
Las marcas de tiempo son la fuente más obvia de no reproducibilidad. Cualquier comando que capture la hora actual produce un resultado diferente en cada build.
Paso 1: Eliminar marcas de tiempo explícitas
Elimina la línea que escribe la hora del build:
# REMOVE this line:
# RUN echo "Built at $(date)" > /build-info
Si necesitas metadatos del build, pásalos como una etiqueta con un valor fijo derivado del código fuente:
ARG BUILD_COMMIT
LABEL org.opencontainers.image.revision=${BUILD_COMMIT}
Paso 2: Establecer SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH es una variable de entorno estandarizada que indica a las herramientas de build que usen una marca de tiempo fija en lugar de la hora actual. Muchas herramientas la respetan, incluyendo tar, gzip, zip y el compilador de Go.
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
Construye con la marca de tiempo del último commit de git:
docker build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
--no-cache \
-t myapp:repro .
Esto asegura que los builds del mismo commit siempre usen la misma marca de tiempo, independientemente de cuándo se ejecute realmente el build.
Paso 3: Usar la salida OCI de BuildKit
BuildKit puede producir imágenes en formato OCI con una creación de capas más determinista:
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
--output type=oci,dest=myapp.tar \
--no-cache \
.
El formato de salida OCI evita algunos de los metadatos no deterministas que el formato de imagen predeterminado de Docker incluye.
Ejercicio 5: Builds reproducibles de Go
Go embebe varias piezas de información no determinista en los binarios compilados por defecto: rutas de archivos locales, un ID de build único y símbolos de depuración que referencian el entorno de build.
Paso 1: Usar flags de build reproducible
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -buildid=" \
-o /app ./cmd/app
Esto es lo que hace cada flag:
| Flag | Propósito |
|---|---|
CGO_ENABLED=0 |
Desactiva cgo, produciendo un binario enlazado estáticamente. Evita la dependencia de bibliotecas C del sistema que pueden diferir entre builds. |
-trimpath |
Elimina todas las rutas del sistema de archivos local del binario compilado. Sin esto, el binario contiene rutas como /src/cmd/app/main.go del entorno de build. |
-ldflags="-s -w" |
Elimina la tabla de símbolos (-s) y la información de depuración DWARF (-w). Estos contienen datos específicos del entorno de build. |
-ldflags="-buildid=" |
Establece el ID de build como vacío. Go normalmente genera un ID de build único que cambia entre builds incluso con código fuente idéntico. |
Paso 2: Verificar la reproducibilidad del binario
# Build twice
docker build --no-cache -t myapp:go1 .
docker build --no-cache -t myapp:go2 .
# Extract and hash the binary from each image
docker create --name tmp1 myapp:go1
docker cp tmp1:/app ./app1
docker rm tmp1
docker create --name tmp2 myapp:go2
docker cp tmp2:/app ./app2
docker rm tmp2
sha256sum app1 app2
Los hashes SHA-256 de app1 y app2 deberían ser idénticos. El binario Go ahora es reproducible bit a bit.
Ejercicio 6: El Dockerfile completamente reproducible
Ahora combinemos todas las técnicas en un único Dockerfile completamente reproducible.
El Dockerfile completo
# syntax=docker/dockerfile:1
# ---- Build Stage ----
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b AS builder
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
WORKDIR /src
# Cache dependency downloads
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -buildid=" \
-o /app ./cmd/app
# ---- Runtime Stage ----
FROM gcr.io/distroless/static-debian12:nonroot@sha256:6ec5aa99dc335b19f6c2bcb8e09cf92404e56f0db4e2f58cf92c4536e1548415
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
COPY --from=builder /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
El .dockerignore completo
.git
.github
.gitignore
*.md
README*
LICENSE
docker-compose*.yml
Makefile
.env
.env.*
*.tar
*.log
tmp/
build/
diff-report/
El .dockerignore es crítico. Sin él, el directorio .git/ se filtra en el contexto del build. Dado que .git/ contiene marcas de tiempo, archivos de bloqueo y otros metadatos cambiantes, hace que cada contexto de build sea único incluso cuando el código fuente es idéntico.
Construir y verificar
SOURCE_EPOCH=$(git log -1 --format=%ct)
# Build twice
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=build1.tar \
--no-cache .
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=build2.tar \
--no-cache .
# Compare
sha256sum build1.tar build2.tar
Con todas las técnicas de reproducibilidad aplicadas, los hashes SHA-256 de los dos tarballs OCI deberían coincidir o ser extremadamente cercanos. Cualquier diferencia restante estará en los metadatos de configuración de la imagen y puede resolverse con el flag --source-date-epoch de BuildKit (disponible en BuildKit 0.13+):
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--source-date-epoch ${SOURCE_EPOCH} \
--output type=oci,dest=build-final.tar \
--no-cache .
Ejercicio 7: Verificar la reproducibilidad en CI/CD
La reproducibilidad solo es valiosa si la verificas continuamente. Un build que es reproducible hoy puede dejar de serlo mañana si alguien agrega una dependencia flotante o una marca de tiempo. La solución es construir dos veces en cada ejecución de CI y asegurar que los resultados sean idénticos.
Workflow de GitHub Actions
Crea .github/workflows/reproducible-build.yml:
name: Verify Reproducible Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify-reproducibility:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute SOURCE_DATE_EPOCH
id: epoch
run: echo "value=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT"
- name: Build image (first pass)
run: |
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.value }} \
--output type=oci,dest=build-pass1.tar \
--no-cache \
.
- name: Record first digest
id: digest1
run: echo "sha=$(sha256sum build-pass1.tar | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Build image (second pass)
run: |
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.value }} \
--output type=oci,dest=build-pass2.tar \
--no-cache \
.
- name: Record second digest
id: digest2
run: echo "sha=$(sha256sum build-pass2.tar | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Compare digests
run: |
echo "Build 1: ${{ steps.digest1.outputs.sha }}"
echo "Build 2: ${{ steps.digest2.outputs.sha }}"
if [ "${{ steps.digest1.outputs.sha }}" != "${{ steps.digest2.outputs.sha }}" ]; then
echo "::error::Builds are NOT reproducible! Digests differ."
echo "Running diffoscope to identify differences..."
pip install diffoscope
diffoscope build-pass1.tar build-pass2.tar --text diff-output.txt || true
cat diff-output.txt
exit 1
fi
echo "Builds are reproducible. Digests match."
- name: Upload diff report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: reproducibility-diff
path: diff-output.txt
- name: Sign the verified image
if: github.ref == 'refs/heads/main'
env:
COSIGN_EXPERIMENTAL: "true"
run: |
# Load the OCI image into Docker
docker load -i build-pass1.tar
# In production, push to a registry and sign with Cosign:
# cosign sign --yes $REGISTRY/$IMAGE@$DIGEST
echo "Image verified as reproducible and ready for signing."
Este workflow hace lo siguiente en cada push y pull request:
- Descarga el código y configura BuildKit.
- Calcula
SOURCE_DATE_EPOCHa partir de la marca de tiempo del último commit. - Construye la imagen desde cero (primera pasada) y registra el digest.
- Construye la imagen desde cero nuevamente (segunda pasada) y registra el digest.
- Compara los dos digests. Si difieren, el job falla y ejecuta
diffoscopepara producir un informe detallado de diferencias. - En caso de éxito en la rama main, la imagen verificada está lista para ser firmada con Cosign.
Esta es la garantía más fuerte que puedes tener: cada ejecución de CI demuestra que tu build es reproducible. Si un desarrollador introduce no determinismo, el build falla inmediatamente.
Ejercicio 8: Diffing de imágenes entre versiones
Los builds reproducibles también te dan la capacidad de hacer diffing entre versiones y verificar que solo los cambios esperados están presentes. Esto es crítico para auditar releases: quieres confirmar que un cambio de versión solo modificó el binario de la aplicación, no la imagen base ni los paquetes del sistema.
Paso 1: Construir la versión 1
SOURCE_EPOCH=$(git log -1 --format=%ct)
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=image-v1.tar \
--no-cache .
Paso 2: Hacer un cambio en el código
Edita cmd/app/main.go para cambiar la cadena de versión:
fmt.Fprintf(w, "Hello from repro-build-lab v2\n")
Haz commit del cambio:
git add cmd/app/main.go
git commit -m "Bump to v2"
Paso 3: Construir la versión 2
SOURCE_EPOCH=$(git log -1 --format=%ct)
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=image-v2.tar \
--no-cache .
Paso 4: Comparar con diffoscope
diffoscope image-v1.tar image-v2.tar --html-dir version-diff-report
Abre el informe. Deberías ver que las únicas diferencias son:
- El binario de la aplicación Go — porque cambiamos el código fuente.
- El valor de
SOURCE_DATE_EPOCH— porque la marca de tiempo del commit cambió.
Las capas de la imagen base, el runtime distroless y todas las demás capas deberían ser completamente idénticas.
Paso 5: Comparar capas con crane
# Load images and push to a local registry for crane inspection
docker run -d -p 5000:5000 --name registry registry:2
# Load and push v1
docker load -i image-v1.tar
docker tag myapp:latest localhost:5000/myapp:v1
docker push localhost:5000/myapp:v1
# Load and push v2
docker load -i image-v2.tar
docker tag myapp:latest localhost:5000/myapp:v2
docker push localhost:5000/myapp:v2
# List layers for each version
crane manifest localhost:5000/myapp:v1 | jq '.layers[].digest'
crane manifest localhost:5000/myapp:v2 | jq '.layers[].digest'
Compara los digests de las capas. Verás que todas las capas son idénticas excepto la que contiene el binario Go. Esto es exactamente lo que quieres: un cambio de versión solo debería modificar la capa de la aplicación, nada más.
Si ves cambios inesperados en las capas (por ejemplo, la capa de la imagen base difiere), significa que algo rompió la reproducibilidad y necesita investigación. Esta comparación capa por capa es una técnica de auditoría poderosa que solo funciona cuando tus builds son reproducibles.
Limpieza
# Remove test images
docker rmi myapp:build1 myapp:build2 myapp:pinned1 myapp:pinned2 \
myapp:pinpkg1 myapp:pinpkg2 myapp:go1 myapp:go2 myapp:repro 2>/dev/null
# Remove OCI tarballs
rm -f build1.tar build2.tar build-pass1.tar build-pass2.tar \
image-v1.tar image-v2.tar myapp.tar
# Remove extracted binaries
rm -f app1 app2
# Remove diff reports
rm -rf diff-report version-diff-report
# Stop and remove the local registry
docker stop registry && docker rm registry 2>/dev/null
# Remove the test project (optional)
cd .. && rm -rf repro-build-lab
Conclusiones clave
- Fija las imágenes base por digest, no por tag. Los tags son punteros mutables. Los digests son garantías criptográficas. Usa
crane digestpara encontrar el digest actual y actualízalo deliberadamente a través de un PR, no silenciosamente durante un build. - Fija todas las versiones de paquetes o evita los gestores de paquetes en la imagen de runtime. Los builds multi-stage con imágenes de runtime distroless o scratch eliminan toda una categoría de no reproducibilidad.
- Elimina todas las fuentes de marcas de tiempo. Usa
SOURCE_DATE_EPOCHderivado de la marca de tiempo del commit de git. Nunca ejecutesdate,timestampo comandos similares en un Dockerfile. - Usa flags de compilador reproducibles. Para Go:
-trimpath,-ldflags="-s -w -buildid="yCGO_ENABLED=0. Otros lenguajes tienen opciones similares. - Verifica la reproducibilidad en CI/CD construyendo dos veces y comparando. Esta es la única forma de garantizar que tu build permanezca reproducible a medida que el proyecto evoluciona. Si los digests divergen, haz que el build falle.
- Usa diffoscope para auditar cambios entre versiones. Los builds reproducibles permiten diffs significativos de imágenes. Puedes verificar que un release solo contiene los cambios que pretendías — nada más.
Próximos pasos
Ahora que puedes producir imágenes de contenedores reproducibles, explora cómo construir una cadena completa de integridad y procedencia alrededor de ellas:
- Build Integrity and Reproducible Builds — profundización en la teoría detrás de los builds reproducibles, los requisitos de SLSA Build Level y cómo la reproducibilidad encaja en un marco más amplio de integridad de builds.
- Artifact Provenance and Attestations: From SLSA to in-toto — aprende cómo generar y verificar attestations de procedencia para tus builds reproducibles, creando una cadena auditable desde el código fuente hasta el despliegue.