Otra forma de optimizar las imágenes de Docker para aplicaciones Java

La historia de la optimización de imágenes para aplicaciones Java comenzó con el artículo del blog de primavera Spring Boot in a Container . Discutió varios aspectos de la creación de imágenes acoplables para aplicaciones de arranque de primavera, incluido un tema tan interesante como la reducción del tamaño de las imágenes. Para nuestros equipos, esto fue relevante por varias razones, por lo que decidimos aplicar esta solución a nuestras aplicaciones.


Como suele suceder, no todo despegó la primera vez, hubo matices con proyectos de varios módulos y un intento de impulsar todo esto en el sistema CI, por lo que en este artículo encontrará una solución a estos problemas.


El objetivo de la optimización es reducir la diferencia entre las imágenes resultantes de un ensamblaje a otro, lo que da un buen resultado en el proceso de entrega continua, por lo que si está interesado en minimizar el tamaño de la imagen como tal, puede consultar otros artículos en el centro


Si no tiene que explicar por qué debería hacer algo con una aplicación de arranque de varios metros antes de colocarla en la imagen, puede pasar inmediatamente a la descripción del enfoque de optimización . Si logró familiarizarse con el artículo del blog de primavera, puede proceder a la solución de los problemas encontrados .


¿Por qué es todo esto, o la otra cara del tarro de grasa?


Por defecto, el jar que produce Spring Boot es un archivo jar ejecutable que contiene el código de la aplicación y todas sus dependencias.


La ventaja de este enfoque es obvia: es conveniente trabajar con un archivo, tiene todo lo que necesita para ejecutar java -jar <myapp>.jar . Dockerfile es trivial y no es de interés.


La desventaja es el almacenamiento ineficiente. En una aplicación de arranque clásica, la proporción de código y bibliotecas claramente no está a favor de nuestro código. Por ejemplo, una aplicación vacía con un elemento web y bibliotecas para trabajar con la base de datos, que se puede generar a través de start.spring.io , tomará 20mb, de los cuales el 98% serán bibliotecas. Y esta relación no cambia mucho durante el proceso de desarrollo.


Pero recopilamos la aplicación más de una vez, pero regularmente en el servidor CI y luego la implementamos en una cadena de entornos. Por lo tanto, 10 conjuntos crecen a 200 mb y 100 a 2 gb, de los cuales las modificaciones tomarán muy poco.


Se puede argumentar que, por el costo actual de almacenamiento, estas son cifras ridículas y no se puede perder el tiempo en tales optimizaciones, pero todo depende del tamaño de la organización y del número de aplicaciones cuyas imágenes deben almacenarse. Las condiciones de implementación también pueden motivar fuertemente: cuando el registro y el servidor están cerca, incluso una diferencia de 100mb no es muy notable, pero en sistemas distribuidos esto puede ser mucho más importante, especialmente cuando necesita implementar en países específicos como China con su firewall y canales inestables al mundo exterior


Entonces, con las razones resueltas, es hora de optimizar.


Optimizamos el montaje, o lo que se puede aprender del blog de primavera


El artículo ofrece una solución razonable: en lugar de una sola capa generada por el COPY my-jar.jar app.jar , necesitamos crear varias capas.
Una capa contendrá bibliotecas, la segunda es nuestro propio código. Para hacer esto, debe descomprimir el archivo jar y copiar el contenido en diferentes capas de la imagen.


El script para preparar el archivo jar se ve así:


 #!/bin/sh set -e path_to_jar=$1 dir=$(dirname "${path_to_jar}") jar_name=$(basename "${path_to_jar}") mkdir -p "${dir}/docker-dist" && cd "${dir}/docker-dist" jar -xf ../"${jar_name}" 

Un dockerfile que utiliza una compilación de varias etapas podría verse así


 FROM openjdk:8-jdk-alpine as build WORKDIR /wd COPY prepare_for_docker.sh /usr/local/bin/prepare_for_docker COPY target/demo.jar /wd/app.jar RUN prepare_for_docker /wd/app.jar FROM openjdk:8-jdk-alpine COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib COPY --from=build /wd/docker-dist/META-INF /app/META-INF COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"] 

En la primera etapa, copiamos todo lo que necesitamos, ejecutamos nuestro script para descomprimir el archivo jar, y en la segunda etapa presentamos bibliotecas separadas y nuestro código por separado en capas.


Es fácil asegurarse de la operatividad:


  1. Recolectando por primera vez
  2. Haga cualquier cambio a nuestro código.
  3. Lanzamos docker build nuevamente y vemos las líneas apreciadas Using cache al copiar todo el directorio lib
     ... Step 5/10 : RUN prepare_for_docker app.jar ---> Running in c8e422491eb2 Removing intermediate container c8e422491eb2 ---> c7dcec4ae18a Step 6/10 : FROM openjdk:8-jdk-alpine ---> a3562aa0b991 Step 7/10 : COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib ---> Using cache ---> 01b600d7e350 Step 8/10 : COPY --from=build /wd/docker-dist/META-INF /app/META-INF ---> Using cache ---> 5c0c03a3c8f1 Step 9/10 : COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ---> 5ffed6ee5696 Step 10/10 : ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"] ---> Running in 99957250fe5d Removing intermediate container 99957250fe5d ---> 6735799d9f32 Successfully built 6735799d9f32 Successfully tagged boot2-sample:latest 

Una forma obvia de mejorar este enfoque es construir una pequeña imagen base con un script para no arrastrarla de un proyecto a otro. Por lo tanto, la primera capa se vuelve más concisa.


 FROM zeldigas/java-layered-builder as build COPY target/demo.jar app.jar RUN prepare_for_docker app.jar 

Estamos finalizando la solución.


Como ya se mencionó al principio del artículo, la solución está funcionando, pero durante la operación se encontraron un par de problemas que se discutirán más adelante.


No todos los archivos en lib igualmente de biblioteca


Si su proyecto es de múltiples módulos (al menos hay un módulo A, del cual depende el módulo B, ensamblado como un tarro de grasa de primavera), al aplicarle la solución original, encontrará que no se produce el almacenamiento en caché de capas. ¿Qué salió mal?


La cuestión está en los módulos adicionales: son fuentes de cambios constantes para la capa, incluso si no realiza ningún cambio en el código del módulo. Esto se debe a la peculiaridad de crear archivos jar maven (con gradle, la situación es un poco mejor, pero no estoy seguro). La tarea de obtener artefactos reproducibles no es el tema de este artículo (aunque, por supuesto, es interesante y alcanzable), por lo que recurrimos a una solución bastante simple.


Distribuimos el contenido de lib en 2 directorios, después de desempaquetar, separando los módulos del proyecto de otras bibliotecas. Finalicemos el script de desempaquetado de jarra de grasa:


 #!/bin/sh set -e path_to_jar=$1 shift #(1) app_modules=$* #(2) dir=$(dirname "${path_to_jar}") jar_name=$(basename "${path_to_jar}") mkdir -p "${dir}/docker-dist" && cd "${dir}/docker-dist" jar -xf ../"${jar_name}" if [ -n "${app_modules}" ]; then #(3) mkdir app-lib for i in $app_modules; do mv "BOOT-INF/lib/$i"* app-lib #(4) done fi 

Como resultado, el script comenzó a admitir la transferencia de parámetros adicionales (ver 1 y 2). Si se pasan argumentos adicionales (3), cada uno de ellos se considera como un prefijo para el nombre del archivo que movemos (4) a un directorio separado.


Ejemplo de Dockerfile para un escenario con uno adicional. shared-module y versión 1.0-SNAPSHOT


 FROM openjdk:8-jdk-alpine as build COPY target/demo.jar /wd/app.jar RUN prepare_for_docker /wd/app.jar shared-module-1.0 FROM openjdk:8-jdk-alpine COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib COPY --from=build /wd/docker-dist/app-lib /app/lib COPY --from=build /wd/docker-dist/META-INF /app/META-INF COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"] 

