Son las 03:12. El teléfono vibra: "La API de Meteora responde lenta. Clientes quejándose desde hace unos minutos." No hay más información, nadie sabe qué ha cambiado y la persona que escribió el agregador está de vacaciones. Tienes acceso por SSH a meteo-01 y el resto depende de ti.

Esta lección es distinta de todas las anteriores. No introduce conceptos nuevos: usa todos los que has aprendido. Vas a recorrer un incidente completo, con las salidas reales de cada comando, las hipótesis que se descartan, las dos que resultan ciertas y una tercera que aparece sin buscarla y que cambia por completo la naturaleza del problema. Verás cómo la teoría del módulo 2 sobre patrones de acceso a disco se convierte en una línea de iostat, cómo la jerarquía de bloqueos del módulo 3 se convierte en un hilo colgado en /proc/<pid>/stack, y cómo el módulo 5 obliga a detener la investigación de rendimiento en seco.

Al final cerraremos el curso: un mapa de lo recorrido, los caminos que se abren y cómo seguir practicando.

Contenido

  1. El guion de actuación
  2. Primera fase: los 60 segundos iniciales
  3. Segundo hallazgo: el agregador y el patrón de acceso
  4. Mitigación frente a solución de fondo
  5. Tercer hallazgo: hilos en estado D que no avanzan
  6. El interbloqueo, su corrección y la prueba
  7. Cuarto hallazgo, inesperado: el incidente cambia de naturaleza
  8. Post mortem sin culpables
  9. Ejercicios finales
  10. Cierre del curso

El guion de actuación

Antes de escribir un solo comando, tres decisiones. Cuestan treinta segundos y cambian el resultado del incidente.

Primera: acotar el síntoma con datos, no con impresiones. "Va lenta" no permite comprobar si algo mejora. Necesitas un número y un instante.

awk '$4 ~ /03:0[0-9]|03:1[0-9]/ {print $NF}' /var/log/meteora/meteo-api.log \
  | sort -n | awk '{a[NR]=$1} END {printf "n=%d p50=%.3f p95=%.3f p99=%.3f\n", NR, a[int(NR*.5)], a[int(NR*.95)], a[int(NR*.99)]}'
# n=41208 p50=0.089 p95=2.914 p99=6.102

Compara con la línea base de un martes normal (p50=0.031 p95=0.104 p99=0.198) y ya tienes el síntoma cuantificado: el p99 se ha multiplicado por 30. Repitiendo la consulta minuto a minuto, la degradación empieza entre las 03:04 y las 03:06. Ese instante es tu ancla: todo lo que investigues se correlaciona con él.

Segunda: decidir si mitigar o investigar primero. Son objetivos en tensión. Mitigar restaura el servicio pero destruye evidencias: si reinicias meteo-api, el estado que explica el fallo desaparece. Investigar preserva las pruebas pero alarga la caída. El criterio es el impacto:

Situación Qué hacer primero
Servicio caído del todo, con daño creciente Mitigar, capturando antes lo más volátil
Degradado pero funcionando, como aquí Investigar 10-15 minutos, luego mitigar
Sospecha de compromiso de seguridad Preservar, contener y escalar

A las 03:12 la API responde, con latencia mala pero sin errores masivos. Decides investigar, con un límite de tiempo explícito: quince minutos.

Tercera: anotarlo todo con marca de tiempo. Abre un cuaderno del incidente y guarda cada salida:

mkdir -p /var/tmp/incidente-2026-08-31
export REG=/var/tmp/incidente-2026-08-31/bitacora.log
anota() { printf '\n===== %s : %s =====\n' "$(date -Is)" "$*" >> "$REG"; }
anota "Inicio. Síntoma: p99 6,1 s (base 0,2 s) desde ~03:05"

Por qué esto importa tanto. Dentro de tres horas no recordarás si aqu-sz era 18 o 27, y el post mortem depende de esos números. Además, si el incidente acaba siendo de seguridad —como ocurrirá—, la bitácora se convierte en parte de la cadena de custodia de 05-04.

Primera fase: los 60 segundos iniciales

Ejecutas la lista de 07-03 entera, en orden, sin saltarte nada.

uptime | tee -a "$REG"
#  03:14:02 up 41 days, 11:07,  2 users,  load average: 28,44, 11,20, 5,03

Qué descarta. Nada todavía, pero orienta: 28 de carga en una máquina de 8 núcleos, con la media de 1 minuto muy por encima de la de 15. El problema es reciente y está creciendo. No hubo reinicio (41 días de uptime), así que descartamos un arranque fallido.

dmesg -T | tail -20 | tee -a "$REG"
# [Mon Aug 31 02:41:03 2026] md0: recovery done.
# [Mon Aug 31 03:05:11 2026] meteo-api[1834]: segfault at 0 ip ... (no aparece)

Qué descarta. No hay muertes por OOM, no hay errores de dispositivo, no hay RAID degradado, no hay segfault. Esto elimina de golpe las tres causas catastróficas más frecuentes. La línea de recovery done a las 02:41 es de una resincronización del RAID ya terminada: podría haber sido la causa, pero acabó 24 minutos antes del síntoma. Anotada como no descartada del todo, porque toda coincidencia temporal merece revisión.

vmstat 1 5 | tee -a "$REG"
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
#  r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
#  1 31 131072 402112   9104 5218836   0    0 16384  1024 5210 9821  4  3  4 89  0
#  2 29 131072 399820   9104 5219004   0    0 15872   896 5104 9740  3  2  5 90  0

Aquí está el primer hallazgo grande. r=1 (un solo proceso listo para ejecutar) frente a b=31 (treinta y un procesos bloqueados en E/S ininterrumpible). si/so a cero: no es memoria. us+sy en torno al 6 % con wa al 89 %: no es CPU. La carga de 28 se explica íntegramente por los procesos en estado D de 02-01, que en Linux —y solo en Linux— cuentan en la carga media.

mpstat -P ALL 1 3 | tail -10 | tee -a "$REG"
# CPU  %usr %nice %sys %iowait %irq %soft %steal %idle
# all   3,6   1,1  2,4    89,4  0,0   0,4    0,0    3,1
#   0   3,9   0,8  2,2    90,1  0,0   0,6    0,0    2,4
#   3   3,1   1,4  2,7    88,9  0,0   0,3    0,0    3,6

Qué descarta. El %iowait está repartido por igual entre los ocho núcleos, así que no es un núcleo saturado ni una interrupción mal distribuida (02-07). Y %steal a cero descarta al hipervisor (06-01): nadie nos está robando CPU.

free -m | tee -a "$REG"
#               total  usada  libre  compartido  búf/caché  disponible
# Mem:          16037   9422    402         912       5218       6104
ss -s | head -3 | tee -a "$REG"
# TCP:   1421 (estab 1188, closed 190, orphaned 0, timewait 189)
cat /proc/pressure/{cpu,io,memory} | tee -a "$REG"
# cpu     some avg10=6.11  avg60=4.02  avg300=2.10
# io      some avg10=94.28 avg60=78.55 avg300=41.09
# io      full avg10=71.62 avg60=58.31 avg300=28.44
# memory  some avg10=0.00  avg60=0.00  avg300=0.00

Qué descartan. disponible de 6,1 GB y presión de memoria cero: la memoria queda definitivamente fuera. Las conexiones establecidas están en el rango normal. Y PSI es concluyente: io full avg10 del 71,6 % significa que en los últimos diez segundos la máquina ha pasado siete de cada diez sin poder progresar por esperar al disco, mientras que la presión de CPU es residual.

Conclusión de la primera fase, a las 03:17: el cuello de botella es la E/S de disco. Han bastado tres minutos y se han descartado con datos la CPU, la memoria, el intercambio, la red, el hipervisor y un fallo de hardware. Anótalo y sigue.

Segundo hallazgo: el agregador y el patrón de acceso

iostat -xz 1 3 | tail -12 | tee -a "$REG"
# Device   r/s     w/s   rkB/s  wkB/s  rrqm/s  wrqm/s  r_await  w_await  aqu-sz  rareq-sz  %util
# md0    4102,0   38,0 16408,0  912,0     0,0     2,0    52,10     3,04   27,40      4,00   99,90
# sda    2054,0   19,0  8216,0  456,0     0,0     1,0    51,88     3,01   13,72      4,00   99,80
# sdb    2048,0   19,0  8192,0  456,0     0,0     1,0    52,33     3,07   13,68      4,00   99,90

Lectura completa de esta salida, que es el corazón del diagnóstico. El ancho de banda es ridículo: 16 MB/s, algo que cualquier disco de hace veinte años daría sin despeinarse. Pero son 4.102 operaciones por segundo, y rareq-sz está clavado en 4,00 KB: peticiones del tamaño mínimo. rrqm/s es cero, es decir, el planificador de E/S (02-05) no puede fusionar nada, lo que solo ocurre cuando los bloques pedidos no son contiguos. Es la firma exacta de la lectura aleatoria.

Un RAID 1 de dos discos mecánicos da del orden de 150-200 IOPS aleatorias por disco, unas 300-400 en lectura sumando ambos porque el espejo reparte las lecturas. Estamos pidiendo más de diez veces esa capacidad. De ahí aqu-sz=27,4 —veintisiete peticiones esperando de media, la saturación de USE— y r_await=52 ms. El %util del 99,9 % es cierto pero poco informativo: lo que duele es la cola.

¿Quién lo provoca?

pidstat -d 1 3 | tee -a "$REG"
# UID  PID   kB_rd/s   kB_wr/s  kB_ccwr/s  iodelay  Command
# 990  1834    412,00     88,00       0,00       41  meteo-api
# 990  9127  15984,00    804,00       0,00     2914  agregador

Qué demuestra. El agregador está leyendo casi 16 MB/s —el total del dispositivo— y acumula un iodelay enorme: es él quien satura el disco. meteo-api apenas lee, pero su iodelay de 41 indica que también está esperando, y esa espera es la latencia que sufre el usuario. Tenemos culpable y víctima.

systemctl status agregador.service | head -8 | tee -a "$REG"
# Active: active (running) since Mon 2026-08-31 03:00:14 CEST; 17min ago
# CGroup: /system.slice/agregador.service └─9127 /usr/local/bin/agregador --hora-anterior

Arrancó a las 03:00:14 por su temporizador y lleva 17 minutos corriendo, cuando normalmente tarda dos. Encaja perfectamente con el inicio del síntoma a las 03:05.

cat /proc/9127/io | tee -a "$REG"
# rchar: 18402144256
# read_bytes: 17962827776
filefrag -v /var/lib/meteora/lecturas/2026-08-30.dat | tail -3 | tee -a "$REG"
# ...
# /var/lib/meteora/lecturas/2026-08-30.dat: 5314 extents found

El hallazgo definitivo. El fichero de un día ocupa 17.280.000 bytes, unos 16,5 MiB, y debería caber en unos pocos extents contiguos. Tiene 5.314. Y read_bytes dice que el agregador ha leído 17,9 GB del disco para procesar un fichero de 16,5 MB: está releyendo el mismo fichero más de mil veces, o accediendo a él de forma completamente desordenada.

Por qué el patrón de acceso importa tanto, que es la teoría de 02-05 y 04-05 hecha carne. En un disco mecánico, una lectura secuencial de 16,5 MB es un solo posicionamiento del cabezal seguido de una transferencia continua: unos 0,15 segundos. Las mismas 16,5 MB leídas como 4.200 peticiones aleatorias de 4 KB son 4.200 posicionamientos de unos 10 ms cada uno: 42 segundos, casi trescientas veces más, con exactamente los mismos bytes leídos. El disco no es lento; el patrón es malo. Y la fragmentación en 5.314 extents convierte incluso una lectura "secuencial" del fichero en algo parecido a acceso aleatorio, porque los bloques lógicamente contiguos están físicamente dispersos: es la consecuencia de que el ingestor haya ido añadiendo al fichero durante 24 horas mientras el sistema de ficheros asignaba espacio donde podía.

Mitigación frente a solución de fondo

Son las 03:24 y hay que restaurar el servicio. Distingue siempre las dos cosas.

Mitigación inmediata (minutos, reversible, no arregla la causa):

ionice -c 3 -p 9127                      # clase idle: solo lee si nadie más quiere el disco
anota "Aplicado ionice -c3 al PID 9127 del agregador"

Qué hace y por qué funciona. ionice -c 3 mueve el proceso a la clase idle del planificador de E/S: sus peticiones solo se atienden cuando no hay ninguna otra pendiente. meteo-api deja de competir en igualdad de condiciones y su latencia debe caer de inmediato.

Verifica, siempre, que la mitigación funciona:

sleep 60; iostat -xz 1 3 | grep md0
# md0  3980,0  36,0 15920,0 864,0  0,0 2,0  11,40  2,90   6,10  4,00  99,70
awk '$4 ~ /03:2[5-9]/ {print $NF}' /var/log/meteora/meteo-api.log | sort -n \
  | awk '{a[NR]=$1} END {printf "p99=%.3f\n", a[int(NR*.99)]}'
# p99=0.940

Resultado parcial. r_await baja de 52 a 11 ms, aqu-sz de 27 a 6, y el p99 de la API pasa de 6,1 s a 0,94 s. Enorme mejora… pero la línea base era 0,198 s. Sigue habiendo algo mal, y ese residuo es lo que llevará al tercer hallazgo. Anotarlo es fundamental: la trampa clásica del incidente es dar por cerrado el caso en cuanto mejora lo bastante.

Mitigación duradera, aplicando los cgroups de 06-02 desde la unidad, para que no dependa de que alguien ejecute ionice a mano:

systemctl edit agregador.service
[Service]
IOSchedulingClass=idle
IOWeight=10
IOReadBandwidthMax=/dev/md0 20M
MemoryMax=1G
CPUWeight=20
systemctl daemon-reload
systemctl show agregador.service -p IOWeight -p IOSchedulingClass
cat /sys/fs/cgroup/system.slice/agregador.service/io.max      # el límite, tal cual lo ve el núcleo

Qué consigue. IOSchedulingClass=idle hace permanente lo del ionice; IOWeight=10 frente al IOWeight=200 de meteo-api reparte veinte a uno cuando ambos compiten; e IOReadBandwidthMax pone un techo duro traducido a io.max en el cgroup del servicio. Es el mismo mecanismo del núcleo que limita un contenedor, aplicado a un servicio nativo. Nota importante: MemoryMax=1G acota además el daño de una eventual fuga, matando solo al agregador en vez de dejar que el OOM killer elija víctima (02-04).

Solución de fondo, que no se hace a las tres de la madrugada pero se anota como acción del post mortem:

Problema real Solución de fondo Por qué
El agregador relee el fichero mil veces Un solo recorrido secuencial acumulando en memoria Convierte 42 s de posicionamientos en 0,15 s de transferencia
Ficheros en 5.314 extents Preasignar con fallocate al crear el fichero del día El sistema de ficheros reserva espacio contiguo de una vez
Lecturas aleatorias sobre datos históricos Índice por hora, o formato columnar Leer solo lo necesario
Compite con el servicio en horas de tráfico Ejecutarlo sobre una réplica, o a una hora valle Elimina la competencia en origen
Nadie se enteró hasta que se quejaron clientes Alerta sobre io full avg60 > 20 % Detección antes que el usuario

Tercer hallazgo: hilos en estado D que no avanzan

Son las 03:31. El disco ya no está saturado, pero el p99 sigue en 0,94 s, casi cinco veces la línea base. Vuelves a mirar meteo-api.

ps -eLo pid,tid,stat,wchan:24,comm | awk '$1==1834' | tee -a "$REG"
#  1834  1834 Sl  ep_poll                  meteo-api
#  1834  1841 Sl  futex_wait_queue         meteo-api
#  1834  1842 D   flock_lock_inode_wait    meteo-api
#  1834  1843 D   flock_lock_inode_wait    meteo-api
#  1834  1844 D   flock_lock_inode_wait    meteo-api
#  1834  1845 Sl  futex_wait_queue         meteo-api

Qué revela. ps -eLo lista hilos (-L), no procesos: sin esa opción no verías nada de esto (03-02). Tres hilos están en estado D y el campo wchan —la función del núcleo en la que duermen— dice exactamente qué esperan: flock_lock_inode_wait, es decir, un bloqueo de fichero de 04-04. Y otros dos están en futex_wait_queue, esperando un mutex de espacio de usuario (03-04).

for t in 1842 1843 1844; do echo "--- TID $t"; cat /proc/1834/task/$t/stack; done | tee -a "$REG"
# --- TID 1842
# [<0>] flock_lock_inode_wait+0x11e/0x150
# [<0>] sys_flock+0x14a/0x1a0
# [<0>] do_syscall_64+0x5c/0xc0

Confirmación desde el núcleo. La pila del hilo dentro del núcleo confirma que está dentro de la llamada flock() y no en otro sitio. Es la misma técnica que aprendiste en 03-06 para diagnosticar un proceso atascado en D.

