Introducción

Esta es una guía para configurar Jenkins de forma que use contenedores Docker como agentes de construcción.

Antes de nada, un apunte: mucha gente usa nodo y agente de Jenkins como si fueran lo mismo, y no lo son.

Un nodo Jenkins, también llamado servidor Jenkins, es cualquier máquina (física o virtual) conectada a la red o al entorno de Jenkins. Los nodos ponen la CPU y el entorno donde se ejecutan los trabajos de construcción. Y tanto los controladores como los agentes cuentan como nodos.

Dicho de otra forma: los nodos de Jenkins son las máquinas en las que se ejecutan los agentes.

Usar contenedores Docker como agentes te simplifica mucho la vida a la hora de crearlos: cada compilación levanta un contenedor nuevo, construye el proyecto y se destruye.

Es una práctica bastante habitual. Vamos a ver cómo se montan esos contenedores.

Configuración de la API REST de Docker

Lo primero que necesitas es un host Docker. El servidor Jenkins (master) se conectará a él para arrancar los agentes.

Esa conexión va por la API REST de Docker, así que hay que habilitar la API remota en el host Docker. Yo voy a usar un Ubuntu server, pero puedes usar cualquier SO que aguante Docker.

En distribuciones Linux, el acceso remoto al demonio de docker se habilita tocando el fichero de unidad systemd docker.service:

  1. Lanza sudo systemctl edit docker.service para abrir y sobrescribir el fichero, o abre directamente con tu editor favorito /lib/systemd/system/docker.service
  2. Aquí vamos a exponer la API REST de Docker en el puerto 4243. Es el puerto al que se conectará el maestro Jenkins para que Docker levante los agentes. Da igual cuál de las dos opciones de arriba hayas elegido: busca ExecStart y sustituye esa línea por esta:
[Service]
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:4243 -H unix:///var/run/docker.sock
  1. Guarda el fichero, recarga la configuración del sistema y reinicia Docker con estos comandos
$ sudo systemctl daemon-reload
$ sudo systemctl restart docker.service
  1. Verifica que la API REST de Docker esté habilitada y escuchando en el puerto 4243
$ curl http://localhost:4243/version
{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine","Version":"24.0.2","Details":{"ApiVersion":"1.43","Arch":"amd64","BuildTime":"2023-05-25T21:51:00.000000000+00:00","Experimental":"false","GitCommit":"659604f","GoVersion":"go1.20.4","KernelVersion":"6.5.0-17-generic","MinAPIVersion":"1.12","Os":"linux"}},{"Name":"containerd","Version":"1.6.21","Details":{"GitCommit":"3dce8eb055cbb6872793272b4f20ed16117344f8"}},{"Name":"runc","Version":"1.1.7","Details":{"GitCommit":"v1.1.7-0-g860f061"}},{"Name":"docker-init","Version":"0.19.0","Details":{"GitCommit":"de40ad0"}}],"Version":"24.0.2","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"659604f","GoVersion":"go1.20.4","Os":"linux","Arch":"amd64","KernelVersion":"6.5.0-17-generic","BuildTime":"2023-05-25T21:51:00.000000000+00:00"}

$ sudo netstat -lntp | grep dockerd
tcp6 0 0 :::4243 :::* LISTEN 3551202/dockerd 

Asegúrate de tener estos puertos abiertos en el firewall del servidor para que acepte conexiones del maestro Jenkins:

  • Docker Remote API port 4243
  • Docker Hostport Range 32768 to 60999

El rango 32768 a 60999 lo usa Docker para asignar un puerto del host por el que Jenkins se conecta al contenedor. Si no lo abres, el nodo de construcción se queda colgado en estado pendiente y no hay manera.

Con la API habilitada y probada, ya puedes ponerte a construir la imagen del agente Docker de Jenkins.

Plugin Docker para Jenkins

