Cerramos el Módulo 4 diciendo que a informe-diario.sh le falta robustez, y prometimos empezar por aquí: find, xargs, tar y ficheros temporales seguros. No es casualidad que la robustez empiece por los ficheros. Casi todo lo que un script de operaciones hace mal lo hace al elegir sobre qué ficheros actuar —borra de más, se salta un directorio, se atraganta con un nombre que lleva un espacio— o al escribir en un temporal que otro proceso puede pisar. En esta lección aprenderás a seleccionar ficheros con precisión quirúrgica, a actuar sobre ellos sin que un nombre raro rompa nada, y a crear archivos temporales y comprimidos que no se conviertan en un agujero de seguridad.

Contenido

  1. Por qué el globbing no basta
  2. Anatomía de find
  3. Criterios de selección
  4. Combinar criterios: -a, -o, ! y paréntesis
  5. -maxdepth y -mindepth: por qué van antes
  6. Acciones: -print, -delete, -exec y -execdir
  7. Nombres con espacios: -print0 y xargs -0
  8. xargs a fondo
  9. Temporales seguros con mktemp
  10. Archivar y comprimir: tar, gzip, zcat, zgrep
  11. Enlaces duros y simbólicos
  12. rsync en dos líneas

  1. Por qué el globbing no basta

