Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Antes de que una característica llegue a producción, en nuestra era de complejos orquestadores y CI/CD, hay un largo camino que recorrer desde el commit hasta las pruebas y la entrega. Antes, podías simplemente subir nuevos archivos por FTP (aunque ahora ya nadie hace eso, ¿verdad?), y el proceso de 'despliegue' tomaba segundos. Ahora, debes crear una solicitud de fusión y esperar un buen tiempo hasta que la característica llegue a los usuarios.

Parte de este camino es la construcción de la imagen de Docker. A veces, la construcción tarda minutos, en ocasiones, decenas de minutos, lo que es difícil de llamar normal. En este artículo, tomaremos una aplicación sencilla, la empaquetaremos en una imagen, aplicaremos varios métodos para acelerar la construcción y examinaremos los matices del funcionamiento de esos métodos.

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Tenemos una buena experiencia en la creación y mantenimiento de sitios web de medios: TASS, The Bell, "Nova Gazeta", Republic… Recientemente ampliamos nuestro portafolio al lanzar un sitio en producción. Reminder. Y mientras rápidamente implementábamos nuevas funciones y corregíamos errores antiguos, el despliegue lento se convirtió en un gran problema.

El despliegue lo hacemos en GitLab. Construimos imágenes, las subimos al GitLab Registry y desplegamos en producción. Lo que más tiempo toma en esta lista es la construcción de imágenes. Por ejemplo: sin optimización, cada construcción del backend tomaba 14 minutos.

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Al final, se hizo evidente que no se podía seguir así, y nos sentamos a investigar por qué las imágenes tardaban tanto en construirse. ¡Finalmente logramos reducir el tiempo de construcción a 30 segundos!

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Para este artículo, para no atarnos al entorno de Reminder, consideraremos un ejemplo de construcción de una aplicación vacía en Angular. Así que, vamos a crear nuestra aplicación:

ng n app

Le añadimos PWA (somos progresistas):

ng add @angular/pwa --project app

Mientras se descargan millones de paquetes npm, vamos a ver cómo está estructurada la imagen de docker. Docker permite empaquetar aplicaciones y ejecutarlas en un entorno aislado llamado contenedor. Gracias a esta aislamiento, es posible ejecutar múltiples contenedores en un solo servidor. Los contenedores son significativamente más ligeros que las máquinas virtuales, ya que se ejecutan directamente en el núcleo del sistema. Para iniciar un contenedor con nuestra aplicación, primero necesitamos crear una imagen que empaquete todo lo necesario para que funcione nuestra aplicación. En esencia, una imagen es una instantánea del sistema de archivos. Por ejemplo, tomemos el Dockerfile:

FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

El Dockerfile es un conjunto de instrucciones; al ejecutar cada una de ellas, Docker guardará los cambios en el sistema de archivos y los aplicará a los anteriores. Cada comando crea su propia capa. La imagen resultante es la combinación de estas capas.

Lo importante a tener en cuenta: cada capa puede ser cacheada. Si no hay cambios desde la última construcción, Docker tomará la capa ya existente en lugar de ejecutar el comando. Dado que la principal mejora en la velocidad de construcción proviene del uso de la caché, nos enfocaremos en medir la velocidad específicamente al construir la imagen con la caché preparada. Así que, paso a paso:

  1. Eliminamos las imágenes localmente para que las ejecuciones anteriores no afecten la prueba.
    docker rmi $(docker images -q)
  2. Ejecutamos la construcción por primera vez.
    time docker build -t app .
  3. Modificamos el archivo src/index.html — simulamos el trabajo de un programador.
  4. Ejecutamos la construcción por segunda vez.
    time docker build -t app .

Si se configura correctamente el entorno para construir las imágenes (de esto hablaremos un poco más adelante), Docker ya tendrá un montón de cachés cuando se inicie la construcción. Nuestra tarea es aprender a utilizar la caché para que la construcción sea lo más rápida posible. Dado que asumimos que la construcción sin caché ocurre solo una vez —la primera—, podemos ignorar cuán lenta fue esa primera vez. En las pruebas, lo que nos importa es la segunda ejecución de la construcción, cuando las cachés ya están listas y estamos listos para hornear nuestro pastel. Sin embargo, algunos consejos también afectarán la primera construcción.

Colocamos el Dockerfile, descrito anteriormente, en la carpeta del proyecto y comenzamos la construcción. Todos los listados presentados han sido abreviados para facilitar la lectura.

$ time docker build -t app .
Enviando el contexto de construcción al demonio de Docker 409MB
Paso 1/5 : FROM node:12.16.2
Estado: Imagen más reciente descargada para node:12.16.2
Paso 2/5 : WORKDIR /app
Paso 3/5 : COPY . .
Paso 4/5 : RUN npm ci
se añadieron 1357 paquetes en 22.47s
Paso 5/5 : RUN npm run build --prod
Fecha: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Tiempo: 37581ms
Construido con éxito c8c279335f46
Etiquetado exitosamente app:latest

real 5m4.541s
user 0m0.000s
sys 0m0.000s

Modificamos el contenido de src/index.html y ejecutamos por segunda vez.

$ time docker build -t app .
Enviando el contexto de construcción al demonio de Docker 409MB
Paso 1/5 : FROM node:12.16.2
Paso 2/5 : WORKDIR /app
 ---> Usando caché
Paso 3/5 : COPY . .
Paso 4/5 : RUN npm ci
se añadieron 1357 paquetes en 22.47s
Paso 5/5 : RUN npm run build --prod
Fecha: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Tiempo: 37902ms
Construido con éxito 79f335df92d3
Etiquetado exitosamente app:latest

real 3m33.262s
user 0m0.000s
sys 0m0.000s

Para ver si hemos obtenido la imagen, ejecutamos el comando docker images:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
app          latest   79f335df92d3   Hace aproximadamente un minuto     1.74GB

Antes de construir, Docker toma todos los archivos del contexto actual y se los envía a su demonio Enviando el contexto de construcción al demonio de Docker 409MB. El contexto de construcción se especifica como el último argumento del comando build. En nuestro caso, es el directorio actual — ".", — y Docker arrastra todo lo que tenemos en esta carpeta. 409 megabytes es mucho: pensemos en cómo solucionarlo.

Reduciendo el contexto

Para reducir el contexto, hay dos opciones. O colocar todos los archivos necesarios para la construcción en una carpeta separada y especificar el contexto a Docker justo para esa carpeta. Esto puede no ser siempre conveniente, por lo que hay una opción para especificar excepciones: qué no debe arrastrarse al contexto. Para ello, colocaremos en el proyecto un archivo .dockerignore y especificaremos lo que no se necesita para la construcción:

.git
/node_modules

y volveremos a iniciar la construcción:

$ time docker build -t app .
Enviando el contexto de construcción al demonio de Docker 607.2kB
Paso 1/5 : FROM node:12.16.2
Paso 2/5 : WORKDIR /app
 ---> Usando caché
Paso 3/5 : COPY . .
Paso 4/5 : RUN npm ci
se añadieron 1357 paquetes en 22.47s
Paso 5/5 : RUN npm run build --prod
Fecha: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Tiempo: 37313ms
Construido con éxito 4942f010792a
Etiquetado con éxito app:latest

real 1m47.763s
user 0m0.000s
sys 0m0.000s

607.2 kB — mucho mejor que 409 MB. Además, hemos reducido el tamaño de la imagen de 1.74 a 1.38 GB:

REPOSITORIO   ETIQUETA   ID DE IMAGEN       CREADO         TAMAÑO
app          latest   4942f010792a   hace 3 minutos   1.38GB

Intentemos reducir aún más el tamaño de la imagen.

Usando Alpine

Otra forma de ahorrar en el tamaño de la imagen es utilizar una imagen base más pequeña. La imagen base es aquella de la cual se prepara nuestra imagen. La capa inferior se especifica con el comando FROM en el Dockerfile. En nuestro caso, estamos utilizando una imagen basada en Ubuntu, en la que ya está instalado Node.js. Y su tamaño es …

$ docker images -a | grep node
node 12.16.2 406aa3abbc6c hace 17 minutos 916MB

… casi un gigabyte. Se puede reducir considerablemente usando una imagen basada en Alpine Linux. Alpine es un Linux muy pequeño. La imagen de Docker para Node.js basada en Alpine pesa solo 88.5 MB. Por lo tanto, reemplacemos nuestra pesada imagen:

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
	python 
	make 
	g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Tuvimos que instalar algunas cosas necesarias para construir la aplicación. Sí, Angular no se construye sin Python ¯(°_o)\/¯

Pero el tamaño de la imagen se redujo en 150 MB:

REPOSITORIO   ETIQUETA   ID DE IMAGEN       CREADO          TAMAÑO
app          latest   aa031edc315a   hace 22 minutos   761MB

Vamos aún más lejos.

Construcción de múltiples etapas

No todo lo que está en la imagen lo necesitamos en producción.

$ docker run app ls -lah
total 576K
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 .
drwxr-xr-x 1 root root 4.0K Apr 16 20:00 ..
-rwxr-xr-x 1 root root 19 Apr 17 2020 .dockerignore
-rwxr-xr-x 1 root root 246 Apr 17 2020 .editorconfig
-rwxr-xr-x 1 root root 631 Apr 17 2020 .gitignore
-rwxr-xr-x 1 root root 181 Apr 17 2020 Dockerfile
-rwxr-xr-x 1 root root 1020 Apr 17 2020 README.md
-rwxr-xr-x 1 root root 3.6K Apr 17 2020 angular.json
-rwxr-xr-x 1 root root 429 Apr 17 2020 browserslist
drwxr-xr-x 3 root root 4.0K Apr 16 19:54 dist
drwxr-xr-x 3 root root 4.0K Apr 17 2020 e2e
-rwxr-xr-x 1 root root 1015 Apr 17 2020 karma.conf.js
-rwxr-xr-x 1 root root 620 Apr 17 2020 ngsw-config.json
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 node_modules
-rwxr-xr-x 1 root root 494.9K Apr 17 2020 package-lock.json
-rwxr-xr-x 1 root root 1.3K Apr 17 2020 package.json
drwxr-xr-x 5 root root 4.0K Apr 17 2020 src
-rwxr-xr-x 1 root root 210 Apr 17 2020 tsconfig.app.json
-rwxr-xr-x 1 root root 489 Apr 17 2020 tsconfig.json
-rwxr-xr-x 1 root root 270 Apr 17 2020 tsconfig.spec.json
-rwxr-xr-x 1 root root 1.9K Apr 17 2020 tslint.json

Con docker run app ls -lah hemos lanzado un contenedor basado en nuestra imagen app y hemos ejecutado en él el comando ls -lah, después de lo cual el contenedor terminó su trabajo.

En producción solo necesitamos la carpeta dist. Sin embargo, debemos exponer los archivos de alguna manera. Podemos iniciar algún servidor HTTP en nodejs. Pero haremos algo más sencillo. Adivina la palabra rusa que tiene cuatro letras «ы». ¡Correcto! Ынжыныксы. Tomaremos una imagen con nginx, colocaremos en ella la carpeta dist y una pequeña configuración:

server {
    listen 80 default_server;
    server_name localhost;
    charset utf-8;
    root /app/dist;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Todo esto lo lograremos con una construcción de múltiples etapas. Cambiaremos nuestro Dockerfile:

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Ahora tenemos dos instrucciones FROM en el Dockerfile, cada una de ellas ejecuta su propia etapa de construcción. La primera la llamamos en Raspberry Pi 3 con núcleos un-def, lts, mp al usar pulseaudio es necesario desconectar una de las tarjetas de audio (auriculares o HDMI), de lo contrario, puede haber un apagado prolongado del equipo y se puede perder el sonido debido a pulseaudio atascado., y a partir del último FROM se preparará nuestra imagen final. El último paso es copiar el artefacto de nuestra construcción de la etapa anterior a la imagen final con nginx. El tamaño de la imagen se ha reducido considerablemente:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   2c6c5da07802   hace 29 minutos   36MB

Vamos a iniciar un contenedor con nuestra imagen y asegurémonos de que todo funciona:

docker run -p8080:80 app

Con la opción -p8080:80 hemos redirigido el puerto 8080 en nuestra máquina host al puerto 80 dentro del contenedor, donde se ejecuta nginx. Abrimos en el navegador http://localhost:8080/ y vemos nuestra aplicación. ¡Todo funciona!

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

La reducción del tamaño de la imagen de 1.74 GB a 36 MB reduce significativamente el tiempo de entrega de su aplicación en producción. Pero regresemos al tiempo de construcción.

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/11 : FROM node:12.16.2-alpine3.11 as builder
Step 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/11 : WORKDIR /app
 ---> Using cache
Step 4/11 : COPY . .
Step 5/11 : RUN npm ci
added 1357 packages in 47.338s
Step 6/11 : RUN npm run build --prod
Date: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Time: 39948ms
 ---> 27f1479221e4
Step 7/11 : FROM nginx:stable-alpine
Step 8/11 : WORKDIR /app
 ---> Using cache
Step 9/11 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 11/11 : COPY --from=builder /app/dist/app .
Successfully built d201471c91ad
Successfully tagged app:latest

real 2m17.700s
user 0m0.000s
sys 0m0.000s

Cambiamos el orden de las capas

Los primeros tres pasos han sido cacheados (sugerencia Using cache). En el cuarto paso se copian todos los archivos del proyecto y en el quinto se instalan las dependencias RUN npm ci — un total de 47.338s. ¿Por qué volver a instalar las dependencias si cambian muy raramente? Vamos a entender por qué no se almacenaron en caché. La cuestión es que Docker revisa capa por capa si el comando y los archivos asociados han cambiado. En el cuarto paso copiamos todos los archivos de nuestro proyecto, y entre ellos, por supuesto, hay cambios, por lo que Docker no solo no toma esa capa de la caché, sino que tampoco toma las siguientes. Hagamos un pequeño cambio en el Dockerfile.

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Primero se copian package.json y package-lock.json, luego se instalan las dependencias, y solo después se copia todo el proyecto. Como resultado:

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/12 : FROM node:12.16.2-alpine3.11 as builder
Step 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/12 : WORKDIR /app
 ---> Using cache
Step 4/12 : COPY package*.json ./
 ---> Using cache
Step 5/12 : RUN npm ci
 ---> Using cache
Step 6/12 : COPY . .
Step 7/12 : RUN npm run build --prod
Date: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Time: 38287ms
 ---> 1b9448c73558
Step 8/12 : FROM nginx:stable-alpine
Step 9/12 : WORKDIR /app
 ---> Using cache
Step 10/12 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 12/12 : COPY --from=builder /app/dist/app .
Successfully built a44dd7c217c3
Successfully tagged app:latest

real 0m46.497s
user 0m0.000s
sys 0m0.000s

46 segundos en lugar de 3 minutos — ¡mucho mejor! Es importante el orden correcto de las capas: primero copiamos lo que no cambia, luego lo que cambia raramente, y al final — lo que cambia con frecuencia.

A continuación, algunas palabras sobre la construcción de imágenes en sistemas CI/CD.

Uso de imágenes anteriores para la caché

Si utilizamos alguna solución SaaS para la construcción, la caché local de Docker puede estar limpia y fresca. Para que Docker tenga de dónde obtener las capas cocidas, proporciónale la imagen compilada anterior.

Tomemos como ejemplo la construcción de nuestra aplicación en GitHub Actions. Usamos esta configuración

on:
  push:
    branches:
      - master

name: Prueba de construcción de docker

jobs:
  deploy:
    name: Construcción
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Iniciar sesión en GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Construcción
      run: |
        docker build 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Subir imagen a GitHub Packages
      run: |
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Cerrar sesión
      run: |
        docker logout docker.pkg.github.com

La imagen se construye y se sube a GitHub Packages en dos minutos y 20 segundos:

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Ahora vamos a modificar la construcción para utilizar la caché basada en las imágenes construidas previamente:

on:
  push:
    branches:
      - master

name: Prueba de construcción de docker

jobs:
  deploy:
    name: Construcción
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Iniciar sesión en GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Descargar imágenes más recientes
      run: |
        docker pull $IMAGE_NAME:latest || true
        docker pull $IMAGE_NAME-builder-stage:latest || true

    - name: Lista de imágenes
      run: |
        docker images

    - name: Construcción
      run: |
        docker build 
          --target builder 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          -t $IMAGE_NAME-builder-stage 
          .
        docker build 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          --cache-from $IMAGE_NAME:latest 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Subir imagen a GitHub Packages
      run: |
        docker push $IMAGE_NAME-builder-stage:latest
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Cerrar sesión
      run: |
        docker logout docker.pkg.github.com

Para comenzar, es necesario explicar por qué se ejecutan dos comandos. buildLa razón es que, en una construcción de múltiples etapas, el resultado será un conjunto de capas de la última etapa. Sin embargo, las capas de etapas anteriores no se incorporarán a la imagen. Por lo tanto, al utilizar la imagen final de una construcción anterior, Docker no podrá encontrar las capas necesarias para construir la imagen con nodejs (etapa builder). Para resolver este problema, se crea una imagen intermedia. $IMAGE_NAME-builder-stage y se envía a GitHub Packages, para poder ser utilizada en construcciones posteriores como fuente de caché.

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

El tiempo total de construcción se ha reducido a un minuto y medio. Se requieren 30 segundos para recuperar las imágenes anteriores.

Creación preliminar de imágenes

Otra forma de abordar el problema de la caché limpia de Docker es trasladar algunas capas a otro Dockerfile, compilarlo por separado, subirlo al Container Registry y usarlo como base.

Creamos nuestra imagen de nodejs para compilar la aplicación Angular. Creamos en el proyecto Dockerfile.node

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++

Construimos y publicamos la imagen pública en Docker Hub:

docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latest

Ahora en nuestro Dockerfile principal usamos la imagen ya preparada:

FROM exsmund/node-for-angular:latest as builder
...

En nuestro ejemplo, el tiempo de construcción no se redujo, pero las imágenes previamente creadas pueden ser útiles si tienes muchos proyectos y en cada uno de ellos tienes que instalar dependencias idénticas.

Consejos sobre cómo acelerar la construcción de imágenes de Docker. Por ejemplo, hasta 30 segundos.

Hemos revisado varios métodos para acelerar la construcción de imágenes de Docker. Si deseas que el despliegue sea rápido, intenta aplicar en tu proyecto:

  • reducción del contexto;
  • uso de imágenes base pequeñas;
  • construcción de múltiples etapas;
  • cambio en el orden de las instrucciones en el Dockerfile para utilizar el caché de manera efectiva;
  • configuración de la caché en sistemas CI/CD;
  • creación previa de imágenes.

Espero que con este ejemplo quede más claro cómo funciona Docker, y podrás ajustar óptimamente tu despliegue. Para experimentar con los ejemplos del artículo, se ha creado un repositorio https://github.com/devopsprodigy/test-docker-build.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster