viernes, 24 de abril de 2020

Montar tu propio cluster de kubernetes con minikube

Introducción a docker y Kubernetes

Kubernetes y Docker son dos tecnologías que van unidas de la mano. No podemos hablar de una sin hablar de la otra. Empecemos por el principio, empecemos por Docker.

¿Que es Docker? Docker es una herramienta que permite portar aplicaciones junto con todas sus dependencias y conseguir que funcionen en cualquier sistema que tenga Docker instalado, con total independencia del sistema operativo que esté corriendo esa máquina por debajo. Esto es muy interesante para distintas tareas. Permite a los sysadmin hacer despliegues de sistemas con configuraciones complejas en muy poco tiempo. O a los developers centrar sus esfuerzos en desarrollar las aplicaciones sin preocuparse por instalar servicios ni configurarlos.

¿Que es una imagen de docker? Una imagen de docker es un sistema de ficheros minimo que contiene todo lo necesario para arrancar una aplicación. Es decir, contiene un sistema operativo, una aplicación y todas sus dependencias resueltas (librerias, herramientas y ficheros de configuración). Las imágenes de docker estan formadas por varias capas de solo lectura (RO). La capa del sistema operativo (FROM) es la de mas bajo nivel. Sobre la capa FROM se instalan paquetes y librerías en una o varias capas de RUN. Y sobre las capas de RUN se monta otra capa de comando (CMD) con el comando que debe ejedcutarse para arrancar la aplicación. Estas imágenes de docker se pueden descargar desde un repositorio público (DockerHub). En el DockerHub se pueden encontrar imágenes prefabricadas de apache, nginx, postgresql, mysql, oracle, mongodb, ubuntu, centos, php, golang, python, java, jenkins, mediawiki, prestashop, yocto, openvpn y un largo sinfín de servicios. También puedes hacer tu propia imagen a mano usando Dockerfiles y posteriormente publicarla en DockerHub. O incluso hacerte tu propio DockerHub privado. Hay mucha información en Internet sobre Dockerfiles, no voy a profundizar mas en este documento.

¿Que diferencia hay entre una distro de Linux y una imagen de docker? A diferencia de una distro de Linux, una imagen de docker contiene solo lo esencial para ejecutar una aplicación dada. Las imágenes no tienen estados, son de solo lectura, lo cual significa que no se puede cambiar su contenido. Se usan como input para crear un contenedor de docker.

¿Que es un contenedor de docker? Un contenedor es una instancia de una imagen de docker. El contenedor es una capa adicional de lectura/escritura (RW) situada sobre las n capas RO de la imagen. Es la capa donde se ejecuta todo (se crean log, se instalan o borran ficheros, se generan resultados, etc). Los cambios en el contenedor son temporales, se pierden tan pronto muere el contenedor. Podemos hacer un simil con los procesos del sistema operativo y los binarios ejecutables: un proceso es una instancia de un binario ejecutable. El proceso es equivalente al contenedor, de la misma manera que la imagen de docker es equivalente al binario ejecutable.

Pongamos un ejemplo para aclarar las cosas. Tenemos una imagen de docker llamada nginx. Al correr con docker un contenedor de esta imagen, arranca una distro minimalista de Linux sobre la que se levanta un servicio de nginx que expone el puerto 80 para escuchar conexiones. Podemos entrar al contenedor, editar el fichero de configuración de nginx y cambiar el puerto expuesto al puerto 8080. Cuando muera este contenedor y levantemos un segundo contenedor con esa misma imagen de nginx, el nuevo contenedor volverá a levantar nginx escuchando en el puerto 80. Ninguno de los cambios aplicados en el primer contenedor estará presente en el segundo.

¿Que diferencia existe entre docker y una maquina virtual (vmware o virtualbox)? Bien, por encima del hardware tenemos siempre corriendo a nuestro querido sistema operativo Linux. Cuando usamos maquinas virtuales (virtualbox o vmware), por encima de Linux corre una capa de software llamada monitor de máquinas virtuales (VMM). Y sobre el VMM corren los distintos sistemas operativos virtualizados. Cada uno de ellos necesita una instalación completa del sistema operativo, reservando una cantidad ingente de recursos, tanto de disco duro como de memoria RAM. En cambio, cuando usamos docker, por encima de Linux corre el Docker Engine, un proceso mas del sistema operativo sobre el cual se pueden ejecutar directamente los contenedores. Lo cual hace que los recursos consumidos (tanto de disco duro como de memoria RAM) sean muy inferiores. Y por tanto docker aproveche mucho mejor los recursos que una máquina virtual. Al ser mucho mas libiano que una maquina virtual podemos correr muchos mas contenedores que máquinas virtuales, mejorando la utilización de los recursos hardware.

"Everything in Google runs in a container". Fueron las palabras citadas allá por Mayo de 2014 por un tal Joe Beda, por aquel entonces ingeniero de software senior en Google, en una conferencia donde explicó que Google corría 2 billones de contenedores cada semana. Imagino que 6 años mas tarde esa cantidad se habra incrementado significativamente. Imaginaros el trabajo DESCOMUNAL que puede suponer administrar todos esos contenedores. ¿Como garantizo que todos esos contenedores estan funcionando? ¿Como garantizo que ninguno esta colgado consumiendo recursos innecesarios? ¿Como reinicio los contenedores que han muerto? Es imposible hacer todo ese trabajo a mano.

Para dar respuesta a todas estas cuestiones, Google diseñó Kubernetes, un sistema de código libre para realizar el despliegue y el manejo de todos esos contenedores de forma automática. Kubernetes actúa como supervisor de todos los contenedores en ejecución. Si un contenedor cae, Kubernetes elimina sus recursos y levanta uno nuevo. Si un contenedor tiene fugas de memoria y empieza a consumir muchos recursos de memoria RAM, Kubernetes lo mata y crea uno nuevo. Si un contenedor consume mucha CPU, Kubernetes lo mata y crea uno nuevo. Si queremos actualizar un subconjunto de los contenedores que estan dando un servicio en producción a una versión mas nueva, Kubernetes es capaz de actualizar los contenedores sin que el servicio en ejecución se caiga en ningún momento (sin downtime). También nos permite deshacer la actualización (un rollback) en caso de que la actualización tenga algún error. Estos son solo algunos ejemplos de lo que Kubernetes puede hacer por nosotros. Kubernetes soporta diferentes entornos para la ejecución de los contenedores. Pero entre todos ellos destaca Docker, el runtime para contenedores mas popular hoy en día. Y es por eso que hemos hablado de Docker en la introducción.

Arquitectura de Docker

Un PC donde tengamos instalado docker se llama Docker Host. Está compuesto por tres componentes importantes: el Docker Engine, el Docker Client y un API REST. El Docker Engine es el servicio sobre el que corren los contenedores de docker. El Docker Client (la herramienta docker) se comunica con el Docker Engine a traves del API REST. Cuando ejecutamos docker en la linea de comandos (cliente) en realidad nos estamos conectando al API REST del Docker Engine para enviarle nuestras peticiones. Y el Docker Engine utiliza la misma API REST para contestar al cliente.

Arquitectura de Kubernetes

A muy grosso modo, un cluster de Kubernetes se divide en dos partes: un Master y un conjunto de n Nodos.

En el Master reside el cerebro de Kubernetes, es la parte que realiza todo el procesamiento y da las ordenes a los Nodos. En el Master hay un API REST que recibe todas las ordenes desde los clientes. El API acepta requests en formato JSON, por lo que podemos manipular directamente esta API con cualquier lenguaje de programación, enviando las REQUEST en el formato JSON esperado para que el Master actúe en consecuencia. Existe una herramienta llamada kubectl que implementa todos los comandos del API y los convierte en mensajes JSON que envía al Master para hacernos la vida un poco mas facil.

Los Nodos son máquinas (virtuales o físicas) donde corren los contenedores. Por tanto, en los Nodos debemos tener instalado docker (deben ser Docker Host).

A traves del API REST (usando kubectl o una aplicación que nosotros mismos podemos desarrollar) yo le puedo decir al Master: "ok, quiero un nuevo contenedor de nginx corriendo". Y el Master se encarga de escanear los Nodos disponibles en el cluster y elegir el mejor calificado para alojar el nuevo contenedor. Desde el Nodo elegido se descarga la imagen de ngix y se arranca el contenedor.

Instalar un Docker Host

Para instalar docker en tu PC, sigue estos pasos:

    $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
    $ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
    $ sudo apt-get update
    $ sudo apt-get install -y docker-ce

Por defecto, ejecutar el cliente de docker requiere privilegios de root, por lo que debes usar sudo para ejecutarlo. Si quieres evitar tener que escribir sudo cada vez que ejecutes el comando docker, puedes añadir tu usuario al grupo "docker" que se ha creado automáticamente durante la instalación.

    $ sudo usermod -aG docker ${YOUR_USERNAME}

Si todo ha ido bien, el demonio de docker debería estar instalado, listo para funcionar y para reiniciarse cada vez que reinicies el Docker Host. Comprueba su estado haciendo esto:

    $ systemctl status docker

La instalación ha instalado también la herramienta docker que actúa como cliente para enviar comandos al daemon a traves del API REST. Compruebalo haciendo esto:

    $ docker run hello-world

La salida debería mostrar algo así como "Hello from Docker".

Instalar el cliente kubectl

Para instalar la herramienta kubectl sigue estos pasos:

    $ curl -LO https://storage.googleapis.com/kubernetes-release/release/`curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt`/bin/linux/amd64/kubectl
    $ chmod +x ./kubectl
    $ mv ./kubectl /usr/local/bin/kubectl

