Laboratorio: Builds Reproducibles de Contenedores — Pinning, Verificación y Diffing de Imágenes

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-info contiene 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:

  1. Descarga el código y configura BuildKit.
  2. Calcula SOURCE_DATE_EPOCH a partir de la marca de tiempo del último commit.
  3. Construye la imagen desde cero (primera pasada) y registra el digest.
  4. Construye la imagen desde cero nuevamente (segunda pasada) y registra el digest.
  5. Compara los dos digests. Si difieren, el job falla y ejecuta diffoscope para producir un informe detallado de diferencias.
  6. 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 digest para 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_EPOCH derivado de la marca de tiempo del commit de git. Nunca ejecutes date, timestamp o comandos similares en un Dockerfile.
  • Usa flags de compilador reproducibles. Para Go: -trimpath, -ldflags="-s -w -buildid=" y CGO_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: