Mi imagen de Nginx
06 Dec 2024
Categorías: DockerfilesComo 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
- Lanza
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.