Octava entrada en la creación de nuestro laboratorio CI. Recuerdo los pasos:
- Instalación de Ubuntu en VMWare Player en Windows 7
- Instalación de Jenkins en Ubuntu
- Instalación GitLab en Ubuntu
- Instalación de Docker y Minikube en Ubuntu
- Desarrollo de un microservicio en Eclipse Windows (1) y (2)
- Angular 6 frontend
- SpringBoot 2.x backend
- Mongo Replicaset (3 replicas)
- Despliegue CI en Minikube utilizando la infraestructura configurada
- Pipeline - Jenkinsfile
- GitLab webhook
- Dockerfile
- Deploy y Service yaml
- Secret
- Ingress
Nos ocupamos en esta entrada de lo que hemos convenido en llamar entorno "dev", aquel entorno local con kubernetes.
Aquí añadimos configuración específica para desplegar nuestra aplicación en minikube. Además, detallaremos la configuración que nos permitirá realizar y completar nuestro laboratorio de Integración continua (Webhook GitLab y Pipeline Jenkins).
Comenzamos primero con el detalle de nuevos "yaml" que han de acompañar a nuestra aplicación. En la segunda parte de esta entrada especificaremos la configuración de CI.
Pero antes de nada, hemos de realizar un par de configuraciones en minikube
Minikube
Antes de nada hemos de arrancar Minikube. Lo más conveniente es arrancarlo con el usuario con el que se lancen más adelante los despliegues.
En nuestro caso vamos a usar Jenkins como herramienta de despliegues. En el proceso de instalación de este producto se crea el usuario jenkins. Este es el usuario que debemos utilizar para el arranque de Minikube.
Para poder usar dicho usuario le hemos de asignar un password (por defecto no lo tiene):
sudo passwd jenkins
cuando nos pregunte el password le ponemos el que deseemos
Lo incluimos además en los grupos "sudoers" (para poder ejecutar comandos con "sudo") y "docker"
sudo usermod -a -G sudoers,docker jenkins
Ahora ya podemos acceder
su jenkins
proporcionamos el password configurado más arriba.
Registro privado
Para poder trabajar con imágenes en un entorno CI es conveniente crearnos un registro privado. Lo utilizaremos como repositorio de imágenes de forma que por cada aplicación nueva dispondremos de un lugar local y centralizado donde depositarlas y desde el cual referenciarlas.
Minikube lleva un addon de registro, pero no lo vamos a utilizar. En alguna otra entrada experimentaremos con él. De momento utilizaremos una imagen pública que hace las veces de registro. Vamos a ello.
En línea de comandos accedemos con usuario jenkins tal como se indicó más arriba. Arrancamos minikube
minikube start
Una vez arrancado cargamos las variables de minikube
eval $(minikube docker-env)
Descargamos y arrancamos la imagen de registro:
docker run -d -p 5000:5000 --restart=always --name registry-srv -v /data/docker-registry:/var/lib/registry registry:2
Esta imagen nos proporciona una api para acceder a información de imágenes, tags, etc.... La url de acceso será "localhost:5000". Esa será la url que usaremos para subir o bajar imágenes de nuestro registro privado. Antes de probar la api habremos de añadir la entrada siguiente al "/etc/hosts" de ubuntu
192.168.99.100 localhost
la ip es la de minikube (se puede consultar con "minikube ip").
Ahora podemos consultar el catálogo de imágenes:
curl -X GET http://localhost:5000/v2/_catalog
al no haber subido ninguna imagen nos devolverá
{"repositories":[]}
Cuando subamos alguna imagen podremos ver también los tags (versiones) de la misma:
curl http://localhost:5000/v2/NOMBRE_IMAGEN/tags/list
Una vez tenemos el registro privado operativo hemos de parar minikube para arrancarlo en forma "insecure". Nos evitaremos de esta forma el acceso con claves, certificados, etc...
Paramos con "minikube stop". Y ahora volvemos a arrancar
minikube start --insecure-registry localhost:5000 --memory 4096 (la reserva de memoria es opcional)
Siempre habremos de arrancar minikube con el flag "--insecure-registry localhost:5000".
Minikube addons
Al arrancar por primera vez minikube con usuario jenkins deberemos activar uno de los addons necesarios para proveer de una url específica a nuestra aplicación u otras que queramos desplegar en este entorno.
Antes revisamos los addons activos
minikube addons list
Podemos habilitar el addon heapster (ver entrada) aunque para esta entrada no es imprescindible.
El addon que si necesitaremos es "ingress", el cual por defecto no está activo, por lo que hemos de activarlo
minikube addons enable ingress
Este addon nos proporcionará un balanceador a partir de un nombre dns local. Más adelante veremos cómo usarlo.
secret
En este laboratorio, a modo de ejemplo "avanzado", vamos a proveer de acceso vía HTTPS a nuestra aplicación. Para ello hemos de crear un secret.
Primero creamos los certificados correspondientes (clave pública y privada). Al dominio local lo vamos a llamar "gincol.blog.com" (podéis ponerle el que mejor os vaya).
Creamos un directorio de trabajo (cloud/secrets) en la home del usuario jenkins "/var/lib/jenkins/cloud/secrets" y nos posicionamos en él.
Creamos las claves pública y privada con openssl para el dominio comentado (gincol.blog.com)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout gincol.blog.com.key -out gincol.blog.com.cert -subj "/CN=gincol.blog.com/O=gincol.blog.com"
Desde el mismo directorio de trabajo, creamos ahora el secret a partir de los certificados creados
kubectl create secret tls gincol.blog.com-secret --key gincol.blog.com.key --cert gincol.blog.com.cert
Validamos
kubectl get secrets
Una vez tenemos el nombre de nuestro dominio privado, creados los certificados y secret, hemos de dar de alta este dominio en nuestro "dns privado", es decir, en el "/etc/host" de ubuntu. La ip asociada será la de minikube.
Revisamos dicha ip
minikube ip
habitualmente se asigna la "192.168.99.100"
añadimos pues la siguiente entrada en dicho "/etc/hosts"
192.168.99.100 gincol.blog.com
Configuración yaml
Este es el detalle le los múltiples ficheros yaml que deberemos añadir a nuestra aplicación. Todos ellos los incluiremos en la carpeta cloud del proyecto ci-root.
ingress
Como ya comentamos, este addon nos proporciona un balanceador asociado a un dominio y para un servicio dado. En nuestro caso, para la aplicación de ejemplo, esta es su configuración (Ingress.yaml):
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: ci
annotations:
kubernetes.io/ingress.class: nginx
spec:
tls:
- hosts:
- gincol.blog.com
secretName: gincol.blog.com-secret
rules:
- host: gincol.blog.com
http:
paths:
- path: /ci
backend:
serviceName: ci-app
servicePort: 8080
Como se puede ver se referencia al secret creado en el anterior apartado (gincol.blog.com-secret).
Se proporciona un path "/ci", además de un puerto, el "8080". El nombre del servicio será "ci-app". Este nombre lo veremos más adelante en la configuración del service.
El nombre del dominio es, como ya hemos mencionado antes, "gincol.blog.com".
volume
Pra proveer de persistenca a la bdd mongo hemos de crear un volumen. Para ello serán necesarios dos ficheros de configuración, el "PersistentVolume.yaml" y el "PersistentVolumeClaim.yaml".
El primero es el que define un volumen "general". El segundo es el "reclama" una parte de espacio al anterior. Este último será el que referenciemos en el deployment de mongo.
La configuración del "PersistentVolume.yaml" es la siguiente:
kind: PersistentVolume
apiVersion: v1
metadata:
name: pv-blog-1
labels:
type: local
spec:
storageClassName: manual
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /data/pv-blog-1/
Reservamos 10 Gb de espacio local. El path asignado será "/data/pv-blog-1". Este path lo podremos encontrar dentro de minikube.
La configuración del "PersistentVolumeClaim.yaml" es esta:
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: blog-claim
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
Kubernetes comprobará el atributo "storageClassName", y buscará un "Persistent Volume" del mismo tipo. Una vez encontrado asignará la memoria pedida en el "PersistentVolumeClaim.yaml", 1 Gb en nuestro caso, del total de memoria configurada, 10 Gb.
El nombre que habremos de referenciar en el deployment de mongo será "blog-claim" para hacer uso de este espacio.
configmap
Un ConfigMap es utilizado para contener pares de tipo "clave: valor" que habitualmente serán referenciados desde los ficheros yaml de deployment de las aplicaciones.
Este es su contenido:
apiVersion: v1
kind: ConfigMap
metadata:
name: ci-config
namespace: default
data:
spring.profiles.active: dev
En el apartado de "data" es donde se pueden poner tantos pares "clave: valor" como se quieran. Nosotros sólo necesitamos indicar el profile activo para este entorno, "dev". La variable podría haber sido cualquiera, hemos puesto "spring.profiles.active" pero podría haber sido "mi.profile", por ejemplo.
service y deployment Aplicación
El deployment y service son los descriptores principales de nuestra aplicación. En el caso de la aplicación web tendrá este contenido (APP.yaml):
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: ci-app
labels:
name: ci-app
spec:
replicas: 2
template:
metadata:
labels:
app: ci-app
tier: backend
spec:
containers:
- name: ci-app
image: localhost:5000/ci-app
env:
- name: SPRING_PROFILES_ACTIVE
valueFrom:
configMapKeyRef:
name: ci-config
key: spring.profiles.active
ports:
- containerPort: 8080
readinessProbe:
tcpSocket:
port: 8080
periodSeconds: 30
initialDelaySeconds: 120
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /ci/liveness
port: 8080
periodSeconds: 30
initialDelaySeconds: 120
timeoutSeconds: 5
---
kind: Service
apiVersion: v1
metadata:
name: ci-app
labels:
name: ci-app
tier: backend
spec:
type: NodePort
ports:
- port: 8080
protocol: TCP
selector:
app: ci-app
tier: backend
Se han configurado dos "Probe", uno de arranque, el "readinessProbe" y otro de actividad, el "livenessProbe". Hasta que el readiness no responda OK Kubernetes no enviará peticiones al servicio.
La variable del configmap es recogida a partir de la configuración "env". Ahí se mapea la variable que nuestra aplicación necesita "SPRING_PROFILES_ACTIVE" con el valor de la variable del configMap "spring.profiles.active", la cual como ya hemos visto vale "dev".
La imagen es recogida desde el registro, a partir de "localhost:5000". Se añade además el nombre de la imagen, que la encontraremos definida en el Jenkinsfile que veremos más abajo.
El servicio expone el puerto 8080 del contenedor. El name "ci-app" es el que se asocia al "serviceName" del ingress anteriormente detallado.
Se han configurado dos replicas, por lo tanto el balanceador enviará peticiones tanto a una como a otra. Habitualmente se seguirá un modelo "round robin", una petición a un pod, la siguiente al otro pod, etc...
service y deployment Mongo
En cuanto mongo, se ha configurado mediante el service y en lugar de deployment se ha usado un StatefulSet.
Esto es así ya que como veremos cuando detallemos el Jenkinsfile, necesitamos un "nombre" constante. El deployment kubernetes genera un nombre con una parte fija y otra variable. Un StatefulSet genera un nombre constante, fácilmente referenciable.
Esta es su configuración:
apiVersion: v1
kind: Service
metadata:
name: ci-db
labels:
name: mongo
spec:
ports:
- port: 27017
targetPort: 27017
clusterIP: None
selector:
role: mongo
---
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
name: ci-ss
spec:
serviceName: ci-db
replicas: 3
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
role: mongo
environment: dev
replicaset: CiRepSet
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: replicaset
operator: In
values:
- CiRepSet
topologyKey: kubernetes.io/hostname
terminationGracePeriodSeconds: 10
containers:
- name: ci-db
image: localhost:5000/ci-db
command:
- "numactl"
- "--interleave=all"
- "mongod"
- "--wiredTigerCacheSizeGB"
- "0.1"
- "--bind_ip"
- "0.0.0.0"
- "--replSet"
- "CiRepSet"
resources:
requests:
cpu: 0.2
memory: 200Mi
ports:
- containerPort: 27017
volumeMounts:
- name: blog-claim
mountPath: /data/db
volumeClaimTemplates:
- metadata:
name: blog-claim
annotations:
volume.beta.kubernetes.io/storage-class: "standard"
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
Como veis, contiene mucha información. Detallamos sólo una parte.
La asociación con la persistencia se hace mediante el nombre del "claim" configurado anteriormente, a saber, "blog-claim"
Se nombra el replicaset "ci-ss", que deberemos referenciar en el Jenkinsfile. El nombre "CiRepSet" también será referenciado desde la url de conexión de la aplicación hacia mongo.
Se indican 3 réplicas, por lo que tendremos un mongo con un nodo "PRIMARY" y dos nodos "SECONDARY". Cualquier modificación que hagamos sobre uno se hará sobre otro. Si se parase uno, por ejemplo el PRIMARY, Kubernetes arrancará otro nodo, y uno de los SECONDARY pasará a ser PRYMARY, etc...
Otros ficheros
Dockerfile
El Dockerfile lo encontramos en la carpeta docker de la raíz del proyecto ci-root. Tiene este contenido:
FROM java:openjdk-8-jdk-alpine
# add el jar con el nombre del "finalName" del pom.xml
ADD ci-backend/target/ci-backend.jar /app.jar
# se modifica la fecha a la actual
RUN sh -c 'touch /app.jar'
# comando a ejecutar
CMD ["java", "-jar", "/app.jar"]
El nombre del fichero es el mismo que ya vimos en la la entrada 3, es decir, Dockerfile-app.
application-dev.yml
El fichero de configuración de la aplicación merece comentarlo, aunque sólo sea lo más importante
spring:
port: 8080
servlet:
context-path: /ci
...
data:
mongodb:
host: ci-db
port: 27017
database: ci
uri: mongodb://ci-db:27017/ci?replicaSet=CiRepSet
autoconfigure:
exclude: org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration
Se le añade un contexto a la aplicación (/ci) que veremos en la url de acceso.
El texto en rojo contiene el nombre del servicio de mongo declarado en el fichero "DB.yaml"
El texto en azul contiene el nombre de la base de datos.
El texto en lila corresponde al nombre del replicaset declarado en el fichero "DB.yaml", y también referenciado en el Jenkinsfile.
Se excluye el uso de mongo embedded con "exclude".
Configuración CI
Pasamos a la configuración que nos permitirá disponer de un entorno CI completo. Para ello abrimos Firefox de Ubuntu y realizamos las configuraciones siguientes:
GitLab
Accedemos al GitLab del Ubuntu, en mi caso a partir de la url "http://gitlab.blog.com:81". Usamos el grupo "blog" ya creado en la tercera entrada.
Creamos el proyecto "ci-root" en su interior
La url del proyecto dentro de GitLab es "http://gitlab.blog.com:81/userblog/ci-root.git". Nos la apuntamos.
Ya tenemos el proyecto listo para recibir código. No obstante, se deberá hacer una configuración adicional para enlazarlo con Jenkins. En el apartado "Enlace Jenkins-GitLab" lo veremos.
Jenkins
Accedemos a Jenkins (en mi caso "http://localhost:8787") y creamos un job como copia del que ya creamos en la tercera entrada.
Hacemos "New Item", entramos un nombre ("ci-root" en nuestro caso) y en "Copy from" ponemos "HolaMundo-CI" (el que ya creamos en la tercera entrada comentada).
Pulsamos finalmente sobre "OK".
Enlace Jenkins-GitLab
El enlace se hace en las dos direcciones. Le hemos de decir a GitLab (vía webhook) que cuando se haga un push del proyecto, informe a Jenkins de que tiene nuevo código disponible para su despliegue.
Hemos de configurar en Jenkins la ruta del repositorio GitLab de donde extraer el código cuando reciba el aviso de GitLab.
Configuramos primero el pipeline Jenkins creado anteriormente ("ci-root").
Primero generamos un nuevo tocken, diferente del que había configurado al crear el job como copia del ejemplo del que partimos. Nos quedamos el nuevo token (en nuestro caso nos ha generado el "151f0d9e05f6dee509c77fe18eb6d508").
En Pilepline Definition, apartado "Repository URL" ponemos la que apuntamos más arriba "http://gitlab.blog.com:81/userblog/ci-root.git". El resto del job lo dejamos tal cual quedó tras su creación.
Anotamos la url del job, la encontramos en el apartado "Build Triggers", en nuestro caso es "http://192.168.153.201:8787/project/ci-root".
En GitLab creamos el webhook, "Settings / Integrations".
Pulsamos el botón "Add webhook". Informamos la url del pipeline de Jenkins y el Token del mismo pipeline.
Ya tenemos el enlace listo. Ahora cada vez que hagamos un push de código, GitLab informará a Jenkins, y este vendrá a recoger el código, lanzanado el contenido del fichero Jenkinsfile, que es el orquestador final del despliegue de la app en Kubernetes.
Veamos dicho Jenkinsfile
Jenkinsfile
Este fichero lo ubicamos en la raíz del proyecto "ci-root". Será leído y ejecutado por el job jenkins descrito más arriba. Su contenido es excesivamente largo para copiarlo aquí, por lo que lo podéis ver al descargar el proyecto del gitlab público donde hemos dejado todo el código.
Como resumen:
Al comienzo, en tools, se referencian las herramientas a usar (maven - mvn53, y java - java8). Estas variables se dieron de alta en Jenkins (ver segunda entrada).
En el stage "Inicializacion" se logan los path de dichas tools como validación de su existencia. También se cargan las variables de minikube para poder desplegar los diferentes elementos de forma correcta.
En el segundo stage, "Construccion" se lanza maven para la construcción del artefacto.
En el stage "Push de las ....", se crean las imágenes y se suben al registro privado
En el stage "Deploy Minikube" se despliegan los diferentes elementos en Minikube. Especial atención merece el apartado de creación del replica set mongo. El comando para tres réplicas es un tanto aparatoso:
sh "kubectl exec ci-ss-0 -c ci-db-container -- mongo --eval 'rs.initiate({_id: \"CiRepSet\", version: 1, members: [ {_id: 0, host: \"ci-ss-0.ci-db.default.svc.cluster.local:27017\"}, {_id: 1, host: \"ci-ss-1.ci-db.default.svc.cluster.local:27017\"}, {_id: 2, host: \"ci-ss-2.ci-db.default.svc.cluster.local:27017\"} ]});'"
Como se puede ver, aquí es donde se aprecia la importancia de usar un StatefulSet en lugar de un Deployment. Los nombres que Kubernetes genera al desplegar Mongo será "ci-ss-0", "ci-ss-1" y "ci-ss-2". Este nombre viene dado por el nombre definido en su respectivo yaml (visto más arriba).
En el apartado "Borrado de imagenes" se hace limpieza de las imágenes innecesarias.
En el último apartado se loga el resultado final (OK, KO, etc...) de la ejecución del job.
Prueba
Bueno, después de tantas entradas y de tantas configuraciones ya estamos en disposición de probar nuestro entorno CI.
Arrancamos nuestra consola git bash de windows y nos posicionamos en la raíz del proyecto ci-root. Debería tener esta composición:
Creamos el repositorio git local.
git init
git remote add origin http://USER:PASSWORD@PATH_REPO_GIT
en nuestro caso, este segundo comando queda así
git remote add origin http://userblog:userblog@gitlab.blog.com:81/userblog/ci-root.git
Añadimos fichero, hacemos commit de los cambio y finalmente push
git add .
git commit -m "subida inicial"
git push origin master
Nos debe aparecer una salida como la siguiente:
Si accedemos a GitLab veremos el proyecto subido:
Si accedemos a Jenkins veremos el lanzamiento del pipeline:
En los logs del job se puede ver como se han descargado las imágenes base de mongo (mongo:latest) y java (java:openjdk-8-jdk-alpine)
También podemos revisar las imágenes subidas al registro privado
curl -X GET http://localhost:5000/v2/_catalog
que nos responde ahora:
{"repositories":["ci-app","ci-db"]}
y los tags de cualquiera de ellas
curl http://localhost:5000/v2/ci-app/tags/list
{"name":"ci-app","tags":["latest"]}
Podemos revisar los services, deploys, pods, etc..., generados
kubectl get svc
kubectl get deploy
kubectl get pods
Si alguno lo vemos en un estado de error, si no nos deja ver los logs ya que el pod realmente no ha llegado a crearse, podemos ver su "descripción" con
kubectl describe pod POD_ID
Al final de la salida podremos ver la causa del error.
Si queremos ver los logs de uno o ambos de los pods de la aplicación podemos hacer
kubectl logs -f POD_ID (con -f queda la consola attachada a la salida de log)
Si queremos ver los logs de ambos pods o bien abrimos dos shell, una por cada pod, o bien nos descargamos el script "kubetail" de la url "https://github.com/johanhaleby/kubetail", creamos el script por ejemplo en el fichero "/cloud/utils/kubetail" de la home de usuario jenkins. Accedemos a dicho path y hacemos
./kubetail ci-app
No mostrará algo como lo siguiente:
Como vemos, los logs de cada pod aparecerán en un color diferente y veremos que al acceder a la aplicación las peticiones son respondidas indistintamente por uno u otro.
Podemos ver el estado del replicaset de mongo desde línea de comandos
kubectl exec ci-db-ss-0 -c ci-db-container -- mongo --eval 'rs.status()'
Nos devolverá un json con el estado de las tres réplicas. Una de ellas nos la marcará como "PRIMARY", y las otras dos como "SECONDARY".
Podemos hacer un delete del pod marcado como PRIMARY. Veremos que automáticamente minikube levanta otro pod en su lugar (siempre ha de haber tres réplicas levantadas) y cualquiera de los que han quedado levantados pasarán a ser PRIMARY, etc...
Ahora accedemos a la aplicación con la url "https://gincol.blog.com/ci":
Podemos jugar un poco con la creación, edición, etc...
También podemos acceder a swagger a partir de "https://gincol.blo.com/ci/swagger-ui.html".
Tras el acceso y uso de la aplicación vemos, como decíamos más arriba accesos a ambos pods
Final
Bueno, el camino ha sido duro y largo, pero creo que ha merecido la pena. Si alguien trabaja en entornos cloud, es necesario disponer de un entorno local de prueba, que no será exactamente igual que el real, pero se le aproximará bastante.
Espero que os haya sido de ayuda. El código completo está en nuestro repo público del GitLab.
Anterior