miércoles, 5 de septiembre de 2018

Laboratorio - Integración Contínua (VIII)

Octava entrada en la creación de nuestro laboratorio CI. Recuerdo los pasos:
  1. Instalación de Ubuntu en VMWare Player en Windows 7
  2. Instalación de Jenkins en Ubuntu
  3. Instalación GitLab en Ubuntu
  4. Instalación de Docker Minikube en Ubuntu
  5. Desarrollo de un microservicio en Eclipse Windows (1) y (2)
    1. Angular 6 frontend
    2. SpringBoot 2.x backend
    3. Mongo Replicaset (3 replicas)
  6. Despliegue CI en Minikube utilizando la infraestructura configurada
    1. Pipeline - Jenkinsfile
    2. GitLab webhook
    3. Dockerfile
    4. Deploy y Service yaml
    5. Secret
    6. 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


No hay comentarios:

Publicar un comentario