PetClinic con Docker

Para realizar el despliegue de PetClinic como contenedor, primero tenemos que dockerizar la aplicación, luego publicar la imagen de contenedor en un registro como DockerHub o Google Container Registry, y por último ejecutar el contenedor en la instancia de despliegue.

A continuación se describe cómo crear un contenedor Docker de la aplicación PetClinic. Los pasos se realizan en local, y al final configuraremos el pipeline de Jenkins para que se realicen automáticamente.

Para trabajar con contenedores Docker en tu equipo local, debes tener Docker instalado. Recuerda iniciar Docker Desktop en Windows, o iniciar el servicio Docker en Linux o Mac. Comprueba que está funcionado ejecutando el comando docker ps

Hasta ahora nos estamos basando en el proyecto PetClinic original (genericamente llamado upstream) disponible en GitHub. En esta sección necesitarás poder hacer cambios sobre el mismo, básicamente vamos a añadir al proyecto un archivo Dockerfile y un archivo Jenkinsfile, así que crea un fork y trabaja con tu fork a partir de ahora. Verás que en los bloques de código de este documento indica como URL del respositorio https://github.com/ualcnsa/spring-petclinic.git, que deberás cambiar por la URL de tu repositorio forkeado.

Creación del Dockerfile multi-stage

Para construir aplicaciones Maven con Docker y luego crear el contendedor de Docker que empaquete la aplicación, la opción recomendada es usar Multi-Stage Builds en tu Dockerfile, de manera que:

  • Una primera fase o stage construye el .jar a partir de una imagen de Maven, como por ejemplo maven:3.8.4-openjdk-11-slim

  • Y luego en una seguna fase, ese .jar lo copia en el contenedor basado en la imagen OpenJDK-11, que es el entorno de ejecución Java necesario.

La documentación de PetClinic indica cómo construir el contenedor usando Spring Boot build plugin. A nosotros nos interesa ver cómo hacerlo de forma genérica para cualquier aplicación Java basada en Maven. Vamos a definir el siguiente archivo Dockerfile que debe estar en la carpeta raíz del proyecto PetClinic:

Dockerfile multi-stage
FROM maven:3.8.4-openjdk-11-slim AS build (1)
WORKDIR /app
# First copy only the pom file. This is the file with less change
COPY ./pom.xml .
# Download the package and make dependencies cached in docker image
RUN mvn -B -f ./pom.xml -s /usr/share/maven/ref/settings-docker.xml clean dependency:go-offline
# Copy the actual code
COPY ./ .
# Then build the code
RUN mvn -B -f ./pom.xml -s /usr/share/maven/ref/settings-docker.xml clean package

# Start with a base image containing Java runtime
FROM openjdk:11-slim (2)
# Make port 8080 available to the world outside this container
EXPOSE 8080
# The application's jar file
ARG JAR_FILE=target/*.jar
# Copy the application's jar to the container
COPY ${JAR_FILE} app.jar
# Run the jar file
ENTRYPOINT ["java","-jar","/app.jar"]
1 Primera fase o stage de build, construye la aplición llamando a los goals clean package de Maven. Contiene los pasos básicos para construir una aplicación Java basada en Maven. Dicha construcción la divide en dos partes, primero copia el pom.xml y descarga las dependencias con dependency:go-offline, y luego copia todos los fuentes y construye el proyecto con package. De esta forma se optimiza la reconstrucción del contenedor ya que la descarga de dependencias es una etapa que dura varios minutos.
2 Segunda fase, crea una imagen con el entorno de ejecución de Java 11 y el empaquetado .jar de la aplicación PetClinic. Contiene los pasos básicos para ejecutar una aplicación String Boot en un contenedor: partiendo de una imagen de openjdk, copia el archivo target/*.jar en el contenedor con el nombre app.jar y lo ejecuta mediante el comando ENTRYPOINT para que no haya ninguna shell sobre el proceso java.

Construye el contenedor con docker build, y ten paciencia, tardará varios minutos!!!

docker build -t petclinic-docker .
docker build petclinic
Fig. 1. Docker build

Si estás trabajando en Windows, la construcción da un error por problemas de codificación de los saltos de línea diferentes entre Windows y Linux. Para resolverlo, sustituye en la primera fase del Dockerfile la linea de construcción que llama a package por estas dos:

# Then build the code - on Linux (1)
# RUN mvn -B -f ./pom.xml -s /usr/share/maven/ref/settings-docker.xml clean package

# Then build the code - On Windows (2)
RUN mvn -B -f ./pom.xml -s /usr/share/maven/ref/settings-docker.xml clean spring-javaformat:apply --no-transfer-progress
RUN mvn -B -f ./pom.xml -s /usr/share/maven/ref/settings-docker.xml package --no-transfer-progress
1 Ejecución de maven package para la construcción del proyecto cuando ejecutas Docker en Linux. La linea aparece comentada.
2 Ejecución de maven package para la construcción del proyecto cuando ejecutas Docker en Windows. Las dos lineas se ejecutan (no están comentadas).

Tras la construcción de la imagen, prueba la ejecución del contenedor en local:

docker run -it -p 8080:8080 -t petclinic-docker

Comprueba que se ha iniciado la aplicación en http://localhost:8080.

Para el contenedor con CTRL+C.

Una vez creada la imagen con docker build y probada su ejecución con docker run, el siguiente paso será publicar la imagen en un registro de contenedores, mediante docker push. Podemos usar DockerHub pero en este caso vamos a usar Google Cloud Container Registry.

Autenticación en Container Registry

Para poder hacer push debemos tener permisos de escritura, y por tanto debemos autenticarnos en el servicio Container Registry.

Authentication permite conectar al Registro de Contenedores con tus credenciales, y hacer push y pull de imágenes. Para ello debes configurar los los permisos necesarios para accdeder al registro, utilizando un JSON key file como método de autenticación:

  • En la Consola Google Cloud, seleccionar el proyecto Google Cloud.

  • En el menú de navegación seleccionar IAM y administración | Cuentas de servicio.

  • Seleccionar Crear cuenta de servicio.

  • Darle un nombre (p.e. container-registry)

  • Seleccionar "Crear y continuar".

  • En el paso Conceder a esta cuenta de servicio acceso al proyecto del asistente, seleccionar el rol Cloud Storage → Admisnitrador de almacenamiento. Continuar y Listo.

  • Editar la Cuenta de servicio. En la sección Claves seleccionar Agregar clave | Crear nueva clave.

  • Dejar JSON en el tipo de clave.

  • Seleccionar Crear. A continuación se descargará la clave privada.

cloud containers registry key create
Fig. 2. Creación Service Account Key for pull/push on Container Registry
  1. Guarda el archivo .json en la carpeta secret de tu proyecto PetClinic.

No olvides añadir la carpeta secret/ al archivo .gitignore para evitar publicar en GitHub tu archivo de credenciales.

  1. Use the service account key as your password to authenticate with Docker. Sustituye keyfile.json por el nombre de tu archivo de credenciales:

    1. En Linux:

cat keyfile.json | docker login -u _json_key --password-stdin https://gcr.io
  1. En Windows:

docker login -u _json_key --password-stdin https://gcr.io < keyfile.json
cloud containers registry login
Fig. 3. Autenticación de Docker contra Container Registry

Publicación y despliegue manual

  1. Construir el contenedor con el nombre completo incluyendo la referencia a Container registry (gcr.io). Primero definimos una variable de entorno con el nombre de nuestro proyecto GCP, y luego construimos de nuevo la imagen con el nombre completo del registro de contenedores:

GOOGLE_CLOUD_PROJECT=cnsa-2022-user123

docker build -t gcr.io/$GOOGLE_CLOUD_PROJECT/petclinic:1.0 .
  1. A continuación vamos a publicar con docker push: habilita la API de Container Registry en tu proyecto GCP, accediendo en el menú a Contaner Registry > Images:

container registry habilitar api
Fig. 4. Habilitar la API Container Registry
  1. Publica la imagen con docker push [HOSTNAME]/[PROJECT-ID]/[IMAGE]:[TAG]:

docker push gcr.io/$GOOGLE_CLOUD_PROJECT/petclinic:1.0
  1. Comprueba que se ha publicado correctamente.

container registry pushed petclinic
Fig. 5. Lista de imágenes en Container Registry

La imagen del contenedor PetClinic ya está disponible en el registro privado de nuestro proyecto GCP. Utilizando nuestras credenciales podremos hacer docker pull de dicha imagen para descargarla en cualquier máquina con docker, y ejecutarlo con docker run.

GOOGLE_CLOUD_PROJECT=cnsa-2022-user123

docker run -p 8080:8080 -t --name petclinic  gcr.io/$GOOGLE_CLOUD_PROJECT/petclinic:1.0

Si conectas a la instancia de despliegue que creamos al principio de esta actividad, y ejecutas el comando docker run anterior, dará un error de autenticación:

docker run petclinic webapp error authentication
Fig. 6. Error de autenticación en Container Registry

Para arreglarlo, habrá que copiar en la máquina de despliegue el archivo de credenciales .json con premisos sobre Container Registry. A continuación se muestran los comandos necesarios para ello. Una vez disponible este archivo en la instancia de despliegue ejecutar el comando docker login y tras ello ya si podremos hacer docker pull y docker run.

# Compiamos el archivo de credenciales
scp ./secret/file.json ubuntu@DNS_MAQUINA_DEPLOY:~/keyfile.json
# Conectamos a la máquina de despliegue
ssh ubuntu@DNS_MAQUINA_DEPLOY
# Autenticamos docker contra Container Registry
cat keyfile.json | docker login -u _json_key --password-stdin https://gcr.io
# Variable de entorno con el nombre del proyecto
GOOGLE_CLOUD_PROJECT=cnsa-2022-user123
# ejecutamos el contenedor desde gcr.io
docker run -d -p 8080:8080 -t --name petclinic gcr.io/$GOOGLE_CLOUD_PROJECT/petclinic:1.0

Si la ejecución de docker run te da error, prueba a ejecutarlo con sudo. Para evitar tener que escribir siempre sudo delante de cualquier comando docker, ejecuta: sudo usermod -aG docker $USER. Tras ello, reinicia la sesión. Prueba ahora sin sudo, a partir de ahora llama siempre a docker sin sudo. Más info aquí

Es posible que la ejecución del contenedor de un error, porque el puerto 8080 ya esté en uso:

Error starting userland proxy: listen tcp 0.0.0.0:8080: bind: address already in use.

Para solucionarlo, bien detén el proceso java que está corriendo con la aplicación PetClinic tal y como la desplegamos en la sección anterior (if pgrep java; then pkill java; fi), o bien utiliza otro puerto, por ejemplo, el 80, que debe estar disponible:

docker run -p 80:8080 -t --name petclinic gcr.io/$GOOGLE_CLOUD_PROJECT/petclinic:1.0

Pero ten en cuenta que si el contenedor ya se ha creado y no ha podido iniciarse porque el puerto 8080 estaba ocupado, si intentas volver a crearlo con docker run te dirá que el contenedor ya existe. Revisa si está ya creado y en ese caso inicialó.

ubuntu@web-deploy-vm-tf:~$ docker ps -a
CONTAINER ID   IMAGE                            COMMAND                CREATED              STATUS    PORTS     NAMES
6e174d959f3b   gcr.io/cnsa-2022/petclinic:1.0   "java -jar /app.jar"   About a minute ago   Created             petclinic

ubuntu@web-deploy-vm-tf:~$ docker start petclinic
petclinic

Ya puedes comprobar en tu navegador que la aplicación PetClinic se está ejecutando en el puerto 8080 de la máquina de despliegue.

Integración y despliegue continuo

Hasta ahora hemos realizado todos los pasos de construcción, prueba y despliegue manualmente. A continuación, vamos a automatizar en Jenkins todo el proceso, cuyas principales tareas son:

  • la construcción de la imagen del contenedor,

  • la publicación de la imagen en el registro, y

  • el despliegue del contenedor.

En Jenkins, son necesarios los siguientes plugins para trabajar con Docker y pipelines, y con Container Registry: Docker Pipeline, que ya está instalado, y tendrás que instalar Google Container Registry Auth.

Definimos un nuevo proyecto en Jenkins de tipo pipeline, con el nombre PetClinic-Docker-abc123 sustituyendo abc123 por nuestro nombre de usuario. Son necesarios 3 fases (stages) en el pipeline: build image, push image, y deploy container.

Construcción y despliegue del contenedor

Comenzamos por la construcción de la imagen:

pipeline {
  agent any
  environment {
    CONTAINER_REGISTRY = 'gcr.io'
    GOOGLE_CLOUD_PROJECT = 'cnsa-2022-abc123'
    CREDENTIALS_ID = 'cnsa-2022-gcr'
  }
  tools {
    maven "Default Maven"
  }
  stages {
    stage("Checkout code") {
      steps {
        // checkout scm
        git  branch:'main', url:'https://github.com/ualcnsa/spring-petclinic.git'
      }
    }
    stage('Compile, Test, Package') {
      steps {
        sh "mvn clean package -Dcheckstyle.skip"
      }
      post {
        success {
          junit '**/target/surefire-reports/TEST-*.xml'
          archiveArtifacts 'target/*.jar'
        }
      }
    }
    stage("Build image") {
      steps {
        script {
          dockerImage = docker.build(
            "${env.CONTAINER_REGISTRY}/${env.GOOGLE_CLOUD_PROJECT}/petclinic:${env.BUILD_ID}",
            "--rm -f Dockerfile ."
          )
        }
      }
    }
  }
}

