Outra maneira de otimizar imagens do docker para aplicativos Java

A história da otimização de imagem para aplicativos java começou com o artigo do Spring spring em um contêiner . Ele discutiu vários aspectos da criação de imagens do docker para aplicativos de inicialização por primavera, incluindo um problema tão interessante como reduzir o tamanho das imagens. Para nossas equipes, isso foi relevante por vários motivos, por isso decidimos aplicar essa solução a nossos aplicativos.


Como geralmente acontece, nem tudo decolou na primeira vez, houve nuances nos projetos com vários módulos e uma tentativa de direcionar tudo isso no sistema de CI; portanto, neste artigo, você encontrará uma solução para esses problemas.


O objetivo da otimização é reduzir a diferença entre as imagens resultantes de montagem em montagem, o que resulta em um bom resultado no processo de entrega contínua; portanto, se você estiver interessado em minimizar o tamanho da imagem, poderá consultar outros artigos no hub.


Se você não precisar explicar por que deve fazer algo com um aplicativo de inicialização com vários medidores antes de colocá-lo na imagem, siga imediatamente a descrição da abordagem de otimização . Se você se familiarizou com o artigo no blog da primavera, pode prosseguir para a solução dos problemas encontrados .


Por que isso é tudo ou o outro lado do frasco de gordura


Por padrão, o jar que o Spring Boot produz é um arquivo jar executável que contém o código do aplicativo e todas as suas dependências.


A vantagem dessa abordagem é óbvia: é conveniente trabalhar com um arquivo, ele tem tudo o que você precisa para executar o java -jar <myapp>.jar . O Dockerfile é trivial e não interessa.


A desvantagem é o armazenamento ineficiente. Em um aplicativo de inicialização clássico, a proporção de código e bibliotecas claramente não é a favor do nosso código. Por exemplo, um aplicativo vazio com uma Web Part e bibliotecas para trabalhar com o banco de dados, que pode ser gerado via start.spring.io , terá 20 MB , dos quais 98% serão bibliotecas. E essa proporção não muda muito durante o processo de desenvolvimento.


Porém, coletamos o aplicativo mais de uma vez, mas regularmente no servidor de IC e, em seguida, implantamos em uma cadeia de ambientes. Assim, 10 montagens crescem a 200mb e 100 a 2gb, das quais as modificações levarão muito pouco.


Pode-se argumentar que, para o custo atual de armazenamento, esses são números ridículos e você não precisa gastar tempo com essas otimizações, mas tudo depende do tamanho da organização e do número de aplicativos cujas imagens precisam ser armazenadas. As condições de implantação também podem motivar fortemente: quando o registro e o servidor estão próximos, mesmo uma diferença de 100mb não é muito perceptível, mas em sistemas distribuídos isso pode ser muito mais importante, especialmente quando você precisa implantar em países específicos como a China, com firewall e canais instáveis para o mundo exterior.


Então, com os motivos descobertos, é hora de otimizar.


Otimizamos a montagem ou o que pode ser aprendido no blog da primavera


O artigo oferece uma solução razoável: em vez de uma única camada gerada pelo COPY my-jar.jar app.jar , precisamos criar várias camadas.
Uma camada irá conter bibliotecas, a segunda é o nosso próprio código. Para fazer isso, você precisa descompactar o arquivo jar e copiar o conteúdo para diferentes camadas da imagem.


O script para preparar o arquivo jar é assim:


 #!/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}" 

Um arquivo docker usando uma compilação de vários estágios pode ter esta aparência


 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"] 

No primeiro estágio, copiamos tudo o que precisamos, executamos nosso script para descompactar o arquivo jar e, no segundo, organizamos bibliotecas separadas e nosso código separadamente em camadas.


É fácil garantir a operabilidade:


  1. Coletando pela primeira vez
  2. Faça qualquer alteração no nosso código.
  3. Iniciamos o docker build novamente e vemos as linhas queridas Using cache ao copiar todo o diretório 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 

Uma maneira óbvia de melhorar essa abordagem é criar uma pequena imagem de base com um script para não arrastá-la de um projeto para outro. Assim, a primeira camada se torna mais concisa.


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

Estamos finalizando a solução


Como já mencionado no começo do artigo, a solução está funcionando, mas durante a operação foram encontrados alguns problemas que serão discutidos mais adiante.


Nem todos os arquivos na lib igualmente de biblioteca


Se o seu projeto for multi-módulo (pelo menos, o módulo A, do qual depende o módulo B, montado como um frasco de gordura de mola), aplicando a solução original a ele, você descobrirá que não ocorre o cache da camada. O que deu errado?


O problema está em módulos adicionais: eles são fontes de alterações constantes para a camada, mesmo que você não faça alterações no código do módulo. Isso se deve à peculiaridade de criar arquivos jar maven (com gradle, a situação é um pouco melhor, mas não tenho certeza). A tarefa de obter artefatos reproduzíveis não é o tópico deste artigo (embora, é claro, seja interessante e viável), portanto, passaremos a uma solução bastante simples.


Distribuímos o conteúdo da lib em 2 diretórios, após descompactar, separando os módulos do projeto de outras bibliotecas. Vamos finalizar o script de descompactação do frasco de gordura:


 #!/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, o script começou a dar suporte à transferência de parâmetros adicionais (consulte 1 e 2). Se argumentos adicionais (3) forem passados, cada um deles será considerado como um prefixo para o nome do arquivo que movemos (4) para um diretório separado.


Exemplo de Dockerfile para um cenário com um adicional. shared-module e versão 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"] 

Executar no servidor de IC


Depois de depurar tudo localmente, satisfeito com o resultado, começamos a executar no servidor de IC e, a partir dos logs de compilação, descobrimos que não havia um milagre, ou melhor, os resultados não eram constantes: em alguns casos, o cache era realizado e na próxima vez que todas as camadas eram novas.


Como resultado, o culpado foi descoberto - cache do docker, ou melhor, sua ausência no caso de diferentes agentes (nosso assembly não é pregado em um agente específico do sistema de IC). Como se viu, se não houver camadas adequadas no cache da janela de encaixe, as camadas com uma soma de verificação diferente serão obtidas do mesmo conjunto de arquivos. Você pode verificar isso localmente, executando a compilação com a opção --no-cache ou --no-cache segunda vez excluindo primeiro a imagem e todas as camadas intermediárias. Como resultado, você obtém uma camada de soma de verificação completamente diferente, que nega todos os esforços anteriores.


Sem o cache correto, obtemos diferentes camadas


Existem várias maneiras de resolver o problema:


  1. Se o seu sistema de CI suportar isso imediatamente (por exemplo, o CI de círculo na parte de planos possui suporte interno para o cache compartilhado durante as montagens)
  2. Baralhar uma seção com um cache de docker entre agentes
  3. Aproveite a janela de encaixe de gerenciamento de cache --cache-from ( --cache-from )

Fomos pelo terceiro caminho, já que, no nosso caso, era o mais simples. A opção permite informar ao daemon de encaixe que imagens ele deve levar em consideração e tentar usar para armazenar em cache durante a montagem. Você pode especificar quantas imagens considerar necessárias, o principal é que elas estejam no sistema de arquivos. Se a imagem especificada não existir, ela será simplesmente ignorada; portanto, é necessário puxar antes de criar.


Aqui está a aparência do conjunto de contêineres com esta abordagem:


 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 

Tentamos reutilizar camadas apenas da imagem mais recente, o que geralmente é suficiente, mas ninguém se preocupa em encerrar uma lógica mais complexa e recuar em algumas versões ou confiar no id dos commits do vcs.


Adaptamos essa abordagem aos recursos do seu IC e obtemos reutilização confiável de camadas com bibliotecas.


Total


A solução mostra bons resultados, especialmente quando usada em projetos com um estágio ativo de desenvolvimento e um pipeline de CD ajustado. O gráfico abaixo mostra o resultado da aplicação da otimização em um dos aplicativos. É claramente visto que o crescimento linear mudou para espasmódico a partir da 70ª montagem (falhas nos anos 60 são conectadas precisamente ao trabalho de depuração nos agentes de construção). As emissões posteriores estão associadas à atualização da imagem base (alta) e das bibliotecas (inferior)



A otimização do armazenamento é um bônus agradável, mas secundário. A aceleração da implantação da nova versão em relação à antiga em várias regiões é muito mais agradável.


Deve-se notar que essa técnica é bastante compatível com outras abordagens destinadas a reduzir o tamanho de uma única imagem (alpina e outras imagens básicas leves, tempo de execução personalizado para o aplicativo). O principal é seguir as regras gerais para montar a imagem em termos de armazenamento em cache e garantir que o resultado seja reproduzível.

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


All Articles