El bog de pepramon

Mi imagen de Nginx

Categorías: Dockerfiles

Como utilizo CertBoot, es necesario que Nginx se reinicie si hay cambios en los certificados SSL. En este post, explico como se ha creado una imagen basada en Nginx que detecta ese cambio y reinicia el servidor.

Introducción

Llevaba un tiempo utilizando Nginx conjuntamente con CertBoot y todo funcionaba correctamente. Por alguna razón, la imagen oficial de Nginx no se actualizó en en más de un mes, y por tanto, el servicio WatchTower no reinició el servicio.

Aunque CertBoot había actualizado correctamente los certificados de Let’sEncypt un mes antes de que caducaran, el tiempo paso, y me encontré que el certificado estaba caducado y no sabía muy bien que había pasado. Finalmente descubrí que el contenedor de Nginx llevaba más de un mes de uptime, y por ello no había leído los nuevos certificados.

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

Estuve buscando como lo podía solucionar para que no me pasara más, y todo lo que encontré era con scripts en el host (cosa que no quería) o con configuraciones que no me gustaban en el docker-compose.

Por ello, como en el pasado he utilizado inotify, se que se puede vigiar un directorio para detectar eventos en ellos. A través de esta herramienta, y haciendo lo más básico, se ha creado una imagen de Nginx que vigila un listado de directorios, y si hay algún cambio en ellos reinicia el servidor al cabo de 10 minutos (para que de tiempo a que se ejecute CertBoot correctamente).

Como se ha implementado la nueva imagen

Se ha dejado la imagen lo más similar posible a la imagen base de Nginx, 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 nginx como base
FROM nginx:stable

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

# For inotify, y se actualizan los paquetes
RUN chmod +x /script_inicio.sh && \
    apt-get update && \
    apt-get -y upgrade && \
    apt-get install -y --no-install-recommends inotify-tools && \
    apt autoremove -y && \
    apt clean && \
    rm -rf /var/lib/apt/lists/* 

# Definir el script como entrypoint
ENTRYPOINT ["/script_inicio.sh"]
CMD ["nginx", "-g", "daemon off;"]

Como se puede ver, nada nuevo bajo el sol, lo que hace es:

  • Copia el nuevo script de inicio
  • Se le da permiso ejecución al nuevo script
  • Se instala inotify-tools
  • Se actualiza el sistema (aprovechando que está basado en Debian)
  • Se define el nuevo entrypoint con el script que se ha copiado al inicio
  • Se define el comando de entrada al contenedor

Como comentario, el CMD es exactamente el mismo que utiliza la imagen oficial de Nginx, de esta manera el comportamiento será el mismo que la imagen oficial

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

# Función para manejar una salida limpia del script
salida_limpia() {
    echo "Iniciando proceso de apagado..."
    
    # Detener procesos de vigilancia de directorios
    if [ ${#PID_DIRS[@]} -gt 0 ]; then
        for pid in "${PID_DIRS[@]}"; do
            kill -SIGTERM "$pid" 2>/dev/null
            wait "$pid" 2>/dev/null
        done
    fi
    
    # Limpiar archivos temporales de bloqueo
    echo "Limpiando archivos temporales..."
    rm -f /tmp/nginx_reload_*_lock
    
    # Detener el proceso de Nginx
    if kill -0 "${NGINX_PID}" 2>/dev/null; then
        echo "Deteniendo Nginx..."
        kill -SIGTERM "${NGINX_PID}"
        wait "${NGINX_PID}"
    fi
    
    echo "Apagado completo."
    exit
}

# Configurar manejo de señales para salida ordenada
# TERM, INT: Parar contenedor (ej.: señal de Docker o Ctrl+C)
# QUIT: Señal adicional de interrupción
trap "exit" TERM INT QUIT
trap "salida_limpia" EXIT SIGINT SIGTERM

# Explicación:
# - TERM: Señal enviada por Docker al detener un contenedor de forma controlada.
# - INT: Señal enviada cuando un usuario presiona Ctrl+C en la terminal.
# - QUIT: Señal de interrupción, útil como alternativa en algunos sistemas.
# - EXIT: Ejecuta la función `salida_limpia` al cerrar el script, garantizando la limpieza.
# 
# Este bloque asegura que el contenedor o script detenga los procesos internos de manera ordenada,
# limpiando recursos y cerrando procesos secundarios como los vigilantes de directorios o Nginx.


# Se lanza nginx con sus parametros y se guarda el PID
echo "Iniciando Nginx..."
/docker-entrypoint.sh "$@" &
NGINX_PID=$!

# Función para recargar Nginx tras detectar cambios en un directorio
recargar_una_vez() {
    # Saber que directorio ha emitido el cambio
    local dir="$1" # Directorio que desencadenó el evento
    local lock_file="/tmp/nginx_reload_${dir//\//-}_lock"

    # Evitar recargas múltiples simultáneas
    if [ ! -f "$lock_file" ]; then
        touch "$lock_file"
        echo "Esperando 10 minutos antes de recargar Nginx..."
        sleep 600s  # Se espera 10 min para recargar nginx
        echo "Recargando Nginx debido a cambios en: $dir"
        kill -HUP ${NGINX_PID}
        rm "$lock_file"
    fi
}

# Compatibilidad con imágenes anteriores (alias para la variable DIRECTORIES)
if [ -n "$DIRECTORIES" ]; then
        DIRECTORIOS=$DIRECTORIES
fi

# Declarar un arreglo para almacenar los PIDs de los procesos de vigilancia
declare -A PID_DIRS

# Configurar vigilancia para los directorios especificados
for DIR in $DIRECTOROS; do
    if [ -d "$DIR" ]; then
        echo "Configurando vigilancia en: $DIR"
        (
        # Bucle para vigilar eventos en el directorio
        while true; do
            inotifywait -m -r -e modify,create,delete,move "$DIR" | 
            while read -r directory event filename; do
                if [[ $filename == *.certbot.lock ]]; then
                    echo "Fichero lock de CertBoot detectado, no se recarga configuración"
                else
                    echo "Evento detectado: $event en $directory$filename"
                    recargar_una_vez "$DIR" &
                fi
            done
        done
        ) &
        PID_DIRS[$DIR]=$! # Guardar el PID del proceso de vigilancia
    else
        echo "El directorio $DIR no existe. Finalizando..."
        sleep 5s
        exit 1
    fi
done

# Esperar a que los procesos terminen (incluyendo Nginx y los vigilantes)
if [ ${#PID_DIRS[@]} -gt 0 ]; then
    wait -n "${NGINX_PID}" "${PID_DIRS[@]}"
    EXIT_CODE=$?
else
    wait -n "${NGINX_PID}"
    EXIT_CODE=$?
fi

# Informar sobre la salida del script
echo "Saliendo con código ${EXIT_CODE}"
exit ${EXIT_CODE}

Primero se hace una función para cuando se pare el contenedor tenga una salida limpia, posteriormente se lanza el entrpoint del contenedor original de Nginx y se guarda el pid, para finalmente, lanzar un proceso por cada directorio que hay en la variable directorios, para finalmente esperar a que todos los proceses acaben.

El punto clave estaría en:

# Se lanza nginx con sus parametros y se guarda el PID
echo "Iniciando Nginx..."
/docker-entrypoint.sh "$@" &
NGINX_PID=$!

Aquí, lo que se hace es utilizar el entrypoint de la imagen base utilizando como argumento el CMD que se le indique, esto permite lanzar Nginx de la imagen oficial exactamente igual que se haría en la imagen original.

La vigilancia de directorios

La función definida como

# Configurar vigilancia para los directorios especificados
for DIR in $DIRECTOROS; do
    if [ -d "$DIR" ]; then
        echo "Configurando vigilancia en: $DIR"
        (
        # Bucle para vigilar eventos en el directorio
        while true; do
            inotifywait -m -r -e modify,create,delete,move "$DIR" | 
            while read -r directory event filename; do
                if [[ $filename == *.certbot.lock ]]; then
                    echo "Fichero lock de CertBoot detectado, no se recarga configuración"
                else
                    echo "Evento detectado: $event en $directory$filename"
                    recargar_una_vez "$DIR" &
                fi
            done
        done
        ) &
        PID_DIRS[$DIR]=$! # Guardar el PID del proceso de vigilancia
    else
        echo "El directorio $DIR no existe. Finalizando..."
        sleep 5s
        exit 1
    fi
done

Lo que hace esta función es que para cada directorio dentro de la variable $DIRECTORIOS realiza los siguientes pasos:

  • Comprueba que el directorio exista
  • Lanza un proceso independiente dentro de un while infinito que:
    • Lanza inotifywait por si hay modificaciones, creaciones, borrado o movimientos de fichero en el directorio
    • Si se detecta un movimiento, comprueba que no es el fichero de lock de certboot
    • En caso que sea un movimiento legítimo, lanza la función recargar una vez

La función recargar una vez, lo que hace es:

  • Comprobar si existe un fichero Lock para ese directorio
  • Si existe, no hace nada, ya que significa que ya hay una recarga en curso
  • Si no existe, crea el fichero de Lock
  • Espera 10 minutos para que los cambios en el directorio terminen
  • Parar y arranca Nbinx

Con todo esto, lo que se busca es que en caso de que existan cambios en un directorio que afecten a la configuración de Nginx, este vuelva a leer su fichero de configuración, mediante la orden kill -HUP ${NGINX_PID}. Esto último no mata el proceso Nginx, sino que le indica que debe releer el fichero de configuración.

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 ejecuta la acción si se cambia el README.md
    paths:
      - '**/*'          # Todos los archivos del repositorio
      - '!README.md'
      
  schedule:
