← Automatización con Scripts en Bash Bloque 6 · Manejo de errores y scripts robustos · Tema 27 de 42

Tema 27

Scripts seguros de repetir

Un script de automatización no se ejecuta una vez: se ejecuta cada noche, se vuelve a lanzar después de un fallo, lo ejecuta otra persona sin saber que ya se había ejecutado. Si cada ejecución agrega otra línea al mismo archivo de configuración o crea otro usuario igual, el sistema se degrada de a poco. Este tema trata de dos propiedades que distinguen un script en el que se puede confiar: que ejecutarlo de nuevo no cause daño, y que dos ejecuciones al mismo tiempo no se pisen.

1. Qué es la idempotencia

Una operación es idempotente si aplicarla una vez o varias veces seguidas deja el sistema en el mismo estado. Un script idempotente puede ejecutarse cuantas veces se quiera: la primera hace los cambios necesarios, y las siguientes, si nada cambió entretanto, no hacen nada.

La palabra viene de las matemáticas, pero la idea es cotidiana. Apretar el botón de un ascensor es idempotente: apretarlo cinco veces no trae cinco ascensores. Agregar una línea al final de un archivo no lo es: cada ejecución agrega otra.

terminal · la misma intención, dos resultados
$ for i in 1 2 3; do echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc; done
$ grep -c 'HOME/bin' ~/.bashrc                   # tres copias de la misma línea
3
$ for i in 1 2 3; do grep -qxF 'export PATH="$HOME/bin:$PATH"' ~/.bashrc \
>   || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc; done
$ grep -c 'HOME/bin' ~/.bashrc                   # en un archivo limpio: una sola
1

El tema 1 contó la idempotencia entre las cualidades de un buen script, y varios scripts del curso ya la tenían: preparar-proyecto del tema 23 solo agregaba al .gitignore las entradas que faltaban, y servidor-web del tema 13 no iniciaba el servidor si ya estaba en ejecución.

2. Por qué importa

En la práctica, todo script de automatización termina ejecutándose más de una vez sobre el mismo sistema, por motivos que no siempre se controlan:

SituaciónSin idempotencia
Una tarea programada que se ejecuta cada díaLos cambios se acumulan día tras día.
Un fallo a mitad de camino, y se vuelve a lanzarLos pasos que ya se habían hecho se repiten, o fallan porque «ya existe».
Un reintento automático, como el de reintentar del tema 15Cada intento fallido deja restos que el siguiente encuentra.
Otra persona lo ejecuta sin saber que ya se había hechoDuplicados, o un error que nadie entiende.
Preparar varias máquinas con el mismo scriptNo se puede volver a ejecutar en todas para aplicar un cambio nuevo.

Con un script idempotente, la respuesta a todas estas situaciones es la misma: volver a ejecutarlo. Ante la duda de si algo se aplicó, se ejecuta de nuevo, y el sistema queda como debe. Esa tranquilidad es lo que hace que la automatización sea confiable.

3. Operaciones que lo son y que no

Muchas operaciones habituales tienen una versión idempotente y otra que no lo es:

IntenciónNo idempotenteIdempotente
Crear un directoriomkdir dir: la segunda vez falla.mkdir -p dir
Borrar un archivorm archivo: la segunda vez falla.rm -f archivo
Agregar una línea a un archivoecho … >> archivo: la duplica.Agregarla solo si no está (sección 4).
Crear un enlace simbólicoln -s destino enlace: la segunda vez falla, o crea el enlace dentro de un directorio.ln -sfn destino enlace
Fijar un valor en un archivoAgregar clave=valor al final.Reemplazarlo si existe; agregarlo si no.
Fijar permisoschmod +x es idempotente……y chmod 755 también, y además deja un estado exacto.
Crear un usuariouseradd ana: la segunda vez falla.Crearlo solo si id ana falla.
Sumar algo a un contadorn=$(( n + 1 )) guardado en un archivo.Calcular el valor a partir del estado, no sumar a ciegas.

4. Comprobar antes de actuar

La técnica general para volver idempotente una operación es preguntar primero si ya está hecha, y actuar solo si no. Es el patrón de las cláusulas de guarda del tema 12, aplicado al estado del sistema:

comprobar y actuar
# Agregar una línea solo si no está, comparando la línea entera y como texto literal.
grep -qxF -- "$linea" "$archivo" || printf '%s\n' "$linea" >> "$archivo"

