10 prácticas recomendadas para crear un contenedor Java con Docker
24 de agosto de 2022
0 minutos de lecturaNota del editor
Esta publicación se publicó originalmente el 18 de febrero de 2021, pero se actualizó para mantenerla vigente.
¿Quieres crear una aplicación Java y ejecutarla dentro de una imagen de Docker? ¡Te ayudaré!
Esta guía rápida te ofrece prácticas recomendadas para crear un contenedor Java listo para producción. En el ejemplo de contenedor Java que crearemos siguiendo estas pautas, nos enfocaremos en crear un contenedor optimizado y seguro para tu aplicación. Puedes usar esta guía al crear un contenedor Java con Docker, y también al revisar el código de otras personas. En ambos casos, es importante enfocarse en optimizar y proteger la imagen de Docker que quieres poner en producción.

Diez prácticas recomendadas
Usa etiquetas explícitas y deterministas para las imágenes base de Docker (¡"latest" no es una versión!)
Instala en la imagen del contenedor Java solo lo que necesitas para producción
Detecta y corrige vulnerabilidades de seguridad en tu imagen de Docker para Java
Usa compilaciones de varias etapas para reducir aún más el tamaño de la imagen de producción
No ejecutes aplicaciones Java como usuario root
Maneja correctamente los eventos para terminar una aplicación Java de forma segura
Detén las aplicaciones Java de manera ordenada
Evita que archivos innecesarios se incluyan en tus imágenes de contenedores Java con
.dockerignoreAsegúrate de que Java reconozca que se ejecuta en un contenedor
Ten cuidado con las herramientas que generan contenedores de Docker automáticamente
Seguridad de contenedores centrada en los desarrolladores
Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.
Cómo NO crear una imagen de contenedor Java
Empecemos con un Dockerfile sencillo para una aplicación Java creada con Maven. En muchos artículos encontramos algo parecido al crear un contenedor Java, como en el siguiente ejemplo:
La mayoría de los artículos de blog empiezan y terminan con instrucciones básicas de Dockerfile como estas para crear imágenes de Docker para Java:
Copia eso en un archivo llamado Dockerfile y luego compílalo y ejecútalo.
Es sencillo y funciona. Sin embargo, ¡esta imagen está llena de errores! No solo debemos saber cómo usar Maven correctamente, sino que también debemos evitar a toda costa crear contenedores Java como el del ejemplo anterior.
Mejoremos este Dockerfile y optimicémoslo paso a paso para crear una imagen de Docker eficiente y segura para tu aplicación Java.
1. Usa etiquetas explícitas y deterministas para las imágenes base de Docker
Al crear una imagen de contenedor Java con Maven, parece obvio usar la imagen de Maven como base: puedes descargarla fácilmente de Dockerhub y empezar. Sin embargo, ¿sabes exactamente qué estás descargando al usar esta imagen base? Puedes hacer referencia a una imagen de Docker mediante su etiqueta, pero si no especificas ninguna, se usará la etiqueta implícita latest.
Esta característica puede parecer interesante, pero usar la imagen predeterminada de Maven puede traer algunos problemas:
Esta característica puede parecer interesante, pero usar la imagen predeterminada de Maven puede traer algunos problemas:
Tus compilaciones de Docker no son idempotentes
Esto significa que el resultado puede ser completamente distinto cada vez que vuelvas a compilar. Las imágenes más recientes de hoy pueden ser diferentes de las más recientes de mañana o de la próxima semana. Las versiones de Maven y del JDK en las que basas tu imagen pueden actualizarse. Esto significa que el bytecode de tu aplicación será distinto y podría producir resultados inesperados. Al volver a compilar una imagen, queremos un comportamiento reproducible y determinista.
La imagen de Docker de Maven se basa en una imagen de sistema operativo completa
Como resultado, muchos binarios adicionales terminan en tu imagen de producción. Muchos de estos binarios no son necesarios para ejecutar tu aplicación. Pero incluirlos en la imagen del contenedor Java tiene algunas desventajas:
Una imagen más grande tarda más en descargarse y volver a compilarse.
Los binarios adicionales pueden introducir vulnerabilidades de seguridad. Cada binario vulnerable representa un riesgo de seguridad que no quieres agregar a tu sistema.
La imagen maven:latest es bastante grande y suele basarse en versiones de Maven y OpenJDK que cambian en cuestión de meses, semanas o incluso días.
Así puedes mitigar este problema:
Usa la imagen base más pequeña posible que se ajuste a tus necesidades. Piénsalo: ¿necesitas un sistema operativo completo, con todos los binarios adicionales, para ejecutar tu programa? Si no, quizá una imagen más pequeña, como
debian-slimo inclusoalpine, funcione igual de bien que una imagen completa de Debian.Especifica las imágenes con mucha precisión. Si usas una versión específica de la imagen, ya puedes controlar y predecir ciertos comportamientos. Si usas la imagen
maven:3.6.3-jdk-11-slim, puedes tener la certeza de que usas JDK 11 y Maven 3.6.3. El ciclo de actualización de seis meses del JDK ya no afectará el comportamiento del contenedor Java. Para ser aún más preciso, puedes usar el hashSHA256de la imagen. Especificar el hash garantiza que siempre tengas exactamente la misma imagen base al volver a compilar tu imagen.
Actualicemos nuestro Dockerfile con esta información:
Para obtener más información sobre cómo elegir imágenes de contenedor seguras, consulta nuestra guía de seguridad de contenedores.
2. Instala en la imagen del contenedor Java solo lo que necesitas para producción
El siguiente comando compila tu programa Java, incluidas todas sus dependencias, dentro del contenedor. Esto significa que tanto el código fuente como el sistema de compilación forman parte del contenedor Java de producción.
Java es un lenguaje compilado. Esto significa que solo necesitamos el artefacto que crea tu entorno de compilación, no el código en sí. También significa que el entorno de compilación no debe formar parte de tu contenedor de producción.
Para ejecutar una imagen Java tampoco necesitamos un JDK completo; basta con un JRE. En esencia, necesitamos crear una imagen con un JRE y el artefacto Java compilado, si se trata de un JAR ejecutable.
Compila tu programa en tu pipeline de CI con Maven (o Gradle) y copia el JAR a tu imagen de producción, como en el Dockerfile actualizado que aparece a continuación:
3. Detecta y corrige vulnerabilidades de seguridad en la imagen de tu contenedor Docker para Java
Ya usamos una imagen pequeña para mi contenedor Java de Docker. Sin embargo, no sé si los binarios de esta imagen base tienen problemas. Usemos la CLI de Snyk para probar nuestra imagen de Docker. Puedes crear una cuenta gratuita de Snyk para seguir los pasos.
Puedes instalar la CLI con npm, brew o scoop, o descargar el binario más reciente de GitHub. En este ejemplo, usaremos npm para instalarla y autenticarnos con nuestra cuenta gratuita:
Con snyk container test, podemos probar cualquier imagen de Docker. También podemos agregar el Dockerfile para obtener mejores recomendaciones de corrección.

