GitHub Actions lento? Cómo reduje el build de Docker un 50% con Layer Caching y Buildx

No hay nada más frustrante para un equipo de ingeniería que mirar una barra de progreso congelada un viernes por la tarde. Recientemente, en un proyecto de microservicios basado en Node.js y Python, nos topamos con un cuello de botella crítico: nuestros pipelines de despliegue estaban tardando más de 15 minutos por servicio. El culpable no eran los tests, sino el paso de construcción de la imagen de Docker. Cada `git push` desencadenaba una descarga masiva de dependencias y una recompilación completa, desperdiciando ciclos de CPU y paciencia.

El problema raíz es que los runners de GitHub Actions son efímeros. A diferencia de tu máquina local, donde Docker mantiene una caché de capas (layers) entre ejecuciones, GitHub te entrega un servidor limpio en cada job. Sin una estrategia explícita de persistencia, Docker no tiene "memoria" de las ejecuciones anteriores. En este artículo, detallaré cómo implementamos Docker Layer Caching avanzado para solucionar esto, logrando una mejora drástica en la Velocidad CI/CD sin comprometer la seguridad.

Análisis del Problema: Por qué `npm install` se ejecuta siempre

Nuestro entorno de producción corre sobre AWS EKS, y utilizamos GitHub Actions para orquestar el CI/CD. La configuración inicial era estándar: un `Dockerfile` optimizado (multi-stage build) y un workflow básico de build-push.

A pesar de tener un Dockerfile bien estructurado —donde copiábamos `package.json` antes que el código fuente para aprovechar la caché de capas—, en el entorno de CI esto fallaba miserablemente. Al inspeccionar los logs, notamos que el hash de las capas base cambiaba o simplemente no se encontraba, forzando a Docker a ejecutar `RUN npm install` (o `pip install`) en cada ejecución. Esto no es solo un problema de tiempo; es un problema de costes en los minutos de facturación de GitHub Actions y ancho de banda.

Síntoma en Logs:
#6 [internal] load metadata for docker.io/library/node:18-alpine
#10 [builder 4/5] RUN npm ci
... 120s ...
La instrucción `npm ci` se ejecutaba en el 100% de los builds, incluso si `package-lock.json` no había cambiado.

Para entender esto a fondo, hay que recordar cómo funciona el sistema de archivos de Docker (UnionFS). Docker verifica si existe una capa padre idéntica en el host local. Como los runners de GitHub empiezan vacíos, esa verificación siempre falla por defecto.

El Intento Fallido: Registry Caching (Inline)

Mi primer intento para la Optimización GitHub Actions fue utilizar el registro de contenedor (ECR en nuestro caso) como fuente de caché. Configuramos `cache-from: type=registry,ref=...` apuntando a la imagen `latest`.

La teoría era buena: descargar las capas de la imagen anterior antes de construir la nueva. Sin embargo, en la práctica, esto resultó contraproducente para imágenes grandes. El tiempo que se ahorraba en compilación se perdía en latencia de red descargando gigabytes de capas desde AWS ECR al runner de GitHub. Además, la caché "inline" (incrustada en la imagen final) no siempre persistía las capas intermedias de compilación (stages de `builder`), que son las más costosas. Necesitábamos algo más inteligente.

La Solución: Docker Buildx + GitHub Cache API

La verdadera solución llegó al integrar Buildx con el backend de caché nativo de GitHub Actions (`type=gha`). Este método permite inyectar las capas de caché directamente en la infraestructura de GitHub, evitando la latencia de red externa y permitiendo cachear capas intermedias (usando `mode=max`).

Aquí presento la configuración final que redujo nuestros tiempos a la mitad. Presten atención a la sección de `docker/build-push-action`.

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # Configuración crítica: Buildx crea un builder instancia que soporta características avanzadas
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # Login necesario si vas a hacer push a un registro privado
      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      # El corazón de la optimización
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: user/app:latest
          # Habilita la exportación de caché hacia la API interna de GitHub
          cache-from: type=gha
          # mode=max asegura que se guarden TODAS las capas intermedias, no solo la final
          cache-to: type=gha,mode=max

En el código anterior, la directiva `type=gha` es la clave. Al usar `mode=max` en `cache-to`, instruimos a Buildx para que guarde las capas de las etapas intermedias (como la etapa donde se compilan assets de frontend o se instalan dependencias de compilación). Si usáramos el modo por defecto (`min`), solo se cachearían las capas que terminan en la imagen final, perdiendo gran parte del beneficio en builds multi-stage.

Resultados y Verificación de Rendimiento

Tras implementar este cambio, realizamos varias ejecuciones para "calentar" la caché. La primera ejecución (Cold Start) tardó lo mismo que antes, pero las subsiguientes (Warm Cache) mostraron una diferencia abismal. Aquí están los datos promediados de 10 despliegues:

Estrategia Tiempo de Build Transferencia de Red Uso de Caché
Sin Caché (Base) 14m 30s Alta (Descarga deps completa) 0%
Registry Cache (ECR) 11m 45s Muy Alta (Download capas) ~30%
GitHub Actions Cache (gha) 6m 10s Mínima (Interna) ~95%

Como pueden observar, reducimos el tiempo total en más de un 50%. La clave está en que la restauración de la caché desde la API de GitHub es extremadamente rápida comparada con descargar capas desde un registro externo. Si buscan Consejos DevOps prácticos, priorizar la localidad de los datos (data locality) es uno de los más valiosos.

Ver Documentación Oficial de Buildx GHA

Limitaciones y Casos Borde

Aunque esta técnica es poderosa, no es una bala de plata. Existen consideraciones importantes que deben tener en cuenta antes de aplicarla en todos sus repositorios:

Primero, el límite de almacenamiento. GitHub Actions impone un límite de 10 GB por repositorio para las cachés. Si usas `mode=max` en un monorepo con docenas de microservicios grandes, podrías llenar este espacio rápidamente. Cuando el límite se alcanza, GitHub elimina las cachés más antiguas (LRU), lo que podría causar "cache misses" inesperados en ramas antiguas.

Scope de la Caché: Las cachés en GitHub Actions están aisladas por rama. Una caché creada en la rama `feature-x` no está disponible inmediatamente para otra rama hermana, aunque la rama base (`main`) sí suele ser accesible como fallback. Asegúrate de entender las reglas de Scope de Caché.

Finalmente, ten cuidado con los secretos. Nunca inyectes secretos en tiempo de build (`ARG`) si esas capas van a ser cacheadas públicamente o compartidas de manera insegura, aunque el backend `gha` es privado por defecto dentro del repo.

Impacto Real: Esta optimización nos ahorró aproximadamente 40 horas de tiempo de espera acumulado por desarrollador al mes.

Conclusión

Implementar Docker Layer Caching mediante el backend `gha` de Buildx es una de las victorias más rápidas que puedes obtener en ingeniería de plataforma. Transforma un proceso lento y costoso en un flujo ágil. No solo mejoras la Velocidad CI/CD, sino que mejoras la felicidad del desarrollador al reducir el ciclo de feedback. Si todavía estás haciendo `npm install` en cada commit, es hora de revisar tu YAML.

Post a Comment