Compartir comentarios
Las respuestas se generan en base a la documentación.

Optimizar el uso del caché en las compilaciones

Al compilar con Docker, se reutiliza una capa del caché de compilación si la instrucción y los archivos de los que depende no han cambiado desde que se compiló anteriormente. Reutilizar capas del caché acelera el proceso de compilación porque Docker no tiene que volver a compilar la capa de nuevo.

Aquí tienes algunas técnicas que puedes utilizar para optimizar el almacenamiento en caché de las compilaciones y acelerar el proceso de compilación:

  • Ordenar las capas: Poner los comandos en tu Dockerfile en un orden lógico puede ayudarte a evitar la invalidación innecesaria del caché.
  • Mantener el contexto pequeño: El contexto es el conjunto de archivos y directorios que se envían al builder para procesar una instrucción de compilación. Mantener el contexto lo más pequeño posible reduce la cantidad de datos que deben enviarse al builder y reduce la probabilidad de invalidación del caché.
  • Usar montajes bind: Los montajes bind te permiten montar un archivo o directorio desde la máquina host en el contenedor de compilación. El uso de montajes bind puede ayudarte a evitar capas innecesarias en la imagen, las cuales pueden ralentizar el proceso de compilación.
  • Usar montajes de caché: Los montajes de caché te permiten especificar un caché de paquetes persistente para utilizarlo durante las compilaciones. El caché persistente ayuda a acelerar los pasos de compilación, especialmente los pasos que implican la instalación de paquetes utilizando un gestor de paquetes. Tener un caché persistente para los paquetes significa que, incluso si vuelves a compilar una capa, solo descargarás los paquetes nuevos o modificados.
  • Usar un caché externo: Un caché externo te permite almacenar el caché de compilación en una ubicación remota. La imagen del caché externo se puede compartir entre varias compilaciones y en diferentes entornos.

Ordenar las capas

Poner los comandos de tu Dockerfile en un orden lógico es un excelente lugar para comenzar. Dado que un cambio provoca la recompilación de los pasos siguientes, intenta hacer que los pasos costosos aparezcan cerca del principio del Dockerfile. Los pasos que cambian con frecuencia deben aparecer cerca del final del Dockerfile, para evitar activar recompilaciones de capas que no han cambiado.

Considera el siguiente ejemplo. Un fragmento de Dockerfile que ejecuta una compilación de JavaScript a partir de los archivos de origen en el directorio actual:

# syntax=docker/dockerfile:1
FROM node
WORKDIR /app
COPY . .          # Copia todos los archivos en el directorio actual
RUN npm install   # Instala dependencias
RUN npm build     # Ejecuta la compilación

Este Dockerfile es bastante ineficiente. Actualizar cualquier archivo provoca una reinstalación de todas las dependencias cada vez que compilas la imagen de Docker, incluso si las dependencias no han cambiado desde la última vez.

En su lugar, el comando COPY se puede dividir en dos. Primero, copia los archivos de gestión de paquetes (en este caso, package.json y yarn.lock). Luego, instala las dependencias. Finalmente, copia el código fuente del proyecto, el cual está sujeto a cambios frecuentes.

# syntax=docker/dockerfile:1
FROM node
WORKDIR /app
COPY package.json yarn.lock .    # Copia archivos de gestión de paquetes
RUN npm install                  # Instala dependencias
COPY . .                         # Copia los archivos del proyecto
RUN npm build                    # Ejecuta la compilación

Al instalar las dependencias en las capas iniciales del Dockerfile, no hay necesidad de volver a compilar esas capas cuando un archivo del proyecto ha cambiado.

Mantener el contexto pequeño

La forma más sencilla de asegurarse de que tu contexto no incluya archivos innecesarios es crear un archivo .dockerignore en la raíz de tu contexto de compilación. El archivo .dockerignore funciona de manera similar a los archivos .gitignore y te permite excluir archivos y directorios del contexto de compilación.

A continuación, se muestra un ejemplo de archivo .dockerignore que excluye el directorio node_modules y todos los archivos y directorios que comiencen con tmp:

.dockerignore
node_modules
tmp*

Las reglas de exclusión especificadas en el archivo .dockerignore se aplican a todo el contexto de compilación, incluidos los subdirectorios. Esto significa que es un mecanismo bastante general, pero es una buena forma de excluir archivos y directorios que sabes que no necesitas en el contexto de compilación, como archivos temporales, archivos de registro (logs) y artefactos de compilación.