#    - cron: "*/5 * * * *"  # Ejecuta cada 5 min para pruebas
    - cron: "29 2 * * *"  # Ejecuta todos los días a las 02:29h

jobs:
  "Actualización de imagen":
    runs-on: custom
    env:
      IMAGEN_BASE: 'docker.io/library/nginx:stable'
      IMAGEN_DEST: 'docker.io/pepramon/nginx: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 actualizaciones de Debian (SO del contenedor)
        if: env.construir != 'true'
        run: |
          echo "Comprobando actualizaciones de Debian. Bajando imagen"
          podman pull $IMAGEN_DEST
          
          comando="apt update > /dev/null 2>&1 && apt list --upgradable 2>/dev/null | grep -v 'Listing' | wc -l"
          paquetes_actualizar=$(podman run --entrypoint "" $IMAGEN_DEST bash -c "$comando")
          echo "Hay $paquetes_actualizar paquetes por actualizar de Debian"
          if [ "$paquetes_actualizar" -gt 0 ]; then
            echo "Actualizaciones disponibles en Debian. Hay que construir imagen"
            echo "construir=true" >> $GITHUB_ENV
          else
            echo "No hay actualizaciones disponibles de Debian."
          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:29
  • 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 Debian 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 todo lo explicado en este post, me parece que queda bastante claro cual es la modificación que se ha realizado a Nginx para que vigile los directorios que se le indiquen sin perder ninguna funcionalidad.

También, que la imagen no es un construir y olvidar, que quedará desactualizada en el tiempo, sino que cada día se comprueba si hay algún cambio, y automáticamente se vuelve a construir y la imagen subiendo el resultado a DockerHub.