Si consultas la salida por consola de la ejecución del pipeline, verás que se algunas tareas se repiten dos veces, como por ejemplo la ejecución de los tests. ¿Por qué crees que es debido? ¿Podría eliminarse alguna fase del pipeline?

Para probar que la imagen del contenedor se ha creado bien, añade esta fase que hace un despliegue en un entorno de "Staging" o "Testing", que en este tutorial va a ser "local" en la propia máquina de Jenkins, es decir, ejecuta un contenedor basado en la imagen que acabamos de crear:

    stage("Deploy to Testing (locally)") {
      steps {
        sh "docker stop petclinic || true && docker rm  petclinic || true" (1)
        sh "docker run -d -p 8080:8080 -t --name petclinic ${env.CONTAINER_REGISTRY}/${env.GOOGLE_CLOUD_PROJECT}/petclinic:${env.BUILD_ID}" (2)
      }
    }
1 Por si ya se ha ejecutado el pipeline anteriormente, y no se ha eliminado el contenedor de la ejecución anterior, es necesario comprobar si el contenedor petclinic ya se está ejecutando y, en tal caso, pararlo con docker stop y eliminarlo con docker rm
2 Con docker run ejecuta el contenedor petclinic a partir de la imagen recién construida. Para que el pipeline pueda finalizar y el contenedor siga ejecutándose, se añade -d que indica modo detached que ejecuta el contenedor en background.