Asegurate de que la versión descargada es reciente:

    $ kubectl version --client

Instalar un cluster de Kubernetes

Como cluster de Kubernetes voy a usar minikube. Minikube es un servicio que nos permite simular un cluster muy pequeño de Kubernetes de manera local, compuesto por un Master y un solo Nodo. Con este cluster voy a tener todas las bases necesarias para aprender a usar Kubernetes. El Master va a ser nuestra maquina host y el Nodo también será nuestra máquina host. Con esto es suficiente para empezar a explorar Kubernetes y aprender muchisimas utilidades que son muy útiles.

Como el Nodo de nuestro cluster va a usar Docker, necesitamos instalar docker en el mismo PC en el que voy a instalar minikube. Es un paso que ya hemos hecho antes.

A continuación sigue estos pasos para instalar minikube:

    $ sudo -s
    # apt-get install conntrack
    # curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
    # chmod +x minikube
    # mv minikube /usr/local/bin
    # minikube start --vm-driver=none

El ultimo comando se toma su tiempo, paciencia.

NOTA: a instalación desde mi usuario me dio problemas. Para poder trabajar con mi usuario con normalidad, una vez finalizada la instalación desde el usuario root, tuve que hacer estos pasos a mano:

    # USER=tu-usuario
    # mv /root/.kube /home/${USER}
    # mv /root/.minikube /home/${USER}
    # chown -R ${USER}.${USER} /home/${USER}/.kube
    # chown -R ${USER}.${USER} /home/${USER}/.minikube
    # sed -i "s|/root|/home/${USER}|g" /home/${USER}/.kube/config
    # sed -i "s|/root|/home/${USER}|g" /home/${USER}/.minikube/machines/minikube/config.json
    # su - ${USER}

Para verificar que todo ha ido bien, ejecuta este comando. La respuesta esperada es esta:

    $ kubectl get pods
    No resources found in default namespace.

    $ kubectl cluster-info
    Kubernetes master is running at https://192.168.1.133:8443
    KubeDNS is running at https://192.168.1.133:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

    $ minikube status
    host: Running
    kubelet: Running
    apiserver: Running
    kubeconfig: Configured

Parece que vamos bien!

Los objetos de Kubernetes

En Kubernetes creamos objetos para indicarle al Master cual es el estado deseado de nuestro cluster. Esos objetos son entidades persistentes dentro del cluster. Una vez que has creado un objeto, el sistema se pone a trabajar para asegurar que el objeto existe dentro del cluster. Y hará todos los cambios necesarios en el cluster para asegurar que se cumpla ese nuevo estado. Para manipular objetos, es decir, crearlos, modificarlos o borrarlos, usamos la API de Kubernetes, normalmente a traves del cliente kubectl. Kubernetes tiene un ecosistema de objetos realmente extenso que puedes consultar con este comando:

    $ kubectl api-resources
    NAME                              SHORTNAMES   APIGROUP                       NAMESPACED   KIND
    configmaps                        cm                                          true         ConfigMap
    endpoints                         ep                                          true         Endpoints
    limitranges                       limits                                      true         LimitRange
    namespaces                        ns                                          false        Namespace
    persistentvolumeclaims            pvc                                         true         PersistentVolumeClaim
    persistentvolumes                 pv                                          false        PersistentVolume
    pods                              po                                          true         Pod
    resourcequotas                    quota                                       true         ResourceQuota
    secrets                                                                       true         Secret
    serviceaccounts                   sa                                          true         ServiceAccount
    services                          svc                                         true         Service
    deployments                       deploy       apps                           true         Deployment
    replicasets                       rs           apps                           true         ReplicaSet
    ingresses                         ing          extensions                     true         Ingress
    clusterrolebindings                            rbac.authorization.k8s.io      false        ClusterRoleBinding
    clusterroles                                   rbac.authorization.k8s.io      false        ClusterRole
    rolebindings                                   rbac.authorization.k8s.io      true         RoleBinding
    roles                                          rbac.authorization.k8s.io      true         Role
    ...

En la salida del comando anterior veremos todos los objetos disponibles del API REST con los que podemos interactuar.

Para crear un objeto en Kubernetes debes especificar su estructura interna en un archivo Yaml. Dentro del archivo Yaml vas a definir campos comunes a todos ellos como el apiVersion (la versión del API REST de Kubernetes que estás usando para crear este objeto), kind (la clase del objeto) o metadata (metadatos para identificar unívocamente al objeto como su nombre o una etiqueta y opcionalmente su Namespace). Después cada objeto tiene una serie de campos específicos de ese objeto que iremos viendo después.

Podemos usar el cliente kubectl para convertir un fichero Yaml a JSON y enviarle al Master del cluster la petición para que cree el objeto y despliegue en el cluster todos los cambios necesarios:

    $ kubectl apply -f /path/to/file.yaml
    $ kubectl apply -f https://www.foo.com/path/to/file.yaml

Fijate que tenemos dos variantes. Bien podemos usar un fichero yaml local creado por nosotros o bien podemos apuntar a un yaml alojado en Internet. Esto nos dá una gran flexibilidad a la hora de trabajar. Para borrar todos los objetos creados por un fichero Yaml en el Master y aplicar el nuevo estado en el cluster, podemos usar estos comandos:

    $ kubectl delete -f /path/to/file.yaml
    $ kubectl delete -f https://www.foo.com/path/to/file.yaml

Veamos ahora algunos de los objetos de Kubernetes en mas detalle:

Namespaces

Los Namespaces básicamente son una separación lógica del cluster dentro del propio cluster. Podemos tener n Namespaces dentro del cluster. Si creamos dos Namespaces distintos, podemos tener objetos diferentes de Kubernetes creados en cada Namespace y entre ellos no se van a ver.

Son utiles para muchas cosas dependiendo del uso que decidamos darles. Se pueden crear Namespaces para desarrollo, producción e integración. O se puede crear un Namespace distinto para cada aplicación que despleguemos dentro del cluster. Tambien se pueden limitar recursos a nivel de Namespaces.

    $ cat namespace.yaml
    apiVersion: v1
    kind: Namespace
    metadata:
      name: ns-dev
      labels:
        name: ns-dev

En este yaml hemos definido un objeto de la clase Namespace llamado ns-dev al que le hemos asignado una etiqueta llamada name con valor ns-dev.

La siguiente es una lista de comandos útiles para para gestionar namespaces.

    $ kubectl create namespace <ns-name>
    $ kubectl delete namespace <ns-name>
    $ kubectl describe namespace <ns-name>
    $ kubectl get namespaces
    $ kubectl get namespaces -l name=development
    $ kubectl get namespaces --show-labels
    $ kubectl get ns

Pods

Los Pods son la unidad básica de ejecución dentro de un cluster de Kubernetes. Cada Pod tiene una IP única dentro del cluster, un espacio de memoria independiente y un hostname. El Pod es básicamente un wrapper sobre un pequeño conjunto de contenedores intimamente relacionados. Todos esos contenedores comparten la IP del Pod, su espacio de memoria y hostname.

    $ cat pod.yml
    apiVersion: v1
    kind: Pod
    metadata:
      name: podtest
    spec:
      containers:
      - name: cont1
        image: nginx:alpine

En este yaml hemos definido un objeto de la clase Pod llamado podtest que define un contenedor llamado "cont1" que usará la imagen de nginx:alpine para funcionar.

La siguiente es una lista de comandos útiles para gestionar Pods:

    $ kubectl delete pod <pod-name>
    $ kubectl describe pod <pod-name>
    $ kubectl get pod <pod-name> -o yaml
    $ kubectl exec -ti <pod-name> -- sh
    $ kubectl get po
    $ kubectl get pods
    $ kubectl get pods --watch

ReplicaSets

Los Pods por si mismo tienen varios problemas. El primero es que si muere alguno de los contenedores que lo componen, nadie se encarga de reiniciarlo. El segundo es que si quiero 1000 replicas de mi Pod, tengo que editar y crear a mano 1000 copias de mi Pod dentro del fichero pod.yaml. No parece muy practico. ¿Como resolver estos problemas?

Utilizando un ReplicaSet, otro objeto de Kubernetes que está en un nivel de abstracción mas alto que los Pods. ¿Por que? Pues porque el ReplicaSet crea Pods. Yo le digo al objeto ReplicaSet: "me vas a crear N replicas de este Pod". Y el ReplicaSet se encargará de crear esas N replicas. Si alguna de esas replicas muere, el ReplicaSet se dará cuenta de ello y creará otro Pod de las mismas características para garantizar que el numero total de Pods es igual a N.

El ReplicaSet utiliza un label que nosotros definimos para indicar cual es el Pod que debe tomar para replicar.

Cada Pod creado y unido a un ReplicaSet RS1 utiliza una referencia interna para apuntar a su ReplicaSet padre (RS1). De forma que si creamos un segundo objeto ReplicaSet RS2, éste solo podrá tomar Pods huerfanos, que todavía no tengan un ReplicaSet padre asignado.

    $ cat rs.yaml
    apiVersion: apps/v1
    kind: ReplicaSet
    metadata:
      name: rs-test
      labels:
        app: rs-test
    spec:
      replicas: 5
      selector:
        matchLabels:
          app: pod-label
      template:
        metadata:
          labels:
            app: pod-label
        spec:
          containers:
          - name: cont1
            image: nginx:1.17.10

En este caso hemos creado un objeto de la clase ReplicaSet llamado rs-test. Indicamos que nos interesan 5 replicas y que debe usar Pods que hagan match con la etiqueta app con valor pod-label. Despues en la template del contenedor le ponemos la misma etiqueta app con valor pod-label para que el ReplicaSet haga match con ese Pod. Finalmente definimos el contenedor que correrá una imagen de nginx en su versión 1.17.10

