El bog de pepramon

Mi imagen de CertBoot

Categorías: Dockerfiles

En este post se describe como se ha creado la imagen de CertBoot para que vaya comprobando y autorenovando los certificados cada cierto tiempo.

Introducción

Hace un tiempo que tenia scripts para lanzar a CertBoot en el host, pero pensé, ¿porque no lo pongo en Docker desde la imagen oficial?.

Revisando la imagen oficial, vi que era muy fácil lanzarlo una vez para renovar los certificados, pero a mi me gustaría algo más automático.

También vi imágenes personalizadas que tiraban de cron, etc.. Pero para eso, mi imagen, y a poder ser sencilla. Es por ello que me lié la manta a la cabeza y preparé una imagen de CertBoot lo más sencilla posible que partiera de la oficial para mantener todas las características.

El código fuente es público y la explicación de como utilizar esta imagen personalizada se puede encontrar en Github

La solución

La solución ha sido lo más sencilla posible, añadir el comando sleep, y un entrypoint lo más sencillo posible.

Como se ha implementado la nueva imagen

Se ha dejado la imagen lo más similar posible a la imagen base de CertBoot de esta manera la nueva imagen funciona como si fuera la original pero con el añadido que se ha comentado.

A continuación se expondrán los ficheros utilizados para su creación y el porque de las cosas.

El Dockerfile

Para empezar, se incluye el Dockerfile para generar la imagen de Docker.

# Utilizamos la imagen oficial de certbot como base
FROM certbot/certbot:latest

# CertBoot latest se basa en Alpine, se actualiza el sistema
RUN apk update && \
    apk  add --no-cache bash && \
    apk upgrade && \
    rm -rf /var/cache/apk/*

# Copiar el script de inicio al contenedor
COPY script_inicio.sh /script_inicio.sh

# Definir el script como entrypoint
ENTRYPOINT ["/bin/sh", "/script_inicio.sh"]

Solo se actualiza el sistema (ya que se construye, que sea lo más actual posible), se añade bash y el entrypoint. Poca cosa.

El script-inicio.sh

El propio script que se utiliza está bastante comentado y se puede seguir bien, es por ello, que se añadirá a continuación y se harán los comentarios clave al final.

#!/bin/bash

# Para una salida limpia si Certboot está en marcha
salir_bien() {
    echo "Apagando CERTBOOT"
    if kill -0 "${CERTBOOT_PID}" 2>/dev/null; then
        kill -SIGTERM "${CERTBOOT_PID}"
        wait "${CERTBOOT_PID}" || echo "CERTBOOT ya estaba detenido"
    fi
}

# Si se reciben las señales 
# Si se recibe SIGTERM, se mata de manera ordena el proceso (lo envía docker por defecto para apagar contenedores)
# INT es SIGINT que sería el Ctrl+c
trap "salir_bien" SIGTERM INT
# Si hay un SIGQUIT se hace un exit. SIGQUIT tiene volcado de memoria
trap "exit" QUIT

while true; do
    certbot $@
    
    # Si existe la variable UID, se cambia el propietario de los certificados
    if [ -n "$UID" ]; then
        echo "Cambiando propietario de los ficheros a uid--> ${UID}"
        chown -R $UID /etc/letsencrypt
    fi
    
    # Si existe la variable GID, se cambia el grupo de los certificados
    if [ -n "$GID" ]; then
        echo "Cambiando grupo propietario de los fichero al gid --> ${GID}"
        chgrp -R $GID /etc/letsencrypt
    fi
    
    # Permiso total al propietario y de lectura al grupo
    echo "Cambiando permisos de fichero a 740"
    chmod -R 740 /etc/letsencrypt
    
    # Por si alguien utilizaba la imagen anterior, se mantiene que funcione con SLEEP
    if [ -n "$SLEEP" ]; then
        DORMIR=$SLEEP
    fi
    
    # Si existe la variable DORMIR, se espera es tiempo, y sino se sale
    if [ -n "$DORMIR" ]; then
        # Tiempo a dormir
        echo "Durmiendo durante ${DORMIR}"
        sleep $DORMIR
    else
        break
    fi
done &

# Guardar el PID del proceso
CERTBOOT_PID=$!

# Se espera a que termine el proceso y recoge el código de salida
wait -n "${CERTBOOT_PID}"
EXIT_CODE=$?

# Se sale
echo "Saliendo con código de salida ${EXIT_CODE}"
exit "${EXIT_CODE}"

El resumen es un bucle infinito que ejecuta el CMD con el que se lanza el contenedor, cambia el propietario y el grupo de los ficheros (según una variable de entorno) y se duerme un tiempo definido en otra variable de entorno y se vuelve a repetir.

Personalmente, me pareció la manera más sencilla de tenerlo funcionando y renovando los certificados cuando toca.

Como se actualiza la imagen

También me gustaría comentar que esta imagen no se crea una vez, se sube a DockerHub y no se hace nada más en el tiempo.

En el servidor está trabajando Gitea para mi uso personal, y también tiene configurado el trabajar con runners, por tanto, puedo ejecutar acciones de Gitea para mantener la imagen actualizada, a continuación se explica que se hace a través del propio código fuente de la acción (ATENCIÓN, se ejecuta en contenedor personalizado!!!)

name: Crear nueva imagen al actualizar
run-name: Se crea nueva imagen y se sube a dockerhub
on:
  push:
    branches:
      - main
      
    # No se lanza la acción si la actualización es del readme.md
    paths:
      - '**/*'          # Todos los archivos del repositorio
      - '!README.md'
      
  schedule:
#    - cron: "*/5 * * * *"  # Ejecuta cada 5 min para pruebas
    - cron: "1 2 * * *"  # Ejecuta todos los días a las 02:1h

jobs:
  "Actualización de imagen":
    runs-on: custom
    env:
      IMAGEN_BASE: 'docker.io/certbot/certbot:latest'
      IMAGEN_DEST: 'docker.io/pepramon/certbot:latest'
    steps:
      - name: Comprobar si es un push
        run: |
          if [ "${% raw %}{{ github.event_name }}{% endraw %}" == "push" ]; then
            echo "Es un push real. Se debe construir la imagen."
            echo "construir=true" >> $GITHUB_ENV
          else
            echo "No es un push. Continuando con las verificaciones cambio imagen o actualizaciones."
          fi

      - name: Comprobar si hay actualizaciones de Alpine
        if: env.construir != 'true'
        run: |
          echo "Se hacen comprobaciones de actualizaciones de Alpine. Bajando imagen"
          podman pull $IMAGEN_DEST
          
          comando="apk update > /dev/null 2>&1 && apk list -u | wc -l"
          paquetes_actualizar=$(podman run --entrypoint "" $IMAGEN_DEST bash -c "$comando")
          
          echo "Hay $paquetes_actualizar paquetes por actualizar de Alpine"
          
          if [ "$paquetes_actualizar" -gt 0 ]; then
            echo "Actualizaciones disponibles en Alpine. Hay que construir imagen"
            echo "construir=true" >> $GITHUB_ENV
          else
            echo "No hay actualizaciones disponibles en Alpine."
          fi

      - name: Comprobando si imagen base ha cambiado
        if: env.construir != 'true'
        run: |
          echo "Realizando pull de las imagenes para comprobar si hay cambios"
          podman pull $IMAGEN_BASE
          # Se guarda que se ha hecho pull
          echo "pullEcho=true" >> $GITHUB_ENV
          
          # Se obtiene el sha de la imagen base
          base=$(podman inspect $IMAGEN_BASE --format '{% raw %}{{index .RepoDigests 0}}{% endraw %}' | awk -F'@' '{print $2}')
          # Se obtiene el guardado en la imagen construida
          imagen=$(podman inspect $IMAGEN_DEST --format '{% raw %}{{ index .Config.Labels "base_digest" }}{% endraw %}')
          
          # Comprobación de si ha cambiado o no
          if [ "$base" == "$imagen" ]; then
            echo "La imagen base no ha cambiado."
          else
            echo "La imagen base ha cambiado. Hay que reconstruir la imagen derivada."
            echo "construir=true" >> $GITHUB_ENV
          fi
          
      - name: Checkout repositorio
        if: env.construir == 'true'
        uses: http://gitea:3000/acciones/checkout@v1
        
      - name: Hacer pull imagen base
        if: {% raw %}${{ env.pullEcho != 'true' && env.construir == 'true' }}{% endraw %}
        run: |
          echo "Haciendo pull de $IMAGEN_BASE"
          podman pull $IMAGEN_BASE
          
      - name: Se construye la imagen
        if: env.construir == 'true'
        run: |
          # Obtén el digest de la imagen base 
          base_digest=$(podman inspect $IMAGEN_BASE --format '{% raw %}{{index .RepoDigests 0}}{% endraw %}' | awk -F'@' '{print $2}')
          
          # Construcción de la imagen
          podman build -t $IMAGEN_DEST --label base_digest="$base_digest" .
          
      - name: Logeo en dockerhub
        if: env.construir == 'true'
        run: podman login -u pepramon -p {% raw %}${{ secrets.DOCKERHUB }}{% endraw %} docker.io
        
      - name: Se sube la imagen
        if: env.construir == 'true'
        run: podman push $IMAGEN_DEST

Aunque se puede seguir bastante bien, hay que comentar que mi runner personalizado tiene instalado Podman en lugar de Docker, ¿porque? pues para probar y aprender.

Hay que tener cuidado, ya que se utilizan nombres de host internos de mi servidor, por tanto, no funcionará fuera de mi instalación y configuración, pero básicamente hace:

  • La acción se ejecuta todos los días a las 2:1
  • Si es un push, al repo, si o si se hace la construcción de la imagen
  • Si no es un push, se construirá de nuevo la imagen si:
    • Hay actualizaciones de Alpine en la imagen ya construida
    • La imagen base ha cambiado
  • El resto de pasos, son para construir la imagen y subirla a DockerHub.

Hay que añadir un comentario a la construcción, como no sabia muy bien como saber si la imagen base con la que se había generado la imagen personalizada había cambiado, se ha añadido ese dato a la propia imagen generada.

Para ello, se obtiene la suma SHA de la imagen base mediante base_digest=$(podman inspect $IMAGEN_BASE --format '{% raw %}{{index .RepoDigests 0}}{% endraw %}' | awk -F'@' '{print $2}'), y al construir la imagen se le añade una etiqueta llamada base_digest donde se almacena desde que imagen base se ha construido (comando de construcción podman build -t $IMAGEN_DEST --label base_digest="$base_digest" .)

Con esto se puede estar seguro de detectar de manera fácil si la imagen base ha cambiado o no (ver paso llamado Comprobando si imagen base ha cambiado)

Conclusiones

Con esto, queda explicada la creación de mi imagen de CertBoot que utilizo conjuntamente con la de Nginx para servir este blog.