lsof -p 1834 | grep -E 'meteora|lock' | tee -a "$REG"
# meteo-api 1834 meteora  7u  REG  9,0  17280000  2621441 /var/lib/meteora/lecturas/2026-08-31.dat
# meteo-api 1834 meteora  9u  REG  9,0        0   2621509 /run/meteora/cache.lock
# meteo-api 1834 meteora 11u  REG  9,0   4194304  2621602 /var/log/meteora/meteo-api.log
cat /proc/locks | grep -E '2621441|2621509' | tee -a "$REG"
# 12: FLOCK  ADVISORY  WRITE 1834 09:00:2621509 0 EOF
# 13: FLOCK  ADVISORY  WRITE 9127 09:00:2621441 0 EOF

El cuadro completo. /proc/locks es la tabla de bloqueos del núcleo, y aquí hay dos protagonistas: el PID 1834 (meteo-api) tiene tomado el bloqueo de la caché (cache.lock), y el PID 9127 (agregador) tiene tomado el del fichero de datos. Cada uno espera el que tiene el otro.

sudo gdb -p 1834 -batch -ex 'thread apply all bt' 2>/dev/null | grep -A4 'Thread 4' | tee -a "$REG"
# Thread 4 (Thread 0x7f2a... (LWP 1842)):
# #0  0x00007f2a... in flock () from /lib/x86_64-linux-gnu/libc.so.6
# #1  0x000055c1... in bloquear_fichero_lecturas () at almacen.c:214
# #2  0x000055c1... in refrescar_cache_desde_disco () at cache.c:96
# #3  0x000055c1... in atender_consulta () at api.c:341

Y aquí está la causa raíz. La pila de usuario delata el orden real de adquisición: refrescar_cache_desde_disco() toma primero el bloqueo de la caché y después pide el del fichero. Pero la jerarquía acordada en 03-06 para todo el sistema es:

configuración → caché → fichero → registro

Un momento: ese código respeta la jerarquía (caché antes que fichero). El que la viola es el otro extremo. Mirando el agregador:

sudo gdb -p 9127 -batch -ex bt 2>/dev/null | head -5 | tee -a "$REG"
# #0  0x00007f4b... in flock () from /lib/x86_64-linux-gnu/libc.so.6
# #1  0x000055aa... in bloquear_cache () at cache.c:58
# #2  0x000055aa... in volcar_medias () at agregador.c:187

Confirmado: interbloqueo por violación de la jerarquía. El agregador tomó primero el bloqueo del fichero y ahora pide el de la caché; meteo-api tomó el de la caché y pide el del fichero. Es el ciclo de espera circular, la cuarta condición de Coffman, en su forma más pura y con solo dos participantes. gdb -batch con -ex 'thread apply all bt' es no destructivo si se usa con cuidado, pero detiene el proceso mientras se ejecuta: úsalo brevemente y sabiendo que añades latencia.

Nota importante sobre por qué no se había visto antes: con el agregador terminando en dos minutos, la ventana de solape era mínima. Al alargarse a 17 minutos por la saturación de disco, la probabilidad de coincidencia se disparó. El primer problema destapó el segundo, que llevaba meses latente. Es un patrón habitual: los interbloqueos raros se vuelven frecuentes cuando algo ralentiza el sistema.

El interbloqueo, su corrección y la prueba

Mitigación inmediata, porque el ciclo no se rompe solo:

anota "Interbloqueo confirmado 1834<->9127. Se termina el agregador para romper el ciclo."
kill -TERM 9127
sleep 5; ps -p 9127 || echo "agregador terminado"
cat /proc/locks | grep -E '2621441|2621509'
# 12: FLOCK  ADVISORY  WRITE 1834 09:00:2621509 0 EOF   (y libera enseguida)

Por qué matar al agregador y no a meteo-api. Es la recuperación por terminación de 03-06, eligiendo la víctima con dos criterios: el agregador es reejecutable —su temporizador volverá a lanzarlo, y con Persistent=true no se pierde la ejecución— mientras que reiniciar meteo-api cortaría 1.188 conexiones establecidas. Además, SIGTERM en lugar de SIGKILL le da la oportunidad de cerrar limpiamente, evitando exactamente los ficheros truncados que detectaría la comprobación tam % 24 != 0 de 07-01.

awk '$4 ~ /03:4[2-9]/ {print $NF}' /var/log/meteora/meteo-api.log | sort -n \
  | awk '{a[NR]=$1} END {printf "n=%d p50=%.3f p99=%.3f\n", NR, a[int(NR*.5)], a[int(NR*.99)]}'
# n=39004 p50=0.033 p99=0.204

Servicio restaurado a las 03:44: p99 de 0,204 s, indistinguible de la línea base de 0,198 s. Y ahora, la corrección de fondo, que es de código:

/* MAL — agregador.c:187, viola la jerarquía: fichero → caché */
flock(fd_fichero, LOCK_EX);
flock(fd_cache,   LOCK_EX);      /* <-- orden invertido */

/* BIEN — jerarquía global: configuración → caché → fichero → registro */
flock(fd_cache,   LOCK_EX);
flock(fd_fichero, LOCK_EX);

Y la prueba de que ya no ocurre, que es la parte que casi todo el mundo se salta:

# 1. Prueba de estrés dirigida en preproducción: forzar el solape 500 veces
for i in $(seq 1 500); do
    systemctl start agregador.service &
    curl -s -o /dev/null "https://meteo-pre/v1/lecturas?estacion=EST-0142&refresh=1" &
    wait
done
# 2. Verificación automática: ningún hilo en D esperando flock
watch -n5 'ps -eLo stat,wchan:24,comm | grep -c "^D.*flock"'
# 3. Comprobación estática permanente: un solo punto de adquisición
grep -rn 'flock(' src/ | grep -v 'bloqueos.c'   # debe estar vacío

Por qué así. La primera prueba reproduce el escenario que en producción era raro; si el interbloqueo persistiera, aparecería en unos pocos intentos. La segunda es una comprobación continua barata que puede convertirse en métrica. La tercera es la más valiosa a largo plazo: centralizar toda adquisición de bloqueos en un único módulo que los tome siempre en el orden de la jerarquía convierte la disciplina en algo que el compilador y una regla de revisión pueden vigilar, en lugar de depender de que cada programador recuerde el acuerdo. Es la prevención de 03-06 llevada a la práctica.

Cuarto hallazgo, inesperado: el incidente cambia de naturaleza

Son las 03:52. El servicio está bien y estás reuniendo material para el post mortem. Revisas si el agregador dejó restos:

ls -la /tmp | tee -a "$REG"
# -rwsr-xr-x 1 root root  1183448 ago 31 02:51 .sysupd

Te detienes. Ese fichero tiene el bit setuid (rws), pertenece a root, tiene nombre oculto y fecha de las 02:51. Nada del sistema de Meteora crea eso. Y entonces compruebas la otra cosa que te había extrañado:

awk '$4 ~ /02:[0-9][0-9]/ {split($4, t, ":"); print t[2]":"t[3]}' \
    /var/log/meteora/meteo-api.log | uniq -c | awk '$1 < 50'
#      0 02:47
#      0 02:58
grep -c . /var/log/meteora/meteo-api.log
journalctl --since '02:40' --until '03:00' | tail -20

Hay un hueco de once minutos (02:47-02:58) en un registro que escribe miles de líneas por minuto. Un servicio que estuviera bloqueado dejaría menos líneas, no cero. Un hueco exacto y limpio en un fichero de registro es, hasta que se demuestre lo contrario, manipulación.

Aquí el incidente deja de ser de rendimiento. Dos indicadores independientes —un binario setuid de root aparecido a las 02:51 en /tmp y un hueco en los registros que lo rodea— apuntan a un posible compromiso. A partir de este punto, todo lo que has aprendido en el módulo 7 se subordina a lo del módulo 5.

Lo primero es lo que NO se hace:

  • No ejecutes el binario, ni siquiera con --help o en una máquina "de pruebas". Es setuid de root.
  • No lo borres. Es la prueba principal.
  • No reinicies la máquina. Perderías toda la memoria, las conexiones y los procesos, que es justo lo más valioso.
  • No sigas "investigando el rendimiento". Cada comando que ejecutas modifica tiempos de acceso, entradas del diario e historial de shell, y contamina la escena.
  • No avises por el canal que podría estar comprometido. Si el atacante tiene acceso, leerá tus mensajes.

Preservar por orden de volatilidad (05-04), de lo más efímero a lo más duradero:

Orden Qué Cómo
1 Memoria RAM Volcado con LiME o avml a un destino externo
2 Estado de procesos y red ps -eLf, ss -tanp, lsof -n, /proc/<pid>/maps
3 Conexiones y tabla ARP ss -tunap, ip neigh
4 Discos Imagen bit a bit con dd, y hash antes y después
5 Registros remotos y copias Los que ya están fuera de la máquina
# Metadatos del fichero SIN ejecutarlo ni alterarlo
stat /tmp/.sysupd | tee -a "$REG"
sha256sum /tmp/.sysupd | tee -a "$REG"
# Copia forense de los registros y de la evidencia, con integridad verificable
tar -czf - /var/log/meteora /var/log/auth.log /tmp/.sysupd \
  | tee /mnt/evidencias/meteo-01-$(date +%s).tgz | sha256sum | tee -a "$REG"
# Contexto del sistema
last -F | head -20 | tee -a "$REG"
ausearch -ts 02:40 -te 03:00 -m EXECVE 2>/dev/null | tee -a "$REG"
find / -xdev -perm -4000 -newermt '2026-08-30' -ls 2>/dev/null | tee -a "$REG"

Qué aporta cada uno. stat da los tres tiempos del inodo sin abrir el fichero. El hash SHA-256 permite demostrar después que la evidencia no se alteró: es el fundamento técnico de la cadena de custodia. last -F muestra los inicios de sesión con fecha completa. ausearch consulta a auditd —si estaba activo, y por eso se instala antes de necesitarlo— por las ejecuciones de esa ventana. Y el find busca otros ficheros setuid recientes en todo el sistema, porque un atacante rara vez deja uno solo.

Contener sin destruir, en este orden:

  1. Aislar la red manteniendo la máquina encendida: reglas nftables que solo permitan tu acceso de administración. Apagarla destruye la memoria; desconectarla del todo también puede alertar al atacante.
  2. No cambiar credenciales todavía si eso avisara al intruso, salvo indicación de quien coordine la respuesta.
  3. Escalar de inmediato: responsable de seguridad, propietario del servicio, dirección, y asesoría jurídica y de cumplimiento. Si hubo acceso a datos personales, en la UE el RGPD marca plazos de notificación de 72 horas que empiezan a correr desde el conocimiento del hecho, y esa decisión no la toma quien está en la consola a las cuatro de la mañana.
  4. Cambiar a un canal de comunicación fuera de banda y documentar quién sabe qué y desde cuándo.

Advertencia expresa. Este relato es material didáctico. Una respuesta a incidentes real debe seguir el procedimiento formal de tu organización y contar con asesoramiento legal. Las decisiones sobre preservación de pruebas, notificación a autoridades y clientes, comunicación pública y eventual denuncia tienen consecuencias jurídicas y contractuales que exceden lo técnico. Si tu organización no tiene ese procedimiento escrito, redactarlo antes del próximo incidente es más valioso que cualquier herramienta.

Y por qué se detiene aquí la investigación de rendimiento. Por tres razones sólidas. Primera, contaminación de evidencias: cada comando altera la escena y puede inutilizar el análisis forense. Segunda, cambio de prioridad: una latencia alta cuesta dinero, un compromiso puede costar los datos de miles de personas. Y tercera, cambio de hipótesis: si hay un intruso, todo lo diagnosticado se vuelve sospechoso —¿el agregador se ralentizó solo, o alguien lo forzó?, ¿el hueco del registro tapa el momento en que se colocó el binario?—. No sabes si el rendimiento fue la causa, la consecuencia o la cortina de humo. Investigar rendimiento sobre un sistema posiblemente comprometido es construir sobre arena.