Usar montajes bind

Es posible que estés familiarizado con los montajes bind al ejecutar contenedores con docker run o Docker Compose. Los montajes bind te permiten montar un archivo o directorio desde la máquina host en un contenedor.

# montaje bind utilizando la opción -v
docker run -v $(pwd):/path/in/container image-name
# montaje bind utilizando la opción --mount
docker run --mount=type=bind,src=.,dst=/path/in/container image-name

Para usar montajes bind en una compilación, puedes usar la opción --mount con la instrucción RUN en tu Dockerfile:

FROM golang:latest
WORKDIR /build
RUN --mount=type=bind,target=. go build -o /app/hello

En este ejemplo, el directorio actual se monta en el contenedor de compilación en /build antes de que se ejecute el comando go build. La salida de la compilación se escribe en /app/hello, que está fuera del punto de montaje. Esta distinción es importante: la salida de la compilación debe escribirse fuera del destino del montaje bind, ya que el montaje es de solo lectura por defecto. El código fuente está disponible en el contenedor de compilación durante la ejecución de esa instrucción RUN. Cuando la instrucción termina de ejecutarse, los archivos montados no persisten en la imagen final, ni en el caché de compilación. Solo permanece la salida del comando go build.

Las instrucciones COPY y ADD en un Dockerfile te permiten copiar archivos desde el contexto de compilación al contenedor de compilación. El uso de montajes bind es beneficioso para la optimización del caché de compilación porque no estás añadiendo capas innecesarias al caché. Si tienes un contexto de compilación que es más bien grande y solo se utiliza para generar un artefacto, es mejor usar montajes bind para montar temporalmente el código fuente requerido para generar el artefacto en la compilación. Si utilizas COPY para añadir los archivos al contenedor de compilación, BuildKit incluirá todos esos archivos en el caché, incluso si los archivos no se utilizan en la imagen final.

Hay algunas cosas a tener en cuenta al usar montajes bind en una compilación:

  • Los montajes bind son de solo lectura por defecto. Si necesitas escribir en el directorio montado, debes especificar la opción rw. Sin embargo, incluso con la opción rw, los cambios no persisten en la imagen final ni en el caché de compilación. Las escrituras de archivos se mantienen durante la ejecución de la instrucción RUN y se descartan una vez que la instrucción termina.

  • Los archivos montados no persisten en la imagen final. Solo la salida de la instrucción RUN persiste en la imagen final. Si necesitas incluir archivos del contexto de compilación en la imagen final, debes usar las instrucciones COPY o ADD.

  • Si el directorio de destino no está vacío, los contenidos del directorio de destino quedan ocultos por los archivos montados. Los contenidos originales se restauran después de que termina la instrucción RUN.

    Por ejemplo, dado un contexto de compilación que contiene únicamente un Dockerfile:

    .
    └── Dockerfile

    Y un Dockerfile que monta el directorio actual en el contenedor de compilación:

    FROM alpine:latest
    WORKDIR /work
    RUN touch foo.txt
    RUN --mount=type=bind,target=. ls
    RUN ls

    El primer comando ls con el montaje bind muestra el contenido del directorio montado. El segundo ls muestra el contenido del contexto de compilación original.

    Registro de compilación
    #8 [stage-0 3/5] RUN touch foo.txt
    #8 DONE 0.1s
    
    #9 [stage-0 4/5] RUN --mount=target=. ls -1
    #9 0.040 Dockerfile
    #9 DONE 0.0s
    
    #10 [stage-0 5/5] RUN ls -1
    #10 0.046 foo.txt
    #10 DONE 0.1s

Usar montajes de caché

Las capas de caché normales en Docker corresponden a una coincidencia exacta de la instrucción y los archivos de los que depende. Si la instrucción y los archivos de los que depende han cambiado desde que se compiló la capa, la capa se invalida y el proceso de compilación debe volver a compilar la capa.

Los montajes de caché son una forma de especificar una ubicación de caché persistente para ser utilizada durante las compilaciones. El caché es acumulativo entre compilaciones, por lo que puedes leer y escribir en él varias veces. Este almacenamiento en caché persistente significa que, incluso si necesitas volver a compilar una capa, solo descargarás los paquetes nuevos o modificados. Cualquier paquete que no haya cambiado se reutilizará desde el montaje de caché.

Para usar montajes de caché en una compilación, puedes usar la opción --mount con la instrucción RUN en tu Dockerfile:

FROM node:latest
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install

En este ejemplo, el comando npm install utiliza un montaje de caché para el directorio /root/.npm, la ubicación predeterminada para el caché de npm. El montaje de caché persiste entre compilaciones, por lo que incluso si terminas volviendo a compilar la capa, solo descargarás los paquetes nuevos o modificados. Cualquier cambio en el caché persiste entre compilaciones, y el caché se comparte entre múltiples compilaciones.

La forma de especificar los montajes de caché depende de la herramienta de compilación que estés utilizando. Si no estás seguro de cómo especificar los montajes de caché, consulta la documentación de la herramienta de compilación que estés utilizando. Aquí tienes algunos ejemplos:

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /app/hello
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  apt update && apt-get --no-install-recommends install -y gcc
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
RUN --mount=type=cache,target=/root/.gem \
    bundle install
RUN --mount=type=cache,target=/app/target/ \
    --mount=type=cache,target=/usr/local/cargo/git/db \
    --mount=type=cache,target=/usr/local/cargo/registry/ \
    cargo build
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore
RUN --mount=type=cache,target=/tmp/cache \
    composer install

Es importante que leas la documentación de la herramienta de compilación que utilizas para asegurarte de estar utilizando las opciones correctas de montaje de caché. Los gestores de paquetes tienen diferentes requisitos sobre cómo utilizan el caché, y el uso de las opciones incorrectas puede provocar comportamientos inesperados. Por ejemplo, Apt necesita acceso exclusivo a sus datos, por lo que los cachés utilizan la opción sharing=locked para garantizar que las compilaciones paralelas que utilizan el mismo montaje de caché se esperen entre sí y no accedan a los mismos archivos de caché al mismo tiempo.

Usar un caché externo

El almacenamiento de caché predeterminado para las compilaciones es interno al builder (instancia de BuildKit) que estés utilizando. Cada builder utiliza su propio almacenamiento de caché. Cuando cambias entre diferentes builders, el caché no se comparte entre ellos. El uso de un caché externo te permite definir una ubicación remota para subir (push) y descargar (pull) los datos del caché.

Los cachés externos son especialmente útiles para los pipelines de CI/CD, donde los builders suelen ser efímeros y los minutos de compilación son valiosos. Reutilizar el caché entre compilaciones puede acelerar drásticamente el proceso de compilación y reducir costos. Incluso puedes hacer uso del mismo caché en tu entorno de desarrollo local.

Para usar un caché externo, especificas las opciones --cache-to y --cache-from con el comando docker buildx build.

  • --cache-to exporta el caché de compilación a la ubicación especificada.
  • --cache-from especifica cachés remotos para que los utilice la compilación.

El siguiente ejemplo muestra cómo configurar un workflow de GitHub Actions utilizando docker/build-push-action, y subir las capas de caché de compilación a una imagen de registro OCI:

.github/workflows/ci.yml
name: ci

on:
  push:

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Iniciar sesión en Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Configurar Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Compilar y subir (push)
        uses: docker/build-push-action@v7
        with:
          push: true
          tags: user/app:latest
          cache-from: type=registry,ref=user/app:buildcache
          cache-to: type=registry,ref=user/app:buildcache,mode=max

Esta configuración le indica a BuildKit que busque el caché en la imagen user/app:buildcache. Y cuando termina la compilación, el nuevo caché de compilación se sube a la misma imagen, sobrescribiendo el caché anterior.

Este caché también se puede utilizar localmente. Para descargar el caché en una compilación local, puedes usar la opción --cache-from con el comando docker buildx build:

$ docker buildx build --cache-from type=registry,ref=user/app:buildcache .

Resumen

Optimizar el uso del caché en las compilaciones puede acelerar significativamente el proceso de compilación. Mantener el contexto de compilación pequeño, utilizar montajes bind, montajes de caché y cachés externos son técnicas que puedes utilizar para aprovechar al máximo el caché de compilación y acelerar el proceso de compilación.

Para obtener más información sobre los conceptos tratados en esta guía, consulta: