Módulo 3: Tuberías, redirección y composición

8. Proyecto: una tubería de análisis de logs

Descripción

En esta cápsula vas a construir, desde cero, una tubería de análisis capaz de leer un archivo de log de servidor de decenas de miles de líneas y responder cinco preguntas de tráfico reales — cuáles son las IPs más activas, cómo se reparten los códigos de estado, cuál es la hora pico, qué rutas están fallando más y qué porcentaje de las peticiones termina en error — cada una en una sola línea de comandos. Vas a guardar ese reporte con tee, separar los errores reales con 2>, y encadenar cada paso con && para que el reporte jamás se genere a partir de datos que no pudiste leer.

Esto es, casi palabra por palabra, lo que hace un ingeniero de guardia cuando suena una alerta a las tres de la mañana: no hay tiempo de escribir un script, no siempre hay acceso para instalar una herramienta nueva, y el sistema de monitoreo a veces está caído justo cuando más se necesita. Lo único que siempre está ahí es la terminal y el archivo de log crudo. Saber convertir "¿qué está pasando?" en una tubería de comandos en menos de un minuto es una habilidad que se nota en una entrevista técnica y se nota todavía más en un incidente real.

Conexión con el módulo: esta cápsula no enseña ningún comando nuevo — junta todo lo que viste en las siete anteriores. cut, sort y uniq (cápsula 6) hacen el trabajo pesado; >, 2> y /dev/null (cápsula 4) separan lo que quieres guardar de lo que quieres descartar; | (cápsula 5) conecta cada pieza con la siguiente; y &&, $? y la agrupación con paréntesis (cápsula 7) deciden si el reporte se termina de construir o se detiene a tiempo. Si alguna de esas piezas se siente floja, esta es la cápsula donde se nota.

De la pregunta al comando: pensar en tuberías

Imagina una línea de ensamblaje: una estación suelda, la siguiente pinta, la siguiente empaca. Cada estación hace una sola cosa y la pieza avanza por una cinta transportadora. Si la estación de soldado detecta una pieza defectuosa, no tiene sentido que siga hacia pintura y empaque — ahí se detiene la cinta, antes de gastar pintura y cajas en algo que ya estaba mal. Una tubería de análisis de logs es exactamente esa línea de ensamblaje, solo que las estaciones se llaman cut, sort, uniq y grep, la cinta transportadora es el |, y lo que decide si la línea sigue o se detiene es el código de salida que ya conoces de la cápsula anterior.

Para que las estaciones puedan trabajar, primero hay que entender la forma de la materia prima: cada línea del log. Un servidor web típico escribe sus líneas en un formato llamado Common Log Format, con campos separados por espacios en un orden fijo. Esta línea real de nuestro archivo de práctica:

8.84.176.107 - - [21/Jul/2026:22:20:01 +0000] "DELETE /index.html HTTP/1.1" 500 2608

se separa por espacios en diez campos:

#CampoQué es
18.84.176.107IP del cliente que hizo la petición
2-identidad remota (RFC 1413, casi siempre vacía)
3-usuario autenticado (vacío si no hubo login)
4[21/Jul/2026:22:20:01primera mitad de la fecha y hora
5+0000]segunda mitad: la zona horaria
6"DELETEmétodo HTTP, con la comilla de apertura pegada
7/index.htmlruta solicitada
8HTTP/1.1"versión del protocolo, con la comilla de cierre pegada
9500código de estado de la respuesta
102608bytes que pesó la respuesta

Fíjate en los campos 4 y 5: la fecha completa lleva un espacio adentro (antes de la zona horaria), así que cut — que solo sabe cortar por el delimitador literal que le indiques, no por "dónde empieza y termina un dato lógico" — la parte en dos campos separados. Vas a usar exactamente esta rareza para extraer la hora en la pregunta 3.

Ejemplo trabajado

El archivo de log

Necesitas un archivo con decenas de miles de líneas para que las preguntas tengan sentido — con cinco líneas cualquier respuesta es trivial. Si tu bootcamp o tu equipo ya te dio un access.log real, úsalo. Si no, este comando fabrica uno de 50,000 líneas con datos realistas (IPs que se repiten con distinta frecuencia, como pasaría con visitantes reales y bots, y una ruta — /api/orders — con más errores 500 que las demás, a propósito, para que la pregunta 4 tenga una respuesta clara).

No hace falta que entiendas cada detalle de este comando — usa awk, una herramienta que no es parte de este módulo. Cópialo tal cual, solo para fabricar datos de prueba:

awk 'BEGIN {
  srand(42)
  split("GET GET GET GET POST PUT DELETE", methods, " ")
  split("/index.html /login /api/users /api/orders /images/logo.png /cart /checkout /api/products /favicon.ico /about", paths, " ")
  split("200 200 200 200 200 301 302 404 404 500", statuses, " ")
  n_ips = 300
  for (i = 1; i <= n_ips; i++)
    ip_pool[i] = int(rand()*223)+1 "." int(rand()*255) "." int(rand()*255) "." int(rand()*255)
  for (i = 1; i <= 50000; i++) {
    idx = (rand() < 0.15) ? int(rand()*5)+1 : int(rand()*n_ips)+1
    ip = ip_pool[idx]
    hour = sprintf("%02d", int(rand()*24)); min = sprintf("%02d", int(rand()*60)); sec = sprintf("%02d", int(rand()*60))
    method = methods[int(rand()*7)+1]; path = paths[int(rand()*10)+1]; status = statuses[int(rand()*10)+1]
    if (path == "/api/orders" && rand() < 0.35) status = "500"
    bytes = int(rand()*5000)+200
    printf "%s - - [21/Jul/2026:%s:%s:%s +0000] \"%s %s HTTP/1.1\" %s %s\n", ip, hour, min, sec, method, path, status, bytes
  }
}' > access.log

wc -l access.log

Qué esperar:

   50000 access.log

Los números exactos que verás en el resto de esta cápsula (qué IP queda primera, cuántos 500 hay) son los que salieron en esta ejecución del generador. Si corres el mismo comando en tu máquina, casi seguro obtendrás números distintos — cada implementación de awk (la de macOS no es la misma que la de una Ubuntu) arma su propia secuencia de números "aleatorios", incluso con la misma semilla. Eso es normal y no es un error: lo que importa es que reproduzcas la forma de cada tubería, no el dígito exacto.

Pregunta 1: las 10 IPs con más peticiones

cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -n 10
  • cut -d' ' -f1 se queda solo con el campo 1 de cada línea (la IP).
  • sort agrupa las IPs iguales una junto a la otra — sin esto, el siguiente paso no sirve de nada (lo ves en Errores comunes).
  • uniq -c colapsa las líneas repetidas consecutivas y antepone cuántas veces apareció cada una.
  • sort -rn reordena esos conteos de mayor a menor (-n numérico, -r invertido).
  • head -n 10 se queda con las primeras 10 líneas.

Qué esperar:

1702 68.6.93.195
1688 8.84.176.107
1675 46.63.162.220
1664 19.140.143.189
1607 71.34.27.193
 169 24.179.72.128
 167 77.182.209.36
 166 67.75.77.224
 166 3.167.184.216
 166 14.9.22.104

Nota el salto entre la quinta línea (1607) y la sexta (169): son los cinco "bots" que el generador concentró a propósito. En un log real, un salto así casi siempre es una señal — un scraper, un ataque de fuerza bruta, o un cliente mal configurado reintentando sin parar.

Pregunta 2: peticiones por código de estado

Con la anatomía del log ya mapeada, el campo 9 es el estado:

cut -d' ' -f9 access.log | sort | uniq -c | sort -rn

Qué esperar:

23885 200
9803 404
6671 500
4865 302
4776 301

Misma tubería que la pregunta 1 — solo cambia el número de campo. Esa es la idea de fondo de este módulo: no memorizas comandos nuevos por cada pregunta, recombinas los mismos cuatro.

Pregunta 3: la hora con más tráfico

Aquí es donde entra la rareza del campo 4 que viste arriba. cut -d' ' -f4 te deja con [21/Jul/2026:HH:MM:SS (sin la zona horaria) — todavía no es la hora sola. Un segundo cut, esta vez con : como delimitador, la aísla:

cut -d' ' -f4 access.log | cut -d: -f2 | sort | uniq -c | sort -rn | head -n 1

Desármalo: [21/Jul/2026:22:20:01 separado por : da los campos [21/Jul/2026, 22, 20, 01 — el campo 2 es la hora.

Qué esperar:

2135 07

La hora 07 tuvo 2,135 peticiones — apenas por encima del resto (con tráfico repartido casi al azar, el "ganador" gana por poco). En un log de producción real, con patrones de uso humano, esta diferencia suele ser mucho más marcada.

Pregunta 4: las rutas con más errores 500

Esta pregunta necesita dos filtros: quedarte solo con las líneas cuyo estado es 500, y de esas, extraer la ruta (campo 7, pero adentro de las comillas del campo 6-8). Usas grep para lo primero — buscando el patrón exacto que separa el estado del resto de la línea — y dos cut con delimitadores distintos para lo segundo:

grep 'HTTP/1.1" 500 ' access.log | cut -d'"' -f2 | cut -d' ' -f2 | sort | uniq -c | sort -rn | head -n 10
  • grep 'HTTP/1.1" 500 ' se queda solo con las líneas donde el estado es 500 (el espacio final evita que "5000" o "50000" hagan falso positivo).
  • cut -d'"' -f2 corta por comillas: de "DELETE /index.html HTTP/1.1" se queda con el segundo trozo, DELETE /index.html HTTP/1.1.
  • cut -d' ' -f2 corta ese trozo por espacios y se queda con el campo 2: la ruta.

Qué esperar:

2045 /api/orders
 540 /api/products
 537 /index.html
 526 /images/logo.png
 521 /checkout
 516 /about
 515 /favicon.ico
 505 /api/users
 487 /login
 479 /cart

/api/orders tiene casi cuatro veces más errores 500 que la siguiente ruta. En un incidente real, esta línea es exactamente el dato que te dice dónde mirar primero: no "el sitio está lento", sino "el endpoint de órdenes está devolviendo error de servidor".

Pregunta 5: porcentaje de peticiones fallidas

Para el porcentaje necesitas dos conteos (total y fallidas) y una división con decimales — algo que la terminal no hace con enteros. bc es una calculadora que lee una expresión de su entrada estándar y escribe el resultado en su salida estándar: el mismo patrón de entrada/salida de todo este módulo, aplicado a aritmética en vez de texto.

TOTAL=$(wc -l < access.log)
FAILED=$(grep -cE 'HTTP/1.1" [45][0-9][0-9] ' access.log)
echo "scale=2; $FAILED * 100 / $TOTAL" | bc
  • wc -l < access.log cuenta las líneas totales (la redirección < en vez de pasarle el nombre del archivo evita que wc imprima el nombre junto al número).
  • grep -cE 'HTTP/1.1" [45][0-9][0-9] ' cuenta (-c) cuántas líneas tienen un estado que arranca con 4 o 5 (-E habilita la clase [45] como expresión regular extendida) — es decir, errores de cliente o de servidor. Los 3xx (redirecciones) no cuentan como fallidas.
  • scale=2 le dice a bc que muestre dos decimales; sin eso, bc trabaja con enteros por defecto y el resultado se truncaría a 0.

Qué esperar:

32.94

Casi un tercio de las peticiones de este log terminó en error — un número que en un servidor real dispararía una alerta de inmediato.

El entregable: un script encadenado

Cinco tuberías sueltas están bien para explorar, pero el entregable de este proyecto es un solo archivo que las corra todas, en orden, deteniéndose apenas algo salga mal — y que separe con 2> los errores reales de los resultados. Guarda esto como analyze-log.sh:

#!/usr/bin/env bash
# analyze-log.sh
# Reporte de tráfico a partir de access.log.
# Se ejecuta con: bash analyze-log.sh

LOG="access.log"

# Guardia: si el archivo no existe, que falle aquí -- con el error
# real de "ls" a la vista -- antes de gastar un solo comando de análisis.
# Código de salida esperado: 0 si el archivo existe, 1 si no.
ls "$LOG" > /dev/null 2> errors.log &&

# Pregunta 1: las 10 IPs con más peticiones.
# Código de salida esperado: 0
cut -d' ' -f1 "$LOG" 2>> errors.log | sort | uniq -c | sort -rn | head -n 10 > top-ips.txt &&

# Pregunta 2: peticiones por código de estado (campo 9).
# Código de salida esperado: 0
cut -d' ' -f9 "$LOG" 2>> errors.log | sort | uniq -c | sort -rn > status-codes.txt &&

# Pregunta 3: la hora con más tráfico.
# Código de salida esperado: 0
cut -d' ' -f4 "$LOG" 2>> errors.log | cut -d: -f2 | sort | uniq -c | sort -rn | head -n 1 > busiest-hour.txt &&

# Pregunta 4: rutas con más errores 500.
# grep devuelve 1 si NO encuentra ningún 500 -- eso no es una falla
# real, así que "|| true" evita que corte la cadena en ese caso.
(grep 'HTTP/1.1" 500 ' "$LOG" 2>> errors.log | cut -d'"' -f2 | cut -d' ' -f2 | sort | uniq -c | sort -rn | head -n 10 > top-500-routes.txt || true) &&

# Pregunta 5: porcentaje de peticiones fallidas (4xx y 5xx).
TOTAL=$(wc -l < "$LOG") &&
FAILED=$(grep -cE 'HTTP/1.1" [45][0-9][0-9] ' "$LOG" 2>> errors.log) &&
echo "scale=2; $FAILED * 100 / $TOTAL" | bc > failure-rate.txt &&

# Eslabón final: arma el reporte completo. "tee" lo guarda en report.txt
# Y ADEMÁS lo deja pasar a pantalla -- por eso "tee" y no ">".
cat top-ips.txt status-codes.txt busiest-hour.txt top-500-routes.txt failure-rate.txt | tee report.txt

STATUS=$?
echo "Código de salida del último eslabón: $STATUS"
exit "$STATUS"

Ejecútalo con bash analyze-log.sh.

Qué esperar (con access.log presente):

1702 68.6.93.195
1688 8.84.176.107
...
32.94
Código de salida del último eslabón: 0

Y en el directorio quedan top-ips.txt, status-codes.txt, busiest-hour.txt, top-500-routes.txt, failure-rate.txt, report.txt (el mismo contenido que viste en pantalla) y un errors.log vacío.

Qué esperar si access.log no existe (por ejemplo, si escribiste mal el nombre en la variable LOG):

Código de salida del último eslabón: 1

errors.log contiene una sola línea (ls: access.log: No such file or directory), y ningún otro archivo se creó — ni top-ips.txt a medias, ni un report.txt incompleto. La cadena se detuvo en el primer eslabón, tal como pedía el objetivo del proyecto.

Cuando ni ls alcanza: PIPESTATUS

El truco de ls funciona porque puedes verificar el problema antes de arrancar el análisis. Pero a veces el comando que puede fallar ya forma parte de la tubería que necesitas correr — no hay manera de "revisarlo antes". Para esos casos, bash guarda el código de salida de cada eslabón (no solo del último) en un arreglo llamado PIPESTATUS:

cut -d' ' -f1 no-existe.log | sort | uniq -c > /dev/null
echo "${PIPESTATUS[@]}"

Qué esperar:

cut: no-existe.log: No such file or directory
1 0 0

El primer número es el código de salida de cut (1, falló), el segundo el de sort (0), el tercero el de uniq -c (0). Con esto sabes exactamente cuál de los tres eslabones falló, no solo si el último tuvo éxito. Si usas zsh en vez de bash, el mismo arreglo existe con el nombre pipestatus (en minúsculas).

Una alternativa, si solo te interesa saber "¿algo de esta tubería falló?" sin importar cuál, es la opción set -o pipefail: a partir de ahí, $? de cualquier tubería refleja el código del último comando que falló (no necesariamente el último de la línea), en vez de reflejar siempre el último eslabón sin importar lo que pasó antes.

Errores comunes

1. Usar uniq -c sin sort antes (conceptual). uniq no elimina duplicados globales: solo colapsa líneas consecutivas iguales. Si las IPs repetidas están dispersas por el archivo (lo normal en un log real, donde un mismo cliente vuelve minutos después), uniq -c sin ordenar antes las cuenta como grupos separados de una sola línea cada uno. Compara:

cut -d' ' -f1 access.log | uniq -c | sort -rn | head -n 3
   3 8.84.176.107
   3 71.34.27.193
   3 68.6.93.195

contra la versión correcta:

cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -n 3
1702 68.6.93.195
1688 8.84.176.107
1675 46.63.162.220

Sin sort, la IP más "repetida" aparenta tener 3 peticiones en vez de 1,702, y el archivo completo parece tener 49,618 IPs distintas en vez de 300. Se detecta porque los números no cuadran con el total de líneas del archivo; se corrige poniendo sort siempre inmediatamente antes de uniq -c.

2. Confundir el campo 4 con la fecha completa. cut -d' ' -f4 access.log no te da [21/Jul/2026:22:20:01 +0000] — te da solo [21/Jul/2026:22:20:01, porque hay un espacio antes de la zona horaria y cut corta ciegamente por ese espacio, sin saber que "lógicamente" ambos trozos son una sola fecha. Se detecta cuando el resultado de un cut se ve truncado a la mitad de algo que esperabas completo; se corrige revisando cuántos campos separados por espacio ocupa realmente el dato (aquí son dos: campo 4 y campo 5).

3. Confiar en el $? de una tubería para saber si un comando de en medio falló. Por defecto, el código de salida de una tubería es el del último comando, sin importar qué pasó antes:

cut -d' ' -f1 no-existe.log | sort | uniq -c | sort -rn | head -n 3
echo "Código de salida: $?"
cut: no-existe.log: No such file or directory
Código de salida: 0

cut falló y lo dijo por stderr, pero sort, uniq -c, sort -rn y head recibieron una entrada vacía y "tuvieron éxito" procesándola — así que la tubería completa reporta 0. Un && que dependiera de ese 0 seguiría de largo como si todo estuviera bien. Se detecta cuando un reporte sale "vacío pero sin error visible"; se corrige revisando errors.log (o $PIPESTATUS, visto arriba) en vez de confiar solo en el código de salida final de la tubería.

Ejercicios

1. Usando access.log, escribe una sola línea de comandos que muestre las 5 rutas más solicitadas en total (sin importar el código de estado), ordenadas de mayor a menor.

Ver solución
cut -d'"' -f2 access.log | cut -d' ' -f2 | sort | uniq -c | sort -rn | head -n 5

Funciona por la misma composición que la pregunta 4, pero sin el grep que filtraba por estado: el primer cut (delimitador ") aísla la petición completa entre comillas, el segundo cut (delimitador espacio) se queda con la ruta, y sort | uniq -c | sort -rn | head cuenta y ordena — el mismo patrón de las cinco preguntas anteriores.

2. Escribe una línea de comandos que cuente cuántas peticiones hizo cada método HTTP (GET, POST, PUT, DELETE).

Ver solución
cut -d'"' -f2 access.log | cut -d' ' -f1 | sort | uniq -c | sort -rn

El primer cut aísla METODO /ruta HTTP/1.1 entre comillas; el segundo se queda con el campo 1 de eso (el método) en vez del campo 2 (la ruta) que usaste en el ejercicio 1. Funciona porque el método siempre es la primera palabra dentro de las comillas, en la misma posición en cada línea.

3. Calcula qué porcentaje de las peticiones tuvo un código de éxito (2xx), reusando el patrón de la pregunta 5.

Ver solución
TOTAL=$(wc -l < access.log)
OK=$(grep -cE 'HTTP/1.1" 2[0-9][0-9] ' access.log)
echo "scale=2; $OK * 100 / $TOTAL" | bc

Es la pregunta 5 con la clase de caracteres cambiada de [45] a 2: en vez de contar líneas cuyo estado empieza con 4 o 5, cuenta las que empiezan con 2. El resto de la tubería (total, bc, scale=2) no cambia porque el problema — dos conteos y una división con decimales — es el mismo.

4. Quieres una línea de comandos que guarde el top 10 de IPs en top-ips.txt y además lo muestre en pantalla, pero solo si access.log existe. Si no existe, no quieres un top-ips.txt vacío ni un mensaje confuso — quieres que el único mensaje visible sea el error real de que el archivo no está. Escríbela y pruébala apuntando a un archivo que no existe.

Ver solución
ls access.log > /dev/null && cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -n 10 | tee top-ips.txt

Funciona por lo mismo que la guardia del script principal: ls se ejecuta primero y su salida estándar se descarta (> /dev/null), pero su salida de error se deja pasar sin redirigir, así que si el archivo no existe verás exactamente ls: access.log: No such file or directory y nada más — el && corta la cadena ahí mismo, antes de que cut o tee toquen el disco. Pruébalo con ls no-existe.log > /dev/null && echo "esto no debería salir" para confirmar que la segunda parte nunca corre.

Resumen y siguiente paso

Lo que acabas de construir no es un truco de una sola vez: es el mismo movimiento — filtrar, ordenar, contar, encadenar según el resultado — que vas a repetir frente a cualquier archivo de texto grande el resto de tu carrera, sea un log de servidor, un CSV exportado de una base de datos o la salida de otro programa. Antes de avanzar deberías poder, sin ver esta cápsula: explicar por qué sort tiene que ir siempre antes de uniq -c; predecir, antes de ejecutar una tubería con &&, si un fallo a la mitad la va a detener o la va a dejar pasar; y armar de memoria una tubería de tres o cuatro comandos para responder una pregunta nueva sobre un archivo de texto que nunca viste.

Todo lo que hiciste hoy lo escribiste comando por comando, a mano, en la terminal. Un script de shell es exactamente esto — comandos encadenados con &&, códigos de salida revisados, errores separados de resultados — guardado en un archivo, con variables y estructuras de control para no repetir la misma lógica dos veces. Eso es lo que abre el módulo 5.

Recursos