# Crear un usuario solo si no existe.
id -u ana > /dev/null 2>&1 || useradd -m ana

# Fijar clave=valor: reemplazar la línea si existe, agregarla si no.
if grep -q "^${clave}=" "$archivo"; then
  sed -i "s|^${clave}=.*|${clave}=${valor}|" "$archivo"
else
  printf '%s=%s\n' "$clave" "$valor" >> "$archivo"
fi

Las opciones de grep importan: -x exige que coincida la línea completa, para no confundirla con otra que la contenga; -F la toma como texto literal, para que sus $, puntos o asteriscos no se interpreten como expresión regular. La versión con sed tiene un límite: si el valor contiene el carácter |, que se usa como separador, o caracteres especiales para sed, hay que escaparlos; lo veremos en el tema 29.

Comprobar antes de actuar tiene además una ventaja para quien usa el script: puede informar qué cambió y qué ya estaba, como hará el script de la sección 11. Un script que dice «0 cambios» cuando todo está en orden es la mejor prueba de que es idempotente.

5. Herramientas idempotentes por diseño

Muchas herramientas ya son idempotentes, o tienen opciones que las vuelven idempotentes. Usarlas es más simple y más seguro que escribir la comprobación a mano:

HerramientaPor qué es idempotente
mkdir -pNo falla si el directorio existe, y crea los intermedios.
rm -f, rm -rfNo falla si lo que se borra no existe.
ln -sfn destino enlace-f reemplaza un enlace existente; -n evita que, si el enlace apunta a un directorio, se cree uno nuevo dentro de él.
install -d -m 750 dirCrea el directorio si falta y fija sus permisos, exista o no.
install -m 644 origen destinoCopia y fija los permisos en un solo paso.
cmp -s a bResponde si dos archivos son idénticos, para copiar solo si cambió.
rsyncSincroniza: copia solo lo que difiere, y una segunda ejecución no copia nada.
systemctl enable --nowHabilita e inicia un servicio; si ya lo estaba, no hace nada.
copiar solo si cambió
if ! cmp -s -- "$origen" "$destino"; then
  install -m 644 -- "$origen" "$destino"
  echo "Actualizado $destino"
fi

Copiar solo si cambió no es solo una cuestión de eficiencia: evita modificar la fecha del archivo de destino sin motivo, lo que importa si otro proceso vigila esa fecha, como la regla -nt del tema 11 o una herramienta de compilación.

6. Pensar en el estado deseado

Detrás de estas técnicas hay un cambio de forma de pensar. Un script no idempotente describe acciones: «agregar esta línea», «crear este usuario». Uno idempotente describe un estado: «esta línea debe estar», «este usuario debe existir». Las acciones se hacen solo cuando el estado actual no es el deseado.

Pensado como acciónPensado como estado
Crear el directorio de respaldos.El directorio de respaldos existe, con permisos 750.
Agregar el alias al .bashrc.El .bashrc contiene el alias, una vez.
Copiar la configuración.La configuración instalada es igual a la del repositorio.
Iniciar el servicio.El servicio está en ejecución.

Es la idea sobre la que están construidas las herramientas de gestión de configuración, como Ansible, que mencionamos en el tema 1: en lugar de escribir pasos, se describe el estado de la máquina, y la herramienta decide qué cambiar. Un script de Bash bien escrito puede seguir la misma filosofía, con funciones cuyos nombres expresan el estado: asegurar_directorio, asegurar_linea, asegurar_copia.

7. Resistir un fallo a mitad de camino

La idempotencia tiene una prueba exigente: el script falla a la mitad, y se lo vuelve a ejecutar. Para que la segunda ejecución termine bien, cada paso debe dejar el sistema en un estado que el siguiente intento pueda reconocer. Dos técnicas, ya vistas, ayudan:

Para procesos largos con pasos costosos, como una migración en varias etapas, se usan a veces marcas: un archivo por cada paso completado, que el script consulta antes de repetirlo. Es simple, pero exige cuidado: la marca debe crearse solo después de que el paso terminó bien, y debe borrarse si el paso necesita rehacerse.

marcas de pasos completados
paso() {                                     # paso NOMBRE COMANDO...
  local nombre="$1"
  shift
  if [[ -e "$MARCAS/$nombre" ]]; then
    echo "ya hecho: $nombre"
    return 0
  fi
  "$@"                                       # con set -e, si falla, no se marca
  touch "$MARCAS/$nombre"
  echo "hecho: $nombre"
}