En 02-05 viste que *.log lo expande el shell antes de que el comando arranque, y que con shopt -s globstar incluso **/*.log recorre subdirectorios. ¿Para qué hace falta find, entonces?

Necesidad ¿Globbing? find
Ficheros por nombre en un directorio
Recorrer subdirectorios Con globstar Nativo
Filtrar por fecha, tamaño, permisos, dueño No
Distinguir fichero / directorio / enlace Con trucos -type
Limitar la profundidad No -maxdepth
Miles de resultados Falla: Argument list too long Sin límite

Ese último punto es el que mata scripts en producción: el globbing construye una línea de comandos con todos los nombres, y el núcleo la rechaza a partir de unos dos megabytes. find procesa los ficheros de uno en uno. La regla práctica: globbing para un puñado de ficheros conocidos en un directorio; find para buscar por criterios o recorrer árboles.

  1. Anatomía de find

find /var/log/veloz -name '*.log' -print
#    └── ruta ────┘ └─ criterio ─┘ └acción┘

Tres partes, siempre en este orden: dónde buscar, qué seleccionar y qué hacer con lo encontrado. Si omites la acción, find asume -print. Si omites los criterios, muestra todo lo que hay bajo la ruta.

Dos detalles que causan sorpresas desde el primer día:

  • El patrón '*.log' va entrecomillado. Sin comillas, el shell lo expandiría antes de lanzar find, con los ficheros del directorio actual, y find recibiría algo que no esperaba. Es el mismo principio de comillas de 03-06: aquí el patrón es para find, no para el shell.
  • find recorre recursivamente desde la ruta indicada. find . puede tardar minutos en un árbol grande.

  1. Criterios de selección

find /srv/veloz/datos -name 'envios*.csv'      # por nombre, sensible a mayúsculas
find /srv/veloz/datos -iname 'ENVIOS*.CSV'     # -i: ignora mayúsculas → encuentra envios.csv
find /srv/veloz -path '*/historico/2026/*'     # el patrón se aplica a la RUTA completa
find /var/log/veloz -type f                    # solo ficheros regulares
find /srv/veloz -type d -name 'historico'      # solo directorios
find ~/veloz-ops -type l                       # solo enlaces simbólicos

Ojo con la diferencia entre -name y -path: -name compara solo el nombre final (el basename de 04-04), mientras que -path compara la ruta entera tal y como find la va generando, así que sus * atraviesan barras.

Los criterios de tamaño y tiempo son los que hacen a find insustituible:

Criterio Significado Ejemplo
-size +100M mayor de 100 MiB (k, M, G; c = bytes) logs desbocados
-size -1k menor de 1 KiB informes vacíos
-mtime +30 modificado hace más de 30 días purga de histórico
-mtime -1 modificado en las últimas 24 h informe de hoy
-mmin -15 modificado en los últimos 15 minutos vigilancia
-newer f más reciente que el fichero f cambios desde el último informe
-user joan propiedad de ese usuario auditoría
-perm 600 permisos exactamente 600 comprobar veloz-ops.conf
-perm -o=w escribible por otros (peligro) auditoría de seguridad
-empty fichero vacío o directorio sin contenido limpieza

El signo importa mucho: + es "más de", - es "menos de", y sin signo es exactamente. Un -mtime 30 significa "modificado en el día 30 contando hacia atrás", casi nunca lo que quieres. Y -mtime cuenta en bloques de 24 horas truncando: para ventanas finas, usa -mmin.

find ~/veloz-ops/logs -type f -name 'informe-*.txt' -mtime +30
# /home/joan/veloz-ops/logs/informe-2026-06-15.txt
# /home/joan/veloz-ops/logs/informe-2026-06-14.txt

Ahí está la purga de informes viejos que necesita el toolkit: todos los informes de más de un mes.

  1. Combinar criterios: -a, -o, ! y paréntesis

Cuando pones dos criterios seguidos, find los une con un Y implícito. Para el resto hay operadores:

find ~/veloz-ops/logs -name '*.txt' -o -name '*.log'      # O lógico
find /var/log/veloz -type f ! -name '*.gz'                # NO: los no comprimidos
find /srv/veloz -type f \( -name '*.csv' -o -name '*.tsv' \) -mtime +90

Los paréntesis se escapan (\\( y \\)) o se entrecomillan ('('), porque para el shell los paréntesis sin escapar significan subshell. Y son imprescindibles en cuanto mezclas -o con otro criterio, porque -a tiene más prioridad que -o: sin paréntesis, ese último ejemplo significaría "los .csv (de cualquier fecha) o los .tsv de más de 90 días".

Este error es especialmente grave combinado con -delete. Comprueba siempre con -print antes de sustituirlo por una acción destructiva.

  1. -maxdepth y -mindepth: por qué van antes

find /srv/veloz/datos -maxdepth 1 -name '*.csv'    # solo el nivel superior
find /srv/veloz/datos/historico -mindepth 2 -maxdepth 2 -type d   # los AAAA/MM

-maxdepth 0 es la propia ruta de partida; 1 son sus hijos directos. -mindepth 1 excluye la ruta de partida, útil para vaciar un directorio sin borrarlo.

Estas dos opciones deben ir antes que cualquier otro criterio. Técnicamente no son criterios sino opciones globales: afectan a todo el recorrido, no a un fichero concreto. Si escribes find . -name '*.log' -maxdepth 1, find avisa con warning: -maxdepth is a global option y lo aplica igualmente, pero es una señal de que no has entendido el orden y algún día te morderá con opciones que no perdonan.

  1. Acciones: -print, -delete, -exec y -execdir

find ~/veloz-ops/logs -name 'informe-*.txt' -mtime +30 -print   # listar (por defecto)
find ~/veloz-ops/logs -name 'informe-*.txt' -mtime +30 -delete  # borrar
find /var/log/veloz -name '*.log.[0-9]' -exec gzip {} \;        # ejecutar por fichero
find /var/log/veloz -name '*.log.[0-9]' -exec gzip {} +         # ejecutar por lotes

-delete es más seguro y rápido que -exec rm {} \; porque no lanza procesos y no tiene problemas con nombres raros, pero debe ir al final: find . -delete -name '*.txt' borra todo el árbol antes de mirar el nombre.

La diferencia entre \; y + es la que más rendimiento cuesta ignorar. El {} es el marcador donde find inserta el nombre del fichero, y el terminador decide cómo se agrupan:

Terminador Invocaciones Con 5.000 ficheros Cuándo usarlo
\; Una por fichero 5.000 procesos (~30 s) El comando solo acepta un argumento, o quieres el código de salida individual
+ Una por lote (miles de argumentos) 2-3 procesos (<1 s) Casi siempre: grep, gzip, rm, chmod

El \; va escapado por el mismo motivo que los paréntesis: sin escapar, el ; termina el comando para el shell.

-execdir es la variante segura —find /srv/veloz/datos -name '*.tmp' -execdir rm -- {} \;—: ejecuta el comando dentro del directorio donde está cada fichero y le pasa ./nombre en lugar de la ruta completa. Eso evita ataques en los que alguien renombra un directorio a mitad del recorrido y evita que un fichero llamado -rf se interprete como opción.

  1. Nombres con espacios: -print0 y xargs -0

Este es el clásico que separa a quien copia recetas de quien entiende el shell: find ~/veloz-ops/logs -name '*.txt' | xargs rm está roto.

find imprime un nombre por línea y xargs separa por espacios en blanco. Un fichero llamado informe julio.txt llega como dos argumentos: xargs intenta borrar informe y julio.txt. Un nombre con salto de línea es aún peor. La solución canónica usa el único byte que no puede aparecer en un nombre de fichero, el nulo:

find ~/veloz-ops/logs -name '*.txt' -mtime +30 -print0 | xargs -0 rm -v

-print0 separa con \0 en lugar de \n; -0 le dice a xargs que espere ese separador. Nunca canalices find a xargs sin ese par. Si el comando lo permite, -exec ... + hace lo mismo sin tubería y sin riesgo.

  1. xargs a fondo

xargs lee de la entrada estándar y construye líneas de comandos con lo leído. Sus opciones útiles:

Opción Efecto
-0 Separador nulo (siempre, con find -print0)
-n N Como mucho N argumentos por invocación
-I{} Sustituye {} por cada elemento, uno a uno (implica -n 1)
-P N Ejecuta hasta N invocaciones en paralelo
-t Muestra en stderr el comando antes de ejecutarlo (depuración)
-r No ejecutar nada si la entrada está vacía (GNU; vital en scripts)
# Comprimir los CSV del histórico, 4 a la vez, mostrando qué hace
find /srv/veloz/datos/historico -name '*.csv' -print0 \
  | xargs -0 -r -t -P 4 -n 1 gzip

Sin -r, si el find no encuentra nada, xargs ejecuta gzip sin argumentos y se queda esperando en la entrada estándar: el script se cuelga sin explicación. Es un fallo real y frecuente en tareas programadas.

-I{} sirve cuando el nombre no va al final del comando —xargs -0 -I{} cp -- {} /srv/veloz/respaldo/—, pero no agrupa: una invocación por fichero, con el coste que ya conoces. Úsalo solo cuando la posición del argumento lo exija.

  1. Temporales seguros con mktemp

informe-diario.sh necesita un fichero intermedio para ordenar la tabla antes de imprimirla. La tentación es tmp=/tmp/informe.$$, y está mal: $$ es el PID, que ni es secreto ni es impredecible. /tmp lo puede escribir cualquiera. Dos ataques clásicos: la condición de carrera (un atacante crea el fichero entre tu comprobación y tu escritura) y el ataque por enlace simbólico (crea /tmp/informe.12345 apuntando a ~/.ssh/authorized_keys, y tu script escribe ahí con tus permisos). La solución es delegar en el sistema:

tmp=$(mktemp)                                   # /tmp/tmp.aX9k2Lp0
tmpdir=$(mktemp -d)                             # directorio, permisos 700
tmp=$(mktemp -t informe-diario.XXXXXX)          # con prefijo reconocible
tmp=$(mktemp -p ~/veloz-ops/logs inf.XXXXXX)    # en un directorio concreto

mktemp crea el fichero atómicamente, con nombre aleatorio y permisos 600 (700 para directorios). No hay ventana de carrera y nadie más puede leerlo. Las XXXXXX (mínimo seis) son donde se inserta la parte aleatoria.

Falta la otra mitad: borrarlo pase lo que pase, también si el script muere a mitad. Eso se hace con trap, y es la primera línea de 05-03: tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT. Quédate con la pareja: quien crea un temporal, en la línea siguiente programa su limpieza.

  1. Archivar y comprimir: tar, gzip, zcat, zgrep

tar agrupa muchos ficheros en uno (.tar); gzip comprime un fichero. Juntos dan el .tar.gz de toda la vida:

cd /srv/veloz/datos          # clave: nos situamos en la raíz del árbol
tar -czf ~/veloz-ops/logs/historico-2026-07.tar.gz historico/2026/07
tar -tzf ~/veloz-ops/logs/historico-2026-07.tar.gz | head -3   # listar sin extraer
tar -xzf ~/veloz-ops/logs/historico-2026-07.tar.gz -C /tmp/restaurar   # extraer

Las letras: c crear, x extraer, t listar, z pasar por gzip, f fichero destino, -C cambiar de directorio antes de actuar. j usa bzip2 y J usa xz (más lento, comprime más).

Usa siempre rutas relativas. Si archivas /srv/veloz/datos/historico, tar avisa con Removing leading '/' y, en versiones antiguas o con la opción -P, la extracción escribiría directamente sobre /srv/veloz/datos en cualquier máquina, machacando lo que hubiera. Con rutas relativas decides dónde restaurar con -C. El patrón correcto es el cd previo (o tar -C /srv/veloz/datos -czf ... historico/2026/07).

--exclude acepta globs y se puede repetir. Y para los logs rotados de /var/log/veloz no hace falta descomprimir nada:

tar -czf respaldo.tar.gz --exclude='*.tmp' --exclude='logs/*' veloz-ops
zcat /var/log/veloz/acceso.log.2.gz | wc -l           # como cat, pero para .gz
zgrep -c ' 500 ' /var/log/veloz/acceso.log.*.gz       # como grep, sobre comprimidos
gzip -d acceso.log.2.gz    # descomprime y BORRA el .gz
gzip -k acceso.log.1       # -k conserva el original

zgrep sobre los rotados es lo que permite a informe-diario.sh responder "¿cuántos errores 500 hubo la semana pasada?" sin ocupar disco extra.

  1. Enlaces duros y simbólicos

ln  /srv/veloz/datos/envios.csv /srv/veloz/datos/envios-hoy.csv     # duro
ln -s /srv/veloz/datos/envios.csv ~/veloz-ops/envios-actual.csv     # simbólico
readlink -f ~/veloz-ops/envios-actual.csv   # /srv/veloz/datos/envios.csv
find ~/veloz-ops -type l ! -exec test -e {} \; -print   # enlaces rotos
Enlace duro (ln) Enlace simbólico (ln -s)
Qué es Otro nombre para los mismos datos Un fichero que contiene una ruta
Entre discos No
A directorios No
Si borras el original Los datos siguen vivos El enlace queda roto
ls -l muestra Un fichero normal enlace -> destino

En la práctica: simbólicos para el 95 % de los casos (apuntar al "último informe", cambiar de versión activa), duros para copias baratas dentro del mismo sistema de ficheros. readlink -f resuelve la cadena completa hasta la ruta real, y es la forma fiable de saber a qué apunta algo.

  1. rsync en dos líneas

rsync -a --delete origen/ destino/ sincroniza árboles copiando solo lo que ha cambiado, funciona sobre SSH y es la herramienta correcta para respaldos incrementales. Su sitio natural es 07-03; por ahora quédate con que existe y con que la barra final del origen cambia el significado.

Errores Comunes y Consejos

  • Olvidar las comillas en -name '*.log'. El shell expande el patrón antes y find busca otra cosa.
  • -delete antes de los criterios. Borra el árbol entero. Ponlo siempre al final y prueba antes con -print.
  • find ... | xargs sin -print0/-0. Un espacio en un nombre y borras lo que no debías.
  • xargs sin -r. Con entrada vacía ejecuta el comando sin argumentos: cuelgue o desastre.
  • -o sin paréntesis. La precedencia de -a cambia el sentido de la expresión sin avisar.
  • -mtime 30 sin signo. Significa "exactamente el día 30", no "más de 30 días".
  • tar con rutas absolutas. Restaurar machaca el sistema original. Usa cd o -C y rutas relativas.
  • Consejo: construye los find destructivos en dos pasos. Primero con -print, revisas la lista, y solo entonces cambias la acción. En un script, guarda la lista en un temporal de mktemp y actúa sobre ella.

Ejercicios

Ejercicio 1. Escribe una función purgar_informes() que borre de ~/veloz-ops/logs los ficheros informe-*.txt de más de 30 días y los ficheros vacíos de cualquier antigüedad, informando de cuántos borró. Debe funcionar con nombres que contengan espacios.

Ejercicio 2. Archiva el histórico del mes anterior (/srv/veloz/datos/historico/AAAA/MM/) en ~/veloz-ops/logs/historico-AAAA-MM.tar.gz excluyendo los .tmp, verifica que el archivo se puede listar y solo entonces borra los originales.

Ejercicio 3. Cuenta cuántas peticiones con código 500 hay en todos los acceso.log* de /var/log/veloz, incluidos los comprimidos, usando un fichero temporal seguro para el resultado intermedio.

Soluciones

Solución 1.

purgar_informes() {
    local dir="${1:-$HOME/veloz-ops/logs}" n=0
    while IFS= read -r -d '' f; do
        rm -- "$f"; (( ++n ))
    done < <(find "$dir" -maxdepth 1 -type f \
                \( -name 'informe-*.txt' -mtime +30 -o -empty \) -print0)
    printf 'Purgados %d ficheros de %s\n' "$n" "$dir"
}

read -d '' lee hasta el byte nulo, que es lo que emite -print0: la pareja exacta del patrón de la sección 7. Los paréntesis agrupan la condición "(viejo Y con ese nombre) O vacío", y -maxdepth 1 impide que la purga se cuele en subdirectorios. El -- antes de "$f" protege de nombres que empiecen por guion.

Solución 2.

mes=$(date -d 'last month' +%m); anio=$(date -d 'last month' +%Y)
destino=~/veloz-ops/logs/historico-$anio-$mes.tar.gz
tar -C /srv/veloz/datos --exclude='*.tmp' -czf "$destino" "historico/$anio/$mes"
if tar -tzf "$destino" > /dev/null; then
    find "/srv/veloz/datos/historico/$anio/$mes" -type f -delete
    printf 'Archivado y purgado %s/%s\n' "$anio" "$mes"
else
    printf 'ERROR: el archivo %s no es legible, no se borra nada\n' "$destino" >&2
fi

El date -d 'last month' viene de 04-06. El orden es innegociable: verificar antes de borrar. El tar -tzf a /dev/null no muestra nada pero devuelve código distinto de cero si el archivo está corrupto, y el if lo aprovecha exactamente como en 03-04.

Solución 3.

tmp=$(mktemp -t veloz500.XXXXXX)
zgrep -h ' 500 ' /var/log/veloz/acceso.log* > "$tmp"
printf 'Errores 500 en total: %d\n' "$(wc -l < "$tmp")"
cut -d' ' -f7 "$tmp" | sort | uniq -c | sort -rn | head -5   # rutas más afectadas
rm -f "$tmp"

zgrep procesa indistintamente los ficheros planos y los .gz, y -h suprime el prefijo con el nombre del fichero para que cut vea siempre la misma estructura de columnas. El rm final es correcto, pero solo se ejecuta si el script llega vivo hasta ahí: en 05-03 lo sustituirás por un trap que garantice la limpieza incluso ante un fallo.

Conclusión

find es el buscador que el globbing no puede sustituir: selecciona por nombre, ruta, tipo, tamaño, fecha, dueño, permisos o vacuidad, combina criterios con -a, -o, ! y paréntesis escapados, acota el recorrido con -maxdepth/-mindepth —que van antes por ser opciones globales— y actúa con -print, -delete, -exec ... \;, -exec ... + (miles de veces más barato) o -execdir. Cuando hace falta canalizar, la pareja -print0 con xargs -0 es obligatoria, y -r, -n, -I{}, -P y -t completan el instrumental. mktemp y mktemp -d crean temporales atómicos, privados e impredecibles, en lugar del /tmp/fichero.$$ que invita a que te pisen. tar con rutas relativas archiva y zcat/zgrep leen los logs rotados sin descomprimirlos. Y los enlaces, duros o simbólicos, dan nombres alternativos a lo que ya existe.

Con esto informe-diario.sh ya sabe elegir sobre qué ficheros trabaja y dónde deja sus intermedios. Pero un script de operaciones no solo toca ficheros: lanza procesos, y a veces varios a la vez. ¿Está viva la veloz-api antes de pedirle datos? ¿Puedo calcular las cuatro ciudades en paralelo en lugar de una tras otra? ¿Qué pasa si el informe de las 6:00 aún corre cuando arranca el de las 6:05? En 05-02 entramos en la gestión de procesos: PIDs, ps, pgrep, trabajos en segundo plano, wait, señales, timeout y el bloqueo con flock que impide que dos informes se pisen.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados