El módulo 6 terminó con una promesa incómoda: saber qué es un sistema operativo no sirve de nada a las tres de la madrugada si no sabes qué comando escribir primero. Y todos esos comandos se escriben en el mismo sitio: la shell. Antes de aprender a diagnosticar hay que entender bien la interfaz por la que pasa el diagnóstico, porque la mayoría de los errores de un administrador novato no son errores de análisis, sino de shell: un nombre de fichero con espacios que se partió en dos argumentos, una tubería que devolvió éxito aunque el primer comando fallara, un PATH mal puesto que ejecutó el binario equivocado.
Esta lección desmonta la shell desde dentro. Verás que no es parte del sistema operativo: es un programa de usuario corriente que hace exactamente lo que aprendiste en 02-01 —fork, execve, wait— y cuya única magia consiste en traducir texto en llamadas al sistema. Entenderás por qué cd no puede ser un programa, por qué 2>&1 > fichero no hace lo que parece y por qué set -euo pipefail debería encabezar todos tus guiones. Terminaremos con una cadena que extrae de meteo-api.log las estaciones más activas y con un script real de comprobación de los datos de Meteora.
Los servicios y el arranque son la lección siguiente, y las herramientas de rendimiento la de después; aquí nos quedamos en la interfaz.
Contenido
- Qué es realmente una shell
- Shells disponibles y cuál usar
- Interactiva, no interactiva, de inicio de sesión: qué fichero se lee
- Anatomía de una orden y comandos internos frente a externos
- El orden de expansión, paso a paso
- Comillas y escapado: el 80 % de los fallos
- Redirección y descriptores de fichero
- Tuberías, estado de salida y
pipefail - Variables de shell, variables de entorno y
PATH - Control de trabajos y señales
- Las herramientas de texto imprescindibles
- Una cadena completa sobre
meteo-api.log - Scripting: del comando suelto al programa
- Script completo: verificación de los datos de Meteora
Qué es realmente una shell
Mucha gente cree que la shell "es Linux" o que forma parte del núcleo. No lo es. /bin/bash es un ejecutable ELF corriente, como ls o como meteo-api, que corre en modo usuario (01-06) y que solo puede hacer lo mismo que cualquier otro programa: llamadas al sistema. Su bucle principal, despojado de lo accesorio, es este: escribir el prompt y leer una línea; analizar el texto (dividirlo en palabras, expandirlo, detectar redirecciones y tuberías); si el comando es interno, ejecutarlo en el propio proceso; si es externo, fork() para crear un hijo, aplicar en él las redirecciones, execve() el programa y waitpid() en el padre; guardar el estado en $? y volver a empezar.
Puedes verlo con la herramienta presentada en 01-06:
strace -f -e trace=clone,execve,wait4 -o /tmp/shell.log bash -c 'ls /etc/meteora'
grep -E 'clone|execve|wait4' /tmp/shell.log
# execve("/bin/bash", ["bash", "-c", "ls /etc/meteora"], 0x7ffd...) = 0
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD, ...) = 4711
# [pid 4711] execve("/bin/ls", ["ls", "/etc/meteora"], 0x55f...) = 0
# wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 4711Qué demuestra. El mecanismo completo, sin misterio: la shell arranca (execve de bash), se duplica (clone, que es lo que implementa fork en Linux), el hijo se transforma en ls (segundo execve) y el padre espera (wait4). -f sigue a los hijos y -e trace= limita el ruido. Todo lo que escribes en un terminal acaba siendo esta secuencia.
De esa naturaleza salen tres consecuencias prácticas. La shell no puede cambiar nada de su proceso padre: si un script hace export PATH=..., esa variable muere con el script, y por eso los ficheros de configuración se cargan con source (o .), que los ejecuta en la shell actual. Cada comando externo cuesta un proceso: un bucle que lanza grep un millón de veces hace un millón de fork+execve, y con las decenas de microsegundos por par que medimos en 02-01 eso son minutos de puro fork; de ahí que las herramientas de texto estén diseñadas para procesar flujos enteros de una vez. Y la shell es sustituible: cambiar bash por zsh no cambia el sistema operativo en absoluto.
Shells disponibles y cuál usar
| Shell | Ruta habitual | Papel | Notas |
|---|---|---|---|
sh |
/bin/sh → dash en Debian |
Shell POSIX mínima | Rápida, sin extensiones; ejecuta los scripts del sistema |
bash |
/bin/bash |
Estándar de facto en Linux | Arrays, [[ ]], PIPESTATUS, sustitución de procesos |
zsh |
/bin/zsh |
Interactiva avanzada | Autocompletado superior, globbing recursivo nativo |
fish |
/usr/bin/fish |
Interactiva amigable | Sintaxis no POSIX: no la uses en scripts |
nologin |
/usr/sbin/nologin |
Ninguna | Rechaza el inicio de sesión; es la "shell" de meteora |
En Debian, /bin/sh apunta a dash, no a bash. Consecuencia real: un script que empieza por #!/bin/sh pero usa [[ ]] o arrays fallará en Debian y funcionará donde /bin/sh sea bash. Es el clásico "en mi máquina funciona".
Y recuerda del módulo 5 que getent passwd meteora devuelve meteora:x:990:990:Meteora service:/var/lib/meteora:/usr/sbin/nologin. El último campo es la shell de inicio de sesión, y nologin es un programa que imprime un mensaje y sale con código 1. Como el proceso de inicio de sesión hace execve de esa shell, nadie puede obtener un intérprete interactivo como meteora aunque consiga su contraseña: el mínimo privilegio de 05-01 aplicado con una sola palabra.
Interactiva, no interactiva, de inicio de sesión: qué fichero se lee
Aquí vive uno de los errores más frustrantes del principiante. Bash tiene dos ejes independientes: interactiva o no (¿lee órdenes de un terminal con un humano delante, o ejecuta un script?) y de inicio de sesión o no (¿es la primera shell de la sesión, o una arrancada dentro de una sesión existente?). Cada combinación lee ficheros distintos:
| Situación | ¿Login? | ¿Interactiva? | Ficheros que lee |
|---|---|---|---|
ssh joan@meteo-01 |
Sí | Sí | /etc/profile y el primero de ~/.bash_profile, ~/.bash_login, ~/.profile |
| Abrir una pestaña del terminal | No | Sí | /etc/bash.bashrc, ~/.bashrc |
bash script.sh |
No | No | Ninguno (salvo $BASH_ENV) |
ssh meteo-01 'comando' |
No | No | Ninguno |
su - meteora |
Sí | Sí | Los de login |
| Servicio de systemd | No | No | Ninguno |
El error clásico, en su forma exacta: pones export METEORA_HOME=/var/lib/meteora en ~/.bashrc, abres una terminal y funciona; luego ssh meteo-01 'echo $METEORA_HOME' sale vacío y un servicio falla porque la variable no existe. No es un fallo de la variable: la shell no interactiva no lee ~/.bashrc.
La regla práctica es sencilla. Lo que define entorno (variables, PATH) va en ~/.profile, que se lee al iniciar sesión y se hereda por todo lo que arranques desde ahí. Lo que define comodidad interactiva (alias, prompt, colores, historial) va en ~/.bashrc, porque en un script no tiene sentido. Y lo que necesita un servicio no va en ningún fichero de shell: va en su unidad de systemd con Environment= o EnvironmentFile= (07-02).
Por eso el ~/.bashrc por defecto de Debian empieza con case $- in *i*) ;; *) return;; esac: $- contiene los flags activos y la letra i solo aparece si la shell es interactiva. Si no lo es, return aborta la lectura. Evita un fallo real y difícil de depurar: si ~/.bashrc escribe algo por la salida estándar —un mensaje de bienvenida, por ejemplo—, rompe scp y rsync, que esperan un flujo limpio por ese canal.
Anatomía de una orden y comandos internos frente a externos
Una línea se divide en palabras separadas por espacios o tabuladores: la primera es el comando, el resto argumentos, y entre ellos suele haber opciones.
| Forma | Ejemplo | Detalle |
|---|---|---|
| Opción corta | -n |
Una letra; agrupable: -in = -i -n |
| Corta con valor | -o fichero o -ofichero |
Depende del programa |
| Opción larga | --color=auto |
Legible; úsala en scripts para que se entienda dentro de un año |
| Fin de opciones | -- |
Todo lo que sigue es operando, aunque empiece por - |
El separador -- no es cosmético. Si creas ./-informe.txt, la orden rm -informe.txt responde rm: opción no válida -- 'i', porque rm interpreta el nombre como una ristra de opciones; rm -- -informe.txt funciona. Es una defensa obligatoria en scripts que manejan nombres de origen externo: quien pueda crear ficheros en un directorio que tú recorres puede inyectar opciones en tus comandos.
Interno frente a externo. Un comando interno (builtin) está compilado dentro de la shell y no crea proceso alguno; uno externo es un fichero en disco que se ejecuta con fork+execve.
type cd echo grep ll
# cd is a shell builtin
# echo is a shell builtin
# grep is /usr/bin/grep
# ll is aliased to `ls -alF'
which cd # (nada: which solo busca ficheros en el PATH)
type -a echo # echo is a shell builtin / echo is /usr/bin/echoQué demuestra. type conoce el mundo entero de la shell —alias, funciones, builtins y externos—, mientras que which solo recorre el PATH y por eso miente sobre los builtins: usa type. Fíjate en echo: existen dos, el interno y /usr/bin/echo, y se comportan distinto ante opciones como -e. Es uno de los motivos por los que en scripts serios se prefiere printf.
¿Y por qué cd no puede ser un programa externo? Porque el directorio de trabajo es un atributo del proceso, guardado en su task_struct y visible en /proc/<pid>/cwd (04-02). Si cd fuera un ejecutable, la shell haría fork, el hijo llamaría a chdir(), cambiaría su directorio y moriría; el padre seguiría exactamente donde estaba. El cambio tiene que ocurrir en el propio proceso de la shell. La misma lógica explica export, umask, ulimit, exec y source.
El orden de expansión, paso a paso
Antes de ejecutar nada, bash transforma la línea siguiendo un orden fijo y no negociable, que explica casi todos los comportamientos "raros":
- Llaves:
{a,b},{1..5}— 2. Tilde:~,~meteora— 3. Parámetros y variables:$VAR,${VAR:-def}— 4. Sustitución de órdenes:$(comando)— 5. Aritmética:$(( 2 + 2 ))— 6. División en palabras segúnIFS— 7. Globbing:*,?,[...]— 8. Eliminación de comillas.
mkdir -p /var/lib/meteora/lecturas/{2026,2025} # llaves: genera texto ANTES de existir nada
echo ~meteora # -> /var/lib/meteora (consulta /etc/passwd)
FECHA=2026-08-31; echo /var/lib/meteora/lecturas/$FECHA.dat
# -> /var/lib/meteora/lecturas/2026-08-31.dat
echo "Ficheros: $(ls /var/lib/meteora/lecturas | wc -l)" # sustitución de órdenes
echo "Bytes por lectura: $(( 17280000 / 720000 ))" # -> 24, nuestra constante del cursoPor qué el orden importa, con tres demostraciones que fallarían si fuese otro:
# (a) El globbing (7) va DESPUÉS de la expansión de variables (3)
PATRON='*.dat'
echo $PATRON # -> 2026-08-30.dat 2026-08-31.dat (¡se expandió!)
echo "$PATRON" # -> *.dat (literal)
# (b) La expansión de llaves (1) va ANTES que la de variables (3)
N=3
echo {1..$N} # -> {1..3} (no es un rango válido y se deja tal cual)
seq 1 "$N" # -> 1 2 3 (la forma correcta)
# (c) La división en palabras (6) va DESPUÉS de la sustitución de órdenes (4)
DIR=$(echo '/tmp/informe agosto')
ls $DIR # ls: no se puede acceder a '/tmp/informe' ... ni a 'agosto'
ls "$DIR" # correctoExplicación. En (a), al sustituir $PATRON obtenemos el texto *.dat y, como el globbing es posterior, ese texto se somete otra vez a expansión de nombres de fichero; con comillas no se divide ni se globaliza. En (b), cuando bash procesa las llaves aún no ha sustituido $N, así que ve un rango inválido: es un límite estructural del orden, no un fallo. En (c), el resultado de $(...) contiene un espacio y la división en palabras lo parte en dos argumentos; las comillas dobles suprimen los pasos 6 y 7. Medido en incidencias reales, (c) es la causa número uno de scripts rotos.
Comillas y escapado: el 80 % de los fallos
| Forma | Qué protege | Qué deja pasar |
|---|---|---|
'texto' |
Todo, sin excepción | Nada; ni siquiera se puede escapar una comilla simple dentro |
"texto" |
División en palabras y globbing | $var, $(cmd), `cmd`, \ y ! (en interactiva) |
\c |
Ese único carácter | — |
| Sin comillas | Nada | Todo se expande y se divide |
La regla de oro, sin matices: entrecomilla todas las expansiones de variable salvo que tengas un motivo explícito para no hacerlo, y ese motivo es raro. El caso de los nombres con espacios merece el ejemplo completo, porque es donde más daño se hace:
# MAL: se rompe con espacios y con nombres que empiezan por guion
for f in $(ls /var/lib/meteora/informes); do rm $f; done
# BIEN: glob directo, comillas y -- como cortafuegos
for f in /var/lib/meteora/informes/*; do
[ -e "$f" ] || continue # protege el caso "no hay ficheros"
rm -- "$f"
doneQué cambia. La versión mala acumula tres defectos: analiza la salida de ls (que formatea para humanos, no para máquinas), pierde la ruta completa y, sin comillas, parte cada nombre por sus espacios, de modo que informe agosto.pdf se convierte en dos borrados fallidos. La versión buena usa el globbing de la shell —que devuelve rutas completas y no divide por espacios—, comprueba que el patrón se expandió realmente (si el directorio está vacío, bash deja el patrón literal) y usa --.
Redirección y descriptores de fichero
Retomamos 04-04: todo proceso arranca con tres descriptores abiertos —0 stdin, 1 stdout, 2 stderr— y la shell simplemente los manipula entre el fork y el execve, con open() y dup2(). Ese detalle temporal es lo que hace que el programa ejecutado no se entere de nada: cuando arranca, ya tiene el fd 1 apuntando donde tú dijiste.
| Sintaxis | Efecto | Sintaxis | Efecto |
|---|---|---|---|
> f |
stdout a f, truncándolo |
< f |
stdin desde f |
>> f |
stdout a f, añadiendo |
<<< 'txt' |
here-string como stdin |
2> f |
stderr a f |
<<EOF |
here-doc como stdin |
2>&1 |
stderr adonde apunta ahora stdout |
&> f |
Ambos a f (bash) |
El orden contraintuitivo. comando > salida.log 2>&1 manda todo al fichero; comando 2>&1 > salida.log manda stderr a la pantalla y solo stdout al fichero. La razón es que 2>&1 significa literalmente "haz que el fd 2 sea una copia de donde apunta el fd 1 en este instante": es una fotografía, no un enlace permanente. En el primer caso el fd 1 ya se movió al fichero cuando se procesa 2>&1; en el segundo todavía apunta al terminal. Es el dup2() de 04-04, sin magia añadida.
# Separar flujos, que es lo profesional en un script automatizado
/usr/local/bin/agregador > /var/log/meteora/agregador.out 2> /var/log/meteora/agregador.err
find / -name 'meteora.conf' 2>/dev/null # descartar los "Permiso denegado"
cat > /etc/meteora/limites.conf <<'EOF' # here-doc SIN expansión de variables
max_lecturas_hora=720000
ruta_datos=/var/lib/meteora/lecturas
EOFQué hacen. El primero deja trazas separadas y permite alertar solo sobre el fichero de errores. El segundo aprovecha que find escribe los avisos por stderr y los resultados por stdout, así que descartando el 2 queda una lista limpia. En el tercero, fíjate en las comillas del delimitador: <<'EOF' no expande variables dentro del bloque, mientras que <<EOF sin comillas sí; confundirlos produce ficheros de configuración con valores vacíos.
Tuberías, estado de salida y pipefail
Una tubería conecta el stdout de un proceso con el stdin del siguiente mediante el pipe() de 03-03: un búfer de 64 KB en el núcleo, con bloqueo automático cuando se llena o se vacía. Los procesos se lanzan todos a la vez, no en secuencia, así que en una máquina con varias CPU se reparten de verdad; y cuando head termina y cierra su extremo, el proceso anterior recibe SIGPIPE y muere, por eso head sobre una tubería enorme no espera a que acabe todo.
El problema del estado de salida. Por defecto, el estado de una tubería es el del último comando, lo que oculta fallos:
cat /var/lib/meteora/lecturas/inexistente.dat | wc -l
echo $?
# cat: ...inexistente.dat: No existe el fichero o el directorio
# 0 <-- ¡éxito! porque wc terminó bien
echo "${PIPESTATUS[@]}" # -> 1 0 (estado de CADA comando)
set -o pipefail # a partir de aquí, la tubería devuelve 1Por qué es grave. En un script con set -e, esa tubería no aborta la ejecución: el guion sigue creyendo que todo va bien y procesa cero líneas como si fueran datos válidos. Es la clase de fallo silencioso que acaba en "el informe de ayer salió vacío y nadie se dio cuenta". PIPESTATUS es un array de bash con el estado de todos los elementos; pipefail cambia la regla para que la tubería devuelva el estado del último comando que falló. Es lo que quieres en todo script no trivial.
tee duplica un flujo: agregador 2>&1 | tee -a /var/log/meteora/agregador.log | grep -i error guarda todo en el registro (con -a para añadir, no truncar) y a la vez deja pasar una copia hacia grep, que muestra por pantalla solo los errores. Sin tee tendrías que elegir entre ver o guardar.
Variables de shell, variables de entorno y PATH
| Tipo | Cómo se crea | ¿La heredan los hijos? | Dónde vive |
|---|---|---|---|
| De shell | VAR=valor |
No | Solo en el proceso shell |
| De entorno | export VAR=valor |
Sí | En el bloque de entorno, copiado por execve |
LOCAL=solo_aqui; export GLOBAL=heredada
bash -c 'echo "[$LOCAL] [$GLOBAL]"' # -> [] [heredada]
tr '\0' '\n' < /proc/$$/environ | grep GLOBAL # el entorno REAL del procesoQué demuestra. El hijo recibe una copia del bloque de entorno, que es el tercer argumento de execve(); las variables no exportadas nunca entran ahí. Y /proc/<pid>/environ —otra vez 02-01— muestra el entorno de cualquier proceso, con los valores separados por bytes nulos, de ahí el tr. Nota de seguridad: nunca pases secretos por el entorno, porque es legible por el propietario del proceso y aparece en los volcados; los secretos de Meteora viven en /etc/meteora/meteora.conf con modo 600 justamente por eso.
PATH es la lista de directorios separados por : donde se buscan los comandos externos, de izquierda a derecha, deteniéndose en la primera coincidencia. Retomando 05-02: incluir . en el PATH, y sobre todo ponerlo delante, es un fallo clásico; si un atacante deja un fichero llamado ls en /tmp y un administrador con sudo hace cd /tmp y escribe ls, ejecuta el programa del atacante con privilegios de root. Reglas: nunca incluyas . ni directorios escribibles por otros; en scripts automatizados usa rutas absolutas o fija PATH explícitamente; y comprueba con type -a qué se va a ejecutar realmente antes de dar nada por hecho.
Control de trabajos y señales
Un trabajo (job) es una tubería completa lanzada desde la shell interactiva. La shell le asigna un número y puede moverlo entre primer plano (recibe el teclado) y segundo plano.
| Acción | Efecto | Señal |
|---|---|---|
comando & |
Arranca en segundo plano | — |
Ctrl-C |
Interrumpe el trabajo en primer plano | SIGINT (2) |
Ctrl-Z |
Lo suspende | SIGTSTP (20) |
Ctrl-\ |
Termina con volcado de memoria | SIGQUIT (3) |
Ctrl-D |
Ninguna señal: cierra stdin (fin de fichero) |
— |
jobs / fg %1 / bg %1 |
Listar, traer a primer plano, reanudar en segundo | SIGCONT en fg/bg |
kill %1 |
Terminar el trabajo | SIGTERM (15) |
gzip /var/lib/meteora/lecturas/2026-07-*.dat
^Z # [1]+ Detenido gzip ...
bg %1 # [1]+ gzip ... &
jobs -l # [1]+ 12934 Ejecutando gzip ... &Qué ha pasado. Ctrl-Z envió SIGTSTP al grupo de procesos en primer plano y el proceso pasó al estado T (detenido) que viste en 02-01; bg le envió SIGCONT y lo dejó corriendo sin el terminal. Puedes confirmarlo con ps -o pid,stat,cmd -p 12934.
SIGHUP y nohup. Al cerrar una sesión SSH el terminal desaparece y el núcleo envía SIGHUP a los procesos asociados, que por defecto mueren. nohup cmd > log 2>&1 & hace que el proceso ignore esa señal; setsid cmd </dev/null >log 2>&1 va más lejos y crea una sesión nueva sin terminal de control, de modo que la señal ni siquiera se genera; y tmux es la opción preferible en la práctica porque además permite reconectar y ver la salida. Para tareas periódicas de verdad, ninguna de las tres: se usa un temporizador de systemd, tema de 07-02.
Las herramientas de texto imprescindibles
| Herramienta | Para qué | Opciones más usadas |
|---|---|---|
grep |
Filtrar líneas | -i, -v, -c, -n, -E, -o, -F, -r |
sed |
Sustituir y editar por líneas | s/a/b/g, -n '5,10p', -i |
awk |
Procesar por campos y calcular | '{print $5}', -F:, bloque END |
cut |
Extraer columnas | -d' ' -f2, -c1-10 |
sort |
Ordenar | -n, -r, -k2, -u, -t: |
uniq |
Colapsar repetidos (¡ordenado antes!) | -c, -d |
wc |
Contar | -l, -c, -w |
head/tail |
Extremos | -n 20, tail -f, tail -F |
find |
Buscar por criterios | -name, -mtime, -size, -exec, -print0 |
xargs |
Convertir entrada en argumentos | -0, -n, -P, -I{} |
Expresiones regulares básicas: ^ y $ son principio y fin de línea; . es cualquier carácter; [0-9] un dígito; *, + y ? significan cero o más, uno o más y opcional (los dos últimos requieren -E); {3} con -E son exactamente tres repeticiones; y | con -E es la alternativa.
grep -E ' 5[0-9]{2} ' /var/log/meteora/meteo-api.log | wc -l # respuestas 5xx
grep -oE '^[0-9]{1,3}(\.[0-9]{1,3}){3}' /var/log/meteora/meteo-api.log | sort -u
tail -F /var/log/meteora/meteo-api.log | grep --line-buffered 'ERROR' # seguimiento en vivoDetalles que importan. -E activa las expresiones extendidas y evita escapar {}, | y +; -o imprime solo la parte coincidente. tail -F (mayúscula) reabre el fichero si se rota —imprescindible con el logrotate de 05-04—, mientras que tail -f se queda mirando un inodo que ya nadie escribe. Y --line-buffered fuerza a grep a escribir línea a línea: sin ella usa búfer de 4 KB al detectar que su salida es una tubería, y verías los errores con minutos de retraso; es exactamente el buffering de 01-06.
find + xargs con seguridad:
# MAL: se rompe con espacios o saltos de línea en los nombres
find /var/lib/meteora/lecturas -name '*.dat' -mtime +90 | xargs rm
# BIEN: separador nulo en ambos extremos
find /var/lib/meteora/lecturas -name '*.dat' -mtime +90 -print0 | xargs -0 --no-run-if-empty rm --
# Alternativa sin xargs, agrupando en pocas invocaciones
find /var/lib/meteora/lecturas -name '*.dat' -mtime +90 -exec rm -- {} +Por qué. El byte nulo es el único carácter que no puede aparecer en un nombre de fichero en Linux, así que -print0 con xargs -0 es el único emparejamiento seguro; --no-run-if-empty evita que rm se ejecute sin argumentos. En la tercera forma, -exec ... + agrupa muchos ficheros por invocación, mientras que -exec ... \; lanza un proceso por fichero: con 10.000 ficheros la diferencia es de unos pocos procesos frente a 10.000 pares fork+execve, o sea segundos frente a minutos.
Una cadena completa sobre meteo-api.log
Formato de línea en /var/log/meteora/meteo-api.log (estilo de registro combinado, con la duración al final):
10.20.3.41 - - [31/Aug/2026:03:12:07 +0200] "GET /v1/lecturas?estacion=EST-0142 HTTP/1.1" 200 8213 0.043
Objetivo: las 10 estaciones que más peticiones generan.
grep -F 'GET /v1/lecturas' /var/log/meteora/meteo-api.log \
| grep -oE 'estacion=EST-[0-9]{4}' \
| cut -d= -f2 | sort | uniq -c | sort -rn | head -n 10
# 48213 EST-0142
# 31904 EST-0007
# 28755 EST-0311| Eslabón | Qué hace | Por qué así |
|---|---|---|
grep -F |
Filtra el endpoint | -F busca texto fijo: más rápido y sin que / o ? se interpreten |
grep -oE |
Extrae solo el parámetro | -o descarta el resto; exigir 4 dígitos elimina valores malformados |
cut -d= -f2 |
Se queda con EST-0142 |
Delimitador =, campo 2 |
sort |
Agrupa iguales | Obligatorio: uniq solo colapsa líneas adyacentes |
uniq -c |
Cuenta cada grupo | Devuelve recuento valor |
sort -rn |
Ordena por recuento | -n numérico (si no, "9" iría tras "48213"); -r descendente |
head -n 10 |
Corta el top 10 | Además cierra la tubería y aborta el resto vía SIGPIPE |
La versión con awk, que hace lo mismo en un solo proceso:
awk -F'estacion=' '/GET \/v1\/lecturas/ && NF>1 { split($2, p, /[" &]/); cuenta[p[1]]++ }
END { for (e in cuenta) printf "%8d %s\n", cuenta[e], e }' \
/var/log/meteora/meteo-api.log | sort -rn | head -n 10Por qué es mejor. -F'estacion=' parte cada línea por esa cadena, de modo que $2 empieza por el identificador, y NF>1 descarta las líneas sin el parámetro; split corta en el primer carácter que no forma parte del valor y p[1] queda con EST-0142; el array asociativo acumula y el bloque END vuelca los totales. Sobre un registro de 5 millones de líneas, la cadena de seis procesos con dos sort completos tarda unos 40 segundos y escribe temporales en disco; la versión awk recorre el fichero una sola vez y solo ordena unas centenas de líneas finales: entre 6 y 8 segundos. La lección general es que sort sobre millones de líneas suele ser el cuello de botella de una tubería, y que agregar antes de ordenar cambia el orden de magnitud.
Scripting: del comando suelto al programa
La primera línea de un script, el shebang, dice al núcleo qué intérprete usar en el execve. El núcleo lee los dos bytes #!, toma el resto como ruta absoluta y ejecuta intérprete ruta_del_script. Escribir #!/bin/bash exige que bash esté exactamente ahí; #!/usr/bin/env bash lo busca en el PATH, lo que funciona también donde vive en /usr/local/bin. La contrapartida es esa dependencia del PATH, así que en scripts con sudo o de servicio conviene fijarlo.
La cabecera obligatoria, opción a opción:
| Opción | Efecto | Por qué |
|---|---|---|
-e |
Aborta si un comando devuelve estado ≠ 0 | Evita seguir trabajando sobre un fallo |
-u |
Error al usar una variable no definida | Convierte rm -rf "$DIR/" con DIR vacío en un error, no en un desastre |
-o pipefail |
La tubería falla si falla cualquier eslabón | Sin esto, -e no ve los fallos en tuberías |
IFS=$'\n\t' |
Divide solo por salto de línea y tabulador | Impide que un espacio parta una palabra |
set -e tiene trampas conocidas: no salta dentro de una función llamada en una condición, ni en el último comando de una tubería sin pipefail, ni con local var=$(cmd) —el estado que cuenta es el de local, que siempre es 0—. Por eso lo correcto es declarar primero (local salida) y asignar después (salida=$(comando)).
if [[ -f "$ARCHIVO" && -s "$ARCHIVO" ]]; then ... ; fi # existe y no está vacío
[[ "$n" -gt 100 ]] # comparación numérica
[[ "$s" == EST-* ]] # patrón (sin comillas a la derecha)
[[ "$s" =~ ^EST-[0-9]+$ ]] # expresión regular
while IFS= read -r linea; do ...; done < fichero
comprobar_tamano() {
local ruta="$1" esperado="$2" real
real=$(stat -c '%s' "$ruta")
[[ "$real" -eq "$esperado" ]] # el estado de la última orden es el retorno
}Detalles clave. [[ ]] es una construcción de bash que no divide palabras ni globaliza dentro, así que es más segura que [ ], que sigue reglas POSIX y falla con valores vacíos. while IFS= read -r linea es la forma canónica de leer líneas: IFS= evita recortar espacios en los extremos y -r impide que \ actúe como escape. Una función devuelve el estado de su última orden, o el que le des con return N.
Los argumentos posicionales son $1, $2…, con $# para el número, "$@" para todos cada uno como una palabra (usa siempre esta forma y no "$*", que los une en una sola cadena), $? para el último estado y $$ para el PID. La convención de códigos de salida es la del sistema: 0 éxito, 1 error genérico, 2 error de uso, y >2 errores específicos que tú definas.
trap para limpieza, imprescindible en cualquier script que cree temporales o tome un bloqueo:
TRABAJO=$(mktemp -d)
limpiar() { local codigo=$?; rm -rf -- "$TRABAJO"; exit "$codigo"; }
trap limpiar EXIT INT TERMQué hace. trap registra un manejador para señales —las mismas de 03-03— y para el pseudoevento EXIT, que se dispara siempre que el script termina, con éxito o con error, de modo que el temporal se borra aunque muera a mitad. Guardar $? al principio de la función y usarlo en exit preserva el código original, que de otro modo pisaría el rm.
Script completo: verificación de los datos de Meteora
Este es el ejercicio de integración de la lección: comprobar que los ficheros de /var/lib/meteora/lecturas/ están completos y al día, y avisar si falta el del día en curso.
#!/usr/bin/env bash
# verificar-lecturas.sh — Integridad y antigüedad de los datos de Meteora.
# Salidas: 0 = correcto | 1 = advertencias | 2 = error de uso | 3 = fallo crítico
set -euo pipefail
IFS=$'\n\t'
readonly DIR_DATOS="/var/lib/meteora/lecturas"
readonly TAM_ESPERADO=17280000 # 720.000 lecturas x 24 B
readonly TOLERANCIA_PCT=2
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
advertencias=0; criticos=0
log() { local n="$1"; shift; printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$n" "$*" >&2; }
aviso() { log WARN "$@"; advertencias=$((advertencias + 1)); }
critico() { log ERROR "$@"; criticos=$((criticos + 1)); }
TRABAJO=$(mktemp -d -t meteora-verif-XXXXXX)
limpiar() { local codigo=$?; rm -rf -- "$TRABAJO"; exit "$codigo"; }
trap limpiar EXIT INT TERM
verboso=0; directorio="$DIR_DATOS"
while getopts ':d:v' o; do
case "$o" in
d) directorio="$OPTARG" ;;
v) verboso=1 ;;
*) log ERROR "Uso: ${0##*/} [-d DIRECTORIO] [-v]"; exit 2 ;;
esac
done
[[ -d "$directorio" && -r "$directorio" ]] || { critico "No se puede leer $directorio"; exit 3; }
# --- 1. ¿Está el fichero de hoy y se está escribiendo? ---
hoy=$(date '+%Y-%m-%d'); fichero_hoy="$directorio/$hoy.dat"
if [[ ! -f "$fichero_hoy" ]]; then
critico "FALTA el fichero del día: $fichero_hoy"
else
edad_min=$(( ( $(date +%s) - $(stat -c '%Y' "$fichero_hoy") ) / 60 ))
(( edad_min > 10 )) && aviso "Sin escrituras desde hace $edad_min min: ¿ingestor parado?"
fi
# --- 2. Integridad de tamaño de los ficheros ya cerrados ---
minimo=$(( TAM_ESPERADO * (100 - TOLERANCIA_PCT) / 100 ))
maximo=$(( TAM_ESPERADO * (100 + TOLERANCIA_PCT) / 100 ))
find "$directorio" -maxdepth 1 -type f -name '*.dat' ! -name "$hoy.dat" -print0 \
| sort -z > "$TRABAJO/ficheros.lst"
while IFS= read -r -d '' fichero; do
tam=$(stat -c '%s' "$fichero"); base=$(basename -- "$fichero")
if (( tam % 24 != 0 )); then
critico "$base: $tam bytes no es múltiplo de 24 (fichero truncado)"
elif (( tam < minimo || tam > maximo )); then
aviso "$base: $tam bytes, fuera del rango [$minimo, $maximo]"
elif (( verboso )); then
log INFO "$base: OK ($((tam / 24)) lecturas)"
fi
done < "$TRABAJO/ficheros.lst"
# --- 3. Huecos en la serie de los últimos 30 días ---
for offset in $(seq 1 30); do
dia=$(date -d "$offset days ago" '+%Y-%m-%d')
[[ -f "$directorio/$dia.dat" ]] || aviso "Falta el fichero del día $dia"
done
# --- 4. Espacio libre ---
uso_pct=$(df --output=pcent "$directorio" | tail -n1 | tr -dc '0-9')
if (( uso_pct >= 90 )); then critico "El sistema de ficheros está al $uso_pct %"
elif (( uso_pct >= 80 )); then aviso "El sistema de ficheros está al $uso_pct %"
fi
log INFO "Verificación terminada: $criticos críticos, $advertencias advertencias"
(( criticos > 0 )) && exit 3
(( advertencias > 0 )) && exit 1
exit 0Decisiones de diseño comentadas:
readonlyyPATHfijo. El script se ejecutará desde un temporizador de systemd, donde no hayPATHheredado ni directorio de trabajo predecible; fijarlo elimina el secuestro de comandos de 05-02.logescribe astderr. Deja libre la salida estándar por si algún día el script produce datos, y journald captura ambos flujos igualmente.mktemp -d+trap. Nunca uses una ruta fija como/tmp/trabajo: es una condición de carrera y un vector de ataque por enlaces simbólicos.mktempcrea un directorio con nombre impredecible y permisos 700.find -print0 | sort -zconread -r -d ''. El trío completo de seguridad frente a nombres raros: el separador es el byte nulo de principio a fin.tam % 24 != 0. Es la verificación de integridad más barata e informativa para este formato: como cadaLecturaocupa exactamente 24 bytes, un tamaño que no sea múltiplo de 24 significa escritura truncada, seguramente por un corte durante unwrite()sinfsyncposterior (04-05).- Tolerancia del 2 %. Un día real rara vez tiene 720.000 lecturas exactas: una estación puede perder cobertura unos minutos. Alertar con tolerancia cero produciría ruido diario y la alerta acabaría ignorada.
- Códigos de salida diferenciados. Permiten que systemd o el sistema de monitorización distingan "hay que mirarlo mañana" (1) de "hay que actuar ahora" (3).
Antes de poner en producción cualquier script, pásale el analizador estático: shellcheck verificar-lecturas.sh detecta exactamente los fallos de esta lección —variables sin entrecomillar, $(ls), [ ] mal usados, cd sin comprobar, read sin -r— y es la herramienta de mayor relación beneficio/esfuerzo del ecosistema de shell.
Errores Comunes y Consejos
| Error | Por qué ocurre | Solución |
|---|---|---|
for f in $(ls) |
ls formatea para humanos, no para máquinas |
for f in ./* |
| Variables sin comillas | La división en palabras es el paso 6 | "$var" siempre |
2>&1 > f en vez de > f 2>&1 |
2>&1 copia el destino actual del fd 1 |
Redirige stdout primero |
| Tubería que "funciona" con datos vacíos | El estado es el del último comando | set -o pipefail |
Variable de ~/.bashrc invisible por SSH no interactivo |
Ese fichero no se lee | ~/.profile o la unidad de systemd |
rm $DIR/* con $DIR vacío |
Se convierte en rm /* |
set -u y comprobar [[ -n "$DIR" ]] |
[ $a == $b ] falla con valores vacíos |
[ ] recibe menos argumentos de los previstos |
[[ $a == $b ]] |
find ... -exec cmd \; lentísimo |
Un proceso por fichero | -exec cmd {} + o xargs -0 |
uniq no agrupa nada |
Solo colapsa líneas adyacentes | sort antes, siempre |
#!/bin/sh con sintaxis de bash |
En Debian sh es dash |
#!/usr/bin/env bash |
| Funciona a mano y falla en automático | Distinto PATH, cwd y entorno |
Rutas absolutas y PATH fijo |
tail -f deja de mostrar líneas |
El fichero se rotó | tail -F |
Consejos que ahorran horas: antes de un rm o un mv masivo, sustituye la acción por echo y revisa la salida completa; usa Ctrl-R para buscar en el historial en lugar de reescribir órdenes largas; depura con set -x o bash -x script.sh, que imprime cada orden ya expandida antes de ejecutarla; comenta el porqué, no el qué; y procura que todo script automatizado sea idempotente, porque tarde o temprano alguien lo reintentará.
Ejercicios
Ejercicio 1: expansión y comillas
Sin ejecutar nada, predice la salida exacta y di qué paso de la expansión la determina. Después compruébalo.
cd /tmp && mkdir -p demo && cd demo
touch 'informe agosto.dat' 'informe-julio.dat' '-raro.dat'
A='*.dat'; N=2
echo 1: $A
echo 2: "$A"
echo 3: {1..$N}
for f in *.dat; do echo "4: [$f]"; doneEjercicio 2: análisis del registro de la API
Con el formato de meteo-api.log de esta lección, escribe una sola tubería (o un awk) para: (1) contar las peticiones con código 5xx; (2) sacar las 5 IP con más peticiones y su recuento; (3) calcular la latencia media de /v1/lecturas en milisegundos; (4) listar las horas del día con más de 100.000 peticiones.
Ejercicio 3: script de rotación de datos antiguos
Escribe archivar-lecturas.sh que comprima con gzip los .dat de /var/lib/meteora/lecturas/ con más de 90 días, salte los ya comprimidos, no toque el del día en curso, use un bloqueo para no solaparse consigo mismo, registre lo hecho, admita un modo -n de simulación y devuelva un código de salida correcto. Debe ser seguro frente a nombres con espacios y pasar shellcheck.
Soluciones
Solución 1
1: informe agosto.dat informe-julio.dat -raro.dat
2: *.dat
3: {1..2}
4: [-raro.dat]
4: [informe agosto.dat]
4: [informe-julio.dat]- Línea 1.
$Ase sustituye en el paso 3 dando el texto*.daty, como el globbing es el paso 7, ese texto se expande otra vez contra el directorio. Es la trampa de guardar patrones en variables. - Línea 2. Las comillas dobles suprimen los pasos 6 y 7, así que se imprime el valor literal.
- Línea 3. La expansión de llaves es el paso 1, anterior a la de variables: bash ve
{1..$N}, que no es un rango válido, y lo deja tal cual. La forma correcta esseq 1 "$N". - Línea 4. El bucle sobre el glob es correcto: cada nombre llega entero, con espacios incluidos, y el orden es el de la configuración regional (por eso
-raro.datva primero). Si dentro usarasrm $fsin comillas,-raro.datse interpretaría como opciones: de ahírm -- "$f".
Solución 2
# 1. Peticiones 5xx
awk '$9 >= 500 && $9 < 600 {n++} END {print n+0}' /var/log/meteora/meteo-api.log
# 2. Top 5 de IP
awk '{c[$1]++} END {for (ip in c) printf "%8d %s\n", c[ip], ip}' \
/var/log/meteora/meteo-api.log | sort -rn | head -n 5
# 3. Latencia media en ms de /v1/lecturas
awk '/GET \/v1\/lecturas/ {suma += $NF; n++}
END {if (n) printf "media=%.1f ms sobre %d peticiones\n", suma*1000/n, n; else print "sin datos"}' \
/var/log/meteora/meteo-api.log
# 4. Horas con más de 100.000 peticiones
awk '{split($4, t, ":"); c[t[2]]++}
END {for (h in c) if (c[h] > 100000) printf "%s:00 -> %d\n", h, c[h]}' \
/var/log/meteora/meteo-api.log | sortComentarios. En (1), $9 es el código de estado del formato combinado y n+0 fuerza a imprimir 0 en lugar de una cadena vacía cuando no hay coincidencias, detalle que importa si la salida alimenta a un sistema de alertas. En (2) se recorre el fichero una sola vez y solo se ordenan las IP distintas, no los millones de líneas. En (3), $NF es el último campo (duración en segundos) y la guarda if (n) evita la división por cero, que en awk es fatal; recuerda que la media oculta la cola, y en 07-03 verás por qué el p99 es la métrica que importa. En (4), el campo 4 es [31/Aug/2026:03:12:07, así que al partirlo por : el elemento t[2] es la hora.
Solución 3
#!/usr/bin/env bash
# archivar-lecturas.sh — Comprime lecturas con más de 90 días.
# Salidas: 0 = correcto | 1 = con incidencias | 2 = uso | 3 = crítico
set -euo pipefail
IFS=$'\n\t'
readonly DIR="/var/lib/meteora/lecturas"
readonly DIAS=90
readonly BLOQUEO="/run/meteora/archivar.lock"
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
simulacion=0; fallos=0; comprimidos=0
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2; }
while getopts ':n' o; do
case "$o" in
n) simulacion=1 ;;
*) log ERROR "Uso: ${0##*/} [-n]"; exit 2 ;;
esac
done
mkdir -p -- "$(dirname -- "$BLOQUEO")"
exec 9>"$BLOQUEO"
flock -n 9 || { log INFO "Ya hay otra ejecución en curso; se omite esta."; exit 0; }
trap 'flock -u 9' EXIT
[[ -d "$DIR" ]] || { log ERROR "No existe $DIR"; exit 3; }
hoy=$(date '+%Y-%m-%d')
while IFS= read -r -d '' f; do
base=$(basename -- "$f")
[[ "$base" == "$hoy.dat" ]] && continue # nunca el del día en curso
[[ -e "$f.gz" ]] && { log WARN "Ya existe $base.gz, se omite"; continue; }
(( simulacion )) && { log INFO "[simulación] gzip $base"; continue; }
if gzip -9 -- "$f"; then
comprimidos=$((comprimidos + 1)); log INFO "Comprimido $base"
else
fallos=$((fallos + 1)); log ERROR "Fallo al comprimir $base"
fi
done < <(find "$DIR" -maxdepth 1 -type f -name '*.dat' -mtime "+$DIAS" -print0)
log INFO "Terminado: $comprimidos comprimidos, $fallos fallos (simulación=$simulacion)"
(( fallos > 0 )) && exit 1
exit 0Decisiones comentadas:
exec 9>"$BLOQUEO"+flock -n 9. Abre el fichero de bloqueo en el descriptor 9 del propio script y toma un bloqueo exclusivo no bloqueante: el mismo mecanismo de 04-04 que usa elagregador. Si otra instancia lo tiene, salimos con código 0, porque "ya se está ejecutando" no es un fallo del sistema y no debe disparar una alerta.done < <(find ...)en lugar defind ... | while. Es crucial: con una tubería, elwhilecorrería en una subshell y los contadores se perderían al terminar, quedando siempre a cero.-mtime "+$DIAS"y-print0delegan el filtrado por fecha enfind, que consulta el inodo directamente, y garantizan que ningún nombre con espacios o saltos de línea rompa el bucle.- Comprobar
-e "$f.gz"evita perder datos si una ejecución anterior quedó a medias;gzip -9es razonable porque los históricos se escriben una vez y se leen rara vez, y sobre registros binarios de 24 bytes muy regulares reduce entre un 60 y un 70 %. - Modo simulación antes de cualquier operación destructiva, y registro por
stderrpara que journald lo recoja tal cual.
Conclusión
La shell ha dejado de ser una caja negra. Ahora sabes que es un programa de usuario más cuyo bucle es leer, expandir, fork, execve, wait, y que esa naturaleza explica todo lo demás: por qué cd tiene que ser interno, por qué las variables no exportadas no llegan a los hijos, por qué un script no puede cambiar el directorio de su padre y por qué cada comando externo cuesta un proceso.
Has visto los cuatro mecanismos que hay que dominar sí o sí. El orden de expansión, con sus ocho pasos, que explica por qué {1..$N} no funciona y por qué un patrón guardado en una variable se expande dos veces. Las comillas, que no son decoración: suprimen la división en palabras y el globbing, y su ausencia es la primera causa de scripts rotos. La redirección, que es dup2() con azúcar sintáctico, de donde sale la asimetría de > f 2>&1 frente a 2>&1 > f. Y las tuberías, que son el pipe() del módulo 3 con su búfer de 64 KB, su SIGPIPE y su trampa del estado de salida que pipefail corrige.
Sobre esa base has montado herramientas reales: una cadena que saca del meteo-api.log las estaciones más activas —y su versión en awk, cinco veces más rápida porque agrega antes de ordenar— y un script de verificación que aplica todas las defensas de golpe: set -euo pipefail, mktemp con trap, -print0 con read -d '', rutas absolutas, getopts, códigos de salida diferenciados y comprobaciones de integridad basadas en el formato real de 24 bytes por lectura.
Pero un script suelto no es un sistema. El agregador debe ejecutarse cada hora aunque la máquina estuviera apagada, meteo-api debe arrancar solo tras el montaje de /var/lib/meteora y reiniciarse si cae, y todo eso debe sobrevivir a un reinicio del servidor. Eso ya no lo resuelve la shell: lo resuelve el gestor de servicios, la pieza que arranca con PID 1 y decide qué corre, cuándo y bajo qué límites.
Es el tema siguiente: Servicios, Arranque y systemd, donde seguiremos a meteo-01 desde que se pulsa el botón de encendido hasta que meteo-api acepta la primera petición en el puerto 443.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