paso descargar  descargar_datos
paso convertir  convertir_formato
paso importar   importar_en_la_base

8. Dos ejecuciones a la vez

El otro peligro de repetir es hacerlo al mismo tiempo. Una tarea de cron que se ejecuta cada cinco minutos y un día tarda siete: cuando arranca la siguiente, la anterior todavía no terminó. Dos copias de un respaldo escriben en el mismo archivo; dos sincronizaciones se pisan; dos rotaciones de registros mueven los mismos archivos. El resultado es impredecible, y suele aparecer justamente cuando el sistema está más cargado.

La solución es un bloqueo (lock): antes de empezar, el script intenta adquirir un cerrojo exclusivo. Si lo consigue, trabaja; si otra copia ya lo tiene, termina sin hacer nada, o espera. Para que funcione, la adquisición tiene que ser atómica: si dos procesos lo intentan en el mismo instante, solo uno puede ganar.

9. flock

En Linux, la herramienta estándar para esto es flock, del paquete util-linux. Usa los bloqueos de archivos del núcleo, que tienen una propiedad muy valiosa: se liberan solos cuando el proceso termina, de cualquier manera, incluso con kill -9 o si la máquina se reinicia. No quedan bloqueos «huérfanos».

impedir dos ejecuciones simultáneas
readonly CERROJO="${XDG_RUNTIME_DIR:-/tmp}/${0##*/}.lock"

exec 9> "$CERROJO"                 # abrir el archivo de bloqueo en el descriptor 9
if ! flock -n 9; then              # intentar bloquearlo, sin esperar
  echo "${0##*/}: ya hay otra ejecución en curso" >&2
  exit 1
fi

# … a partir de aquí, solo una copia del script llega a ejecutarse …
  1. exec 9> archivo abre el archivo de bloqueo, creándolo si no existe, y lo deja abierto en el descriptor 9 durante todo el script. El contenido del archivo no importa. abrir
  2. flock -n 9 pide un bloqueo exclusivo sobre ese descriptor. Con -n, si otro proceso ya lo tiene, falla enseguida en lugar de esperar. bloquear
  3. El bloqueo se mantiene mientras el descriptor esté abierto, es decir, hasta que el script termine. Al terminar, el sistema lo libera. sistema operativo
FormaEfecto
flock -n 9Si está ocupado, fallar enseguida.
flock -w 60 9Esperar hasta 60 segundos; si no se libera, fallar.
flock 9Esperar lo que haga falta.
flock -n archivo comandoEjecutar un comando con el bloqueo, sin modificar el script.

La última forma es especialmente útil en la tabla de cron: protege cualquier script de las ejecuciones superpuestas sin tocar su código.

crontab
*/5 * * * *  flock -n /tmp/sincronizar.lock /home/ana/bin/sincronizar

¿Fallar o esperar? Depende de la tarea. Para una tarea periódica, como una sincronización cada cinco minutos, si la anterior todavía corre, lo razonable es no hacer nada: la próxima ejecución recogerá lo pendiente. Para una tarea que no debe perderse, como procesar un pedido, conviene esperar, con un límite.

10. Otras formas de bloquear

Donde no hay flock, como en macOS, existen alternativas, todas con alguna desventaja:

TécnicaCómo funcionaProblema
mkdir como cerrojomkdir dir.lock es atómico y falla si ya existe; el script lo borra al terminar con un trap.Si el script muere con kill -9, el directorio queda, y hay que borrarlo a mano.
set -o noclobberCon esta opción, > falla si el archivo existe, lo que permite crear un archivo de bloqueo de forma atómica.El mismo: un bloqueo huérfano si el script no llega a limpiarlo.
Archivo con el PIDEl script guarda su número de proceso y, al arrancar, comprueba con kill -0 si ese proceso sigue vivo, como servidor-web del tema 13.Comprobar y escribir son dos pasos: dos copias pueden pasar la comprobación a la vez. Y un PID viejo puede pertenecer ahora a otro proceso.
un cerrojo con mkdir, donde no hay flock
readonly CERROJO="/tmp/${0##*/}.lock.d"
if ! mkdir "$CERROJO" 2> /dev/null; then
  echo "${0##*/}: ya hay otra ejecución en curso (o quedó $CERROJO)" >&2
  exit 1