¿Para que quiero yo tener tantas replicas de un Pod? Una razón para tener los Pods replicados es para alta disponibilidad: en caso que uno de ellos muera, todavía voy a tener 4 Pods sirviendo la pagina index.html. Si mueren 3 Pods, sigo teniendo dos mas disponibles para servir la pagina index.html. Obviamente el ReplicaSet de va a encargar de recrear los Pods cuando mueran. Otra razón para tener los Pods replicados es que cuantos mas Pods tengamos, voy a ser capaces de manejar mas carga. Por ejemplo, si un Pod es capaz de de manejar 100 peticiones/sec, entonces 5 Pods serán capaces de manejar 500 peticiones/sec. En esencia esta la idea de escalar horizontalmente, para ser capaces de manejar mas tráfico.

La siguiente es una lista de comandos útiles para gestionar ReplicaSet:

    $ kubectl get replicaset
    $ kubectl get rs
    $ kubectl describe rs <rs-name>
    $ kubectl get rs <rs-name> -o yaml
    $ kubectl get rs --show-labels

Deployments

El ReplicaSet también tiene problemas. Imagina que por alguna razón quiero actualizar la imagen de mis contenedores y usar nginx:1.18.0 en lugar de nginx:1.17.10. Si aplico los cambios en el yaml no se van a actualizar. ¿Por que? Pues por la sencilla razón de que el ReplicaSet solo esta pendiente del número de replicas que hay en funcionamiento que cumplan las condiciones del selector (labels). Si hay cambios a nivel de template del Pod, al aplicar los cambios, el ReplicaSet no se va a enterar. Por tanto, no cambiará NADA. Esto se traduce en que un ReplicaSet no puede actualizar los Pods, para cambiar por ejemplo la imagen de los contenedores.

Para solucionar este problema se crea un nuevo objeto de Kubernetes: el Deployment. Al crear un Deployment tengo que especificar un template para mi ReplicaSet. Es decir, al crear un Deployment, éste va a crear un ReplicaSet. Y en el template de mi ReplicaSet yo le digo el template de los Pod (imagen) y cuantos quiero. Lo que significa que también se van a crear Pods, debido al ReplicaSet que se crea.

¿Que pasa si quiero actualizar el contenedor? El Deployment se da cuenta que debe actualizar el ReplicaSet RS. Y para ello crea un segundo ReplicaSet RS2 guiado por dos parámetros: MaxUnavailable y MaxSurge. El parámetro MaxUnavailable significa el % de Pods que voy a permitir que mueran. Un 25% significa que como máximo voy a permitir que un 25% de los Pods de RS estén muertos. El MaxSurge significa el % por encima del 100% que voy a permitir que esten vivos. Un 25% significa que como maximo tendre 125% de los Pods de RS estén vivos. Entonces el Deployment irá matando Pods de RS (hasta que queden 0 Pods en RS) y creando Pods en RS2, hasta tener N Pods en ejecución en RS2. Recordemos que los Pods de RS2 ya corren la imagen nueva de nginx:1.18.0

    $ cat dep.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: deployment-test
      labels:
        app: front
    spec:
      replicas: 5
      selector:
        matchLabels:
          app: front
      template:
        metadata:
          labels:
            app: front
        spec:
          containers:
          - name: nginx
            image: nginx:1.18.0

Aqui creamos un objeto de la clase Deployment. En sus metadatos definimos su nombre "deployment-test" y una etiqueta app con valor front. A continuación en el campo "spec" empieza a describir el objeto ReplicaSet, que ya conocemos. Tenemos el numero de replicas deseadas (5 replicas) y el selector, es decir, cual va a ser el label que el ReplicaSet va a utilizar para encontrar a sus Pod. Despues viene el campo "template", que define básicamente el Pod que voy a crear. Obviamente, el Pod tiene que cumplir con el selector del ReplicaSet. Por tanto, voy a ponerle una etiqueta (label) igual a la que hemos definido en el selector (app=front). Ya por último, dentro del campo "spec" especificamos a los contenedores que forman parte del Pod. Vemos que sera un solo contenedor llamado nginx que usará la imagen nginx:alpine.

La siguiente es una lista de comandos útiles para gestionar ReplicaSet:

    $ kubectl get deployment
    $ kubectl delete deployment <deploy-name>
    $ kubectl describe deployment <deploy-name>

Services

Sabemos que cada Pod corriendo dentro del cluster tiene una dirección IP diferente. Entonces, si dentro de un objeto ReplicaSet tenemos varios Pods replicados, y cada uno tiene su propia dirección IP. ¿como accedemos a todos los Pods que están corriendo en un Deployment desde un solo punto?

Para resolver este problema, en Kubernetes existe un objeto que se llama Service. Básicamente un Service es un objeto aislado, independiente, que tiene una IP fija única dentro del cluster que Kubernetes se encarga de garantizar durante todo el tiempo de vida de ese objeto.

El Service define un selector que básicamente es una etiqueta que tienen que cumplir todos los Pods que controle este objeto Service. El Service mantiene una lista de endpoints actualizada en tiempo real. Si un Pod con la etiqueta del selector se crea, su IP se añade a la lista de endpoints del Service. Si un Pod con la etiqueta del selector se destruye, su IP se elimina de la lista de endpoints del Service.

Yo como usuario le podré hacer solicitudes a la IP del objeto Service. Y el objeto Service se encargará de reenviar la petición a la IP de uno de los Pod que mantenga en su lista de endpoints. Dos peticiones diferentes no tienen por que ser atendidas por el mismo Pod. Dicho de otro modo, el Service va a estar actuando como balanceador de carga sobre los Pods que esté monitorizando. Por otro lado, al disponer de varios Pods replicados, tenemos alta disponibilidad del servicio.

Hay varios tipos de objeto Service. Los dos mas importantes son ClusterIP y NodePort. El Service de tipo ClusterIP asigna al objeto Service una IP interna del cluster que es accesible por otros Pods del cluster. Pero esta IP no es accesible desde Internet. En cambio, el Service el tipo NodePort, además de asignar al objeto Service una IP interna del cluster, expone un puerto del Nodo del cluster hacia el exterior, de forma que a través de ese puerto podamos consumir nuestro servicio desde fuera del cluster, usando la IP del Nodo.

    $ cat svc.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: my-service
      labels:
        app: front
    spec:
      type: NodePort
      selector:
        app: front
      ports:
        - protocol: TCP
          port: 8080
          targetPort: 80

Aquí creamos un objeto de la clase Service llamado my-service del tipo ClusterIP. El selector indica que este objeto elegirá a los Pod que definan una etiqueta app con valor front para añadirlos a su lista de endpoints. Las conexiones a este Pod por el puerto 8080 se redirigiran al puerto 80. Para que sea el Pod escuchando en el puerto 80 (corriendo nginx) el que atienda la petición.

La siguiente es una lista de comandos útiles para gestionar Services:

    $ kubectl get svc
    $ kubectl get svc -l app=front
    $ kubectl get svc -n <ns-name>
    $ kubectl describe svc <svc-name>
    $ kubectl describe endpoints <svc-name>

Manos a la obra con un ejemplo práctico

En este ejemplo voy a poner en práctica todos los conceptos que hemos explicado durante este articulo. Voy a crear un objeto Deployment con 5 replicas de contenedores que van a correr una imagen de nginx. Después voy a crear un objeto Service de tipo NodePort para acceder al servicio nginx desde cualquier IP de la LAN donde tengas conectado el PC donde está corriendo tu Nodo del cluster. Después haremos algos experimentos para ver como se actualiza un Deployment y comprobaremos que funcione el balanceador de carga.

El yaml de este servicio lo he dejado alojado en mi cuenta personal de GitHub. Puedes verlo aquí: https://raw.githubusercontent.com/aicastell/k8s-resources/master/service/blog.yaml. Si abres el fichero en tu browser veras la definición de dos objetos de Kubernetes: el Deployment y el Service de tipo NodePort. La definición de ambos objetos es muy parecida a la que hemos hecho en secciones anteriores. Fijate que se pueden definir varios objetos de Kubernetes en un mismo fichero de yaml separando cada objeto por tres guiones "---". Vamos a aplicar este fichero yaml al vuelo, sin necesidad de descargarlo desde Internet en un archivo local:

    $ kubectl apply -f https://github.com/aicastell/k8s-resources/blob/master/service/blog.yaml

Vamos a consultar los Pods disponibles:

    $ kubectl get pods
    NAME                             READY   STATUS    RESTARTS   AGE
    my-deployment-7768665d6b-7rn7q   1/1     Running   0          52s
    my-deployment-7768665d6b-9xrvb   1/1     Running   0          52s
    my-deployment-7768665d6b-s6d8l   1/1     Running   0          52s
    my-deployment-7768665d6b-wkm24   1/1     Running   0          52s
    my-deployment-7768665d6b-xxf54   1/1     Running   0          52s

Vemos que hay 5 Pods corriendo, tal y como esperábamos. Vamos a eliminar uno de esos Pods a ver que ocurre. Eliminemos el llamado my-deployment-7768665d6b-7rn7q:

    $ kubectl delete pod my-deployment-7768665d6b-7rn7q
    pod "my-deployment-7768665d6b-7rn7q" deleted

Seguidamente vuelvo a consultar los Pods disponibles:

    $ kubectl get pods
    NAME                             READY   STATUS    RESTARTS   AGE
    my-deployment-7768665d6b-9xrvb   1/1     Running   0          2m44s
    my-deployment-7768665d6b-c8p9l   1/1     Running   0          15s
    my-deployment-7768665d6b-s6d8l   1/1     Running   0          2m44s
    my-deployment-7768665d6b-wkm24   1/1     Running   0          2m44s
    my-deployment-7768665d6b-xxf54   1/1     Running   0          2m44s

