DEV Community

Cover image for Java em Containers: Estratégias Modernas para Build
Denis Arruda Santos
Denis Arruda Santos

Posted on

Java em Containers: Estratégias Modernas para Build

Introdução

Quando pensamos em entregar software com qualidade e em automatizar ao máximo esse processo, a infraestrutura como código e os containers desempenham um papel fundamental.

Criar containers nem sempre é a parte mais divertida do nosso dia a dia, mas faz parte do nosso trabalho como desenvolvedores: não apenas escrever código, mas também garantir que ele seja executado da melhor forma possível em produção.

O objetivo deste artigo é avaliar as principais opções disponíveis hoje para a criação de containers para aplicações Java, apresentando uma visão geral de cada abordagem. Nenhuma delas é universalmente melhor que as outras, todas possuem vantagens e desvantagens. A ideia é ajudar você a escolher a alternativa mais adequada ao seu contexto e, com isso, aprimorar o processo de build do seu projeto.

O que é um Container

Antes de escolher a alternativa mais adequada, é fundamental entender o que realmente é um container. Compreender esse conceito nos ajuda a enxergar o que pode ser melhorado.

Um container pode ser entendido como um processo autocontido, que reúne todas as dependências necessárias para executar uma aplicação, como bibliotecas, configurações e runtime. Diferente de uma máquina virtual, ele não precisa carregar um sistema operacional completo, o que o torna muito mais leve e rápido.

Os containers são executados sobre um Docker Daemon, que é o responsável por gerenciar imagens, redes, volumes e a execução dos processos. Além disso, eles compartilham o kernel do sistema operacional do host onde estão rodando.

Essa arquitetura permite que múltiplos containers coexistam no mesmo ambiente de forma eficiente.

Dockerfile simples

O Dockerfile é como se fosse uma receita para a criação de uma imagem de container. É nele que descrevemos, passo a passo, como o ambiente da aplicação deve ser montado, quais dependências devem ser instaladas e como o sistema deve ser inicializado.

Cada instrução presente no Dockerfile gera uma camada na imagem final. Essas camadas são armazenadas em cache pelo Docker e reaproveitadas sempre que possível, tornando o processo de build mais rápido e eficiente.

Por esse motivo, a ordem das instruções é extremamente importante. As primeiras camadas devem conter aquilo que muda com menos frequência, como a imagem base e as dependências do projeto. Já as últimas camadas devem conter os elementos que mudam mais frequentemente, como o código-fonte e os arquivos compilados.

A seguir, temos um exemplo de Dockerfile simples:

FROM bellsoft/liberica-openjre-debian:25
COPY app.jar .
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

Esse modelo é frequentemente utilizado por ser fácil de entender, mas ele também apresenta algumas limitações importantes.

A primeira linha define a imagem base, que, nesse caso, contém um sistema Linux Debian com a JRE do Java na versão 25. Essa escolha facilita a configuração, pois já inclui o runtime necessário.

Em seguida, o comando COPY assume que já existe um arquivo app.jar pronto para uso. No entanto, o Dockerfile não deixa claro como esse artefato foi gerado. Isso pode gerar inconsistências entre ambientes e dificultar a reprodução do build.

Outro ponto importante é que esse Dockerfile não define um usuário para executar a aplicação. Como consequência, o container será iniciado com o usuário root, o que representa um risco de segurança.

Apesar de funcional, esse tipo de Dockerfile deve ser encarado apenas como um ponto de partida.

Dockerfile Multi-stage

O Dockerfile pode ser composto por múltiplos estágios, em um modelo conhecido como multi-stage. Nesse modelo, cada estágio é responsável por uma etapa específica e gera uma imagem intermediária, que pode servir de entrada no estágio seguinte.

Essa separação traz diversos benefícios. Um dos principais é a padronização do processo de build, já que toda a construção do projeto passa a ser definida dentro do próprio Dockerfile. Com isso, não é mais necessário depender de configurações externas para gerar o artefato do projeto.

A seguir, temos um exemplo de Dockerfile multi-stage:

FROM maven:3-eclipse-temurin-25 AS build
WORKDIR /opt/app
COPY src ./src
COPY pom.xml .
RUN --mount=type=cache,target=~/.m2 mvn -f /opt/app/pom.xml package -DskipTests

FROM bellsoft/liberica-openjre-debian:25
RUN mkdir -p /opt/app
COPY --from=build /opt/app/target/app.jar /opt/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/opt/app.jar"]
Enter fullscreen mode Exit fullscreen mode

Esse Dockerfile é dividido em dois estágios principais: o estágio de build e o estágio de execução.

No primeiro estágio, identificado pelo alias build, é utilizada a imagem maven:3-eclipse-temurin-25, que já contém o Maven e o JDK necessários para compilar a aplicação.

Em seguida, os diretórios src e o arquivo pom.xml são copiados para dentro da imagem. Esses arquivos são necessários para que o Maven possa resolver as dependências e compilar o projeto. Ela utiliza um recurso para criar um cache persistente do repositório local do Maven.

No segundo estágio, é utilizada a imagem bellsoft/liberica-openjre-debian:25. Essa imagem é leve e contém apenas a JRE necessária para executar a aplicação.

Em seguida, o artefato app.jar gerado no estágio de build é copiado para essa imagem.

Por fim, a instrução ENTRYPOINT define o comando responsável por iniciar a aplicação Java quando o container é executado.

Esse modelo permite um processo de build mais padronizado.

Criando JREs Customizadas com jdeps e jlink

Desde o Java versão 9, a JVM passou a ser organizada em módulos, permitindo um controle sobre quais módulos da JVM são necessárias para uma aplicação. A partir dessa modularização, surgiram ferramentas como o jdeps e o jlink, que possibilitam a criação de uma JRE customizada contendo apenas os módulos necessários para uma aplicação.

Uma JRE reduzida também contribui para a segurança da aplicação. Quanto menor o número de módulos e bibliotecas presentes na imagem, menor é a superfície de ataque e, consequentemente, o risco de exploração de vulnerabilidades.

A seguir, temos um exemplo de Dockerfile multi-stage utilizando jdeps e jlink:

FROM maven:3-eclipse-temurin-25 AS maven-build
WORKDIR /opt/app
COPY src ./src
COPY pom.xml .
RUN --mount=type=cache,target=~/.m2 mvn -f /opt/app/pom.xml package -DskipTests

FROM eclipse-temurin:25-alpine AS jre-build
WORKDIR /opt/app
COPY --from=maven-build /opt/app/target/app.jar .
RUN jar xf app.jar
RUN jdeps --ignore-missing-deps -q  \
    --recursive  \
    --multi-release 25  \
    --print-module-deps  \
    --class-path 'BOOT-INF/lib/*'  \
    app.jar > deps.info
RUN jlink \
    --verbose \
    --add-modules $(cat deps.info) \
    --strip-debug \
    --compress 2 \
    --no-header-files \
    --no-man-pages \
    --output /customjre

FROM alpine:3.18
COPY --from=jre-build /customjre /opt/jre
ENV JAVA_HOME=/opt/jre
ENV PATH="$PATH:$JAVA_HOME/bin"

COPY --from=maven-build /opt/app/target/app.jar /opt/app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/opt/app.jar"]
Enter fullscreen mode Exit fullscreen mode

Nesse exemplo, combinamos o multi-stage com o uso do jdeps e do jlink para gerar uma JRE customizada, resultando em uma imagem final ainda mais enxuta.

No segundo estágio, utilizamos uma imagem baseada em Alpine com o JDK, necessária para executar o jdeps e o jlink.

O jdeps é utilizado para identificar quais módulos da JVM são utilizados pela aplicação. O resultado é gravado no arquivo deps.info.

Em seguida, o jlink utiliza a lista gerada pelo jdeps para montar uma runtime personalizada.

No último estágio, utilizamos uma imagem Alpine mínima. Copiamos apenas a JRE customizada criada anteriormente e configuramos as variáveis de ambiente para que o sistema utilize essa versão do Java.

Em seguida, copiamos novamente o JAR da aplicação, para a imagem final.

Extração de Camadas em Aplicações Spring Boot

Quando utilizamos o Spring Boot para empacotar uma aplicação no formato de uber JAR ou fat JAR, todas as dependências, bibliotecas e o código da aplicação ficam em um único arquivo. Esse modelo facilita a distribuição, mas não é o mais eficiente para construção de imagens Docker.

O Spring Boot também oferece um mecanismo de extração de camadas. O objetivo principal é otimizar o processo de build da imagem, aproveitando melhor o cache do Docker.

Com esse mecanimos, o JAR é dividido em quatro camadas:

  • Dependências do Spring Framework
  • Spring Boot Loader
  • Dependências da aplicação
  • Código da aplicação

Cada uma dessas partes passa a ser tratada como uma camada independente dentro da imagem Docker.

Isso permite organizar o Dockerfile de forma que os componentes que mudam com menos frequência, como as dependências e o loader do Spring, sejam copiados primeiro. Já o código da aplicação, que sofre alterações com mais frequência, fica nas últimas camadas.

Além disso, ao separar as camadas, o tempo de inicialização da aplicação Spring Boot também pode ser reduzido em alguns milissegundos.

A seguir, um exemplo de Dockerfile com extração das camadas:

FROM maven:3-eclipse-temurin-25 AS build
WORKDIR /build
COPY src ./src
COPY pom.xml .
RUN --mount=type=cache,target=~/.m2 mvn -f /build/pom.xml package -DskipTests

FROM bellsoft/liberica-openjre-debian:25 AS extractor
WORKDIR /extract
COPY --from=build /build/target/app.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM bellsoft/liberica-openjre-debian:25
WORKDIR /application
COPY --from=extractor /extract/extracted/dependencies/ ./
COPY --from=extractor /extract/extracted/spring-boot-loader/ ./
COPY --from=extractor /extract/extracted/snapshot-dependencies/ ./
COPY --from=extractor /extract/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Enter fullscreen mode Exit fullscreen mode

Assim como nos exemplos anteriores, o primeiro estágio é responsável por compilar o projeto utilizando Maven.

No segundo estágio, utilizamos uma imagem que contém apenas a JRE, suficiente para realizar a extração das camadas.

Em seguida, executamos o comando java -Djarmode=tools -jar application.jar extract --layers para extrair as camadas do arquivo.

No último estágio, copiamos cada camada separadamente.

Abordagem Ultimate

A versão que vamos chamar aqui de Ultimate combina as principais técnicas apresentadas ao longo deste artigo para criar imagens Docker para aplicações Java. Nessa abordagem, unimos o uso de multi-stage, a criação de uma JRE customizada com jdeps e jlink, e a extração de camadas do Spring Boot.

Além disso, essa estratégia também inclui a criação de um usuário específico, com permissões apenas para executar a aplicação. Dessa forma, evitamos que o container seja iniciado com o usuário root, reduzindo o risco em caso de falhas ou vulnerabilidades.

Ao combinar essas técnicas, conseguimos separar as responsabilidade dentro do processo de build. Um estágio é responsável pela compilação, outro pela análise e geração da JRE runtime, outro pela extração das camadas, e o estágio final reúne apenas o que é necessário para execução.

Em seguida, o Dockerfile na versão Ultimate:

FROM maven:3-eclipse-temurin-25 AS build
WORKDIR /build
COPY src ./src
COPY pom.xml .
RUN --mount=type=cache,target=~/.m2 mvn -f /build/pom.xml package -DskipTests

FROM eclipse-temurin:25-alpine AS jre-build
WORKDIR /jrebuild
COPY --from=build /build/target/app.jar .
RUN jar xf app.jar
RUN jdeps --ignore-missing-deps -q \
    --recursive \
    --multi-release 25 \
    --print-module-deps \
    --class-path 'BOOT-INF/lib/*' \
    app.jar > deps.info
RUN jlink \
    --verbose \
    --add-modules $(cat deps.info) \
    --strip-debug \
    --compress 2 \
    --no-header-files \
    --no-man-pages \
    --output /customjre

FROM bellsoft/liberica-openjre-debian:25 AS extractor
WORKDIR /extract
COPY --from=build /build/target/app.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM alpine:3.18
# Create appuser
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /application
# Copy custom JRE
COPY --from=jre-build /customjre /opt/jre
ENV JAVA_HOME=/opt/jre
ENV PATH="$PATH:$JAVA_HOME/bin"
# Copy layered jar contents
COPY --from=extractor /extract/extracted/dependencies/ ./
COPY --from=extractor /extract/extracted/spring-boot-loader/ ./
COPY --from=extractor /extract/extracted/snapshot-dependencies/ ./
COPY --from=extractor /extract/extracted/application/ ./
# Set permissions
RUN chown -R appuser:appgroup /application
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]
Enter fullscreen mode Exit fullscreen mode

Jib: Construindo Imagens Java Sem Dockerfile

O Jib é um plugin desenvolvido pelo Google que tem como objetivo simplificar a criação de imagens de container para aplicações Java. Ele está disponível tanto para Maven quanto para Gradle e foi projetado especificamente para projetos baseados na JVM.

Uma das principais características do Jib é permitir a construção da imagem sem a necessidade de escrever um Dockerfile.

Além disso, o Jib não depende de um Docker Daemon para funcionar. Ele consegue construir e publicar imagens diretamente em registries externos.

Buildpacks: Containers Inteligentes sem Dockerfile

Os Buildpacks surgiram inicialmente no Heroku e, depois, evoluíram para um projeto mantido pela CNCF (Cloud Native Computing Foundation). O principal objetivo dessa tecnologia é automatizar a criação de imagens de container para diferentes stacks.

Ao utilizar Buildpacks, não é necessário escrever um Dockerfile. A ferramenta identifica a stack utilizada e seleciona os componentes mais adequados para gerar uma imagem otimizada, seguindo boas práticas de segurança e performance.

Os Buildpacks dependem de um Docker Daemon para funcionar.

No ecossistema Spring, essa abordagem é ainda mais simples. Tanto o plugin do Maven quanto o do Gradle oferecem um target específico para construir a imagem do container, que internamente utiliza Buildpacks.

Por exemplo, no Maven, é possível gerar a imagem com um comando como:

mvn spring-boot:build-image
Enter fullscreen mode Exit fullscreen mode

Esse comando utiliza Buildpacks por padrão.

Considerações Finais

Neste artigo, exploramos diferentes abordagens para a construção de containers para aplicações Java, desde Dockerfiles simples até estratégias mais avançadas e ferramentas automatizadas como Jib e Buildpacks.

Cada uma dessas opções possui vantagens e não existe uma solução única que seja ideal para todos os cenários.

Uma das boas práticas mais importante é configurar a execução da aplicação com um usuário que possua apenas as permissões necessárias, evitando o uso do usuário root.

Para ver um exemplo prático dessas abordagens aplicadas em um projeto real, você pode consultar o repositório no GitHub: TaskManager Spring Boot. Nele estão disponíveis diferentes configurações de Dockerfiles seguindo os modos explicados neste artigo.

Agora, experimente aplicar essas técnicas no seu projeto. Teste a criação de uma JRE customizada com jdeps e jlink e observe o quanto o tamanho da imagem é reduzido. Experimente também a extração de camadas do Spring Boot e verifique se o tempo de inicialização da aplicação se torna mais rápido. Depois, compartilhe seus resultados, comparações e aprendizados nos comentários. Essa troca de experiências é fundamental para evoluirmos juntos como comunidade.

À medida que o projeto cresce e os requisitos mudam, é importante revisitar essas decisões e buscar constantemente melhorias no processo de build e entrega. Investir tempo nessas práticas é investir diretamente na qualidade, confiabilidade e sustentabilidade do software.

Top comments (0)