La aplicación debe estar accesible en el puerto 8080 en tu máquina de Jenkins. Para asegurarnos que la aplicación se está ejecutando bien, debemos problarlo "manualmente". Para automatizar esta prueba, lo adecuado sería realizar unos tests end-to-end, con Selenium. Esto se explicará en otra actividad, dedicada al testing.

    stage('End-to-end Test image') {
        // Ideally, we would run some end-to-end tests against our running container.
        steps{
            sh 'echo "End-to-end Tests passed"'
        }
    }

Publicación en el registro

El siguiente paso es publicar la imagen en el registro.

  1. Primero, es necesario crear unas credenciales en Jenkins para poder hacer push en Container Registry:

    1. Go to jenkins home, Manage Jenkins, click on “Manage credentials” and “(global)”

    2. Click on “Add Credentials” in left menu.

    3. Select Google Service Account from private key for the “Kind” field, and enter your project. Then upload the JSON private key.

jenkins credentials container registry
Fig. 7. Credenciales en Jenkins para Container Registry
  1. Una vez guardadas las credenciales, vamos a definir la fase para publicar la imagen del contenedor:

  stage("Push image") {
    steps {
      script {
        docker.withRegistry('https://'+ CONTAINER_REGISTRY, 'gcr:'+ GOOGLE_CLOUD_PROJECT) {
          dockerImage.push("latest")
          dockerImage.push("${env.BUILD_ID}")
        }
      }
    }
  }

Comprobar que se ha publicado correctamente en el registro.

jenkins published container registry
Fig. 8. Imagen publicada en Container Registry, etiquetada con el número de build

Despliegue en producción

Por último, quedaría el paso de desplegar al entorno de producción. Una vez empaquetada como un contenedor, Google Cloud permite desplegar de varias formas:

  • en máquina virtual con GCE,

  • en plataforma como servicio con Google App Engine,

  • en Kubernetes con GKE,

  • y en Cloud Run, un servicio de Google Cloud específico para el despliegue de contenedores.

Para nosotros, la máquina virtual de despliegue es nuestro entorno de producción en el que vamos a desplegar el contenedor.

Los pasos para el despliegue de la nueva imagen del contenedor consistirán en ejecutar los siguientes comandos sobre la máquina de despliegue:

  • docker stop del contenedor por si estuviera ejecutándose

  • docker rm para eliminar el contenedor existente, que puede estar basado en una imagen de una versión anterior

  • docker run para ejecutar el contenedor, que automáticamente hará un docker pull de la imagen actualizada del registro. Lo lanzaremos en el puerto 80 ya que el 8080 está ocupado por el despliegue que hicimos sin contenedor.

Estas acciones debemos añadirlas a un stage del pipeline de Jenkins que se encargará de desplegar el nuevo contenedor automáticamente. En el siguiente código, sustituye DNS_DEPLOY_INSTANCE por el nombre DNS de tu instancia de despliegue. También puedes definirla como una variable de entorno al inicio del pipeline.

    stage('Deploy to Production') {
      steps{
        // Check to manual approving deploy to production.
        // It implemenents Continuous Delivery instead of Continuous Deployment
        input message: "Proceed Deploy to Production?" (1)
        sh '''
          ssh -i ~/.ssh/id_rsa_deploy ubuntu@DNS_DEPLOY_INSTANCE "if docker ps -q --filter name=petclinic | grep . ; then docker stop petclinic ; fi" (2)
          ssh -i ~/.ssh/id_rsa_deploy ubuntu@DNS_DEPLOY_INSTANCE "if docker ps -a -q --filter name=petclinic | grep . ; then docker rm -fv petclinic ; fi" (3)
          ssh -i ~/.ssh/id_rsa_deploy ubuntu@DNS_DEPLOY_INSTANCE "docker run -d -p 80:8080 -t --name petclinic ${CONTAINER_REGISTRY}/${GOOGLE_CLOUD_PROJECT}/petclinic:latest" (4)
        '''
      }
    }
1 Pide confirmación al usuario, que tendrán que pulsar un botón de Proceed para continuar la ejecución del pipeline. Permite asegurar que el despliegue a producción requiere intervención de una persona, implementando entrega continua (continuous delivery) en lugar de despliegue continuo (continuous deployment).
2 Ejecuta en la instancia de despliegue el comando docker stop que detiene el contenedor petclinic en caso de que ya se estuviera ejecutando de un despliegue anterior. Esto se comprueba con docker ps …​.
3 Ejecuta en la instancia de despliegue el comando docker rm que elimina el contenedor petclinic en caso de que exista de un despliegue anterior. Esto se comprueba con docker ps -a …​. Estos dos pasos, primero parar el contenedor y luego eliminar el contenedor, son necesarios antes de volver a lanzar un nuevo contenedor con el mismo nombre. Se ejecuta en dos pasos para evitar errores en caso de que el contenedor exista pero no esté en ejecución, lo que podría dar lugar a un error en el despliegue.
4 Ejecuta en la instancia de despliegue el comando para ejecutar el contenedor basado en la última versión de la imagen, lanzándolo con -d que indica modo detached que ejecuta el contenedor en background, para que el pipeline finalice y el contenedor permanezca en ejecución.
jenkins proceed to deploy production
Fig. 9. Proceed deploy to production?

Algunos comandos útiles de Docker:

# Remove all stopped containers
docker rm $(docker ps -a -q)
# Remove all images
docker rmi $(docker images -q)

Usalos si te aparece algun mensaje de error del tipo no space left on device, ya que la máquina Jenkins están construyendo muchas imágenes y se queda sin espacio de disco.

La aplicación PetClinic debe estar accesible en producción, en el puerto 8080 en la instancia de despliegue. Para asegurarnos, debemos problarlo "manualmente". Para automatizar esta prueba en producción, lo adecuado de nuevo sería realizar unos tests end-to-end, con Selenium. Esto se explicará en otra actividad, dedicada al testing.

    stage('End-to-end Test on Production') {
        // Ideally, we would run some end-to-end tests against our running container.
        steps{
            sh 'echo "End-to-end Tests passed on Production"'
        }
    }

Por último, es una buena práctica eliminar las imágenes que se van generando en cada build, para liberar espacio en la máquina de Jenkins. Primero paramos y eliminamos el contenedor que desplegamos anteriormente en la fase del pipeline Deploy to Testing (locally); luego eliminamos la imagen.

    stage('Remove Unused docker image') {
      steps{
        // input message:"Proceed with removing image locally?" (1)
        sh 'if docker ps -q --filter name=petclinic | grep . ; then docker stop petclinic && docker rm -fv petclinic; fi' (2)
        sh 'docker rmi ${CONTAINER_REGISTRY}/${GOOGLE_CLOUD_PROJECT}/petclinic:$BUILD_NUMBER' (3)
      }
    }
1 Pide confirmación al usuario, que tendrán que pulsar un botón de Proceed para continuar la ejecución del pipeline
2 Para y elimina el contenedor local
3 Elimina la imagen de contenedor en local con docker rmi para liberar espacio.
jenkins petclinic full pipeline proceed
Fig. 10. Input message (paso comentado en el ejemplo)

El pipeline completo, con todas sus fases, debe quedar así:

jenkins petclinic full pipeline
Fig. 11. Pipeline completo

ENHORABUENA!!! Has conseguido definir un pipeline completo de integración y despliegue continuos, y con contenedores. Este proceso se puede aplicar, con pequeñas adaptaciones, a cualquier otro proyecto Java basados en Maven.

Si usas otras tecnologías, como NodeJs, hay que adaptar cada una de las fases a su equivalente en en la tecnología concreta. Vamos a ver como hacerlo con NodeJs en la siguiente sección.