fi
trap 'rmdir "$CERROJO"' EXIT

En Linux, flock es la opción correcta: atómico, sin bloqueos huérfanos y en una línea.

11. Un script completo

Este script deja el entorno de trabajo de un usuario en un estado conocido: crea los directorios que necesitan los scripts del curso, asegura dos líneas en el .bashrc e instala un script del proyecto de herramientas. Es idempotente, informa qué cambió y qué ya estaba, y usa flock para que dos ejecuciones no se pisen.

configurar-equipo
#!/usr/bin/env bash
#
# configurar-equipo — deja el entorno de trabajo del usuario en un estado conocido.
# Se puede ejecutar cuantas veces se quiera: solo cambia lo que falta.
#
set -euo pipefail

readonly CERROJO="${XDG_RUNTIME_DIR:-/tmp}/configurar-equipo.lock"
exec 9> "$CERROJO"
if ! flock -n 9; then
  echo "${0##*/}: ya hay otra ejecución en curso" >&2
  exit 1
fi

cambios=0
hecho()      { echo "  cambiado:  $*"; cambios=$(( cambios + 1 )); }
sin_cambio() { echo "  ya estaba: $*"; }

# asegurar_directorio DIR — DIR existe.
asegurar_directorio() {
  if [[ -d "$1" ]]; then
    sin_cambio "directorio ${1/#$HOME/\~}"
  else
    mkdir -p -- "$1"
    hecho "directorio ${1/#$HOME/\~}"
  fi
}

# asegurar_linea ARCHIVO LÍNEA — ARCHIVO contiene LÍNEA, una vez.
asegurar_linea() {
  local archivo="$1" linea="$2"
  touch -- "$archivo"
  if grep -qxF -- "$linea" "$archivo"; then
    sin_cambio "línea en ${archivo/#$HOME/\~}: $linea"
  else
    printf '%s\n' "$linea" >> "$archivo"
    hecho "línea en ${archivo/#$HOME/\~}: $linea"
  fi
}

# asegurar_copia ORIGEN DESTINO — DESTINO es igual a ORIGEN (escritura atómica).
asegurar_copia() {
  local origen="$1" destino="$2"
  if [[ -f "$destino" ]] && cmp -s -- "$origen" "$destino"; then
    sin_cambio "copia ${destino/#$HOME/\~}"
  else
    local tmp
    tmp=$(mktemp "$destino.XXXXXX")
    cp -- "$origen" "$tmp"
    mv -f -- "$tmp" "$destino"
    hecho "copia ${destino/#$HOME/\~}"
  fi
}

echo "Configurando el entorno de $(id -un)…"
asegurar_directorio "$HOME/bin"
asegurar_directorio "$HOME/.local/state"
asegurar_linea "$HOME/.bashrc" 'export PATH="$HOME/bin:$PATH"'
asegurar_linea "$HOME/.bashrc" "alias ll='ls -lh'"
asegurar_copia "$HOME/proyectos/herramientas/bin/respaldar" "$HOME/bin/respaldar"
echo "Listo: $cambios cambios."
terminal
$ configurar-equipo
Configurando el entorno de ana…
  cambiado:  directorio ~/bin
  cambiado:  directorio ~/.local/state
  cambiado:  línea en ~/.bashrc: export PATH="$HOME/bin:$PATH"
  cambiado:  línea en ~/.bashrc: alias ll='ls -lh'
  cambiado:  copia ~/bin/respaldar
Listo: 5 cambios.
$ configurar-equipo
Configurando el entorno de ana…
  ya estaba: directorio ~/bin
  ya estaba: directorio ~/.local/state
  ya estaba: línea en ~/.bashrc: export PATH="$HOME/bin:$PATH"
  ya estaba: línea en ~/.bashrc: alias ll='ls -lh'
  ya estaba: copia ~/bin/respaldar
Listo: 0 cambios.
$ configurar-equipo & configurar-equipo      # dos a la vez
configurar-equipo: ya hay otra ejecución en curso

12. Errores frecuentes

EscribisteQué pasaCorrección
echo "…" >> archivo en un script que se repiteLa línea se duplica en cada ejecución.grep -qxF -- … || echo … >>
grep -q "$linea" para comprobarCoincide con líneas que la contienen, y trata la línea como expresión regular.grep -qxF --
mkdir dir en un script que se repiteFalla la segunda vez.mkdir -p dir
ln -s destino enlace repetidoFalla, o crea un enlace dentro del directorio enlazado.ln -sfn destino enlace
Copiar siempre, aunque no haya cambiosCambia la fecha del destino sin motivo.cmp -s antes de copiar.
Un script de cron frecuente sin bloqueoDos ejecuciones superpuestas se pisan.flock -n, en el script o en la línea de cron.
Un archivo PID comprobado y luego escritoDos copias pueden pasar la comprobación a la vez.flock, que es atómico.
Un cerrojo con mkdir sin trapQueda bloqueado para siempre tras el primer fallo.trap 'rmdir …' EXIT, o flock.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué significa que un script sea idempotente?

Que ejecutarlo una vez o varias seguidas deja el sistema en el mismo estado. Las ejecuciones posteriores a la primera no cambian nada si nada cambió entretanto.

2. Menciona tres situaciones en que un script se ejecuta más de una vez sobre el mismo sistema.

Una tarea programada que se ejecuta periódicamente; un reintento después de un fallo; otra persona que lo ejecuta sin saber que ya se había hecho; preparar de nuevo varias máquinas para aplicar un cambio.

3. ¿Por qué echo "alias ll='ls -l'" >> ~/.bashrc no es idempotente, y cómo se corrige?

Porque cada ejecución agrega otra copia de la línea. Se corrige agregándola solo si no está: grep -qxF -- "$linea" ~/.bashrc || printf '%s\n' "$linea" >> ~/.bashrc.

4. ¿Para qué sirven las opciones -x y -F de grep al comprobar si una línea ya está?

-x exige que coincida la línea completa, no una parte; -F trata el texto como literal, para que sus caracteres especiales no se interpreten como expresión regular.

5. ¿Qué hacen las opciones -f y -n de ln -sfn?

-f reemplaza el enlace si ya existe. -n trata un enlace que apunta a un directorio como un archivo, de modo que se reemplaza el enlace en lugar de crear uno nuevo dentro del directorio.

6. ¿Qué diferencia hay entre pensar en acciones y pensar en el estado deseado?

Pensar en acciones lleva a «hacer esto»; pensar en el estado, a «esto debe ser así». Con el segundo enfoque, el script compara el estado actual con el deseado y actúa solo si difieren, y por eso resulta idempotente.

7. ¿Por qué dos ejecuciones simultáneas de una tarea de cron pueden ser un problema?

Porque trabajan sobre los mismos archivos al mismo tiempo, y pueden pisarse: escribir el mismo respaldo, mover los mismos archivos, dejar datos inconsistentes.

8. ¿Qué hace exec 9> archivo seguido de flock -n 9?

Abre el archivo de bloqueo en el descriptor 9 durante todo el script, y pide un bloqueo exclusivo sobre él sin esperar. Si otro proceso ya lo tiene, flock falla y el script puede terminar.

9. ¿Qué ventaja tiene flock sobre un archivo de bloqueo creado y borrado por el script?

Que el bloqueo se libera solo cuando el proceso termina, de cualquier manera, incluso con kill -9. Un archivo de bloqueo propio puede quedar huérfano si el script muere sin borrarlo.

10. ¿Cómo se protege una tarea de cron de las ejecuciones superpuestas sin modificar el script?

Ejecutándola mediante flock en la propia línea de cron: flock -n /tmp/tarea.lock /ruta/al/script.

Para practicar

  1. Reproduce el ejemplo de la sección 1 en un archivo de prueba, con y sin la comprobación.
  2. Escribe una función asegurar_valor ARCHIVO CLAVE VALOR que deje en un archivo clave=valor exactamente una línea con esa clave y ese valor, y pruébala varias veces con valores distintos.
  3. Escribe una función asegurar_enlace DESTINO ENLACE que compruebe con readlink si el enlace ya apunta al destino, y lo cree o lo corrija si no.
  4. Revisa los scripts de temas anteriores y marca las operaciones que no son idempotentes. Corrige al menos dos.
  5. Escribe un script que tarde 30 segundos (con sleep) y protégelo con flock -n. Ejecútalo en dos terminales a la vez. Prueba también flock -w 5.
  6. Agrega a configurar-equipo dos estados más que te resulten útiles, y comprueba que la segunda ejecución sigue informando «0 cambios».