Ejecutar en el servidor CI


Habiendo depurado todo localmente, satisfecho con el resultado, comenzamos a ejecutar en el servidor CI y desde los registros de compilación encontramos que no ocurrió un milagro, o más bien los resultados no fueron constantes: en algunos casos, se realizó el almacenamiento en caché y la próxima vez que todas las capas eran nuevas.


Como resultado, se descubrió al culpable: la memoria caché de la ventana acoplable, o más bien su ausencia en el caso de diferentes agentes (nuestro ensamblado no está clavado a un agente específico del sistema de CI). Resultó que si no hay capas adecuadas en la memoria caché de la ventana acoplable, se obtienen capas con una suma de comprobación diferente del mismo conjunto de archivos. Puede verificar esto localmente, ejecutando la compilación con la --no-cache , o --no-cache por segunda vez eliminando primero la imagen y todas las capas intermedias. Como resultado, obtienes una capa de suma de comprobación completamente diferente, que niega todos los esfuerzos anteriores.


Sin el caché correcto, obtenemos diferentes capas


Hay varias formas de resolver el problema:


  1. Si su sistema CI admite esto de forma inmediata (por ejemplo, Circle CI en la parte de planes tiene soporte incorporado para la caché compartida durante los ensamblajes)
  2. Mezclar una sección con un caché de acoplador entre agentes
  3. Aproveche el --cache-from administración de caché --cache-from ( --cache-from )

Fuimos por el tercer camino, ya que en nuestro caso fue el más simple. La opción le permite decirle al docker daemon qué imágenes debe tener en cuenta e intentar usar para el almacenamiento en caché durante el ensamblaje. Puede especificar tantas imágenes como considere necesarias, lo principal es que están en el sistema de archivos. Si la imagen especificada no existe, simplemente se ignorará, por lo que debe extraerla antes de crearla.


Así es como se ve el ensamblaje del contenedor con este enfoque:


 set -e version=... #      docker pull registy.example.com/my-image:latest || true #         docker build -t registry.example.com/my-image:$version --cache-from registry.example.com/my-image:latest . #   registry    latest docker tag registry.example.com/my-image:$version registry.example.com/my-image:latest docker push registry.example.com/my-image:$version docker push registry.example.com/my-image:latest 

Intentamos reutilizar capas solo de la imagen más reciente, que a menudo es suficiente, pero nadie se molesta en terminar con una lógica más compleja y recurrir a algunas versiones o confiar en el id de vcs commits.


Adaptamos este enfoque a las capacidades de su CI y obtenemos reutilización confiable de capas con bibliotecas.


Total


La solución muestra buenos resultados, especialmente cuando se usa en proyectos con una etapa activa de desarrollo y una tubería de CD sintonizada. El siguiente gráfico muestra el resultado de aplicar la optimización a una de las aplicaciones. Se ve claramente que el crecimiento lineal ha cambiado a espasmódico a partir de la 70ª asamblea (las fallas en los años 60 están conectadas precisamente con el trabajo de depuración en los agentes de compilación). Las emisiones posteriores se asocian con la actualización de la imagen base (alta) y las bibliotecas (baja)



La optimización del almacenamiento en nuestro caso es una ventaja agradable, pero más bien secundaria. La aceleración del despliegue de la nueva versión sobre la anterior en varias regiones es mucho más agradable.


Cabe señalar que esta técnica es bastante compatible con otros enfoques destinados a reducir el tamaño de una sola imagen (imágenes básicas alpinas y otras ligeras, tiempo de ejecución personalizado para la aplicación). Lo principal es seguir las reglas generales para ensamblar la imagen en términos de almacenamiento en caché y asegurarse de que el resultado sea reproducible.

Source: https://habr.com/ru/post/458260/


All Articles