Snyk encontró 58 problemas de seguridad en esta imagen base. La mayoría se relacionan con binarios incluidos en la distribución Debian Linux.
Con base en esta información, cambiaremos la imagen base por una imagen openjdk11:JRE basada en Alpine y proporcionada por adoptopenjdk, y especificaremos el SHA para ser más precisos.
Al probar esta versión con snyk container, no se muestran vulnerabilidades conocidas en esta imagen base.
De manera similar, puedes probar tu aplicación Java ejecutando snyk test en la raíz de tu proyecto. Te recomiendo probar tanto tu aplicación como la imagen de contenedor Java que creaste mientras desarrollas en tu máquina local. Además, automatiza la misma prueba para la imagen y la aplicación en tu pipeline de CI.
Además, recuerda que con el tiempo se descubrirán nuevas vulnerabilidades. Cuando se identifique una, probablemente querrás recibir una notificación.
Usar snyk monitor para tu aplicación y snyk container monitor para tus imágenes de Docker te ayudará con eso. Al monitorear la versión que está actualmente en producción, podrás actuar adecuadamente cuando se encuentren nuevos problemas de seguridad.

También puedes conectar tu repositorio de Git a Snyk para que podamos ayudarte a detectar y corregir vulnerabilidades en esa parte de tu ciclo de vida de desarrollo de software.
Actualicemos nuestro Dockerfile:
Consulta nuestra guía para obtener más información sobre cómo analizar contenedores de Docker en busca de vulnerabilidades.
4. Usa compilaciones de varias etapas para tu contenedor Java
Antes explicamos que no necesitamos compilar nuestra aplicación Java en un contenedor: solo necesitamos el resultado de esa compilación. Sin embargo, en algunos casos resulta conveniente compilar nuestra aplicación como parte del proceso de creación de la imagen de Docker.
Por suerte, podemos dividir la creación de tu imagen de Docker en varias etapas. Podemos crear una imagen de compilación con todas las herramientas que necesitamos para compilar nuestra aplicación y luego crear una segunda etapa para generar la imagen de producción real, que solo contiene lo necesario para ejecutarla en producción.
Evita las filtraciones de información confidencial
Al crear aplicaciones Java e imágenes de Docker, es probable que necesites conectarte a un repositorio privado de Maven. Normalmente, estas configuraciones se guardan en tu settings.xml, en tu máquina local o entorno de compilación. Con las compilaciones de varias etapas, puedes copiar sin riesgos el archivo settings.xml a tu contenedor de compilación. La configuración con las credenciales no terminará en tu imagen de producción. Además, si necesitas usar credenciales como argumentos de línea de comandos, puedes hacerlo de forma segura en la imagen de compilación. No terminarán en la imagen de producción.
Con las compilaciones de varias etapas puedes crear varias etapas y copiar solo el resultado a tus imágenes de producción finales. Separar la compilación del código de la creación de la imagen te permite enfocarte en implementar solo lo necesario para ejecutar la aplicación en la imagen final del contenedor y evita que filtres código fuente, compiladores u otro contenido en los entornos implementados.
Por cierto, revisa el resultado de docker history para la imagen de contenedor Java creada:
El resultado solo muestra información de la imagen del contenedor de producción, no de la imagen de compilación.

5. No ejecutes contenedores como usuario root
Al crear un contenedor de Docker, debes aplicar el principio de privilegio mínimo como parte de la seguridad de tu contenedor Java (es decir, conceder solo los privilegios que realmente necesita). Si por algún motivo un atacante logra infiltrarse en tu aplicación, no querrás que pueda acceder a todo.
Contar con varias capas de seguridad ayuda a reducir el daño potencial que puede causar un atacante cuando tu sistema se ve comprometido. Por eso, debes asegurarte de no ejecutar tu aplicación como usuario root.
Aquí hay un problema: al crear un contenedor de Docker, de forma predeterminada se ejecuta como usuario root. Aunque esto es práctico durante el desarrollo, no querrás hacerlo en tus imágenes de producción. Supongamos que, por algún motivo, un atacante accede a una terminal o puede ejecutar código. En ese caso, tendrá privilegios considerables en ese contenedor en ejecución y posiblemente podrá acceder a los sistemas de archivos del host mediante montajes de enlace con permisos de acceso demasiado amplios.
La solución es bastante sencilla. Consulta la documentación de tu imagen base y, si ya incluye un usuario con menos privilegios, úsalo agregando una línea USER a tu Dockerfile con ese usuario. De lo contrario, crea un usuario específico con privilegios limitados para ejecutar tu aplicación y úsalo. Solo asegúrate de probar que el usuario pueda ejecutarla.
Actualicemos nuestro Dockerfile según corresponda. Observa que solo debemos ocuparnos del usuario en la segunda parte de la compilación del contenedor de varias etapas.
Nota: Otra forma de ejecutar la aplicación como un usuario diferente es especificarlo en la línea de comandos o en la configuración del orquestador. Por ejemplo, el comando run de la CLI de Docker tiene un parámetro -u para este propósito, y las especificaciones de Pod de Kubernetes ofrecen la misma configuración mediante el campo SecurityContext:runAsUser. Sin embargo, especificarlo en la especificación de compilación permite repetir el proceso.
6. Maneja correctamente los eventos para terminar de forma segura una aplicación web Java en Docker
En muchos ejemplos vemos el error común de usar el entorno de compilación para iniciar la aplicación Java en contenedor.
Aunque ya hablamos de por qué deberíamos incluir Maven o Gradle en nuestro contenedor Docker para Java, hay otras razones para evitar este tipo de herramientas:
CMD “mvn” “exec:java”CMD [“mvn”, “spring-boot run”]CMD “gradle” “bootRun”CMD “run-app.sh”
Al ejecutar una aplicación en Docker, la primera aplicación se ejecuta como ID de proceso 1 (PID 1). El kernel de Linux trata al PID 1 de una manera especial. Por lo general, el proceso con PID 1 es el proceso init. Si ejecutamos nuestra aplicación Java con Maven, ¿cómo podemos asegurarnos de que Maven reenvíe señales como SIGTERM al proceso Java? ¡No podemos!
En cambio, si ejecutas tu contenedor Docker como en el ejemplo a continuación, tu aplicación Java tendrá el PID 1 y las señales se transmitirán correctamente.
Ten en cuenta que los comandos docker kill ... y docker stop ... solo envían señales al proceso del contenedor con PID 1. Si ejecutas un script de shell que inicia tu aplicación Java, ten en cuenta que una instancia de shell, como /bin/sh, no reenvía señales a los procesos secundarios. Esto significa que tu aplicación nunca recibirá ese SIGTERM.
Es importante saber que, en Linux, el PID 1 también tiene algunas responsabilidades adicionales. Se describen muy bien en el artículo Docker y el problema de recolección de procesos zombi con PID 1. En algunos casos, no quieres tener el PID 1 porque no sabes cómo manejar estos problemas. Una excelente solución es usar dumb-init.
Al ejecutar tu contenedor Docker de esta manera, dumb-init ocupa el PID 1 y se encarga de todas las responsabilidades. Tu proceso Java ya no tiene que hacerlo.
Nuestro Dockerfile actualizado ahora se ve así:
7. Cierra tus aplicaciones web Java de forma ordenada
Cuando tu aplicación recibe una señal de apagado, lo ideal es que todo se cierre de forma ordenada. Según cómo desarrolles tu aplicación, una señal de interrupción (SIGINT) o CTRL + C puede terminar el proceso de inmediato.
Quizás no quieras que esto suceda, ya que puede provocar comportamientos inesperados o incluso la pérdida de datos.
Si ejecutas tu aplicación como parte de un servidor web, como Payara o Apache Tomcat, lo más probable es que el servidor web se encargue de un apagado ordenado. Lo mismo puede ocurrir con ciertos frameworks que puedes usar para crear una aplicación ejecutable. Por ejemplo, Spring Boot incluye una versión de Tomcat que se encarga eficazmente del apagado.
Si creas una aplicación Java independiente o generas manualmente un JAR ejecutable, debes encargarte tú mismo de estas señales de interrupción.
La solución es bastante sencilla. Puedes agregar un hook de apagado al runtime, como en el ejemplo a continuación. Cuando se recibe una señal como SIGINT, se inicia un nuevo Thread que puede encargarse de un apagado ordenado.
Es cierto que esto tiene más que ver con las aplicaciones web en general que con Dockerfile, pero es aún más importante en entornos orquestados. Obtén más información sobre la orquestación de contenedores en nuestra guía.
8. Mantén los archivos innecesarios fuera de las imágenes de contenedor Java con .dockerignore
Para excluir ciertos archivos de tu repositorio Git (público), puedes usar el archivo .gitignore. Así evitas llenar el repositorio Git de archivos innecesarios. Además, esta herramienta ayuda a evitar que se filtren archivos confidenciales a un repositorio público.
Para las imágenes Docker, tenemos algo similar: el archivo .dockerignore. Al igual que el archivo de exclusión de Git, ignora los archivos que coinciden con los patrones especificados y evita que se copien archivos o directorios no deseados en tu imagen Docker.
Técnicamente, esto funciona así: cuando se inicia un comando docker build, la herramienta CLI envía el contenido del directorio de contexto de compilación (de forma predeterminada, es el directorio que contiene el Dockerfile) al runtime del contenedor. Esa copia es la que se usa durante los pasos definidos en el Dockerfile. Cualquier archivo que coincida con un patrón de .dockerignore no se copiará y, por lo tanto, no estará disponible para que lo incluyan los comandos COPY o ADD durante la compilación. Esto también acelera las compilaciones, ya que la copia del contexto se realiza mucho más rápido si excluyes árboles de directorios grandes, como .git. La diferencia es especialmente notable si compilas en un motor Docker que se ejecuta en otro servidor conectado a través de una red.
El ejemplo simplificado de este artículo supone que trabajamos con un único JAR ejecutable. Esta no es, ni mucho menos, la única forma de distribuir una aplicación Java. En las aplicaciones Java independientes, también es posible que no creemos un JAR con todas las dependencias integradas en el artefacto. Según el contexto, quizá tengamos que copiar directorios completos con los JAR de los que depende nuestra aplicación, u otros archivos. No queremos que se filtren por accidente datos confidenciales en nuestras imágenes Docker, especialmente si las publicamos de forma pública.
Este es un ejemplo de .dockerignore:
Estos son los beneficios de usar el archivo .dockerignore:
Excluye las dependencias que solo usas para pruebas.
Te protege de exponer secretos, como credenciales incluidas en archivos
.envoaws.jsonque podrían terminar en la imagen Docker de Java. Ten en cuenta que los archivos de registro de depuración también pueden contener secretos o información confidencial que no quieras exponer.Mantiene tu imagen Docker limpia y ordenada, lo que en esencia hace que ocupe menos espacio. Además, ayuda a evitar comportamientos inesperados.
Acelera las operaciones de docker build al reducir el tamaño del directorio de contexto de compilación que se debe copiar.
9. Asegúrate de que Java reconozca los contenedores
La máquina virtual de Java (JVM) es increíble. Se ajusta según el sistema en el que se ejecuta. Su comportamiento permite optimizar dinámicamente el tamaño del heap. Sin embargo, en las versiones anteriores, como Java 8 y Java 9, la JVM no reconocía los límites de CPU ni de memoria establecidos por el contenedor. La JVM de estas versiones antiguas de Java veía toda la memoria y todas las CPU disponibles en el sistema host. Básicamente, se ignoran los ajustes del grupo de Docker.
Con el lanzamiento de Java 10, la JVM ahora reconoce los contenedores y sus restricciones. La función UseContainerSupport es una opción de la JVM que viene activada de forma predeterminada. Esta función de reconocimiento de contenedores, lanzada en Java 10, se incorporó también en Java-8u191.
En las versiones anteriores a Java 8, puedes intentar restringir manualmente el tamaño del heap con la opción -Xmx, pero es un proceso tedioso. Además, el tamaño del heap no equivale a la memoria que usa Java. En Java-8u131 y Java 9, la función de reconocimiento de contenedores es experimental, por lo que tienes que activar las opciones experimentales de la JVM y la opción de límite de memoria.
Lo mejor es actualizar a una versión de Java posterior a la 10 para que la compatibilidad con contenedores esté activada de forma predeterminada. Por desgracia, muchas empresas todavía dependen en gran medida de Java 8. Por eso, debes actualizar a una versión más reciente de Java en tus imágenes Docker, preferiblemente la última versión LTS, o asegurarte de usar como mínimo Java 8u191.
¿Todo esto no parece estar relacionado específicamente con la seguridad de los contenedores Java? Sin embargo, si lo piensas, la disponibilidad es uno de los tres atributos de la tríada CIA de la seguridad de la información.
10. Ten cuidado con las herramientas de generación automática de contenedores Docker
Al explorar Internet, probablemente encontrarás excelentes herramientas y complementos para tu sistema de compilación. Entre ellos, hay herramientas que pueden ayudarte a crear contenedores Docker para Java e incluso publicarlos automáticamente, si quieres.
Desde la perspectiva de un desarrollador, esto parece increíble, ya que no tienes que enfocarte en mantener los Dockerfiles además de crear la aplicación.
Un ejemplo de este tipo de complemento es JIB. Solo tenemos que configurar el complemento de compilación, como se muestra a continuación, y ejecutar mvn jib:dockerBuild:
Así se crea sin complicaciones una imagen Docker con el nombre especificado.
Se puede hacer algo similar con Spring Boot a partir de la versión 2.3, ejecutando el objetivo mvn:
En ambos casos, el sistema crea automáticamente una imagen de contenedor Docker para Java. Debo admitir que estos contenedores también son relativamente pequeños. Esto se debe a que usan eclipse-temurin o buildpacks como base para las imágenes. Pero, sin importar el tamaño de tu imagen, ¿cómo sabes si estos contenedores son seguros? Necesitas investigarlo a fondo, e incluso entonces no puedes tener la certeza de que seguirán siéndolo en el futuro.
Al analizar ambas imágenes con snyk container, encontramos un par de vulnerabilidades relacionadas con las imágenes base que usan estos dos sistemas. Además, no sabemos si los privilegios de usuario están configurados correctamente, entre otros aspectos que ya analizamos aquí.
No digo que no debas usar estas herramientas para crear imágenes Docker para Java. Sin embargo, si planeas publicar estas imágenes, debes investigar a fondo todos los aspectos de seguridad de los contenedores Java. Analizar el contenedor sería un buen comienzo. Si decides usar una herramienta alternativa, asegúrate de investigar los ajustes predeterminados, como las imágenes base y el usuario predeterminado, y reemplázalos por los adecuados para tu aplicación. Ser explícito con estos ajustes, tal como lo harías en un Dockerfile, siempre es una decisión más segura.
Resumen
Con este último paso concluye la guía completa para crear contenedores Docker seguros para aplicaciones Java. Consideramos optimizaciones de rendimiento y seguridad para asegurarnos de crear imágenes Docker para Java listas para producción.
Te recomiendo consultar estos recursos adicionales:
10 prácticas recomendadas para la seguridad de imágenes Docker
Cómo crear imágenes de contenedor Java con Jib

Seguridad de contenedores centrada en los desarrolladores
Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.
Preguntas frecuentes
¿Cómo puedo comprobar la versión de Java en un contenedor Docker?
Una forma definitiva de comprobar qué versión de Java está instalada en una imagen de contenedor Docker es ejecutar el comando java -version en ella:
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)
OpenJDK 64-Bit Server VM Temurin-11.0.16+8 (build 11.0.16+8, mixed mode)
Si la imagen tiene configurado un entrypoint que lo impide, puedes anularlo con el parámetro --entrypoint, como se muestra en este ejemplo:
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)
OpenJDK 64-Bit Server VM Temurin-17.0.4+8 (build 17.0.4+8, mixed mode, shareing)
¿Cómo instalo Java en un contenedor de Docker?
La forma recomendada de instalar Java en tus imágenes de contenedor es usar una imagen base oficial que incluya la versión que necesitas. Por ejemplo:
FROM eclipse-temurin:11.0.16_8-jdk
COPY myapp.jar /myapp.jar
...
¿Cómo cambio la versión de Java en un contenedor de Docker?
La mejor forma de cambiar la versión de Java que usas en la imagen de tu contenedor es actualizar la imagen base a la versión que necesitas. Solo cambia la línea FROM de tu Dockerfile por la etiqueta correcta de la versión que necesitas. Luego, vuelve a compilar la imagen con una etiqueta adecuada y súbela al registro de imágenes. Por último, actualiza tu implementación para que use esta nueva etiqueta.