Vemos como de nuevo tenemos 5 Pods disponibles. El ReplicaSet ha actuado generando un nuevo Pod hace exactamente 15 segundos.

Veamos ahora los Service disponibles:

    $ kubectl get svc
    NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
    kubernetes   ClusterIP   10.96.0.1      none          443/TCP          17h
    my-service   NodePort    10.97.76.248   none          8080:30498/TCP   4m12s

Vemos que tenemos dos objetos de tipo Service: kubernetes y my-service. El Service llamado "kubernetes" de tipo ClusterIP es creado por el propio Kubernetes cuando se arranca el cluster, pero no voy a entrar en detalles. El Service llamado "my-service" de tipo NodePort es el que hemos creado antes. Vemos como tiene una IP interna del cluster 10.97.76.248 y vemos que está exponiendo el puerto TCP 30498 del nodo. Desde cualquier IP de nuestra LAN podemos validar si el servicio contesta, haciendo un GET por http a la IP de nuestro Nodo y al puerto 30498, de esta manera:

    $ curl 192.168.1.133:30498
    Welcome to nginx!

    If you see this page, the nginx web server is successfully installed and
    working. Further configuration is required.

    For online documentation and support please refer to
    nginx.org.

    Commercial support is available at
    nginx.com.

    Thank you for using nginx.

Y aquí tenemos la respuesta de nuestro servicio nginx. ¿No te parece maravilloso?

Pero voy a ir mas lejos. Vamos ahora a hacer una actualización en todos los Pods de nuestro Deployment. Vamos a modificar el fichero index.html que sirven los contenedores. Para ello descarga el fichero blog.yaml en el PC donde estas corriendo Kubernetes:

    $ wget https://raw.githubusercontent.com/aicastell/k8s-resources/master/service/blog.yaml

Después edita el fichero y borra el comentario (borra el caracter #).

Graba los cambios, sal del fichero y aplica la nueva configuración:

    $ kubectl apply -f blog.yaml
    deployment.apps/my-deployment configured
    service/my-service unchanged

En la respuesta que nos da kubectl vemos que el objeto Deployment ha sido configurado y el Service no ha sufrido modificaciones. Si listamos ahora los Pods en ejecución:

    $ kubectl get pods
    NAME                             READY   STATUS    RESTARTS   AGE
    my-deployment-86cb867556-4spks   1/1     Running   0          34s
    my-deployment-86cb867556-rvm79   1/1     Running   0          40s
    my-deployment-86cb867556-s596q   1/1     Running   0          40s
    my-deployment-86cb867556-zc6s7   1/1     Running   0          35s
    my-deployment-86cb867556-zdgsp   1/1     Running   0          40s

Vemos que todos los Pods son nuevos, todos los nombres han cambiado. ¿Como ha ocurrido esto? El objeto Deployment ha creado un nuevo ReplicaSet, y ha ido matando los Pods viejos y creando los Pods nuevos hasta disponer del número de 5 Pods en el ReplicaSet nuevo (el numero deseado) y 0 Pods en el ReplicaSet viejo.

¿Que pasa ahora si accedemos varias veces a nuestro servicio?

    $ curl 192.168.1.133:30498
    Hello from my-deployment-86cb867556-rvm79

    $ curl 192.168.1.133:30498
    Hello from my-deployment-86cb867556-zdgsp

    $ curl 192.168.1.133:30498
    Hello from my-deployment-86cb867556-4spks

    $ curl 192.168.1.133:30498
    Hello from my-deployment-86cb867556-4spks

    $ curl 192.168.1.133:30498
    Hello from my-deployment-86cb867556-s596q

Vemos como nuestros Pods están contestando con un index.html diferente. Por tanto la actualización del contenedor se ha realizado correctamente. Pero también vemos como cada petición GET la atiende un Pod diferente. Lo cual indica que estamos balanceando carga. ¿No os parece impresionante? :)

Cierre del articulo

He dejado muchos objetos de Kubernetes sin comentar. Temas como limitar recursos, control de acceso a usuarios, volumenes, y otras muchas cosas. Pero Kubernetes es una herramienta muy extensa y explicar todos estos conceptos en un solo post haría esta entrada interminable.

Con los temas explicados creo que os habréis hecho una buena idea de lo que és Kubernetes, como funciona y para qué os puede ser útil. Este puede ser un buen punto de partida para que os animéis a experimentar con esta herramienta. Espero que os haya gustado el articulo y nos leemos en una próxima ocasión! :)

miércoles, 22 de abril de 2020

Mi primera Transpirenaica


Retrocedo en el tiempo hasta el 20 de Agosto de 2017, hace ya mas de dos años. Un día inolvidable para el que escribe estas palabras. Aún en la plaza de Obradoiro tras haber completado en bicicleta los 1000Km que separan mi ciudad natal, Castellón de la Plana, de Santiago de Compostela en 8 días, la emoción de conseguir tal hazaña me animó a publicar en redes sociales un ofrecimiento para que fuera alguno de mis amigos quien me planteara el siguiente reto en bicicleta.

“Estamos en Santiago de Compostela. Va por todos vosotros! Reto conseguido, y a pensar en locuras nuevas. ¡Se aceptan propuestas!.”


Entre todas las felicitaciones que aún me emociono al leer, una que especialmente despertó mi curiosidad. Y sin ser del todo consciente en ese momento, estaba plantando la semilla del que iba a ser el siguiente gran reto en bicicleta:


“Una Transpirenaica... Un entrenamiento para ti.”


Sin tener demasiado claro de que se trataba la Transpirenaica me puse a buscar información. Enseguida me di cuenta que la estupidez humana no tiene limites, LoL. Una distancia similar pero con el doble de desnivel positivo subido para llegar a Santiago. La dificultad superaba con creces la aventura que acababa de terminar. Ni tiempo tuve de disfrutar mi llegada a Santiago, ya me había metido en la cabeza otra locura de mayor envergadura...




Y de esta manera es como comienza la narración de la que se ha convertido en la mayor de mis aventuras: la publicación de mi primer libro! Ya a la venta disponible en tiendas online tanto en CasaDelLibro como en Amazon.

Estamos pasando unas semanas de confinamiento muy duras por toda la situación generada por la covid-19. La solidaridad y el compromiso con esta sociedad nos han mantenido confinados todo este tiempo, sin salir de casa, sin poder practicar deportes al aire libre (#YoMeQuedoEnCasa). Los ciclistas, acostumbrados a pasar muchas horas pedaleando al aire libre, estamos sufriendo especialmente esta falta de actividad física durante tantos días.

Por eso me dirijo especialmente a vosotros, a los que os gusta salir en bici como a mi y estáis sufriendo este confinamiento. Animaros a leer el libro y durante unas horas volveréis a sentir la libertad de rodar en bicicleta por esas carreteras... Tras su lectura, quien sabe, igual os pica el gusanillo y os animáis a planificar esta misma ruta. Quizá estés un paso mas mas cerca de convertirte en el siguiente ciclista que completa una Transpirenaica en solitario.

A los tres primeros que me expliquéis en los comentarios cual ha sido vuestra mayor aventura en bici os enviaré de manera gratuita un ejemplar del libro con una dedicatoria personalizada. Podéis pasarme vuestro nombre y dirección de entrega enviando un email a la dirección miprimeratranspirenaica@gmail.com.

Por último quiero pediros un favor: darle difusión a la obra, compartiéndola con vuestros contactos, especialmente con aquellos a los que les guste la bicicleta tanto como a mi! Gracias! Y mucha fuerza para salir de esta cuanto antes!



sábado, 9 de marzo de 2019

La herramienta repo

Introducción

Hoy vamos a hablar sobre la gestión de proyectos grandes. Y cuando digo grandes, me refiero a proyectos realmente grandes. Si os parece bien usaremos el proyecto Android como ejemplo de partida.

El proyecto Android está compuesto por muchos repositorios Git diferentes almacenados en servidores deslocalizados y repartidos por todo el mundo. Y cuando digo muchos, me refiero a una cantidad enorme (y casi insana) de repositorios Git diferentes. Cada repositorio Git esta formado por un conjunto de ficheros fuente y documentación, y almacena un historico de los cambios realizados en cada fichero por los desarrolladores que aportan código a ese repositorio. Estos desarrolladores residen en distintas ciudades del mundo y la mayoría ni siquiera se conocen entre ellos en persona. Esto es Android señor@s. Pensad en ello cada ves que cojáis el móvil. Yo cada día me sigo preguntando como es posible que todo esto funcione tan bien xDD.

Usar varios repositorios Git diferentes para aislar cada componente del proyecto es una buena practica, incuestionable hoy en día, ya que reduce la complejidad del proyecto, facilita el trabajo colaborativo entre distintos desarrolladores, y en general mejora la organización del proyecto. Sin embargo, gestionar tantos repositorios diferentes puede suponer un esfuerzo titánico para la persona encargada de hacer ese trabajo.

Imagina que eres la persona encargada de mantener el proyecto Android, compuesto por mas de 700 repositorios Git (si 700, has leído bien). Tras un tiempo de desarrollo, llega el momento de publicar la primera Release Candidate (RC.1) del proyecto. Publicas la RC.1. Pero el desarrollo continúa, los repositorios evolucionan con nuevas funcionalidades, llegan nuevos commit, se crean nuevas ramas, algunas se fusionan, aparecen nuevos repositorios, etc. Pasado un tiempo, alguien detecta un bug en la RC.1. Necesitas volver a la situación del proyecto en el momento de publicar la RC.1 para hacer el bugfix. ¿Como vas a recordar el conjunto de repositorios git de que estaba compuesto el proyecto en el momento de publicar la RC.1? ¿En qué commit se encontraba cada repositorio en el momento de hacer la RC.1? ¿Como volvemos a la situación de la RC.1 para poder corregir el bug? ¿Y como regresamos a la situación actual después de viajar al pasado?

Si piensas en resolver esto de manera manual, la respuesta es que si, efectivamente es posible hacerlo. Sin embargo, sería un trabajo monstruoso, propenso a errores, costoso, doloroso y suficientemente aburrido como para pedir una baja por depresión. Los ingenieros necesitamos solucionar estos problemas de manera mas eficiente y automatizada. No nos gusta hacer trabajos aburridos, repetitivos y de manera manual. Aunque es posible que tengas algo de experiencia con Git y pienses en los submodulos para resolver este problema. No, no sigas por esta vía. He sufrido los submodulos de Git para intentar hacer estas cosas y no, no funcionan todo lo bien que deberían. Son un continuo dolor de cabeza que en el peor de los casos pueden terminar por hacerte perder parte de tu trabajo. Personalmente no os recomiendo usar submodulos de Git para esta cuestión.

Para resolver todos estos problemas Google desarrolla su propia herramienta, a la que llama "repo", en un intento de centralizar la gestión de todos los repositorios de los que esta compuesto un proyecto grande de forma sencilla y ágil. Es un script escrito en Python que funciona a modo de wrapper de la herramienta Git. De acuerdo, a mi no me hagáis caso. Pero si Google decide no usar submodulos de Git para gestionar los repositorios del proyecto Android, por algo será.

Funcionamiento de repo

repo utiliza como entrada un fichero de texto en formato XML conocido como fichero de manifest. En el fichero de manifest se define una lista de los repositorios Git de los que está compuesto el proyecto, y la revisión (rama, o tag, o commit ID) en la que se encuentra cada uno de esos repositorios en el momento de hacer la foto del proyecto.

La persona encargada de gestionar el proyecto creará este fichero antes de publicar la RC. Para el caso que nos ocupa, crearía un fichero "default.xml" para la RC.1. Puesto que a lo largo del tiempo se generan distintas RC (en Android mas de 900 RC, tal y como veremos después), tendremos un montón de ficheros default.xml (uno por cada nueva release que se genere). Todos esos ficheros de manifest se deberían guardar en algún sitio seguro. La mejor manera de guardarlos será en un repositorio de ficheros de manifest que de ahora en adelante llamaremos manifest.git (aunque puedes llamarlo como prefieras). En ese repositorio usaremos una rama distinta por cada RC que se genere, en la que se guardará la versión particular del fichero default.xml para esa RC.

A grandes rasgos, esta es la manera en la que Google se encarga de gestionar el proyecto Android.

Instalación de repo

Empezamos instalando la herramienta repo. Hoy en día se puede instalar haciendo uso de los repositorios de paquetes oficiales de cualquier distribución de Linux basada en Ubuntu/Debian usando apt:

    $ sudo apt install repo

Inicialización del sandbox

Una vez instalada, puedes usar repo para inicializar tu sandbox de trabajo, descargando tu repositorio de ficheros de manifest:

     $ repo init -u <url-de-manifest.git> [-b <branch-name>] [-m manifest-name.xml] 

Por ejemplo, para inicializar un sandbox de trabajo para Android, haríamos lo siguiente:

    $ mkdir -p sandbox/android
    $ cd sandbox/android
    $ repo init -u https://android.googlesource.com/platform/manifest 

Se pueden usar algunos parámetros opcionales. El parámetro -b te permite indicar la rama del repositorio manifest que quieres descargar (la primera vez que lo descargas, seguramente no la sepas). El parámetro -m te permite indicar el nombre del fichero manifest que quieres usar, en caso de que hayas usado un nombre diferente al que se descarga por defecto (default.xml).

El comando anterior genera un fichero local .repo con información que debes conocer:

    $ ls -l .repo/
    total 12
    drwxrwxr-x 3 aicastell aicastell 4096 mar 9 11:50 manifests
    drwxrwxr-x 10 aicastell aicastell 4096 mar 9 11:50 manifests.git
    lrwxrwxrwx 1 aicastell aicastell 21 mar 9 11:50 manifest.xml -> manifests/default.xml
    drwxrwxr-x 7 aicastell aicastell 4096 mar 9 11:50 repo

Empezando por el final, el directorio repo/ es el repositorio Git donde se descarga la última versión del comando repo:

    $ cd .repo/repo/ 
    $ git remote -v 
    origin https://gerrit.googlesource.com/git-repo (fetch)
    origin https://gerrit.googlesource.com/git-repo (push)

Puede parecer absurdo descargar esta herramienta, ya que previamente ha sido instalada en el PC con el comando apt. Sin embargo, en realidad tiene su explicación. En este directorio se descarga la última versión disponible de la herramienta. Cuando repo se ejecuta, detecta que la versión descargada es mas nueva que la que se ejecuta, y te avisa para que la actualices con un simple comando "cp":

    ... A new repo command ( 1.25) is available.
    ... You should upgrade soon:
    cp /path/to/.repo/repo/repo /usr/bin/repo

El directorio manifests/ es el repositorio Git de ficheros manifest descargado localmente:

    $ cd .repo/manifests/
    $ git remote -v
    origin https://android.googlesource.com/platform/manifest (fetch)
    origin https://android.googlesource.com/platform/manifest (push) 

Husmeando un poco mas:

    $ git branch -r | wc -l
    980

vemos que a fecha de hoy 3 de Marzo de 2019, Android tiene ni mas ni menos que 980 ramas distintas. Es decir, 980 versiones distintas del fichero manifest. Vamos a situarnos en una rama en concreto del repositorio:

    $ git checkout android-9.0.0_r9 -b android-9.0.0_r9

Veamos el último commit de esa rama:

    $ git log -1
    commit d69a58cdccdfa187db6179693aa33474fa379a60 
    Author: The Android Open Source Project
    Date: Mon Oct 1 15:13:25 2018 -0700

        Manifest for Android 9.0.0 Release 9

Parece que la versión 9.0.0 fue lanzada el 1 de Octubre de 2018 sobre las 15:13:25, poco después de comer el mismo día que mi hermano cumplía 38 años! :)

Por último vemos que hay un symlink (o enlace simbólico) .repo/manifest.xml que siempre apuntará dentro del repositorio de ficheros manifest al fichero de manifest actualmente en uso, que como ya hemos dicho antes, por defecto se llama default.xml, pero puedes indicar otro diferente con el parámetro -m.

Sincronización de repo

Una vez tenemos decidido el fichero de manifest que queremos usar (apuntado por el symlink .repo/manifest.xml), lanzamos este comando:

    $ repo sync -j 4 -c

El comando repo sync actualiza en los directorios locales los repositorios remotos definidos en el fichero manifest actualmente en uso. Si el repositorio remoto todavía no existe en local, clonará un nuevo directorio desde el repositorio remoto, y configurará las ramas según lo especificado en el fichero manifest. Si el repositorio local ya existe, actualizará los cambios remotos y hará un rebase de cualquier cambio local sobre los nuevos cambios remotos descargados.

Este comando también tiene algunos parámetros opcionales. El parámetro "-j" especifica el numero de procesos concurrentes que se encargarán de hacer la descarga. Al ser opcional, si no lo especificas, por defecto el valor es 1. Si ajustas este valor al numero de procesadores de tu máquina, reducirás el tiempo de descarga considerablemente. Otro parámetro opcional interesante es el flag "-c". Hemos dicho que Android esta compuesto de cientos de repositorios Git, y por tanto cada uno tiene su propio histórico de ramas. La pregunta es, ¿realmente necesitamos hacer un git-clone de todas las ramas de cada repositorio que compone Android? Probablemente no. Aquí es donde el flag "-c" se vuelve útil. Con este flag, repo solo descargará la rama especificada en el manifest, no todas las ramas creadas en el servidor remoto. Esto nos ahorrará bastante espacio y también reducirá el tiempo total de la descarga.

El fichero manifest

Vamos ahora a analizar el formato interno del fichero manifest. El manifest del proyecto Android es bastante largo. Usamos un ejemplo recortado para facilitar su comprensión:

     $ cat default.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <manifest>
      <remote name="aosp"   fetch="https://android.googlesource.com/"/>
      <remote name="udinic" fetch="https://github.com/udinic/"/>

      <default revision="android-9.0.0_r9" remote="aosp" sync-j="4" />

      <project path="art"                name="platform/art" />
      <project path="bionic"             name="platform/bionic" />
      <project path="tools/adt/eclipse"  name="platform/tools/adt/eclipse" />
      <project path="dalvik"             name="platform_dalvik"          remote="udinic"/>
      <project path="frameworks/base"    name="platform_frameworks_base" remote="udinic" />
    </manifest>

Como hemos dicho antes, vemos que se trata de un fichero XML que podemos editar y manipular.

Todo el contenido del fichero va metido dentro de un nodo del arbol XML llamado "manifest". Del nodo "manifest" cuelgan al principio una lista de nodos llamados "remote" que identifican los servidores Git que alojan los distintos repositorios del proyecto. El atributo "name" indica el nombre que damos a ese servidor para identificarlo dentro del fichero manifest. El atributo "fetch" indica la URL de la que se hará el clone. Puedes definir tantos nodos remote como necesites.

Después se define un nodo "default". En este nodo se indica un atributo "remote" que indica atributo "name" del nodo "remote" del que se van a hacer los clones de los repositorios. Y un atributo "revisión" que indica la revisión (la branch o el tag) por defecto.

A continuación se especifican una lista de nodos "project", uno por cada repositorio que debe descargarse. Nota personal: creo que la etiqueta "project" es una mala elección que lleva a confusión, no hablamos del proyecto (Android), hablamos de los repositorios que lo componen. Con el atributo opcional "remote" se indica el servidor donde está alojado el repositorio. Si no aparece, por defecto se usará el indicado en el nodo "default". Con el atributo "name" se indica el nombre del repositorio que vas a descargar. Por ejemplo, name="platform_frameworks_base" hace que el checkout se realice del repositorio https://github.com/udinic/platform_frameworks_base.git. Con el atributo "path" indicas el directorio local donde debes dejar lo descargado. Por ejemplo, si usas path=".", se creará un directorio local "platform_frameworks_base". Pero si usas path="temp", se creará un directorio local "temp/platform_frameworks_base".

Un ejemplo práctico

De acuerdo, no somos Google y no tenemos proyectos tan grandes entre manos. Pero igualmente podemos aprovechar lo aprendido hasta ahora para gestionar nuestros proyectos compuestos por dos o mas repositorios. Veamos un ejemplo práctico:

Imagina que trabajas en un proyecto compuesto por tres repositorios: repo1.git, repo2.git y repo3.git.

Lo primero que vas a necesitar es crear un repositorio adicional para el manifest (manifest.git), que contenga un fichero de manifest default.xml donde se especifique la lista de repositorios de los que consta el proyecto y otros datos como los servidores desde donde se hacen los checkouts, la rama que se descarga de cada repositorio, o el path local donde se guarda el repositorio descargado:

    <?xml version="1.0" encoding="UTF-8"?>
    <manifest>
        <remote name="github" fetch="https://github.com/aicastell" />
 
        <default revision="develop" remote="github" />
 
        <project path="src/repo1/" name="repo1.git" />
        <project path="src/repo2/" name="repo2.git" />
        <project path="src/repo3/" name="repo3.git" />
    </manifest>

Fíjate que hemos definido la revisión "develop" en el nodo "default" que afecta a todos los repositorios. No tendría por que ser así, podrías usar una revisión diferente en cada nodo project. Pero en este ejemplo lo vamos a hacer de esta manera, asumiendo que todos los repositorios repo.git tienen creada una rama "develop".

Una vez que hemos creado el repositorio de manifest, editado el fichero default.xml, guardado los cambios, commiteados y pusheados a la rama "master", usa este conjuro para descargar en tu sandbox local todos los repositorios bajo control de repo:

    $ cd sandbox
    $ repo init -u https://github.com/aicastell/manifest.git
    $ repo sync

Tras el repo sync vemos que los repositorios descargados no tienen nombre de rama asignado:

    $ cd repo1.git
    $ git branch
    * (no branch)

Sin embargo el commit ID al que apunta cada repositorio es el mismo que el apuntado por su rama "develop", por lo que haciendo un checkout resolvemos el problema:

    $ git checkout develop
    $ git branch
    develop

Estudia ahora el contenido del directorio sandbox:

    $ find *  -maxdepth 1 | sort
    src
    src/repo1
    src/repo2
    src/repo3

Vemos como efectivamente se han descargado los tres repositorios en el path indicado en los nodos project.

De ahora en adelante continúas tu trabajo en cada repositorio repo.git con tu workflow habitual, creando ramas y tags, haciendo commits, subiendo tus cambios con push, y de cuando en cuando haciendo una pull requests para fusionar unas ramas con otras.

El proyecto ha avanzado tanto que llega el momento de generar una nueva release candidate: la RC.2. El proceso a seguir sería el siguiente:

Primero sitúa todos los repositorios repo.git en la rama que quieres usar para inmortalizar el momento RC.2. Si esa rama se llama igual en todos ellos (en este ejemplo estamos usando "develop"), este comando facilita el trabajo:

    $ repo forall -c 'git checkout develop'

Una vez que todos los repositorios repo.git están apuntando a la rama "develop", procede a crear en todos ellos una nueva rama llamada "RC.2" usando este comando:

    $ repo forall -c 'git branch RC.2; git checkout RC.2; git push $REPO_REMOTE RC.2'

La variable de entorno REPO_REMOTE se rellena de forma automática con el nombre del repositorio por el que se itera, según lo indicado en el campo "name" de cada nodo project. Por tanto, ahora tenemos una nueva rama en todos los repositorios repo.git llamada "RC.2"

Llega el momento de anotar lo hecho en el fichero default.xml del proyecto manifest. Crea en el repositorio de manifest una nueva rama llamada "RC.2" y adapta el default.xml para que refleje la nueva realidad que acabamos de inmortalizar:

    <?xml version="1.0" encoding="UTF-8"?>
    <manifest>
        <remote name="github" fetch="https://github.com/aicastell" />
 
        <default revision="RC.2" remote="github" />
 
        <project path="src/repo1/" name="repo1.git" />
        <project path="src/repo2/" name="repo2.git" />
        <project path="src/repo3/" name="repo3.git" />
    </manifest>

El desarrollo del proyecto proseguirá. Pero si en algún momento deseas volver al estado de la RC.2, lo podrás hacer invocando este conjuto:

    $ cd sandbox
    $ repo init -u https://github.com/aicastell/manifest.git -b RC.2
    $ repo sync

Cierre del post

Espero que con esto quede mas o menos claro el funcionamiento de esta herramienta. Los que ya la conocíais hayáis aclarado un poco mas sobre su funcionamiento. Y los que no, le perdáis el miedo y os animéis a usarla a partir de ahora. Os espero en la próxima entrega. Let the source be with you. Hasta la próxima! :)

jueves, 1 de noviembre de 2018

El device tree: una visión general

Introducción

En este post hablaré sobre el Device Tree y su estrecha relación con el Linux kernel.


No es un tema sencillo de explicar puesto que requiere de conocimientos en diversas materias (hardware, firmware, y software). No obstante, la falta de información sobre esta temática en mi idioma materno junto con la escasez de documentación introductoria en general me han animado a escribir este articulo introductorio. Espero que sea de vuestro agrado y os ayude a entender mejor como funciona todo este asunto. Vamos a ver que tal sale esto!


¿Qué problema intenta resolver el Device Tree?

Como todo sistema operativo, el Linux kernel necesita conocer la topología del hardware de nuestro sistema y su configuración. Detalles como cuantas CPU tiene nuestro sistema, cuanta memoria, cual es la dirección base donde esta mapeada la memoria, cuantos buses tenemos, que dispositivos hay conectados en cada bus. Todo sistema operativo necesita conocer estos detalles sobre el hardware antes de poder cargar los device drivers encargados de manejar el sistema de forma adecuada.

Para algunas arquitecturas esto no es un problema porque el hardware se describe a si mismo al arrancar el kernel. Por ejemplo, en arquitecturas PC/x86, el firmware de las placas base (BIOS/UEFI) y los buses del sistema (USB, PCI, etc), ayudan al Linux kernel a identificar la topología hardware del sistema y la configuración en tiempo de ejecución. Por tanto en la plataforma PC/x86 no tenemos este problema.

Sin embargo, en otras arquitecturas como PowerPC, SPARC, OpenRISC, ARM y algunas mas, normalmente no tenemos BIOS ni UEFI. La mayor parte de los periféricos y dispositivos de I/O no son capaces de describirse a si mismos al kernel. Dicho de otro modo, el kernel no tiene una forma fácil de saber que hardware se esta utilizando. ¿Como sabe el kernel que hardware tiene disponible el sistema?


¿Como se resolvía este problema antes de su existencia?

Durante mucho tiempo la solución consistió en describir el hardware en el propio código fuente del kernel. Veamos unos ejemplos extraídos de una placa ARM que continúa en el mainline del kernel, tal cual.

    $ cat arch/arm/mach-imx/mach-pca100.c
    [...]

    static struct i2c_board_info pca100_i2c_devices[] = {
        {
            I2C_BOARD_INFO("at24", 0x52), /* E0=0, E1=1, E2=0 */
            .platform_data = &board_eeprom,
        }, {
            I2C_BOARD_INFO("pcf8563", 0x51),
        }, {
            I2C_BOARD_INFO("lm75", 0x4a),
        }
    };


Vemos una estructura del tipo struct i2c_board_info que define el hardware que hay conectado al bus I2C: tenemos una memoria eeprom (at24), tenemos un reloj de tiempo real (pcf8563) y tenemos un sensor de temperatura (lm75).

Tenemos otro ejemplo usando el bus SPI:

    static struct spi_board_info pca100_spi_board_info[] __initdata = {
        {
            .modalias = "at25",
            .max_speed_hz = 30000,
            .bus_num = 0, 
            .chip_select = 1, 
            .platform_data = &at25320,
        },
    };   

Vemos una estructura spi_board_info que define el hardware que hay conectado al bus SPI: tenemos el nombre del dispositivo "at25" conectado al bus SPI, que usa el Chip Select 1, con una frecuencia máxima de reloj de 30Khz.

Usar el código fuente del Linux kernel para describir el hardware de nuestro sistema no era una buena idea. Si tenemos dos o tres boards diferentes, mas o menos se puede mantener el código. Pero cuando tenemos miles de boards diferentes, las cosas se complican. Las principales desventajas son:
  • Cualquier cambio de hardware requiere modificar el código fuente y recompilar el kernel para generar una nueva imagen.
  • No hay una manera estandar de implementar el código de inicialización de la board. Cada fabricante usa su propio método.
  • Existe mucho código duplicado, distintas boards implementan código para hacer lo mismo, pero cada uno lo hace de una manera diferente.
  • Es imposible tener una sola imagen del kernel que funcione en distintas plataformas hardware.
Como vemos, tenemos muchas desventajas.


¿Como resuelve el problema el Device Tree?

La idea para superar todos estos problemas consiste en borrar del código fuente del kernel de Linux toda la parte de descripción y configuración del hardware y realizar todo ese proceso de descripción y configuración en un fichero separado conocido como Device Tree.

Es responsabilidad del desarrollador describir toda la topología del hardware y su configuración en un fichero de texto llamado Device Tree Source (DTS), con la extensión .dts, que tiene un aspecto muy similar a un XML o a un JSON. Este fichero de texto se compila generando un binario llamado Device Tree Blob (DTB), con extensión .dtb.

El bootloader pasa a ser el encargado de pasar el DTB al Linux kernel durante el proceso de boot del sistema. El Linux kernel es capaz de interpretar el DTB para identificar la topología del hardware y su configuración, cargar los drivers necesarios y arrancar el sistema correctamente.

De esta manera, un solo código fuente del kernel de Linux puede ser usada en multiples boards. Solo debemos asegurarnos de pasarle al kernel el DTB adecuado durante el proceso de boot, de manera que el  kernel pueda arrancar el hardware de nuestro board.


¿Donde se guarda el Device Tree?

Cada board soportada por el Linux kernel tiene un fichero DTS guardado dentro del código fuente del Linux kernel. Por ejemplo, los Device Tree para todas las board ARM estan disponibles en este directorio:

    $ ls arch/arm/boot/dts
    aks-cdu.dts                 at91sam9xr35ek.dts
    am335x-bone.dts             at91sam9x5cm.dtsi
    am335x-evm.dts              at91sam9x5d.dtsi
    am335x-evmsk.dts            at91sam9x5ek.dtsi
    am33xx.dtsi                 bcm11351.dtsi
    am3517-evm.dts              bcm2835.dtsi
    am3517_mt_ventoux.dts       bcm2835-rpi-b.dts
    animeo_ip.dts               bcm958625k.dts
    armada-370-db.dts           [...]

Todas y cada una de las boards ARM soportadas por nuestro kernel tienen un fichero con extensión .dts en este directorio. En el listado anterior puedes ver que también aparecen ficheros con extensión .dtsi. Son ficheros include sobre el que hablaremos posteriormente.


¿Como se obtiene el DTB?

El DTS necesita ser compilado para obtener el DTB. Existe un compilador llamado Device Tree Compiler (DTC), disponible en los propios fuentes del Linux kernel, en este directorio:

    scripts/src

El DTC es el responsable de compilar el DTS para obtener el DTB.

Para automatizar la compilación de todos los DTS de una arquitectura en concreto, hay un target en el Makefile principal del kernel llamado "dtbs". Por ejemplo, para compilar todos los DTB de la arquitectura ARM, definimos las variales ARCH y CROSS_COMPILE y utilizamos este comando:

    $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- dtbs

Para compilar el DTB de un solo board, utilizamos como target el nombre del Device Tree de ese board:

    $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- imx6dl-colibri-eval-v3.dtb

En el ejemplo anterior hemos compilado el Device Tree de la board imx6dl-colibri-eval-v3

Después de la compilación, el fichero DTB generado se guarda en el mismo directorio en el que se alojan los Device Tree sources:

    arch/arm/boot/dts


¿Como pasamos el DTB al Linux kernel?

Durante el proceso de boot, el bootloader debe pasar el DTB al kernel antes de que kernel tome el control del arranque del sistema.

La idea general consiste en copiar el Device Tree en memoria RAM e indicarle al kernel la dirección donde se ha copiado.

En el caso concreto de la arquitectura ARM, el bootloader copia el fichero DTB en cualquier dirección de la memoria RAM y a través del registro R2 le indica al kernel la dirección base donde se ha copiado. Durante el arranque, el kernel echará un vistazo a ese registro, irá a la dirección de memoria RAM indicada, leerá el DTB, lo validará y si su formato es correcto, lo leerá y arrancará el sistema configurando el hardware según lo indicado.

El bootloader principal en ARM (U-Boot) soporta esta característica de pasar el Device Tree al kernel. Sin embargo, si usas un bootloader que no soporta el Device Tree, o estas usando una versión muy antigua del U-Boot que todavía no tenía soporte, debes hacer algo diferente si quieres arrancar el sistema con un Device Tree. Lo que debes hacer primero es configurar el kernel habilitando la opción CONFIG_ARM_APPENDED_DTB, y luego crear una imagen del kernel que contenga la imagen del kernel y el DTB concatenados:

    $ cat zImage board.dtb > zImage_dtb

Durante el proceso de boot, el kernel de Linux se dara cuenta de que tiene habilitada la opción del configure CONFIG_ARM_APPENDED_DTB y obtendrá el DTB a partir de la dirección donde finaliza la imagen del kernel en RAM.


Sintaxis del Device Tree

Hasta aquí hemos entendido qué es, para qué sirve y como se usa un Device Tree. Vamos a aprender ahora a leer e interpretar un Device Tree. Veamos su sintaxis y su semántica.

En términos generales el Device Tree es una estructura de datos capaz de describir la topología y la configuración del hardware presente en el sistema, incluyendo:

    * La cantidad y el tipo de las CPUs 
    * La dirección base y el tamaño de la memoria RAM
    * Los buses presentes en el sistema
    * Los dispositivos conectados a esos buses, ademas de información específica de cada dispositivo como lineas de interrupción, direcciones de I/O, relojes, canales DMA, etc

En lineas generales, esta es la sintaxis del Device Tree:

    / {
        node@0 {
            string-property = "A string";
            string-list-property = "first string", "second string";
            byte-string-property = [0x01 0x23 0x34 0x56];

            child-node@0 {
                first-child-property;
                second-child-property = <1>;
                reference-to-something = <&node1>";
            };

            child-node@1 {
            };
        };

        node1: node@1 {
            empty-property;
            cell-property = <1 2 3 4>
        };
    };

Como vemos el formato interno de un Device Tree se parece bastante al formato de un XML o de un JSON. Tenemos nodos, subnodos, y propiedades.

El primer nodo es llamado nodo root (raíz), y como vemos empieza con un slash "/".

Dentro del nodo raíz se define un sub-nodo llamado node@0 y otro sub-nodo llamado node@1. Hay una convención para identificar a los nodos: les damos un nombre, seguido por una arroba "@" y una dirección. Pero recuerda, es solo una convención, podrías poner lo que te diera la real gana.

Cada nodo se usa para describir a un dispositivo del sistema. Por tanto, usando la convención definida, al nodo le damos el nombre del dispositivo y después de la arroba ponemos la dirección del dispositivo dentro del mapa de memoria. Por ejemplo si estas describiendo un dispositivo como un puerto UART que tienen registros internos, debes poner la dirección base donde estan mapeados esos registros internos. Si estas describiendo un dispositivo que esta conectado al bus I2C, debes poner la dirección del dispositivo en el bus I2C.

Para algunos dispositivos no necesitas poner una dirección después de la arroba. Por ejemplo si estas describiendo un dispositivo analógico, no necesita una dirección. Pondrías solo el nombre del dispositivo analógico y nada mas.

Vemos que tenemos un nodo llamado node@0, que empieza y termina donde indican sus llaves, y tenemos un nodo node@1 que empieza y termina donde indican sus llaves. El nodo node@1 tiene una etiqueta "node1" que no tenemos en el nodo node@0. Las etiquetas se usan para referenciar al nodo en cualquier parte del Device Tree sin usar su nombre. Por ejemplo, vemos en el nodo node@0 que hay una referencia llamada reference-to-something que apunta a <&node1>, lo que indica que esta apuntando al nodo node@1 a través de su etiqueta. 

Vemos que cada nodo define una serie de propiedades y unos sub-nodos. Por ejemplo, el nodo node@0 define tres propiedades: una propiedad de tipo string llamada string-property, una lista de propiedades de tipo string llamada string-list-property y una lista de bytes llamada byte-string-property, que como vemos se define usando corchetes. Y define dos sub-nodos llamados child-node@0 y child-node@1. A su vez, el sub-nodo child-node@0 define tres propiedades first-child-property (que puede usarse como un booleano para indicar al device driver si esta propiedad esta habilitada o no), second-child-property (que define un entero) y reference-to-something que se usa como referencia al nodo node1.


Semántica del Device Tree

Acabamos de explicar la sintaxis del Device Tree. Ahora vamos a hablar sobre su semántica, echando un vistazo a un trozo real de un Device Tree. Aquí tenemos un nodo que describe una UART:

    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>;
        interrupts = <0 26 0x4000>
        clocks = <&clks 160>, <&clks 161>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "disabled";
    };

Tenemos el nombre del nodo, que es serial@02020000, y que tiene asociado una etiqueta llamada "uart1", que podemos referenciar en cualquier parte del Device Tree. Mas adelante veremos cómo se puede usar esta etiqueta para cambiar cualquiera de las propiedades de este nodo.

Dentro del nodo tenemos definidas una serie de propiedades. Una de las mas importantes es la propiedad "compatible". Esta propiedad es utilizada por el Linux kernel para identificar el driver que manejará este nodo del Device Tree.

El device driver se registra en el kernel indicando que es capaz de gestionar nodos del Device Tree que tengan una propiedad compatible con valor "fsl,imx6q-uart". Cuando el kernel encuentra un nodo en el Device Tree con el que es compatible, llama a la función "probe" de ese driver para inicializar ese dispositivo. Lo veremos después con mas detalle.

Al final tenemos una propiedad "status" que es otra propiedad importante porque permite habilitar o deshabilitar un nodo en un Device Tree. Cuando el kernel parsea el nodo y encuentra que el valor de la la propiedad status es "disabled", no llamará a la función "probe" del device driver. Por lo tanto, esta es otra manera de deshabilitar un dispositivo del sistema: puedes deshabilitar el driver en el kernel o simplemente puedes deshabilitar el driver en el Device Tree. Si quieres que el kernel llame a la función probe del device driver, debes cambiar esta propiedad con el valor "okey". Lo cierto es que desconozco el motivo por el que se ha decidido poner "okey" y no "enabled", pero si pones "enabled", no funciona. Si no defines la propiedad status, el estado del nodo por defecto es enabled. Debes ser explicito para deshabilitar un nodo del device tree.

Tenemos también una propiedad llamada "reg", que sigue con dos valores: el primero 0x02020000 es la dirección base del dispositivo, por lo tanto este dispositivo usa memory mapped I/O, y el segundo 0x4000 es el tamaño del bloque de registros internos de este dispositivo. Generalmente puedes encontrar esta información en el Reference Manual (RM) del procesador. 

A continuación tenemos la propiedad "interrupts". Lo que indica es que el procesador utiliza la interrupción 26 para el dispositivo UART1. Necesitamos esta información en el device driver para registrar el interrupt handler y manejar las interrupciones del dispositivo.

Como estamos viendo, en el Device Tree se registran los dispositivos y toda la información necesitada para que los device drivers puedan manejar esos dispositivos.

Veamos ahora un pedazo del device driver que maneja este dispositivo en el Device Tree:

    static const struct of_device_id imx_uart_dt_ids[] = {
        { .compatible = "fsl,imx6q-uart", .data = &imx_uart_devdata[IMX6Q_UART], },
        { .compatible = "fsl,imx1-uart", .data = &imx_uart_devdata[IMX1_UART], },
        { .compatible = "fsl,imx21-uart", .data = &imx_uart_devdata[IMX21_UART], },
        { /* sentinel */ } 
    };   

    static struct platform_driver serial_imx_driver = {
        .probe     = serial_imx_probe,
        .remove    = serial_imx_remove,
        .id_table  = imx_uart_devtype,
        .driver    = {  
            .name  = "imx-uart",
            .of_match_table = imx_uart_dt_ids,
            .pm = &imx_serial_port_pm_ops,
        },
    };   

Como vemos se declara una estructura del tipo of_device_id donde se indica la compatibilidad del device driver. Este device driver en concreto es compatible con 3 dispositivos: "fsl,imx6q-uart", "fsl,imx1-uart" y "fsl,imx21-uart". Por lo tanto es capaz de manejar tres dispositivos diferentes.

Puesto que éste es un driver de plataforma, se declara una estructura de tipo platform_driver donde entre otras se define la función probe (serial_imx_probe) que será llamada por el kernel durante la carga de este driver. Veamos con mas detalle esta función:

    static int serial_imx_probe(struct platform_device *pdev)
    {
        [...]
        res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
        [...]
        base = devm_ioremap(&pdev->dev, res->start, PAGE_SIZE);
        [...]
        sport->port.irq = platform_get_irq(pdev, 0);
        [...]
        sport->clk_ipg = devm_clk_get(&pdev->dev, "ipg");
        [...]
    }

Esta función utiliza funciones definidas en el Linux kernel para acceder a la información del Device Tree. Por ejemplo, la función platform_get_irq devuelve el número 26 que previamente definimos en la propiedad interrupts del Device Tree. De esta manera, el driver puede registrar el irq handler para esta irq. 

Y así es como se hace. La idea es describir el hardware y toda la información necesaria para que el device driver pueda configurarse funcionar correctamente.


Modularidad con los Device Tree includes

Imagina ahora que tenemos dos boards muy similares. ¿Voy a tener que crear dos Device Tree muy similares y tener que mantener cada uno por separado? No, esa no es una buena idea porque tendremos problemas para mantenerlos.

El Device Tree se puede dividir en varios ficheros para hacerlo mas modular y reusable. Lo que haremos será crear un fichero DTS que pueda ser incluído en otros ficheros DTS. Es lo que se conoce como ficheros DTS de include, o DTSI, que llevan la extensión .dtsi.

Cada board tiene su propio fichero .dts. Los ficheros .dts pueden incluir ficheros .dtsi. Y los ficheros .dtsi pueden incluir a su vez a otros ficheros .dtsi. Las inclusiones se realizan con la directiva #include y son procesados a través de un mecanismo de overlays que explicaremos después. 

En la practica esto se convierte en un diseño muy modular y usable.

Veamos un ejemplo extraído del código fuente del Linux kernel:

    $ cat arch/arm/boot/dts/imx6dl-colibri-eval-v3.dts:

    /dts-v1/;

    #include <dt-bindings/input/input.h>
    #include <dt-bindings/interrupt-controller/irq.h>
    #include "imx6qdl.dtsi"
    #include "imx6qdl-colibri.dtsi"

    / {
        model = "Toradex Colibri iMX6DL/S on Colibri Evaluation Board V3";
        compatible = "toradex,colibri imx6dl-eval", "toradex,colibri_imx6dl", "fsl,imx6dl";
        [...]
    };

    &uart1 {
        status = "okay";
    };

    [...]

Podemos ver que al principio utiliza la directiva #include para incluir algunos ficheros. Básicamente está incluyendo el Device Tree que describe a la familia de SoC imx6dl (imx6qdl.dtsi). Luego incluye el imx6qdl-colibri.dtsi, que es el Device Tree de una familia de boards basadas en el iMX.6 llamada Colibri. Y luego define algunos nodos y propiedades específicos de esta board.

Cuando el compilador parsea este fichero, primero parseará el primer include, después el segundo include. Si este segundo include se cambia algún nodo o propiedad que ya hubiera sido descrito por el primer nodo, lo cambiará. Por tanto, el compilador maneja estos includes utilizando un mecanismo de overlays. Si luego en nuestro Device Tree cambiamos alguna propiedad o algún nodo de este device tree, la última propiedad establecida será la que tendrá efecto.

Nos fijamos ahora en el contenido del fichero imx6qdl.dtsi:

    [...]
    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>
        interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>
        clocks = <&clks IMX6QDL_CLK_UART_IPG>,
                 <&clks IMX6QDL_CLK_UART_SERIAL>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "disabled";
    };
    [...]

En este nodo se describe el dispositivo uart1. Pero esta descrita de forma que su estado esta en disabled por defecto, como puedes ver en la propiedad status. En nuestro Device Tree hemos usado su etiqueta (uart1) para redefinir la propiedad status con valor "okay" para habilitar la uart1. El Device Tree final que tendremos tras el parseo será este:

    [...]
    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>
        interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>
        clocks = <&clks IMX6QDL_CLK_UART_IPG>,
                 <&clks IMX6QDL_CLK_UART_SERIAL>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "okay";
    };
    [...]

Como vemos, el nodo status ha cambiado su valor de disabled a okay. Podemos hacer esto con cualquier propiedad y con cualquier nodo del Device Tree.


Los Device Tree bindings

Imagina que quieres habilitar un acelerómetro, o un magnetómetro o cualquier otro dispositivo en un Device Tree. ¿Donde puedo encontrar documentación sobre las propiedades que necesito definir para que el kernel de Linux reconozca ese dispositivo?

Las propiedades de cada nodo del Device Tree se definen y documentan en un fichero llamado Device Tree bindings. Cada documento describe que propiedades se aceptan, con qué valores, que propiedades son obligatorias, cuales son opcionales, etc. ¿Donde podemos localizar los Device Tree bindings? Todas estas bindings estan (o por lo menos deberían estar) documentadas en el código fuente del kernel de Linux, en este directorio:

    Documentation/devicetree/bindigs

Veamos un ejemplo sobre el códec de audio sgtl5000:

    $ cat Documentation/devicetree/bindings/sound/sgtl5000.txt

    * Freescale SGTL5000 Stereo Codec

    Required properties:
    - compatible : "fsl,sgtl5000".

    - reg : the I2C address of the device

    - clocks : the clock provider of SYS_MCLK

    Example:

    codec: sgtl5000@0a {
        compatible = "fsl,sgtl5000";
        reg = <0x0a>;
        clocks = <&clks 150>;
    };

Como vemos es un fichero de texto. Primero se hace una descripción del dispositivo. Después se habla de las propiedades obligatorias (required properties). En este caso en concreto tenemos 3 propiedades obligatorias: compatible, reg y clocks. Podríamos tener propiedades opcionales, aunque no las hay en este ejemplo en concreto. Y al final se muestra un ejemplo que demuestra como usar las propiedades. Ese ejemplo es importante ya que lo podemos usar como un punto de inicio para describir el hardware en nuestro propio Device Tree. Por supuesto se trata de un ejemplo, así que deberemos verificar que la dirección base, que en el ejemplo es 0x0a, o el reloj que vamos a utilizar sean los correctos. Pero en cualquier caso es un punto de inicio para definir el Device Tree de nuestra
nueva placa.


Cierre del post

Termino de esta manera con la redacción de un post sobre el Device Tree bastante técnico que empecé a escribir hace algunos meses y y no encontraba el momento oportuno para publicar. Espero que este articulo ayude a la comunidad de desarrolladores del Linux Kernel en español a mejorar el entendimiento de un sistema operativo que tantos y tantos progresos ha traído a nuestra sociedad en general. Como siempre estaré encantado de atender vuestras dudas y opiniones en los comentarios. Un placer, gracias por leerme y hasta una próxima ocasión!

Visitas:

Seguidores