10 práticas recomendadas para criar um contêiner Java com Docker
24 de agosto de 2022
0 minutos de leituraNota do editor
Esta publicação foi originalmente publicada em 18 de fevereiro de 2021, mas foi atualizada para refletir as informações mais recentes.
Quer criar uma aplicação Java e executá-la em uma imagem Docker? Deixe que eu ajudo você!
Este guia rápido apresenta práticas recomendadas para criar um contêiner Java pronto para produção. No exemplo de contêiner Java que vamos criar seguindo estas orientações, o foco será desenvolver um contêiner otimizado e seguro para sua aplicação. Você pode usar este guia ao criar um contêiner Java com Docker e também ao revisar o código de outras pessoas. Nos dois casos, é importante priorizar a otimização e a segurança da imagem Docker que você pretende colocar em produção.

Dez práticas recomendadas
Use tags explícitas e determinísticas para as imagens-base do Docker ("latest" não é uma versão!)
Instale na imagem do contêiner Java somente o que você precisa para produção
Encontre e corrija vulnerabilidades de segurança na imagem Docker Java
Use builds em vários estágios para reduzir ainda mais o tamanho da imagem de produção
Não execute aplicações Java como root
Trate os eventos corretamente para encerrar uma aplicação Java com segurança
Encerre aplicações Java de forma adequada
Evite arquivos desnecessários nas imagens dos seus contêineres Java usando
.dockerignoreVerifique se o Java está ciente do contêiner
Tenha cuidado com ferramentas que geram contêineres Docker automaticamente
Segurança de contêineres que prioriza os desenvolvedores
O Snyk encontra e corrige automaticamente vulnerabilidades em imagens de contêiner e workloads do Kubernetes.
Como NÃO criar uma imagem de contêiner Java
Vamos começar com um Dockerfile simples para uma aplicação Java criada com Maven. Em muitos artigos, vemos algo parecido ao criar um contêiner Java, como no exemplo abaixo:
A maioria dos artigos de blog começa e termina com instruções básicas de Dockerfile como estas para criar imagens Docker Java:
Copie esse conteúdo para um arquivo chamado Dockerfile e, em seguida, crie e execute a imagem.
É simples e funciona. No entanto, essa imagem está cheia de erros! Além de precisarmos saber usar o Maven corretamente, devemos evitar a todo custo criar contêineres Java como no exemplo acima.
Vamos aprimorar este Dockerfile e otimizá-lo passo a passo para criar uma imagem Docker eficiente e segura para sua aplicação Java.
1. Use tags explícitas e determinísticas para as imagens-base do Docker
Ao criar uma imagem de contêiner Java com Maven, parece óbvio usar a imagem Maven como base: é só baixá-la do Dockerhub e começar. Mas você sabe exatamente o que está baixando ao usar essa imagem-base? Você pode referenciar uma imagem Docker pela tag, mas, se não especificar nenhuma, será usada a tag implícita latest.
Essa pode parecer uma funcionalidade interessante, mas há alguns problemas potenciais nessa estratégia de usar a imagem Maven padrão:
Essa pode parecer uma funcionalidade interessante, mas há alguns problemas potenciais nessa estratégia de usar a imagem Maven padrão:
Seus builds Docker não são idempotentes
Isso significa que, ao refazer o build, o resultado pode ser completamente diferente. As imagens mais recentes de hoje podem não ser as mesmas de amanhã ou da próxima semana. Com isso, as versões do Maven e do JDK usadas como base para criar a imagem podem ser atualizadas. Isso altera o bytecode da sua aplicação e pode causar resultados inesperados. Ao refazer uma imagem, queremos um comportamento determinístico e reproduzível.
A imagem Docker do Maven é baseada em uma imagem completa de sistema operacional
Com isso, muitos binários adicionais acabam na imagem final de produção. Boa parte deles não é necessária para executar sua aplicação. Mas incluí-los na imagem do contêiner Java traz algumas desvantagens:
Uma imagem maior leva mais tempo para baixar e recriar.
Os binários adicionais podem introduzir vulnerabilidades de segurança. Todo binário com uma vulnerabilidade representa um risco potencial que você não quer adicionar ao seu sistema.
A imagem maven:latest é bastante grande e costuma se basear em versões do Maven e do OpenJDK que mudam em questão de meses, semanas ou até dias.
Veja como reduzir esses riscos:
Use a menor imagem-base possível que atenda às suas necessidades. Pense bem: você precisa de um sistema operacional completo, com todos os binários adicionais, para executar seu programa? Se não, talvez uma imagem menor, como
debian-slimou atéalpine, funcione tão bem quanto uma imagem Debian completa.Especifique as imagens com precisão. Ao usar uma versão específica da imagem, você já consegue controlar e prever certos comportamentos. Se usar a imagem
maven:3.6.3-jdk-11-slim, terá a garantia de usar o JDK 11 e o Maven 3.6.3. O ciclo de atualização de seis meses do JDK não afetará mais o comportamento do contêiner Java. Para especificar a imagem com ainda mais precisão, você pode usar o hashSHA256. Com ele, você garante que a mesma imagem-base será usada sempre que recriar a sua imagem.
Vamos atualizar nosso Dockerfile com essas informações:
Para saber mais sobre como escolher imagens de contêiner seguras, confira nosso guia de segurança de contêineres.
2. Instale na imagem do contêiner Java somente o que você precisa para produção
O comando a seguir compila seu programa Java — incluindo todas as dependências — dentro do contêiner. Isso significa que tanto o código-fonte quanto o sistema de build fazem parte do contêiner Java de produção.
Java é uma linguagem compilada. Isso significa que precisamos apenas do artefato gerado pelo ambiente de build, não do próprio código. Também significa que o ambiente de build não deve fazer parte do contêiner de produção.
Para executar uma imagem Java, também não precisamos de um JDK completo: um JRE é suficiente. Ou seja, precisamos criar uma imagem com um JRE e o artefato Java compilado, caso seja um JAR executável.
Compile seu programa no pipeline de CI usando Maven (ou Gradle) e copie o JAR para a imagem de produção, como no Dockerfile atualizado abaixo:
3. Encontre e corrija vulnerabilidades de segurança na imagem Docker do contêiner Java
Já estamos usando uma imagem pequena para meu contêiner Java no Docker. No entanto, não sei se os binários dessa imagem-base têm algum problema. Vamos usar a Snyk CLI para testar nossa imagem Docker. Você pode criar uma conta gratuita na Snyk para acompanhar.
Você pode instalar a CLI usando npm, brew, scoop ou baixar o binário mais recente do GitHub. Neste exemplo, vamos instalar com npm e autenticar com nossa conta gratuita:
Com snyk container test, podemos testar qualquer imagem Docker. Também podemos adicionar o Dockerfile para receber recomendações de correção mais precisas.

A Snyk encontrou 58 problemas de segurança nesta imagem-base. A maioria está relacionada a binários incluídos na distribuição Linux Debian.
Com base nessas informações, vamos trocar a imagem-base por uma imagem openjdk11:JRE baseada em Alpine, fornecida por adoptopenjdk, e especificar o SHA para sermos mais precisos.
Ao testar esta versão com snyk container, não são encontradas vulnerabilidades conhecidas nesta imagem-base.
Da mesma forma, você pode testar sua aplicação Java executando snyk test na raiz do projeto. Recomendo testar tanto sua aplicação quanto a imagem de contêiner Java criada durante o desenvolvimento na sua máquina local. Além disso, automatize os mesmos testes para a imagem e para a aplicação no seu pipeline de CI.
Lembre-se também de que novas vulnerabilidades são descobertas com o tempo. Quando uma nova vulnerabilidade for identificada, você provavelmente vai querer receber uma notificação.
Usar snyk monitor na sua aplicação e snyk container monitor nas suas imagens Docker vai ajudar. Ao monitorar a versão que está em produção, você poderá agir quando novos problemas de segurança forem encontrados.

Outra opção é conectar seu repositório Git à Snyk para que possamos ajudar a encontrar e corrigir vulnerabilidades nessa etapa do seu ciclo de desenvolvimento de software.
Vamos atualizar nosso Dockerfile:
Confira nosso guia para saber mais sobre como analisar contêineres Docker em busca de vulnerabilidades.
4. Use builds em vários estágios no seu contêiner Java
No início deste artigo, explicamos que não precisamos criar nossa aplicação Java dentro de um contêiner — precisamos apenas do resultado desse build. No entanto, em alguns casos, é conveniente criar a aplicação como parte do processo de build da imagem Docker.
Felizmente, podemos dividir a criação da imagem Docker em vários estágios. Primeiro, criamos uma imagem de build com todas as ferramentas necessárias para compilar a aplicação. Depois, criamos um segundo estágio para gerar a imagem de produção propriamente dita, contendo apenas o que é necessário para executá-la em produção.
Evite o vazamento de informações confidenciais
Ao criar aplicações Java e imagens Docker, é provável que você precise se conectar a um repositório Maven privado. Normalmente, essas configurações ficam no arquivo settings.xml da sua máquina local ou do ambiente de build. Com builds em vários estágios, você pode copiar o settings.xml com segurança para o contêiner de build. As configurações com as credenciais não vão parar na imagem de produção. Além disso, se precisar passar credenciais como argumentos de linha de comando, você pode fazer isso com segurança na imagem de build, sem que elas acabem na imagem de produção.
Com builds em vários estágios, você pode criar diversos estágios e copiar apenas o resultado para a imagem final de produção. Separar a compilação do código da criação da imagem permite incluir no contêiner final apenas o que é necessário para executar a aplicação e evita que código-fonte, compiladores ou outros conteúdos vazem para os ambientes implantados.
Ah, e aproveite para conferir a saída de docker history da imagem de contêiner Java criada:
A saída mostra apenas informações da imagem do contêiner de produção, não da imagem de build.

5. Não execute contêineres como root
Ao criar um contêiner Docker, você deve aplicar o princípio do menor privilégio como parte da segurança do contêiner Java (ou seja, conceda apenas as permissões realmente necessárias). Se, por algum motivo, um invasor conseguir invadir sua aplicação, você não vai querer que ele tenha acesso a tudo.
Adotar várias camadas de segurança ajuda a reduzir os danos que um invasor pode causar quando seu sistema é comprometido. Por isso, você precisa garantir que sua aplicação não seja executada como usuário root.
Há apenas um problema: por padrão, um contêiner Docker é executado como root. Embora isso seja conveniente durante o desenvolvimento, você não vai querer esse comportamento nas imagens de produção. Se, por qualquer motivo, um invasor obtiver acesso a um terminal ou conseguir executar código, ele terá privilégios significativos no contêiner em execução e poderá até acessar os sistemas de arquivos do host por meio de montagens bind com permissões de acesso excessivas.
A solução é bem simples. Consulte a documentação da sua imagem-base e, se ela já incluir um usuário com menos privilégios, basta usá-lo adicionando uma linha USER com esse usuário ao Dockerfile. Caso contrário, crie um usuário específico com privilégios limitados para executar sua aplicação. Não se esqueça de testar se esse usuário consegue executá-la.
Vamos atualizar nosso Dockerfile. Observe que só precisamos nos preocupar com o usuário na segunda parte do build do contêiner em vários estágios.
Observação: Outra maneira de executar como outro usuário é especificá-lo na linha de comando ou na configuração do orquestrador. Por exemplo, o comando run da CLI do Docker tem um parâmetro -u para isso, e as especificações de Pod do Kubernetes oferecem a mesma configuração pelo campo SecurityContext:runAsUser. Mas especificá-lo na configuração do build torna o processo repetível.
6. Trate os eventos corretamente para encerrar com segurança uma aplicação web Java no Docker
Em muitos exemplos, vemos o erro comum de usar o ambiente de build para iniciar a aplicação Java em contêiner.
Embora já tenhamos discutido por que devemos incluir Maven ou Gradle no nosso contêiner Docker para Java, há outros motivos para evitar esse tipo de abordagem:
CMD “mvn” “exec:java”CMD [“mvn”, “spring-boot run”]CMD “gradle” “bootRun”CMD “run-app.sh”
Ao executar um aplicativo no Docker, o primeiro aplicativo é executado como ID de processo 1 (PID 1). O kernel do Linux trata o PID 1 de forma especial. Em geral, o processo com PID 1 é o processo init. Se executarmos nosso aplicativo Java usando Maven, como podemos ter certeza de que o Maven encaminha sinais como SIGTERM para o processo Java? Não podemos!
Se, em vez disso, você executar seu contêiner Docker como no exemplo abaixo, seu aplicativo Java terá PID 1, garantindo que os sinais sejam encaminhados corretamente.
Observe que os comandos docker kill ... e docker stop ... enviam sinais somente ao processo do contêiner com PID 1. Se você estiver executando um script de shell que inicia seu aplicativo Java, lembre-se de que uma instância do shell, como /bin/sh, não encaminha sinais aos processos filhos. Isso significa que seu aplicativo nunca receberá esse SIGTERM.
É importante saber que, no Linux, o PID 1 também tem algumas responsabilidades adicionais. Elas são muito bem descritas no artigo Docker and the PID 1 zombie reaping problem. Em alguns casos, você não vai querer ser o PID 1 porque não sabe como lidar com esses problemas. Uma ótima solução é usar dumb-init.
Ao executar seu contêiner Docker dessa forma, o dumb-init ocupa o PID 1 e cuida de todas essas responsabilidades. Seu processo Java não precisa mais fazer isso.
Nosso Dockerfile atualizado fica mais ou menos assim:
7. Encerre seus aplicativos web Java de forma controlada
Quando seu aplicativo recebe um sinal para encerrar, o ideal é que tudo seja finalizado de forma controlada. Dependendo de como você desenvolveu o aplicativo, um sinal de interrupção (SIGINT) ou CTRL + C pode encerrar o processo imediatamente.
Talvez você não queira que isso aconteça, pois esse tipo de situação pode causar comportamentos inesperados ou até perda de dados.
Quando você executa seu aplicativo em um servidor web como Payara ou Apache Tomcat, provavelmente o servidor cuida do encerramento controlado. O mesmo pode ocorrer com alguns frameworks usados para criar aplicativos executáveis. O Spring Boot, por exemplo, tem uma versão integrada do Tomcat que cuida do encerramento.
Ao criar um aplicativo Java independente ou um JAR executável manualmente, você precisa cuidar desses sinais de interrupção.
A solução é simples: você pode adicionar um hook de encerramento ao runtime, como no exemplo abaixo. Quando um sinal como SIGINT é recebido, uma nova Thread é iniciada para cuidar do encerramento controlado.
É verdade que esse é um aspecto mais genérico de aplicativos web do que algo relacionado ao Dockerfile, mas ele é ainda mais importante em ambientes orquestrados. Saiba mais sobre orquestração de contêineres com nosso guia.
8. Evite arquivos desnecessários nas imagens de contêiner Java com .dockerignore
Para evitar que certos arquivos do seu repositório Git (público) sejam incluídos, você pode usar o arquivo .gitignore. Ele impede que arquivos desnecessários poluam o repositório Git. Além disso, ajuda a evitar que arquivos confidenciais sejam expostos em um repositório público.
Para imagens Docker, existe algo semelhante: o arquivo .dockerignore. Assim como o arquivo de exclusão do Git, ele ignora os arquivos que correspondem aos padrões especificados e impede que arquivos ou diretórios indesejados sejam copiados para a imagem Docker.
Tecnicamente, quando um comando docker build é iniciado, a ferramenta de CLI envia o conteúdo do diretório de contexto da compilação (por padrão, o diretório que contém o Dockerfile) para o runtime do contêiner. Essa cópia é usada nas etapas definidas no Dockerfile. Qualquer arquivo que corresponda a um padrão em .dockerignore não será copiado e, portanto, não poderá ser incluído pelos comandos COPY ou ADD durante a compilação. Isso também acelera as compilações, pois a cópia do contexto é muito mais rápida quando você exclui árvores de diretórios grandes, como .git. Esse ganho é ainda mais perceptível quando você compila em um mecanismo Docker executado em outro servidor por meio de uma conexão de rede.
O exemplo simplificado deste artigo pressupõe que estamos trabalhando com um único JAR executável. Essa não é, de forma alguma, a única maneira de distribuir um aplicativo Java. Em aplicativos Java independentes, também é possível não criar um JAR com todas as dependências incorporadas ao artefato. Dependendo do contexto, pode ser necessário copiar diretórios inteiros com os JARs dos quais o aplicativo depende ou outros arquivos. Não queremos que informações confidenciais sejam incluídas por acidente nas imagens Docker — principalmente se elas forem publicadas.
Veja um exemplo de .dockerignore:
As vantagens de usar o arquivo .dockerignore são:
Exclui dependências usadas apenas para testes.
Ajuda a evitar a exposição de segredos, como credenciais em arquivos
.envouaws.jsonque poderiam acabar na imagem Docker Java. Arquivos de log de depuração também podem conter segredos ou informações confidenciais que você não quer expor.Mantém sua imagem Docker organizada e, consequentemente, menor. Além disso, ajuda a evitar comportamentos inesperados.
Acelera as operações de docker build ao reduzir o tamanho do diretório de contexto da compilação que precisa ser copiado.
9. Verifique se o Java reconhece os contêineres
A Máquina Virtual Java (JVM) é incrível. Ela se ajusta ao sistema em que é executada. Há ajustes baseados no comportamento que otimizam dinamicamente o tamanho do heap. No entanto, em versões mais antigas, como Java 8 e Java 9, a JVM não reconhecia os limites de CPU nem de memória definidos pelo contêiner. A JVM dessas versões mais antigas considerava toda a memória e todas as CPUs disponíveis no sistema host. Em outras palavras, as configurações do grupo Docker eram ignoradas.
Com o lançamento do Java 10, a JVM passou a reconhecer os contêineres e as restrições definidas por eles. O recurso UseContainerSupport é uma flag da JVM e vem ativado por padrão. Esse recurso de reconhecimento de contêineres, lançado no Java 10, foi incorporado retroativamente ao Java-8u191.
Em versões anteriores ao Java 8, você pode tentar limitar manualmente o tamanho do heap com a flag -Xmx, mas isso dá bastante trabalho. Além disso, o tamanho do heap não equivale à memória usada pelo Java. No Java-8u131 e no Java 9, o recurso de reconhecimento de contêineres é experimental: você precisa ativar as opções experimentais da JVM e a flag de limite de memória.
A melhor opção é atualizar para uma versão mais recente do Java — posterior à 10 — para ter o suporte a contêineres ativado por padrão. Infelizmente, muitas empresas ainda dependem muito do Java 8. Isso significa que você deve atualizar para uma versão mais recente do Java nas imagens Docker, de preferência a versão LTS mais recente, ou usar pelo menos o Java 8u191.
Isso tudo não parece específico à segurança de contêineres Java, certo? Mas, se você pensar bem, a disponibilidade é um dos três atributos da tríade CIA da segurança da informação.
10. Tenha cuidado com ferramentas que geram contêineres Docker automaticamente
Ao navegar pela internet, você provavelmente vai encontrar ótimas ferramentas e plugins para seu sistema de compilação. Alguns deles ajudam a criar contêineres Docker para Java e até a publicá-los automaticamente, se você quiser.
Do ponto de vista de quem desenvolve, isso parece ótimo, pois você não precisa se preocupar em manter Dockerfiles enquanto cria o aplicativo.
Um exemplo desse tipo de plugin é o JIB. Basta configurar o plugin de compilação, como mostrado abaixo, e executar mvn jib:dockerBuild:
Ele vai criar, sem complicação, uma imagem Docker com o nome especificado.
Também é possível fazer algo semelhante com o Spring Boot, a partir da versão 2.3, executando o target mvn:
Nos dois casos, o sistema cria automaticamente uma imagem de contêiner Docker para Java. Vale dizer que essas imagens também são relativamente pequenas, pois usam eclipse-temurin ou buildpacks como base. Mas, independentemente do tamanho da imagem, como saber se esses contêineres são seguros? Você precisa investigá-los a fundo e, mesmo assim, não há garantia de que esse nível de segurança será mantido no futuro.
A verificação das duas imagens com snyk container revela algumas vulnerabilidades relacionadas às imagens base usadas por esses sistemas. Além disso, não sabemos se os privilégios do usuário estão configurados corretamente, entre outros aspectos que já discutimos aqui.
Não estou dizendo que você não deve usar essas ferramentas para criar imagens Docker para Java. Mas, se pretende publicá-las, precisa investigar todos os aspectos de segurança de contêineres Java. Verificar o contêiner é um bom começo. Se optar por outra ferramenta, investigue as configurações padrão, como a imagem base e o usuário padrão, e substitua-as por opções adequadas ao seu aplicativo. Definir essas configurações explicitamente, como você faria em um Dockerfile, é sempre uma decisão mais segura.
Resumo
Com essa última etapa, concluímos este guia sobre como colocar aplicativos Java em contêineres Docker com segurança. Levamos em conta otimizações de desempenho e segurança para garantir a criação de imagens Docker para Java prontas para produção!
Confira também estes recursos, que recomendo muito:
Docker para quem desenvolve em Java: 5 dicas para não comprometer sua segurança
Como criar imagens de contêiner Java usando Jib

Segurança de contêineres que prioriza os desenvolvedores
O Snyk encontra e corrige automaticamente vulnerabilidades em imagens de contêiner e workloads do Kubernetes.
Perguntas frequentes
Como verificar a versão do Java em um contêiner Docker?
Uma maneira confiável de verificar qual versão do Java está instalada em uma imagem de contêiner Docker é executar nela o comando java -version:
docker run --rm -it eclipse-temurin:11 java -version
openjdk version "11.0.16" 2022-07-19
OpenJDK Runtime Environment Temurin-11.0.16+8 (build 11.0.16+8)
64-Bit Server VM Temurin-11.0.16+8 (build 11.0.16+8, mixed mode)
Se a imagem tiver um entrypoint configurado que impeça isso, você pode substituí-lo pelo parâmetro --entrypoint, como neste exemplo:
docker run --rm -it --entrypoint java tomcat:8 -version
opnjdk version "17.0.4" 2022-07-19
OpenJDK Runtime Environment Temurin-17.0.4+8 (build 17.0.4+8)
64-Bit Server VM Temurin-17.0.4+8 (build 17.0.4+8, mixed mode, shareing)
Como instalo o Java em um contêiner Docker?
A maneira recomendada de instalar o Java nas imagens do seu contêiner é usar uma imagem base oficial que contenha a versão necessária. Por exemplo:
FROM eclipse-temurin:11.0.16_8-jdk
COPY myapp.jar /myapp.jar
...
Como altero a versão do Java em um contêiner Docker?
A melhor maneira de alterar a versão do Java usada na imagem do contêiner é atualizar a imagem base para a versão desejada. Basta alterar a linha FROM no Dockerfile para a tag correta da versão necessária, reconstruir a imagem com uma tag apropriada e enviá-la ao registro de imagens. Por fim, atualize a implantação para usar essa nova tag.