Antes de meternos con la imagen Docker, un par de cosas sobre el plugin Docker para Jenkins (https://plugins.jenkins.io/docker-plugin/)

Según la documentación oficial del plugin Docker:

This plugin allows containers to be dynamically provisioned as Jenkins nodes using Docker. It is a Jenkins Cloud plugin for Docker.

Además, la documentación establece:

The aim of this docker plugin is to be able to use a Docker host to dynamically provision a docker container as a Jenkins agent node, let that run a single build, then tear-down that node, without the build process (or Jenkins job definition) requiring any awareness of docker.

Dos notas importantes:

  • El plugin no usa el cliente nativo de docker del sistema operativo, sino docker-java. Así que no necesitas instalar un cliente de docker ni en Jenkins ni en tus agentes.
  • El plugin tampoco te proporciona un demonio de Docker, solo deja que Jenkins use uno. Instala Docker en algún sitio y el plugin se encargará de que Jenkins lo aproveche.

Instalación del plugin Docker para Jenkins

  1. Ve al Administrador de Plugins (Panel de control de Jenkins -> Administrar Jenkins -> Administrar Plugins)
  2. Selecciona la pestaña Disponible, busca “Docker”, instala el plugin Docker (https://plugins.jenkins.io/docker-plugin/) y reinicia Jenkins.

docker plugin

Configuración del plugin Docker para Jenkins y creación de agentes de contenedores

Una vez instalado, ve al Panel de control de Jenkins » Administrar Jenkins » Administrar nodos y nubes.

El plugin Docker es una implementación “Cloud”, por lo que necesitamos seleccionar Configurar nubes.

docker configuration

Haz clic en Agregar una nueva nube y selecciona “Docker”

add a new cloud

Dentro de docker hay que rellenar los datos como ves en la imagen:

docker details

Lo primero es ponerle nombre a la nube (“docker” en este ejemplo, aunque puedes usar cualquier otro que te resulte descriptivo). Verás también dos paneles de configuración: Detalles de la nube Docker y Plantillas de agentes Docker.

Detalles de la nube Docker

Al abrir Detalles de la nube Docker, el campo que importa es URI del host de Docker. Cuando lo rellenes, dale a Probar conexión a ver si responde. Si da error, vuelve a la configuración de la API REST remota de Docker, porque ahí está el problema.

docker cloud details

Plantillas de agentes Docker

En la sección de plantillas de agentes Docker puedes definir tantos agentes como te hagan falta. Ojo con un detalle: todos los agentes que definas aquí van a correr sobre el host de docker que has configurado antes. Con esto puedes tener varios contenedores distintos haciendo de agentes de Jenkins.

Como ves en la imagen, haciendo clic en Agregar plantilla de Docker creas un agente de contenedor concreto.

specific container agent

Al hacer clic en Agregar plantilla de Docker, verás un formulario como este:

docker agent templates

De todos los campos, estos son los que te interesan:

  • Etiquetas. Son las etiquetas de identificación del agente. Dentro de un pipeline, los agentes no se identifican por nombre sino por sus etiquetas, así que la sección de agentes del pipeline dice en qué etiqueta se va a ejecutar todo el pipeline (o una etapa concreta) dentro del entorno de Jenkins.
  • Nombre. Un simple prefijo para identificar los nodos creados a partir de esta plantilla. Es opcional.
  • Imagen de Docker. El campo más importante, porque aquí es donde indicas la imagen que se va a ejecutar.

Antes de poner ahí una imagen, hay algo que tienes que saber: el plugin Docker permite crear agentes de tres formas distintas según el método de arranque del agente. Son tres maneras diferentes de que el maestro Jenkins y los agentes se comuniquen entre ellos. Y según cuál elijas, la imagen tendrá unos requisitos u otros.

Los campos menos relevantes los vamos a saltar para centrarnos en el que decide todo lo demás: el Método de conexión.

connect method

Según la documentación del plugin Docker:

For all connection methods, Jenkins will start by triggering a docker run. Then, after this step, there will optionally be more steps to establish the connection. There are currently three alternative ways to connect your Jenkins master to the dynamically provisioned Docker agents.

Y los tres métodos de conexión son:

There are different pros and cons for each connection method. Depending on your environment, choose the one matching your needs. More detailed prerequisites are provided once you select a given method.

  1. Attach Docker container. Arranca un contenedor y luego se mete dentro con docker exec, todo por la API de Docker. El agente no necesita alcanzar al maestro por red para nada, porque toda la conversación pasa por la API de Docker.
  2. Conectar con JNLP. Al contenedor solo le llega un docker run inicial con el secreto correcto. A partir de ahí es el agente de remoting el que abre la conexión hacia el maestro por red, así que el agente sí tiene que poder llegar a la dirección y el puerto del maestro.
  3. Conectar con SSH. Aquí se espera que la imagen tenga un servidor SSH corriendo. Jenkins trata esa máquina igual que a cualquier otro agente conectado por SSH: el maestro se conecta, le copia el agente de remoting y lo arranca.

Pros y contras de cada método de conexión

  • Método #1 (Adjuntar contenedor Docker). Es el más fácil de montar. Como verás más abajo, dentro de la imagen del agente solo necesitas un JDK y el agent.jar de Jenkins. Además la conexión va en un solo sentido (maestro -> agente). Lo único malo es que tienes que actualizar el agent.jar a mano cuando cambie la versión del maestro. Aunque para eso ya hay una imagen de agente lista para usar que trae dentro el JDK y el agent.jar.
  • Método #2 (Conectar con JNLP). Se apoya en JNLP, una tecnología que descarga el código del agente desde el maestro. Usa TCP o WebSockets para abrir una conexión de entrada hacia el controlador de Jenkins, o sea que el agente tiene que llegar a la dirección y el puerto del maestro, y eso según cómo tengas segmentada la red puede ser un dolor de cabeza. Súmale que JNLP es una tecnología “bastante” antigua sobre la que ya se habla de deprecación y retirada. Con todo eso encima de la mesa, es la que menos recomiendo.
  • Método #3 (Conectar con SSH). Cuesta un poco más de montar, pero es la opción más segura y la que mejor se mantiene con el tiempo. El agent.jar se copia de forma segura al contenedor, así que siempre está actualizado, y la conexión va en un solo sentido (maestro -> agente), con lo que no necesitas abrir conectividad desde el host de docker de vuelta al maestro.

Puestos a elegir, yo me quedo con la opción #3 (Conectar con SSH), y en segundo lugar con la #1 (Adjuntar contenedor Docker). La #2 (Conectar con JNLP) la desaconsejo por lo que acabo de contar, aunque puede tener sentido en algún escenario muy concreto.

Cómo construyas la imagen depende del método de conexión que elijas, así que vamos a ver las tres opciones una por una.

Método de conexión #1: Adjuntar contenedor Docker

Esta es la forma más simple de conexión. Como decía antes, aquí el agente no necesita llegar al maestro por red, porque todo pasa por la API de Docker. Eso te quita de encima buena parte de la configuración de red.

Si eliges este método, la imagen de Docker es bastante sencilla. Le basta con tener un JDK instalado y el ejecutable del agente Jenkins (agent.jar). Puedes construirla desde cero o tirar directamente de la que ya viene hecha: jenkins/agent

Y si prefieres montarte la tuya, usa jenkins/agent como base y añádele lo que necesites.

FROM jenkins/agent
RUN apt-get update && apt-get install XXX
... your dockerfile instructions here …

Método de conexión #2: Conectar con JNLP

Ya te he dicho que es el que menos recomiendo. Pero si aun así vas a por él, la imagen de Docker necesita un JDK instalado y el agent.jar de Jenkins. La conexión va en un sentido (maestro -> agente), así que el maestro tiene que poder alcanzar al contenedor por red.

Para la imagen puedes partir de jenkins/inbound-agent y personalizarla desde ahí.

FROM jenkins/inbound-agent
RUN apt-get update && apt-get install XXX
... your dockerfile instructions here …

Método de conexión #3: Conectar con SSH

Este método es el más seguro, el más fiable y el que menos guerra te va a dar con el tiempo. No es tan directo como los anteriores, pero tampoco nada del otro mundo ;)

En los ejemplos que vienen vamos a autenticar con un par de claves, así que conviene repasar rápido cómo se genera.

Al generar claves SSH obtienes dos: una privada y una pública.

  • La clave pública hay que instalarla en el servidor al que te quieres conectar. Es lo que le permite reconocer y autenticar al cliente.
  • La clave privada es la importante, porque es la que te identifica a ti como cliente. Guárdala a buen recaudo. Si se te escapa y encima no le has puesto passphrase, cualquiera puede coger esa clave y autenticarse haciéndose pasar por ti.

Para generar el par de claves SSH lanza este comando:

$ ssh-keygen -t rsa -b 4096

El flag -t dice qué tipo de clave quieres crear (puedes usar “dsa”, “ecdsa”, “ecdsa-sk”, “ed25519”, “ed25519-sk” o “rsa”) y -b el número de bits (con RSA, el mínimo son 1024 bits y por defecto se usan 3072).

Nota IMPORTANTE:

Incluiremos la clave pública en nuestro contenedor. Y usaremos la clave privada para autenticarnos desde Jenkins.

Hasta donde hemos probado:

Si usas claves RSA, ¡¡el plugin Docker solo funciona con claves de 4096 bits!! Mientras escribo esto no sé si es un bug o una limitación conocida del plugin, pero merece la pena avisar: con el tamaño por defecto (3072) la autenticación no te va a funcionar y te vas a volver loco buscando el fallo en otro sitio.

Con el par de claves ya generado, quedan dos cosas por hacer:

  1. Guardar la clave privada en el Administrador de Credenciales de Jenkins, para que los pipelines puedan usarla
  2. Instalar la clave pública en el contenedor, para que el contenedor reconozca al cliente

Para lo primero, crea una credencial nueva en el Administrador de Credenciales de Jenkins (tipo: SSH Username with private key)

new credential

Y para que el contenedor tenga la clave pública, la metemos en el fichero $HOME/.ssh/authorized_keys de la imagen, donde $HOME es el directorio del usuario con el que nos vamos a conectar.

$ cat my_public_key >> authorized_keys

Lo que queremos es un contenedor Docker haciendo de agente Jenkins. Y para eso hay que construir una imagen que acepte conexiones ssh desde Jenkins y funcione como agente.

En los métodos anteriores la imagen tenía que traer de una forma u otra el jar del agente de Jenkins. Aquí también hace falta, pero con una ventaja: te da igual, porque el código del agente se copia por detrás sin que tengas que hacer nada.

Así que tu único trabajo es meter en la imagen lo necesario para que sea un servidor SSH.

El Dockerfile básicamente hace esto:

  1. Instalar un servidor SSH
  2. Instalar JDK
  3. Agregar un usuario (jenkins en nuestro caso)
  4. Agregar la clave pública al directorio de inicio del usuario jenkins
  5. Exponer el puerto SSH y arrancar el servidor SSH

Cómo se escriba eso depende de la imagen base que uses, así que aquí van varios ejemplos.

Ubuntu

Coge este Dockerfile como plantilla y adáptalo a lo que necesites.

FROM ubuntu:18.04

# Update the repository
RUN apt-get update && \
    apt-get -qy full-upgrade && \
# Install git
    apt-get install -qy git && \
# Install a basic SSH server
    apt-get install -qy openssh-server && \
    mkdir -p /var/run/sshd && \
# Install JDK 11
    apt-get install -qy openjdk-11-jdk && \
# Cleanup old packages
    apt-get -qy autoremove && \
# Add user jenkins to the image
    adduser --quiet jenkins && \
# Set password for the jenkins user
    echo "jenkins:jenkins" | chpasswd

# Copy authorized keys
COPY --chown=jenkins ./authorized_keys /home/jenkins/.ssh/authorized_keys
RUN chown -R jenkins:jenkins /home/jenkins/.ssh/
RUN chmod 700 /home/jenkins/.ssh/
RUN chmod 600 /home/jenkins/.ssh/authorized_keys

# Standard SSH port
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]

Cuando configures la plantilla del agente Docker, acuérdate de elegir el método “Conectar con SSH” y, como clave SSH, “Usar credenciales SSH configuradas”. En Credenciales SSH selecciona la credencial que creaste antes con la clave privada.

connect with SSH

Alpine

Con Alpine el Dockerfile cambia un poco, porque el sistema operativo de debajo no es el mismo.

FROM alpine:latest
RUN apk add --no-cache openssh openssh-server openssh-keygen
RUN addgroup -S jenkins
RUN adduser -D -G jenkins jenkins
RUN echo "jenkins:jenkins" | chpasswd

# Copy authorized keys
COPY --chown=jenkins ./authorized_keys /home/jenkins/.ssh/authorized_keys
RUN chown -R jenkins:jenkins /home/jenkins/.ssh/
RUN chmod 700 /home/jenkins/.ssh/
RUN chmod 600 /home/jenkins/.ssh/authorized_keys

EXPOSE 22
RUN ssh-keygen -A
CMD ["/usr/sbin/sshd", "-D"]

From jenkins/ssh-agent

Jenkins también publica una imagen base para que construyas tu contenedor encima. Sale más a cuenta que empezar de cero.

FROM jenkins/ssh-agent:latest
RUN apt-get update
RUN apt-get install -qy zip && \
apt-get install -qy curl
COPY --chown=jenkins ./authorized_keys "${JENKINS_AGENT_HOME}"/.ssh/authorized_keys

Resumen y conclusiones

A lo largo del documento hemos configurado Jenkins para que use contenedores Docker como agentes, y hemos pasado por los tres métodos de conexión con los requisitos de cada uno.

También hemos construido la imagen de Docker que hace de agente Jenkins y hemos dejado a Jenkins configurado para usarla.

Si tuviera que quedarme con una opción, sería SSH: es la más segura y la que menos mantenimiento te va a pedir. Cuesta un poco más al principio, pero se recupera con creces.

Con el plugin Docker para Jenkins puedes aprovisionar contenedores como agentes sobre la marcha, sin tener máquinas encendidas esperando trabajo. Que es justo lo que quieres cuando la cola de builds empieza a crecer.