Post mortem sin culpables

Un post mortem sin culpables (blameless) parte de una premisa demostrada: las personas actúan razonablemente con la información que tienen en el momento. Si el objetivo es señalar a alguien, la gente oculta información y el mismo fallo vuelve a ocurrir. Si el objetivo es entender el sistema, se aprende.

Cronología (todas las horas en CEST del 31/08/2026):

Hora Hecho
02:41 Termina una resincronización del RAID (descartada como causa)
02:47-02:58 Hueco inexplicado en meteo-api.log
02:51 Aparece /tmp/.sysupd, setuid de root
03:00:14 El temporizador lanza el agregador
~03:05 El p99 de la API empieza a degradarse
03:12 Aviso de clientes
03:17 Acotado a E/S: io full 71 %, aqu-sz 27, rareq-sz 4 KB
03:22 Identificado el agregador (pidstat -d, filefrag: 5.314 extents)
03:24 Mitigación con ionice; p99 baja de 6,1 s a 0,94 s
03:31 Hilos de meteo-api en D esperando flock
03:40 Confirmado interbloqueo por violación de la jerarquía
03:42 SIGTERM al agregador; ciclo roto
03:44 Servicio restaurado: p99 0,204 s
03:52 Hallado el binario setuid y el hueco del registro
03:55 El incidente se reclasifica como seguridad; escalado

Causa raíz frente a causas contribuyentes. La distinción no es académica: determina en qué se invierte el esfuerzo.

  • Causa raíz de la degradación: el agregador lee los datos con un patrón aleatorio de 4 KB —releyendo 17,9 GB para procesar 16,5 MB— sobre ficheros fragmentados en más de 5.000 extents, lo que excede en un orden de magnitud la capacidad de IOPS del RAID 1.
  • Contribuyente 1: no había límites de E/S en la unidad del agregador, así que podía consumir el 100 % del disco compitiendo de igual a igual con el servicio de cara al usuario.
  • Contribuyente 2: el agregador viola la jerarquía de bloqueos acordada. Latente durante meses, se manifestó al alargarse su ejecución. No fue la causa, pero sin él el servicio habría vuelto a la normalidad en el minuto 12 en lugar del 32.
  • Contribuyente 3: no había alerta sobre presión de E/S. El aviso llegó de los clientes, no del sistema, con siete minutos de retraso.
  • Contribuyente 4: /tmp estaba montado sin noexec ni nosuid, lo que permitió que un binario setuid fuera ejecutable ahí.
  • Contribuyente 5: los registros solo estaban en la máquina, por lo que el hueco no se puede contrastar con ninguna copia externa.

Acciones preventivas concretas, cada una con dueño y plazo:

# Acción Módulo Plazo
1 Límites IOWeight, IOReadBandwidthMax y MemoryMax en agregador.service 06-02, 07-02 Hecho
2 Alerta sobre io full avg60 > 20 % durante 5 min 07-03 3 días
3 Reescribir la lectura del agregador en un solo recorrido secuencial 02-05 2 semanas
4 Preasignar los ficheros del día con fallocate 04-05 2 semanas
5 Centralizar flock en un módulo único que imponga la jerarquía 03-06 3 semanas
6 Prueba de estrés de solape en integración continua 3 semanas
7 Remontar /tmp con noexec,nosuid,nodev 04-03 1 día
8 Auditoría periódica de ficheros setuid y AIDE al día 05-03 1 semana
9 Envío de registros a un colector externo en modo solo-añadir 05-04 1 semana
10 Procedimiento escrito de respuesta a incidentes, con contactos legales 05-04 1 mes

Fíjate en el patrón: ninguna acción es "tener más cuidado". Todas son cambios en el sistema —límites, alertas, montajes, estructura de código— que hacen que el fallo sea imposible o que se detecte solo. Ese es el criterio para saber si un post mortem ha servido de algo.

Errores Comunes y Consejos

Error Consecuencia Qué hacer
Reiniciar el servicio "a ver si se arregla" Destruye la evidencia y el problema vuelve Captura estado antes de mitigar
Parar al primer hallazgo Aquí habrías dejado el p99 en 0,94 s Compara siempre con la línea base
Aplicar varias mitigaciones a la vez No sabes cuál funcionó Una cada vez, midiendo
Confundir mitigación con solución El incidente se repite el mes siguiente Registra ambas por separado
No anotar con marca de tiempo El post mortem se basa en recuerdos Bitácora con tee desde el minuto uno
strace sobre el servicio en producción Ralentización de 10× a 100× perf, eBPF, o pilas de /proc
Ejecutar un binario sospechoso "para ver qué hace" Puede ser el paso final del ataque No tocarlo; preservar y escalar
Seguir con el rendimiento tras indicios de intrusión Contaminas pruebas y priorizas mal Detener, contener, escalar
Post mortem con nombres propios La gente oculta información Sin culpables, sobre el sistema
Acciones preventivas vagas No cambian nada Cambios concretos, con dueño y plazo

Consejos finales para el turno de guardia: empieza siempre por dmesg; cuantifica antes de tocar y vuelve a medir después; pon un límite de tiempo a la fase de investigación y respétalo; avisa pronto aunque no tengas la respuesta, porque la gente tolera mucho mejor la incertidumbre comunicada que el silencio; y desconfía de la primera explicación que encaja, porque los incidentes reales, como este, suelen tener más de una causa.

Ejercicios

Ejercicio 1: el servicio que muere cada madrugada

Cada noche entre las 02:00 y las 04:00, meteo-api deja de responder unos segundos. systemctl status dice active (running) cuando lo miras por la mañana, pero Main PID ha cambiado. Datos recogidos:

dmesg -T | grep -i oom
[Mon Aug 31 03:22:41 2026] agregador invoked oom-killer: gfp_mask=0x140cca, order=0, oom_score_adj=0
[Mon Aug 31 03:22:41 2026] Out of memory: Killed process 1834 (meteo-api)
  total-vm:4210408kB, anon-rss:2914208kB, file-rss:0kB, shmem-rss:8192kB, UID:990

free -m (03:20): total 16037  usada 15102  libre 198  búf/caché 737  disponible 402
vmstat  (03:20): r=3 b=2 si=1204 so=1890 cs=14022
/proc/pressure/memory: some avg60=58.11  full avg60=31.04

Diagnostica con el método de 07-03: quién causó el problema, por qué murió quien murió, y qué mitigación y qué solución de fondo aplicarías.

Ejercicio 2: el servicio que no arranca tras un cambio

Tras editar /etc/meteora/meteora.conf para añadir una ruta de caché nueva, meteo-api no arranca:

Active: failed (Result: exit-code) since Mon 2026-08-31 09:14:02 CEST; 30s ago
Process: 20114 ExecStart=/usr/local/bin/meteo-api --config /etc/meteora/meteora.conf (code=exited, status=1/FAILURE)
journalctl -u meteo-api -n 3:
  meteo-api[20114]: fatal: no se puede crear /var/cache/meteora/idx: Read-only file system

Ejecutado a mano como meteora, el binario arranca sin problemas. Explica la contradicción y da la solución correcta, además de dos incorrectas que hay que evitar y por qué.

Soluciones

Solución 1

Diagnóstico. Los indicadores son inequívocos y ninguno es de E/S: disponible de solo 402 MB, si=1.204 y so=1.890 páginas por segundo simultáneamente —el sistema mete y saca páginas a la vez, que es la definición de thrashing (02-04)— y una presión de memoria del 58 % (some) con 31 % de full: casi un tercio del tiempo la máquina entera no progresa.

Quién causó el problema y quién murió no son el mismo. La primera línea de dmesg lo dice literalmente: agregador invoked oom-killer, es decir, fue la petición de memoria del agregador la que agotó el sistema y disparó el mecanismo. Pero el elegido fue meteo-api, con 2,9 GB de anon-rss. La razón está en el oom_score, que es esencialmente proporcional a la memoria residente: el OOM killer mata al proceso más grande, no al culpable. Y como meteo-api corre bajo systemd con Restart=on-failure, se reinicia solo, lo que explica el active (running) con un Main PID distinto por la mañana y el hecho de que nadie se enterase.

La ventana 02:00-04:00 coincide con las ejecuciones nocturnas del agregador; el mecanismo es una acumulación de memoria en el proceso de agregación —probablemente carga en RAM todo el histórico en lugar de procesarlo por bloques.

Mitigación (esta noche, sin tocar código):

# systemctl edit agregador.service
[Service]
MemoryMax=1G
MemoryHigh=768M

Con esto, cuando el agregador supere 1 GB, el OOM killer del cgroup lo matará a él y solo a él, sin tocar el resto del sistema; MemoryHigh añade un escalón previo en el que el núcleo lo frena y reclama páginas agresivamente antes de llegar al límite duro. Es exactamente el mecanismo de 06-02. Conviene además proteger a la víctima con MemoryMin=512M en meteo-api.service, que reserva memoria que el núcleo no le reclamará.

Solución de fondo: procesar el histórico por bloques con un consumo acotado y constante, en lugar de cargarlo entero. Y verificación: seguir el RSS del agregador durante una ejecución (while true; do awk '/VmRSS/{print $2}' /proc/$(pgrep -x agregador)/status; sleep 10; done) para comprobar que se estabiliza en lugar de crecer linealmente. Alerta preventiva: memory some avg60 > 20 % durante 5 minutos, que habría avisado semanas antes que el primer cliente.

Lo que no hay que hacer: bajar vm.swappiness (no falta menos swap, falta memoria), añadir RAM sin entender el crecimiento (solo retrasa el problema), ni ajustar oom_score_adj de meteo-api para que no lo elijan (moverías la muerte a otro proceso inocente).

Solución 2

La contradicción es aparente y su explicación es el endurecimiento de la unidad. A mano, el binario corre con el sistema de ficheros normal y meteora puede escribir donde sus permisos le dejen. Bajo systemd, la unidad tiene ProtectSystem=strict, que monta todo el árbol del sistema en solo lectura dentro del espacio de nombres de montaje del servicio, con las únicas excepciones declaradas en ReadWritePaths=: /var/lib/meteora, /var/log/meteora y /run/meteora. La ruta nueva, /var/cache/meteora, no está en esa lista, así que el proceso ve un sistema de ficheros de solo lectura y falla con EROFS. Es el mecanismo de aislamiento de 05-03 funcionando exactamente como debe.

Solución correcta, que además delega en systemd la creación del directorio con el propietario y modo adecuados:

# systemctl edit meteo-api.service
[Service]
CacheDirectory=meteora
CacheDirectoryMode=0750
systemctl daemon-reload && systemctl restart meteo-api.service
systemctl show meteo-api.service -p ReadWritePaths
journalctl -u meteo-api.service -n 20 --no-pager

CacheDirectory=meteora crea /var/cache/meteora con dueño meteora:meteora, lo añade automáticamente a las rutas escribibles y lo gestiona en el ciclo de vida del servicio. La alternativa aceptable, si el directorio ya existe y lo gestiona otro proceso, es añadir ReadWritePaths=/var/cache/meteora.

Dos soluciones incorrectas y por qué:

  1. Quitar ProtectSystem=strict. Resuelve el síntoma desarmando la protección: el servicio pasa a poder escribir en todo el sistema, incluidos /etc y /usr. Cambias un problema de configuración de cinco líneas por un aumento permanente de la superficie de ataque, justo en el servicio expuesto a Internet. La puntuación de systemd-analyze security lo reflejaría de inmediato.
  2. Ejecutar el servicio como root (o darle chmod 777 al directorio). Tira por la borda toda la cadena de decisiones del curso: la cuenta sin shell, el UID 990, CAP_NET_BIND_SERVICE en lugar de root, NoNewPrivileges. Y 777 permitiría a cualquier usuario del sistema —incluido un proceso comprometido— manipular la caché del servicio.

Lección general: cuando un servicio funciona a mano y falla bajo systemd, la diferencia está casi siempre en el entorno (PATH, directorio de trabajo, variables) o en el endurecimiento (ProtectSystem, ReadWritePaths, SystemCallFilter, capabilities). Y la respuesta correcta es casi siempre declarar la excepción concreta, nunca desactivar la protección.

Conclusión

Has resuelto un incidente completo, y al hacerlo has usado el curso entero. Merece la pena ver el mapa, porque cada pieza teórica ha acabado convertida en una herramienta de diagnóstico.

Del módulo 1 venía la idea de que el sistema operativo es una máquina extendida y un gestor de recursos, y que todo pasa por llamadas al sistema; sin eso, strace, wchan y /proc/<pid>/stack serían magia. Del módulo 2 salieron los estados de proceso —D y su peso en la carga media—, los patrones de acceso a disco que explican por qué 16,5 MB pueden tardar 42 segundos, el OOM killer eligiendo por tamaño y no por culpa, y RSS frente a VSZ para detectar una fuga. Del módulo 3 vinieron los hilos, flock, el futex y, sobre todo, la jerarquía de bloqueos cuya violación produjo el interbloqueo y cuya centralización lo previene. Del módulo 4, los inodos y /proc/locks, la fragmentación en extents que filefrag reveló, y la escritura atómica que evita ficheros truncados. Del módulo 5, todo lo que ocurrió a partir de las 03:52: el binario setuid, el hueco en el registro, el orden de volatilidad, la cadena de custodia, noexec en /tmp y la obligación de escalar. Del módulo 6, los cgroups que limitaron el agregador con io.max y MemoryMax. Y del módulo 7, la shell que lo ejecutó todo, systemd que lo gobierna y el método que ordenó la investigación.

Ese es el mensaje central del curso: la teoría no es un peaje previo a la práctica, es lo que permite interpretar lo que ves. Dos personas ejecutan iostat -x y ven los mismos números; solo una sabe que rareq-sz de 4 KB con rrqm/s a cero significa acceso aleatorio, que la cola importa más que la utilización y que un RAID 1 duplica lecturas pero no escrituras. La diferencia no está en el comando, sino en los módulos 2 y 4.

Los caminos que se abren desde aquí:

Camino Qué profundizar Siguiente paso natural
Administración de sistemas Redes, almacenamiento, alta disponibilidad, copias Certificaciones LFCS/RHCSA; montar tu propio laboratorio
DevOps y nube Infraestructura como código, CI/CD, Kubernetes, observabilidad Terraform, Ansible, un clúster de prácticas
Seguridad Análisis forense, respuesta a incidentes, hardening, criptografía Ingeniería inversa, CTF, auditoría de sistemas reales
Sistemas empotrados y tiempo real Yocto, FreeRTOS, Zephyr, drivers Una placa barata y un proyecto con plazos reales
Desarrollo de núcleo C, estructuras de datos del núcleo, subsistemas Linux Kernel Development; compilar y parchear un núcleo

Todos comparten la misma base: la que acabas de terminar.

Cómo seguir practicando, que es lo único que consolida esto:

  • Monta una máquina virtual de laboratorio y rómpela a propósito. Provoca un OOM, satura el disco con fio, crea un interbloqueo con dos scripts y flock, borra un fichero de unidad y arréglalo. Nada enseña como reparar algo que has roto tú.
  • Lee /proc con curiosidad. Cada fichero de /proc/<pid>/ es una ventana a una estructura del núcleo. Dedica una tarde a recorrer status, maps, io, limits, stack, fd/ y environ de un proceso real: entenderás más que con muchos capítulos.
  • Reproduce los ejercicios del curso en tu propia máquina, cambiando los números. Los del módulo 3 y los laboratorios de 07-03 son los que más rinden.
  • Lee registros aunque no pase nada. Familiarizarte con el aspecto de un journalctl normal es lo que te permitirá detectar lo anormal en tres segundos.
  • Escribe tus propios post mortem, aunque el incidente sea doméstico y tú el único lector. Poner por escrito la cronología y la causa raíz es lo que convierte una experiencia en conocimiento.

Empezamos definiendo el sistema operativo como una máquina extendida que oculta la complejidad del hardware. Terminamos a las cuatro de la mañana en un servidor de verdad, leyendo esa complejidad a través de las ventanas que el propio sistema nos ofrece: /proc, el diario, los contadores del núcleo. Entre un punto y otro hay siete módulos, pero en realidad hay una sola idea repetida: el sistema operativo no es una caja negra. Es un programa, escrito por personas, que toma decisiones comprensibles sobre recursos limitados, y que deja rastro de todas ellas. Aprender a leer ese rastro es lo que separa a quien usa un ordenador de quien lo entiende.

Ya sabes hacerlo. La próxima vez que el teléfono suene a las tres de la madrugada, no sabrás la respuesta —nadie la sabe—, pero sabrás cómo encontrarla: cuantificar el síntoma, descartar con datos, buscar la saturación y no la utilización, desconfiar de la primera explicación que encaje y anotarlo todo para que la siguiente persona lo tenga más fácil.

Gracias por llegar hasta aquí. Ahora apaga esto, abre una terminal y rompe algo.

Fundamentos de Sistemas Operativos

Módulo 1: Introducción a los Sistemas Operativos

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados