Construir y ejecutar una aplicación Spring Boot con Docker
Construye y ejecuta tu aplicación Spring Boot como un contenedor Docker con esta guía práctica.
En este post, usaremos Docker para crear una imagen de una aplicación Spring Boot y ejecutarla en un contenedor. Comenzaremos con un único Dockerfile, luego también cubriremos cómo escalar nuestra aplicación usando Docker Compose, y veremos cómo construir nuestro código Java usando Docker.
La aplicación Hola Spring Boot
Actualizado: Los ejemplos de código usan Java 10 y Spring Boot 2.
La aplicación que emplearemos a través de este post es bastante simple: una aplicación Spring Boot Web con un REST controller que nos da un cálido saludo español. Además del ‘Hola’, mostraremos desde qué dirección IP viene el saludo. Eso será útil para nosotros para ver cómo funciona Docker.
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Hola Docker Spring Boot. Si te resulta útil, ¡dale una estrella!
Crear un controller de ejemplo
Te guiaré a través de este post como si estuvieras creando la aplicación desde cero, pero también puedes clonar el repositorio existente y jugar con él.
Primero, creemos el esqueleto de la aplicación usando Spring Boot Initializr: http://start.spring.io, incluyendo ‘Web’ como la única dependencia y eligiendo Maven (para seguir las mismas instrucciones que en este post). Luego, codificamos este simple REST Controller:
package com.thepracticaldeveloper.hola;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.InetAddress;
import java.net.UnknownHostException;
@RestController
public final class HolaController {
@GetMapping
public final String hola() throws UnknownHostException {
return "Hola! Puedes encontrarme en " + InetAddress.getLocalHost().getHostAddress();
}
}
Como no hay una ruta especificada, ese texto se devuelve cuando hacemos una petición GET al contexto raíz. Démosle una prueba.
Ejecutar la aplicación
Usa tu instalación local de Maven o el wrapper (comando mvnw) normalmente incluido por el inicializador de Spring. Desde la carpeta raíz de la aplicación, ejecuta:
mvn spring-boot:run
La aplicación Spring Boot debería iniciarse y mostrar un mensaje similar a este:
Started HolaApplication in 2.198 seconds (JVM running for 5.628)
Si navegas con tu navegador (o usas herramientas de línea de comandos como curl o httpie) a localhost:8080, verás la respuesta de nuestra amigable aplicación (la IP puede variar por supuesto):
Hola! Puedes encontrarme en 192.168.0.21
Como alternativa, también puedes ejecutar la aplicación empaquetándola primero en un archivo jar, y luego ejecutarlo con el comando java. En ese caso, usa Maven para crear el paquete:
mvn clean package
El archivo .jar resultante se colocará en una nueva carpeta target. Ahora puedes ejecutar este comando y obtendrás la aplicación ejecutándose como antes:
java -jar ./target/hola-docker-1.0.0-SNAPSHOT.jar
Entender los básicos
Hasta ahora no hay nada que ver con Docker, pero es importante destacar un par de conceptos para entender completamente el resto de este post:
- Usaste tu máquina para construir la aplicación (usando Maven, ya sea previamente instalado o el wrapper).
- Usaste tu máquina para ejecutar la aplicación (ya sea usando el plugin spring-boot o el archivo jar).
Dockerizar Spring Boot (o cualquier archivo .jar ejecutable)
Empecemos a jugar con Docker. Si aún no lo has hecho, necesitas instalar Docker en una de sus versiones (Windows, Mac, Linux). Si usas un sistema Windows o Mac antiguo, necesitarás usar Docker Toolbox. En ese caso, ten en cuenta que cuando me refiera a localhost, debes reemplazarlo con la IP de tu VM (en la que Docker se ejecuta).
Para mantener la ruta de aprendizaje lo más suave posible, pasaré por varios pasos que te mostrarán diferentes cosas que puedes hacer con Docker y una aplicación java:
- Usar un Dockerfile para definir una imagen, luego construirla y ejecutarla con comandos Docker simples.
- Usar un
docker-compose.ymly su interfaz de línea de comandos para extender las funcionalidades al ejecutar múltiples contenedores, y simplificarlo teniendo una configuración predefinida. - Usar docker para construir nuestro código java también (nota que esto es más un experimento que un caso de la vida real).
Configuración mínima: Dockerfile
Dockerfile para Spring Boot
El requisito mínimo que tenemos para ejecutar nuestra aplicación Spring Boot en un contenedor es crear un archivo llamado Dockerfile con instrucciones para construir la imagen. Luego ejecutaremos un contenedor Docker usando esa imagen.
FROM openjdk:10-jre-slim
COPY ./target/hola-docker-1.0.0-SNAPSHOT.jar /usr/src/hola/
WORKDIR /usr/src/hola
EXPOSE 8080
CMD ["java", "-jar", "hola-docker-1.0.0-SNAPSHOT.jar"]
Como ves, lo mantuve realmente simple. La imagen está basada en un Linux slim con JRE 10; encima de eso copiamos el archivo JAR, cambiamos al directorio de trabajo en el que está ese paquete, y ejecutamos el mismo comando que hicimos antes cuando ejecutamos desde nuestra máquina. La instrucción EXPOSE le está diciendo a Docker que el puerto 8080 puede ser expuesto fuera del contenedor, pero nota que también necesitaremos hacer el puerto disponible cuando lo ejecutemos de todos modos. Si quieres más detalles sobre lo que hace cada línea, te recomiendo echar un vistazo a la documentación de referencia de Dockerfile.
Si usas Java 8 en su lugar, puedes beneficiarte de una imagen Linux aún más pequeña (alpine). Desafortunadamente, la distribución alpine aún no es compatible con Java 10 al momento de escribir este post.
Construir la imagen
Construimos la imagen para que esté disponible en nuestro registro Docker local. Le damos un nombre con la bandera -t, y especificamos el directorio actual como aquel en el que vive el Dockerfile.
docker build -t hola-manual-build .
El comando mostrará el estado de cada paso mientras construye la imagen. Descargará la imagen base si aún no está disponible en tus imágenes docker existentes, así que ten paciencia. Si ahora ejecutamos el comando docker images para listar las imágenes disponibles en nuestro registro, veremos la nueva:
REPOSITORY TAG IMAGE ID CREATED SIZE
hola-manual-build latest 190f9d37da59 7 hours ago 302MB
Ejecutar la aplicación Spring Boot con Docker
Ya casi estamos allí para tener nuestra aplicación en funcionamiento en Docker. Solo necesitamos crear un contenedor usando la nueva imagen:
docker run -p 8000:8080 hola-manual-build
Al usar la bandera -p le estamos diciendo a docker que exponga el puerto 8080 del contenedor (a la derecha de los dos puntos) en el puerto 8000 del host (a la izquierda, nuestra máquina). Podemos acceder desde nuestra máquina a localhost:8000 (también puedes usar tu navegador) y ver el mensaje de saludo de nuevo, esta vez viniendo del contenedor Docker:
moises$ http localhost:8000
HTTP/1.1 200
Content-Length: 38
Content-Type: text/plain;charset=UTF-8
Date: Sun, 10 Dec 2017 10:05:01 GMT
Hola! Puedes encontrarme en 172.17.0.2
Nota que la IP es diferente de la anterior ya que ahora la aplicación está desplegada dentro de un contenedor Docker. Cada contenedor obtendrá una nueva IP asignada dentro de la red de Docker.
Dockerfile: Recapitulación
¡Lo logramos! Ahora tenemos la aplicación java Spring Boot ejecutándose en un contenedor Docker. Nota la diferencia con el caso anterior:
- Usaste tu máquina para construir la aplicación (usando Maven, ya sea previamente instalado o el wrapper).
- Usaste Docker para ejecutar la aplicación:
- Construiste la imagen usando
docker build. - Ejecutaste el contenedor usando
docker run, especificando el puerto para hacerlo disponible desde el host.
- Construiste la imagen usando
Ejecutar una aplicación Spring Boot usando docker-compose
Docker Compose es una herramienta para ejecutar múltiples contenedores, definir cómo están conectados, cuántas instancias deben ser desplegadas, etc. También te permite definir cómo debe construirse la imagen. Usémosla en nuestro escenario para simplificar la forma en que ejecutamos el contenedor.
Definir el archivo docker-compose
Creamos un archivo docker-compose.yml con este contenido:
version: '2.2'
services:
hola:
build:
context: ./
dockerfile: Dockerfile
image: holaweb
ports:
- 8080
networks:
- network1
networks:
network1:
Creamos solo un servicio: hola. Definimos cómo se construye: usando la misma carpeta como contexto, ya que allí está el Dockerfile y los diferentes archivos ubicados. No necesitamos especificar el parámetro dockerfile, pero lo introduciremos aquí ya que planeamos modificarlo más tarde. Damos un nombre a la imagen para que, si no está presente, la herramienta compose la construya y asigne ese nombre a la imagen resultante. Si está allí, no construirá la imagen de nuevo a menos que le indiquemos que lo haga (veremos cómo más tarde).
Construir y ejecutar la aplicación
Nota que estamos usando ports para hacer disponible el puerto 8080 del contenedor desde afuera. En este caso, no estamos especificando el puerto del host, así que Docker elegirá uno aleatorio. Ejecutemos docker-compose y veamos qué pasa:
$ docker-compose up
Esta debería ser la salida del comando:
$ docker-compose up
Building hola
Step 1/5 : FROM openjdk:10-jre-slim
---> 65f8e952b8a9
Step 2/5 : COPY ./target/hola-docker-1.0.0-SNAPSHOT.jar /usr/src/hola/
---> 4b9605f98946
Step 3/5 : WORKDIR /usr/src/hola
Removing intermediate container 4a9ec666872f
---> 0e487bfb3314
Step 4/5 : EXPOSE 8080
---> Running in 4765084c3829
Removing intermediate container 4765084c3829
---> 92e311cc1504
Step 5/5 : CMD ["java", "-jar", "hola-docker-1.0.0-SNAPSHOT.jar"]
---> Running in c2a9cd73bb8b
Removing intermediate container c2a9cd73bb8b
---> ada28f26d28a
Successfully built ada28f26d28a
Successfully tagged holaweb:latest
WARNING: Image for service hola was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
Creating spring-boot-hola-docker_hola_1 ... done
Attaching to spring-boot-hola-docker_hola_1
hola_1 |
hola_1 | . ____ _ __ _ _
hola_1 | /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
hola_1 | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
hola_1 | \\/ ___)| |_)| | | | | || (_| | ) ) ) )
hola_1 | ' |____| .__|_| |_|_| |_\__, | / / / /
hola_1 | =========|_|==============|___/=/_/_/_/
hola_1 | :: Spring Boot :: (v2.0.3.RELEASE)
hola_1 |
hola_1 | [Application logs...]
Como puedes ver, el primer paso en docker-compose construye la imagen, ya que no hay una imagen holaweb disponible. La etiquetará con ese nombre, y luego la ejecutará según el resto de los detalles en el archivo docker-compose.yml. Expondrá el puerto del contenedor al host en un puerto que aún no conocemos. Averigüémoslo (necesitas abrir una nueva terminal):
$ docker-compose ps
Y aquí está la salida de ese comando:
Name Command State Ports
------------------------------------------------------------------------------
hola_hola_1 java -jar hola-docker-1.0. ... Up 0.0.0.0:32768->8080/tcp
Así que ahora sabemos que el puerto del host es 32768 (puede variar en tu caso). Navegamos a localhost:32768 para tener nuestro saludo, de nuevo viniendo de un contenedor.
Hola! Puedes encontrarme en 172.20.0.2
Estas son algunas observaciones sobre esta solución:
- Las ejecuciones subsiguientes de
docker-compose upno requerirán construir la imagen. - Sin embargo, puedes reconstruir la imagen cuando quieras, usando:
docker-compose build, que construirá todos los servicios pero no los ejecutará.docker-compose up --build, que construirá todos los servicios y luego los ejecutará.
Docker-compose: Recapitulación
Resumamos lo que obtuvimos hasta ahora con esta solución docker-compose:
- Usaste tu máquina para construir la aplicación (usando Maven, ya sea previamente instalado o el wrapper).
- Usaste
docker-composepara construir y ejecutar la aplicación.- Construiste y ejecutaste el contenedor en un comando, usando
docker-compose up
- Construiste y ejecutaste el contenedor en un comando, usando
Escalar la aplicación Spring Boot usando docker-compose
Cambios en docker-compose
Podemos usar docker-compose para ejecutar múltiples instancias de contenedor de la misma imagen. Ya nos preparamos para eso, dejando que Docker asigne un puerto aleatorio en el host para nuestra aplicación, así que solo necesitamos agregar una línea a nuestro archivo docker-compose.yml para ejecutar múltiples instancias (en este caso 3):
version: '2.2'
services:
hola:
build:
context: ./
dockerfile: Dockerfile
image: holaweb
ports:
- 8080
networks:
- network1
scale: 3
networks:
network1:
Solo agregando esa línea scale: 3, obtendremos tres contenedores en funcionamiento cuando ejecutemos:
$ docker-compose up
Listar las múltiples instancias Docker
Verás en la salida cómo los tres contenedores con las tres aplicaciones Spring Boot se inician en paralelo. En una nueva terminal, ejecutemos ahora:
$ docker-compose ps
Para que podamos listar cuáles son los puertos expuestos desde Docker. En mi caso, esta es la salida:
$ docker-compose ps
Name Command State Ports
------------------------------------------------------------------------------
hola_hola_1 java -jar hola-docker-1.0. ... Up 0.0.0.0:32774->8080/tcp
hola_hola_2 java -jar hola-docker-1.0. ... Up 0.0.0.0:32773->8080/tcp
hola_hola_3 java -jar hola-docker-1.0. ... Up 0.0.0.0:32775->8080/tcp
Puedes ver qué fácil es tener múltiples instancias de nuestros contenedores en funcionamiento. También puedes sobrescribir el número de instancias desde la línea de comandos, ejecutando docker-compose up --scale holaweb=[numero_de_instancias].
Testing
Si hacemos las peticiones GET a los tres puertos diferentes, veremos cómo cada contenedor responde con una IP diferente en su saludo:
moises$ http localhost:32774
HTTP/1.1 200
Content-Length: 38
Content-Type: text/plain;charset=UTF-8
Date: Sun, 10 Dec 2017 11:11:34 GMT
Hola! Puedes encontrarme en 172.20.0.3
moises$ http localhost:32773
HTTP/1.1 200
Content-Length: 38
Content-Type: text/plain;charset=UTF-8
Date: Sun, 10 Dec 2017 11:12:08 GMT
Hola! Puedes encontrarme en 172.20.0.2
moises$ http localhost:32775
HTTP/1.1 200
Content-Length: 38
Content-Type: text/plain;charset=UTF-8
Date: Sun, 10 Dec 2017 11:12:11 GMT
Hola! Puedes encontrarme en 172.20.0.4
Si eres experimentado con temas como descubrimiento de servicios y enrutamiento, debes estar visualizando ahora cuáles son las posibilidades que docker trae. Podríamos incluir en este escenario un registro de servicios y un gateway API y beneficiarnos del balanceo de carga sin demasiado esfuerzo. Intentaré cubrir eso en un post diferente.
Aprende más: Mi libro puede ayudarte a captar estos patrones - Service Discovery y Routing
Construir una aplicación Spring Boot usando Docker
Introducción
Este es un escenario más avanzado en el que usamos un contenedor docker para construir la aplicación y también para ejecutarla. No hagas esto a menos que tengas una buena razón; la mayoría de las herramientas modernas de Integración Continua usan Docker ellas mismas para crear contenedores dinámicos para construir los paquetes de aplicación, ejecutar las pruebas, etc.
De todos modos, quería cubrir esto ya que es una buena muestra de las posibilidades de Docker y puede tener sentido en algunas situaciones específicas:
- Quieres construir localmente pero estás trabajando en una máquina que no cumple los requisitos para construir la aplicación. Por ejemplo, necesitas herramientas linux / mac pero estás ejecutando en windows.
- Tu aplicación requiere una configuración compleja para ser construida así que es conveniente tener una imagen docker pre-cocinada con todos los paquetes y configuración requeridos que puedas ejecutar en cualquier lugar.
Sin embargo, ten en cuenta que para el escenario mostrado aquí es más fácil construir el código como lo hemos estado haciendo hasta ahora, simplemente usando java y maven en nuestra máquina mientras construimos la aplicación y luego docker para ejecutarla. De todos modos, lo usaremos como referencia para que puedas entender la idea y aplicarla (si es necesario) a otros casos.
Dockerfile para construir código Java y ejecutarlo
Creemos un Dockerfile diferente, que llamaremos Dockerfile-build:
FROM maven:3.5.4-jdk-10-slim
WORKDIR /usr/src/java-code
COPY . /usr/src/java-code/
RUN mvn package
WORKDIR /usr/src/java-app
RUN cp /usr/src/java-code/target/*.jar ./app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
Estas son las observaciones más importantes:
- En lugar de usar un JRE como imagen base, estamos usando uno con Maven en él. Ese es una capa encima de la imagen Java, así que ambas herramientas estarán disponibles.
- La primera parte del Dockerfile está copiando el código java a una carpeta en un contenedor y ejecutando
mvn package. Recuerda que este paso se ejecutará mientras se construye la imagen y, como resultado, el archivo jar se confirmará en esa capa de imagen dentro de una nueva carpetatarget, como se espera cuando ejecutamos ese comando maven. - La segunda parte del Dockerfile es similar a la presentada arriba. La única diferencia es que ahora no estamos tomando el archivo jar del contexto del host, sino usándolo del paso de imagen anterior.
- Como ahora estamos copiando todos los contenidos del directorio a la imagen docker, tiene sentido crear un archivo
.dockerignorepara evitar copiar cosas que no necesitamos allí (ver el repositorio para un ejemplo).
Combinarlo desde docker-compose
Reutilizamos nuestro archivo docker-compose.yml existente para construir la imagen con este nuevo enfoque, cambiando la línea que apunta al Dockerfile para que sea
dockerfile: Dockerfile-build
Luego ejecutamos:
$ docker-compose build
Veremos cómo el proceso de construcción está empaquetando la aplicación java, descargando todas las dependencias requeridas y ejecutando las pruebas. Los pasos subsiguientes para construir la imagen están tomando el archivo .jar resultante y copiándolo a una carpeta diferente.
Como podrías esperar, ahora podemos ejecutar el contenedor con el mismo resultado que antes, usando docker-compose up.
¿Cuáles son las diferencias?
Usemos la misma estructura que antes (secciones de Recapitulación) para mostrarte qué pasó en este caso:
- Usaste docker para construir la aplicación (así que no necesitas tener Maven ni Java instalados en tu máquina).
- Usaste docker-compose para construir y ejecutar la aplicación.
- Construiste y ejecutaste el contenedor en un comando, usando
docker-compose up
- Construiste y ejecutaste el contenedor en un comando, usando
Eso podría verse como una ventaja pero recuerda mis comentarios previos: ese escenario tiene solo unas pocas aplicaciones de la vida real.
Otra diferencia notable es el tamaño de la imagen docker. Si ejecutamos docker images con la imagen construida desde el primer dockerfile, obtengo un resultado alrededor de 300 MB:
REPOSITORY TAG IMAGE ID CREATED SIZE
holaweb latest ada28f26d28a 12 minutes ago 302MB
Sin embargo, si hacemos eso después de construir la imagen con Dockerfile-build, el tamaño de la imagen se vuelve bastante más grande:
REPOSITORY TAG IMAGE ID CREATED SIZE
holaweb latest e3330a5ad9cb 19 seconds ago 482MB
Es el resultado esperado ya que ahora nuestra imagen tiene más capas y también incluye el código fuente en ella.
Acceder al código fuente
La última diferencia que quiero señalar es que puedes acceder al código fuente cuando ejecutas un contenedor. Para hacer eso, puedes adjuntar una terminal bash a un contenedor en ejecución, por ejemplo, cuando ejecutas múltiples instancias, podemos hacer esto:
moises$ docker-compose exec --index=1 hola /bin/bash
root@77fcfddd5402:/usr/src/java-app# ls -l /usr/src
total 8
drwxr-xr-x 1 root root 4096 Jul 8 17:04 java-app
drwxr-xr-x 1 root root 4096 Jul 8 17:04 java-code
root@77fcfddd5402:/usr/src/java-app# ls /usr/src/java-code/
Dockerfile README.md pom.xml target
Dockerfile-build docker-compose.yml src
root@77fcfddd5402:/usr/src/java-app#
¿Cómo pueden ayudar las herramientas CI aquí?
Aunque este es un experimento funcional, solo hay unos pocos escenarios de la vida real en los que construir tu aplicación Java con Docker por ti mismo podría ser útil. Si no necesitas una configuración compleja en tu máquina para construir la aplicación, puedes confiar en herramientas como Jenkins Pipelines, CircleCI, GitLab, etc.
Estas herramientas ya construyen tus aplicaciones en contenedores Docker out-of-the-box, así que solo necesitas configurarlas apropiadamente y usar su lenguaje de definición de pipeline para especificar cómo construir tu código - en este caso, usando maven.
Resumen
En este post, pasamos por diferentes pasos para ver cómo podemos contenerizar una aplicación Java Spring Boot usando Docker.
- Primero, creamos un simple
Dockerfiley construimos la imagen y la ejecutamos dentro de un contenedor usando comandos docker simples. - Después de eso, introdujimos
docker-composey vimos cómo puede ayudar a simplificar la configuración y ejecución. Esto sería aún más visible cuando coexisten múltiples servicios. También cubrimos cómo escalar fácilmente nuestro servicio. - Por último, echamos un vistazo a la posibilidad de construir la aplicación usando Docker también y revisamos el razonamiento para hacer eso.
Espero que hayas encontrado útil la guía. ¡Usa un comentario o la página de Facebook para darme algo de feedback!
Get More Architecture Insights
Join hundreds of software architects and engineers getting practical insights delivered to their inbox.