Universidad Nacional de Luján
Departamento de Ciencias Básicas
Seminario de Integración Profesional 2026 Dr. David Petrocelli
📑 Índice del documento

Trabajo Práctico Nº 2 — Parte 2

Observabilidad — Logging centralizado con EFK (Elasticsearch + Fluent Bit + Kibana)

Fecha de Entrega: 05/05/2026

🧭 Esto es la Parte 2 de 4 sobre observabilidad.

  • Parte 1 — Loki + Promtail/Alloy + Grafana
  • Parte 2 (acá) — EFK = Elasticsearch + Fluent Bit + Kibana
  • Parte 3 — OpenTelemetry Collector + SDK
  • Parte 4 — Cierre: decisiones arquitectónicas + ADR comparativo

Esta Parte 2 NO reemplaza a Parte 1 — los dos stacks van a coexistir en el mismo cluster (namespaces distintos: observability para Loki, elastic para EFK). Eso es a propósito: van a poder comparar latencia, footprint y ergonomía con datos reales.

🤝 Partes 1 y 2 se entregan el mismo día (05/05) en un solo push final. Pueden trabajar en paralelo: Loki es más rápido de levantar y deja datos acumulándose mientras montás EFK. Cuando llegás al cookbook de queries (Hit #4 acá), ya tenés 1-2 h de logs reales en ambos backends para comparar.

Pre-requisitos: Loki desplegado de Parte 1 (puede ser en paralelo en este mismo push). Cluster k3s con al menos 8 GB de RAM libre + 15 GB de disco — Elasticsearch es mucho más pesado que Loki, y este es el primer mensaje pedagógico de la Parte 2.


Pre-requisitos


Requisitos, consideraciones y formato de entrega

Aplican los mismos requisitos generales del TP 1 + Parte 1 (repo público, README por hit, video, integración con IA documentada, sin secrets commiteados) más los siguientes específicos de esta Parte 2.

Infra base obligatoria — bloqueante

🚧 Sin esto la entrega no se puede evaluar. La cátedra corre tu helm install + kubectl apply + abre Kibana en el browser para verificar dashboards y queries. Si el stack no levanta, no se llega a corregir nada más → nota 0. Mismo criterio que Parte 1.

Otros requisitos


Contenidos del programa relacionados


Práctica

En la Parte 1 montaron Loki + Promtail + Grafana y vieron que el modelo “label-first” es simple, barato y suficiente para queries operacionales tipo “errores por producto en 24h”. Lo que no vieron es lo que pasa cuando alguien pregunta:

“Encontrame todos los logs donde aparezca la substring MercadoLibre cookie expired en cualquier campo, incluyendo los stacktraces.”

Con LogQL eso es un grep lineal sobre todos los chunks del stream — funciona, pero es lento (segundos a minutos según volumen). Elasticsearch construye un inverted index sobre el cuerpo del log: la misma query es milisegundos, sin importar cuántos GB tenés. Ese es el caso de uso donde EFK gana. La pregunta del TP es: ¿vale el costo extra de RAM + ops complexity + license restriction?

Para responderla con datos vamos a:

  1. Levantar EFK al lado de Loki (no en lugar de).
  2. Mandar los mismos logs del scraper a los dos stacks (Promtail los manda a Loki, Fluent Bit los manda a Elasticsearch).
  3. Correr queries equivalentes en ambos y comparar latencia, ergonomía y footprint.
  4. Documentar todo en el ADR 0009 para que la Parte 4 cierre la decisión.

Restricciones idénticas a Parte 1 (cluster local, sin cloud, sin servicios pagos) — pero ahora con menos margen de RAM. Si el cluster no aguanta, bajen Loki temporalmente durante el desarrollo y vuelvan a subir los dos para la entrega final. Documentenlo en el README del repo.


Hit #1 — Deploy del ECK Operator + Elasticsearch single-node + Kibana

ECK (Elastic Cloud on Kubernetes) es el operator oficial de Elastic. Maneja el lifecycle de Elasticsearch / Kibana / Beats / APM Server vía CRDs. Para esta Parte 2 alcanza con Elasticsearch y Kibana.

1.1 — Namespace y repo Helm

kubectl create namespace elastic
kubectl create namespace elastic-system   # convención: el operator vive aparte

helm repo add elastic https://helm.elastic.co
helm repo update

1.2 — Instalar el ECK Operator

efk/helm/eck-operator-values.yaml:

# El operator vigila todo el cluster pero corre en su propio namespace
managedNamespaces: [elastic]

resources:
  requests: { cpu: 50m,  memory: 128Mi }
  limits:   { cpu: 100m, memory: 256Mi }

# El operator NO es la pieza que necesita HA en este TP
replicaCount: 1
helm install eck-operator elastic/eck-operator \
  --version 2.16.0 \
  --namespace elastic-system \
  --values efk/helm/eck-operator-values.yaml

kubectl -n elastic-system rollout status statefulset/elastic-operator --timeout=180s

💡 Por qué StatefulSet y no Deployment: el operator usa leader election sobre un PVC para garantizar que sólo una instancia haga reconciliation a la vez. Es transparente — pero si lo ven en kubectl get sts -n elastic-system, no es bug.

1.3 — Custom Resource Elasticsearch (1 nodo, modo single-node)

efk/manifests/elasticsearch.yaml:

apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: scraper
  namespace: elastic
spec:
  version: 8.17.3
  # Para producción se usan al menos 3 nodos (master quorum). Para el TP, single-node
  # es lo único que entra en RAM. ECK acepta un solo nodo si discovery.type=single-node.
  nodeSets:
    - name: default
      count: 1
      config:
        node.roles: ["master", "data", "ingest"]
        # Sin esta línea, ES espera al menos 2 masters y queda en "yellow" para siempre.
        node.store.allow_mmap: false
        # En cluster local hay un solo nodo => single-shard, sin réplicas.
        index.number_of_shards: 1
        index.number_of_replicas: 0
      podTemplate:
        spec:
          containers:
            - name: elasticsearch
              env:
                # Heap fijo: 1 GB (50% del limit de 2 GiB)
                - name: ES_JAVA_OPTS
                  value: "-Xms1g -Xmx1g"
              resources:
                requests: { cpu: 500m,  memory: 1Gi }
                limits:   { cpu: 1000m, memory: 2Gi }
      volumeClaimTemplates:
        - metadata:
            name: elasticsearch-data
          spec:
            accessModes: [ReadWriteOnce]
            storageClassName: local-path
            resources:
              requests:
                storage: 10Gi
kubectl apply -f efk/manifests/elasticsearch.yaml

# El cluster pasa por phases: ApplyingChanges → Ready. Tarda 1-3 min en arrancar la JVM.
kubectl -n elastic get elasticsearch scraper -w
# Esperar a HEALTH=green PHASE=Ready

Verificar via API:

PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user \
  -o jsonpath='{.data.elastic}' | base64 -d)

kubectl -n elastic port-forward svc/scraper-es-http 9200:9200 &
sleep 2

curl -k -u "elastic:$PASSWORD" https://localhost:9200/_cluster/health | jq
# Esperado: { "status": "green", "number_of_nodes": 1, ... }

1.4 — Custom Resource Kibana

efk/manifests/kibana.yaml:

apiVersion: kibana.k8s.elastic.co/v1
kind: Kibana
metadata:
  name: scraper
  namespace: elastic
spec:
  version: 8.17.3
  count: 1
  elasticsearchRef:
    name: scraper
  podTemplate:
    spec:
      containers:
        - name: kibana
          resources:
            requests: { cpu: 200m, memory: 512Mi }
            limits:   { cpu: 500m, memory: 1Gi }

efk/manifests/kibana-nodeport.yaml:

apiVersion: v1
kind: Service
metadata:
  name: kibana-nodeport
  namespace: elastic
spec:
  type: NodePort
  selector:
    common.k8s.elastic.co/type: kibana
    kibana.k8s.elastic.co/name: scraper
  ports:
    - name: https
      port: 5601
      targetPort: 5601
      nodePort: 30001
kubectl apply -f efk/manifests/kibana.yaml
kubectl apply -f efk/manifests/kibana-nodeport.yaml

kubectl -n elastic rollout status deployment/scraper-kb --timeout=180s

Output esperado del Hit #1

$ kubectl -n elastic get elasticsearch,kibana
NAME           HEALTH   NODES   VERSION   PHASE   AGE
scraper        green    1       8.17.3    Ready   4m

NAME           HEALTH   NODES   VERSION   AGE
scraper        green    1       8.17.3    3m

$ kubectl -n elastic get pods
NAME                       READY   STATUS    RESTARTS   AGE
scraper-es-default-0       1/1     Running   0          4m
scraper-kb-69b8c4d4-xxxxx  1/1     Running   0          3m

Abrir https://<node-ip>:30001 → aceptar cert auto-firmado → login con elastic + el password recuperado del secret. Tienen que ver el welcome page de Kibana 8.17. No hay datos todavía — eso llega en el Hit #2.


Hit #2 — Fluent Bit como DaemonSet, pipeline al scraper

Acá es donde el modelo se diferencia de Promtail (Parte 1). Fluent Bit tiene un pipeline explícito de Input → Parser → Filter → Output que se configura por secciones — más expresivo, más verboso, pero permite cosas que Promtail no (multiline, parsers en C nativo, output a múltiples backends en paralelo).

El objetivo de este Hit: que los mismos JSON logs del scraper que ya van a Loki vayan también a Elasticsearch, parseados, enriquecidos con metadata Kubernetes, e indexados en scraper-logs-*.

2.1 — Helm chart oficial Fluent Bit

helm repo add fluent https://fluent.github.io/helm-charts
helm repo update

2.2 — Values con pipeline explícito

efk/helm/fluent-bit-values.yaml:

# Imagen oficial CNCF, no la de "fluent-bit-eck"
image:
  repository: fluent/fluent-bit
  tag: 3.2.4

resources:
  requests: { cpu: 50m,  memory: 64Mi }
  limits:   { cpu: 200m, memory: 128Mi }

# Tolera taints del control plane (k3s single-node)
tolerations:
  - effect: NoSchedule
    operator: Exists

# RBAC para leer pod metadata via API
serviceAccount:
  create: true
rbac:
  create: true

# Necesita acceder al secret de ES para autenticarse
extraVolumes:
  - name: es-certs
    secret:
      secretName: scraper-es-http-certs-public
extraVolumeMounts:
  - name: es-certs
    mountPath: /etc/fluent-bit/certs
    readOnly: true

env:
  - name: ES_PASSWORD
    valueFrom:
      secretKeyRef:
        name: scraper-es-elastic-user
        key: elastic

config:
  service: |
    [SERVICE]
        Daemon         Off
        Flush          5
        Log_Level      info
        Parsers_File   /fluent-bit/etc/parsers.conf
        HTTP_Server    On
        HTTP_Listen    0.0.0.0
        HTTP_Port      2020

  inputs: |
    [INPUT]
        Name              tail
        Tag               kube.*
        Path              /var/log/containers/*ml-scraper*.log
        Parser            cri
        DB                /var/log/flb_kube.db
        Mem_Buf_Limit     5MB
        Skip_Long_Lines   On
        Refresh_Interval  10

  filters: |
    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_CA_File        /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
        Kube_Token_File     /var/run/secrets/kubernetes.io/serviceaccount/token
        Merge_Log           On
        Merge_Log_Key       log_processed
        K8S-Logging.Parser  On
        K8S-Logging.Exclude On
        Annotations         Off
        Labels              On

    # Sólo nos quedamos con pods cuyo label app=scraper (igual que Hit #2 de Parte 1)
    [FILTER]
        Name    grep
        Match   kube.*
        Regex   $kubernetes['labels']['app'] ^scraper$

    # Parsea el JSON-line emitido por logging_setup.py (ya estructurado del Hit #3 de Parte 1)
    [FILTER]
        Name         parser
        Match        kube.*
        Key_Name     log
        Parser       json_scraper
        Reserve_Data On
        Preserve_Key Off

  outputs: |
    [OUTPUT]
        Name              es
        Match             kube.*
        Host              scraper-es-http.elastic.svc.cluster.local
        Port              9200
        HTTP_User         elastic
        HTTP_Passwd       ${ES_PASSWORD}
        tls               On
        tls.verify        Off
        Suppress_Type_Name On
        Logstash_Format   On
        Logstash_Prefix   scraper-logs
        Logstash_DateFormat %Y.%m.%d
        Time_Key          @timestamp
        Replace_Dots      On
        Retry_Limit       5

  customParsers: |
    [PARSER]
        Name        cri
        Format      regex
        Regex       ^(?<time>[^ ]+) (?<stream>stdout|stderr) (?<logtag>[^ ]*) (?<log>.*)$
        Time_Key    time
        Time_Format %Y-%m-%dT%H:%M:%S.%L%z

    [PARSER]
        Name         json_scraper
        Format       json
        Time_Key     timestamp
        Time_Format  %Y-%m-%dT%H:%M:%S%z
        Time_Keep    On
helm install fluent-bit fluent/fluent-bit \
  --version 0.48.5 \
  --namespace elastic \
  --values efk/helm/fluent-bit-values.yaml

kubectl -n elastic rollout status ds/fluent-bit --timeout=120s

💡 Por qué Logstash_Format si no usamos Logstash: el output es de Fluent Bit usa la convención de índices con sufijo de fecha (scraper-logs-2026.05.10) — esa convención la heredó de Logstash y se la sigue llamando “Logstash format” aunque acá no haya Logstash en ningún lado. Tener un índice por día es lo que después permite el rollover de ILM (Hit #3) sin pelear con scripts.

2.3 — Disparar tráfico para validar

kubectl -n ml-scraper create job --from=cronjob/scraper-hourly scraper-efk-test-1
kubectl -n ml-scraper wait --for=condition=complete job/scraper-efk-test-1 --timeout=600s

Output esperado del Hit #2

# Verificar que el índice se creó y tiene docs
kubectl -n elastic port-forward svc/scraper-es-http 9200:9200 &
sleep 2
PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)

curl -sk -u "elastic:$PASSWORD" "https://localhost:9200/_cat/indices?v" | grep scraper
# Esperado: yellow  open  scraper-logs-2026.05.10  ...  N docs

Y en Kibana → Stack ManagementIndex Management → tiene que aparecer scraper-logs-YYYY.MM.DD. En Discover (después de crear el data view del Hit #3) van a aparecer los logs con todos los campos JSON parseados: level, producto, logger, message, kubernetes.namespace, kubernetes.pod_name, etc.

Capturen un screenshot de Kibana → Discover mostrando los campos extraídos y commitéenlo en efk/screenshots/hit2-fluentbit-discover.png.


Hit #3 — Index pattern + ILM (rollover, retention 7 días)

Sin política de retención, Elasticsearch acumula índices indefinidamente y termina llenando el disco. ILM (Index Lifecycle Management) es el equivalente Elastic del retention_period: 168h que usaron en Loki Parte 1 — pero más expresivo: define fases (hot / warm / cold / frozen / delete) con transiciones por edad, tamaño o número de docs.

Para esta Parte 2 alcanza con un ciclo simple: hot 1 día → warm 6 días → delete.

3.1 — Crear data view en Kibana

Stack Management → Data ViewsCreate data view:

Guardar. Esto permite que Discover, Dashboards, y Lens “vean” los índices.

3.2 — ILM policy

efk/manifests/ilm-policy.json:

{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_age": "1d",
            "max_primary_shard_size": "1gb"
          },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "1d",
        "actions": {
          "shrink":      { "number_of_shards": 1 },
          "forcemerge":  { "max_num_segments": 1 },
          "set_priority": { "priority": 50 }
        }
      },
      "delete": {
        "min_age": "7d",
        "actions": {
          "delete": { "delete_searchable_snapshot": true }
        }
      }
    }
  }
}

Aplicar via API:

PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)

curl -sk -u "elastic:$PASSWORD" \
  -X PUT "https://localhost:9200/_ilm/policy/scraper-logs" \
  -H "Content-Type: application/json" \
  -d @efk/manifests/ilm-policy.json
# Esperado: {"acknowledged": true}

3.3 — Index template que asocia ILM a scraper-logs-*

curl -sk -u "elastic:$PASSWORD" \
  -X PUT "https://localhost:9200/_index_template/scraper-logs-template" \
  -H "Content-Type: application/json" \
  -d '{
    "index_patterns": ["scraper-logs-*"],
    "template": {
      "settings": {
        "number_of_shards": 1,
        "number_of_replicas": 0,
        "index.lifecycle.name": "scraper-logs",
        "index.lifecycle.rollover_alias": "scraper-logs"
      }
    }
  }'

⚠️ Single-node = number_of_replicas: 0 o el cluster queda en yellow permanente. En producción real (3+ nodos) usen number_of_replicas: 1 mínimo.

Output esperado del Hit #3

En Kibana → Stack ManagementIndex Lifecycle Policies → aparece la policy scraper-logs con las 3 fases. Y en Index Management → cada índice scraper-logs-YYYY.MM.DD tiene la columna Lifecycle policy = scraper-logs y muestra la fase actual.

Para ver que el rollover efectivamente funciona durante el desarrollo (sin esperar 24h), pueden forzarlo:

curl -sk -u "elastic:$PASSWORD" \
  -X POST "https://localhost:9200/scraper-logs/_rollover" \
  -H "Content-Type: application/json" \
  -d '{"conditions":{"max_age":"0ms"}}'

Capturen el screenshot de la policy en efk/screenshots/hit3-ilm-policy.png.


Hit #4 — Cookbook KQL: 6+ queries útiles

Documenten en efk/queries/kql-cookbook.md al menos 6 queries en KQL (Kibana Query Language, NO Lucene query — language: lucene está deprecado desde 8.0). Cada query lleva: pregunta de negocio · query · screenshot del resultado · comentario.

💡 KQL vs Lucene query: Lucene es el lenguaje viejo (field:value AND field:value). KQL es el nuevo (field: value and field: value), case-insensitive en operadores, soporta wildcards más predecibles, y es el default de Kibana 8+. Para esta entrega usen KQL exclusivamente. En el cookbook dejen una nota mencionando el equivalente Lucene de cada query — la cátedra valora que sepan que existe la diferencia.

Mínimo obligatorio (6 queries que la cátedra valida — pueden agregar más):

# Pregunta Query KQL
Q1 Errores por producto en las últimas 24h level: "ERROR" and producto: * (filtro de tiempo en el time picker)
Q2 Top selectores faltantes (filtros que el scraper no encontró) message: "Filtro * no disponible" and producto: *
Q3 Distribución de duración del Job (campo job_duration_ms del scraper) event: "scrape_completado" and job_duration_ms >= 0 (visualizar como histograma)
Q4 Logs con timeout de Selenium en cualquier producto message: *timeout* and (logger: "selenium*" or logger: "extractors")
Q5 Eventos del CronJob específico (correlación por job_name) kubernetes.labels.job_name: "scraper-test-1"
Q6 Errores que NO sean del módulo de Postgres (excluir false positives conocidos) level: "ERROR" and not logger: "psycopg*"

Para cada una, documenten en el cookbook:

  1. La pregunta de negocio (1 línea).
  2. La query KQL exacta (sin _source filters — KQL no maneja eso, eso es Query DSL).
  3. El equivalente Lucene (1 línea, para mostrar que entendieron la diferencia).
  4. Un screenshot del resultado en Discover, con time range relevante visible.
  5. Por qué la query está escrita así — sobre todo: cuándo conviene * (wildcard expensive) vs un campo keyword (fast).

📚 Documentación KQL canónica: https://www.elastic.co/guide/en/kibana/current/kuery-query.html. Cortita y al hueso. La sección “Query and filter context” es la que se les va a hacer útil cuando pasen una query de Discover a un panel de dashboard.

Equivalente entre KQL y LogQL

Comparen mentalmente con las queries del cookbook de Parte 1. Q1 acá vs Q1 de Parte 1: ambas responden la misma pregunta, pero el modelo es distinto — Loki cuenta sobre streams etiquetados, Elasticsearch agrega sobre documents indexados. El ADR 0009 tiene que tocar este punto.


Hit #5 — Dashboard Kibana provisionado as-code (NDJSON + import API)

A diferencia de Grafana (donde el dashboard JSON va a un ConfigMap montado), Kibana usa import via REST API: los saved objects se exportan como NDJSON (newline-delimited JSON, un objeto por línea) y se importan con POST /api/saved_objects/_import.

5.1 — Construir el dashboard en la UI

Construyan un dashboard único en Kibana → Dashboards → Create dashboard con al menos 6 paneles (más que los 5 del dashboard equivalente de Parte 1 — Kibana es más rico para visualizaciones agregadas):

  1. Metric — Total de eventos hoy (@timestamp last 24h).
  2. Metric — % de eventos level: "ERROR" vs total.
  3. Bar chart vertical — Top 5 productos con más errores (Q1 del cookbook).
  4. Pie chart — Distribución por level (INFO / WARNING / ERROR).
  5. Line chart — Eventos por minuto last 6h, breakdown por level.
  6. Table — Última corrida exitosa por producto (Q de “max @timestamp by producto where event=scrape_completado”).

Bonus aceptado (no obligatorio):

5.2 — Exportar a NDJSON

Stack Management → Saved Objects → seleccionar el dashboard + sus visualizaciones + el data view → Export → guardar como efk/dashboards/scraper-overview.ndjson.

⚠️ Marcar “Include related objects” o el NDJSON va a quedar incompleto y el import en otro cluster va a fallar con “Missing references”.

5.3 — Import via API en install.sh

PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)

# Port-forward a Kibana
kubectl -n elastic port-forward svc/scraper-kb-http 5601:5601 &
PF_PID=$!
sleep 5

curl -sk -u "elastic:$PASSWORD" \
  -X POST "https://localhost:5601/api/saved_objects/_import?overwrite=true" \
  -H "kbn-xsrf: true" \
  -F file=@efk/dashboards/scraper-overview.ndjson | jq '.success'
# Esperado: true

kill $PF_PID

💡 El header kbn-xsrf: true es obligatorio. Kibana exige CSRF token en todos los POST/PUT/DELETE — sin ese header el request falla con 400 incluso con auth válida. Es el primer pitfall que encuentran todos en su primer intento.

Output esperado del Hit #5

Después de re-aplicar y re-disparar el scraper-efk-test-1:

Capturen el screenshot final en efk/screenshots/hit5-dashboard.png — esto es lo que la cátedra mira primero.


Hit #6 — Alertas via Kibana Alerting (opcional, bonus +5 %)

Configuren una rule en Kibana Alerting que dispare cuando se cumpla:

Más de 5 eventos level: "ERROR" del scraper en cualquier ventana de 1 hora.

Notificación al mismo Discord webhook que usaron en Parte 1 — eso permite que la alerta de Loki y la de EFK aparezcan en el mismo canal y la cátedra pueda verificar las dos.

Estructura mínima

Documenten en efk/README.md cómo se setea DISCORD_WEBHOOK_URL y commiteen el screenshot del mensaje de alerta en efk/screenshots/hit6-discord-alert-efk.png.


Cómo entregar

  1. Push final al repo público (mismo repo del TP 1 + Parte 1) antes del 05/05/2026 23:59 ART.
  2. README raíz actualizado con sección nueva “TP 2 · Parte 2 — EFK”:
    • Cómo ejecutar efk/install.sh desde cero.
    • Variables de entorno requeridas (DISCORD_WEBHOOK_URL opcional para Hit #6 — la password de elastic la genera ECK sola).
    • Link al efk/README.md con detalles.
  3. Carpeta efk/ completa según estructura obligatoria.
  4. docs/adr/0009-stack-de-logging-efk.md comparando ergonomía / footprint / licencia vs Loki.
  5. Carpeta efk/screenshots/ con mínimo:
    • hit2-fluentbit-discover.png — Discover mostrando los logs con campos JSON parseados.
    • hit3-ilm-policy.png — la policy scraper-logs con sus 3 fases.
    • hit5-dashboard.png — dashboard Scraper Overview renderizado con datos reales.
    • (bonus) hit6-discord-alert-efk.png — alerta de EFK en Discord.
  6. Video corto (3-5 min) mostrando: install.sh corriendo de cero, Kibana abriéndose en :30001, demo de las 6 queries del Hit #4 en Discover, dashboard del Hit #5 con datos.
  7. Mensaje en el canal Discord de la materia con link al repo + video.

📡 Canal Discord: https://discord.com/channels/1482135908508500148/1482135909456679139 Antes de pedir ayuda, revisá Common pitfalls.


Auto-verificación previa a la entrega

Si algo de esta lista falla, no entregues. 8 puntos seguros que se pierden con 5 minutos de checklist.

1) install.sh corre limpio en cluster vacío

kubectl delete namespace elastic --wait=true
kubectl delete namespace elastic-system --wait=true

cd efk && ./install.sh
# Tiene que terminar con exit 0 y los 7 ✓ del output esperado del Hit #1

2) Elasticsearch está green y Kibana available

kubectl -n elastic get elasticsearch,kibana
# elasticsearch HEALTH=green PHASE=Ready
# kibana HEALTH=green

3) Fluent Bit DaemonSet está Running en todos los nodos

kubectl -n elastic get ds fluent-bit
# DESIRED == READY (en cluster single-node ambos = 1)

kubectl -n elastic logs ds/fluent-bit | tail -20
# Tiene que mostrar líneas tipo "[output:es:es.0] HTTP status=200/201"

4) Elasticsearch responde a queries via HTTP

PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)
kubectl -n elastic port-forward svc/scraper-es-http 9200:9200 &
sleep 2

curl -sk -u "elastic:$PASSWORD" "https://localhost:9200/_cat/indices?v" | grep scraper-logs
# Esperado: al menos 1 línea con "open" e índice yellow/green con docs > 0

5) ILM policy scraper-logs aplicada

curl -sk -u "elastic:$PASSWORD" "https://localhost:9200/_ilm/policy/scraper-logs" | jq '.scraper-logs.policy.phases | keys'
# Esperado: ["delete","hot","warm"]

6) Las 6 queries KQL del Hit #4 corren sin error

Abran Kibana → Discover → data view scraper-logs → peguen cada query del cookbook. Ninguna debe dar error de sintaxis. Pueden devolver 0 resultados si no hay datos en ese rango, pero no rojo.

7) Dashboard provisionado aparece en la UI

# Después de install.sh:
# Abrir Kibana → Dashboards → buscar "Scraper Overview" → tiene que estar
# Abrirlo → los 6 paneles tienen que renderear (datos o "No results", pero no error)

8) gitleaks no detecta el password de Elastic ni el webhook Discord

gitleaks detect --no-git --verbose
# Esperado: 0 leaks. Ni la password de "elastic" ni el DISCORD_WEBHOOK_URL en el repo.

Common pitfalls

vm.max_map_count muy bajo en el host

Elasticsearch necesita vm.max_map_count >= 262144. En k3d / Docker Desktop el default es 65536 y ES se cae al arrancar con error max virtual memory areas vm.max_map_count [65530] is too low. Mitigación:

sudo sysctl -w vm.max_map_count=262144
# Persistir en /etc/sysctl.d/99-elastic.conf

En k3d puro hay que ejecutarlo en el host del Docker, no dentro del contenedor.

Cluster yellow permanente

Causa #1: number_of_replicas > 0 con un solo nodo. Las réplicas no pueden asignarse y el cluster queda yellow. Solución: en single-node siempre replicas: 0.

Causa #2: el .kibana_* internal index tiene réplicas hardcoded. Inofensivo en single-node, pero molesta en logs. Documentado en el ADR como “expected en dev cluster”.

Fluent Bit no manda nada a ES

Mirar kubectl -n elastic logs ds/fluent-bit | grep -i error. Causas frecuentes:

  1. TLS verify falla — el cert auto-firmado de ES no es trusted. Mitigación: tls.verify Off en el output es (ok para single-cluster local; NO hacer esto en prod).
  2. Auth falla — verificar que el secret scraper-es-elastic-user está montado y que ${ES_PASSWORD} se sustituye en runtime.
  3. Pipeline backpressure — si ES está OOMeando, Fluent Bit empieza a tirar [ warn] [engine] failed to flush chunk. La solución no es subir Mem_Buf_Limit — es bajar el ingest o subir el heap de ES.

kbn-xsrf: true falta en el import

Si el curl al endpoint _import devuelve 400 con "Request must contain a kbn-xsrf header", agreguen el header. Es el footgun #1 de la API de Kibana — todo POST / PUT / DELETE lo necesita.

El NDJSON exportado no incluye el data view

Cuando exportan el dashboard sin marcar “Include related objects”, el archivo no incluye el data view scraper-logs y al importar en un cluster limpio explota con “Missing references: index-pattern: scraper-logs”. Siempre exportar con la opción tildada.

Memoria — el cluster local muere

Síntoma: kubectl get pods -A muestra Loki y/o ES en OOMKilled. Causa: 6 GB no alcanzan para los dos stacks + Postgres + scraper. Mitigación durante el desarrollo: mantener uno de los dos stacks en replicas: 0 y subirlos los dos sólo para la corrida final de evaluación. Documentarlo en efk/README.md (“comportamiento esperado en cluster < 8 GB”).

Confundir KQL con Lucene query language

Si pegan level:ERROR AND producto:iphone (sin espacios después de :, AND mayúscula), funciona en Lucene pero NO en KQL — KQL espera level: "ERROR" and producto: "iphone". La barra de Discover por default usa KQL desde 8.x; el toggle a Lucene está en el ícono de la lupita.


Criterios de evaluación

Requisitos bloqueantes

Tabla de puntaje (100 %)

Criterio Peso
Hit #1 — ECK Operator + Elasticsearch (green) + Kibana corriendo 20 %
Hit #2 — Fluent Bit DaemonSet + pipeline al scraper + parser JSON funcionando 20 %
Hit #3 — ILM policy scraper-logs (3 fases) + index template asociado 15 %
Hit #4kql-cookbook.md con las 6 queries documentadas (pregunta + KQL + equivalente Lucene + por qué) 15 %
Hit #5 — dashboard scraper-overview.ndjson importado as-code y mostrando datos reales 20 %
ADR 0009-stack-de-logging-efk.md comparando contra Loki en footprint / latency / lic. 10 %
Bonus Hit #6 — alerta a Discord funcionando + screenshot +5 %

Material de apoyo

Tabla comparativa Loki (Parte 1) vs EFK (Parte 2) — números honestos

Esta tabla es el insumo principal del ADR 0009 y de la decisión final de la Parte 4. Mídanla en su propio cluster — los números abajo son referencia ballpark con datos del scraper.

Dimensión Loki + Promtail + Grafana EFK = Elasticsearch + Fluent Bit + Kibana
RAM resident del backend ~250-400 Mi (Loki single-binary) ~1.5-2.0 Gi (ES single-node con heap 1G)
RAM resident del agente ~50-100 Mi (Promtail) ~40-80 Mi (Fluent Bit, escrito en C — más liviano)
RAM resident del visualizador ~150-250 Mi (Grafana) ~600-900 Mi (Kibana, Node.js + Chrome PDF)
Total RAM stack ~0.5-0.8 Gi ~2.5-3.0 Gi (5× más pesado)
Disco para 7 días retention ~500 Mi - 2 Gi (chunks comprimidos) ~3-8 Gi (índice invertido + .doc files)
Latency query “errores 24h” ~100-300 ms (filtros sobre labels) ~20-80 ms (inverted index hit)
Latency query full-text “encontrar substring” ~5-30 segundos (grep lineal sobre chunks) ~50-200 ms (inverted index)
Cardinality tolerance Baja — explota con > 100 valores únicos por label Alta — Lucene maneja millones de terms por field
Setup complexity 3 charts Helm + 1 ConfigMap dashboard ECK Operator + 2 CRDs + 1 chart Fluent Bit + import API
Query language LogQL (label-first, PromQL-like) KQL (campo-valor, intuitivo) + Query DSL JSON (avanzado)
Visualización Grafana (dashboards as-code, alerting maduro) Kibana (Lens auto, machine learning add-on, más visualizaciones)
Licencia Apache 2.0 (verdaderamente OSS) Elastic License v2 (source-available, NO OSS, prohíbe SaaS competing)
Alternativa OSS — (ya es Apache 2.0) OpenSearch + OpenSearch Dashboards (fork AWS, Apache 2.0)
Costo cluster local $0 — entra cómodo en 6 GB $0 — pero pide 8 GB mínimo
Costo cloud (estimado, 100 GB/día) ~$30/mes (Grafana Cloud Logs) ~$200/mes (Elastic Cloud) — 6-7× más caro
Curva de aprendizaje Empinada al principio (LogQL es nuevo), después rápida Empinada al principio (Query DSL es complejo), KQL es amigable
Caso donde gana Logs operacionales, label-driven, bajo volumen Full-text search, alta cardinalidad, búsqueda exploratoria

Lectura honesta de la tabla:

Esqueleto de install.sh

#!/usr/bin/env bash
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "→ Namespaces"
kubectl create namespace elastic        --dry-run=client -o yaml | kubectl apply -f -
kubectl create namespace elastic-system --dry-run=client -o yaml | kubectl apply -f -

echo "→ Helm repos"
helm repo add elastic https://helm.elastic.co >/dev/null 2>&1 || true
helm repo add fluent  https://fluent.github.io/helm-charts >/dev/null 2>&1 || true
helm repo update >/dev/null

echo "→ ECK Operator"
helm upgrade --install eck-operator elastic/eck-operator \
  --version 2.16.0 \
  --namespace elastic-system \
  --values "$DIR/helm/eck-operator-values.yaml" \
  --wait --timeout 5m

echo "→ Elasticsearch + Kibana via CRDs"
kubectl apply -f "$DIR/manifests/elasticsearch.yaml"
kubectl apply -f "$DIR/manifests/kibana.yaml"
kubectl apply -f "$DIR/manifests/kibana-nodeport.yaml"

echo "→ Esperando Elasticsearch ready (puede tardar 2-3 min)"
kubectl -n elastic wait --for=jsonpath='{.status.health}'=green elasticsearch/scraper --timeout=300s
kubectl -n elastic wait --for=jsonpath='{.status.health}'=green kibana/scraper --timeout=300s

echo "→ Fluent Bit"
helm upgrade --install fluent-bit fluent/fluent-bit \
  --version 0.48.5 \
  --namespace elastic \
  --values "$DIR/helm/fluent-bit-values.yaml" \
  --wait --timeout 3m

echo "→ ILM policy + index template"
PASSWORD=$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)
kubectl -n elastic port-forward svc/scraper-es-http 9200:9200 >/dev/null 2>&1 &
PF_ES=$!
trap "kill $PF_ES 2>/dev/null || true" EXIT
sleep 3

curl -sk -u "elastic:$PASSWORD" -X PUT "https://localhost:9200/_ilm/policy/scraper-logs" \
  -H "Content-Type: application/json" -d @"$DIR/manifests/ilm-policy.json" >/dev/null

curl -sk -u "elastic:$PASSWORD" -X PUT "https://localhost:9200/_index_template/scraper-logs-template" \
  -H "Content-Type: application/json" -d '{
    "index_patterns": ["scraper-logs-*"],
    "template": {
      "settings": {
        "number_of_shards": 1,
        "number_of_replicas": 0,
        "index.lifecycle.name": "scraper-logs",
        "index.lifecycle.rollover_alias": "scraper-logs"
      }
    }
  }' >/dev/null

echo "→ Import dashboard NDJSON"
kubectl -n elastic port-forward svc/scraper-kb-http 5601:5601 >/dev/null 2>&1 &
PF_KB=$!
trap "kill $PF_ES $PF_KB 2>/dev/null || true" EXIT
sleep 5

curl -sk -u "elastic:$PASSWORD" \
  -X POST "https://localhost:5601/api/saved_objects/_import?overwrite=true" \
  -H "kbn-xsrf: true" \
  -F file=@"$DIR/dashboards/scraper-overview.ndjson" >/dev/null

NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
echo ""
echo "✓ ECK Operator running"
echo "✓ Elasticsearch green"
echo "✓ Kibana available"
echo "✓ Fluent Bit DaemonSet ready"
echo "✓ ILM policy 'scraper-logs' aplicada"
echo "✓ Index template asociado"
echo "✓ Dashboard 'Scraper Overview' importado"
echo "→ Abrir https://${NODE_IP}:30001   (elastic / \$(kubectl -n elastic get secret scraper-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d))"

chmod +x install.sh antes de commitear.

Esqueleto de fluent-bit-config.yaml (referencia rápida)

Si ven errores en el pipeline de Fluent Bit, este es el esqueleto mínimo que funciona — cualquier error suyo está en una de estas 4 secciones:

[SERVICE]
    Daemon         Off
    Flush          5
    Log_Level      info
    Parsers_File   parsers.conf

[INPUT]
    Name              tail
    Tag               kube.*
    Path              /var/log/containers/*.log
    Parser            cri
    DB                /var/log/flb_kube.db

[FILTER]
    Name                kubernetes
    Match               kube.*
    Kube_URL            https://kubernetes.default.svc:443
    Merge_Log           On
    K8S-Logging.Parser  On

[FILTER]
    Name    grep
    Match   kube.*
    Regex   $kubernetes['labels']['app'] ^scraper$

[OUTPUT]
    Name              es
    Match             kube.*
    Host              scraper-es-http.elastic.svc.cluster.local
    Port              9200
    HTTP_User         elastic
    HTTP_Passwd       ${ES_PASSWORD}
    tls               On
    tls.verify        Off
    Logstash_Format   On
    Logstash_Prefix   scraper-logs
    Replace_Dots      On

Esqueleto de query KQL (referencia rápida)

# Sintaxis básica — campo: valor
level: "ERROR"

# AND / OR / NOT (lower-case en KQL)
level: "ERROR" and producto: "iphone"
level: "ERROR" or level: "WARNING"
level: "ERROR" and not logger: "psycopg*"

# Wildcard (caro en campos text, barato en keyword)
message: *timeout*
producto: *

# Rangos
job_duration_ms >= 5000
@timestamp >= "2026-05-10T00:00:00Z" and @timestamp < "2026-05-11T00:00:00Z"

# Existence
producto: *

# Nested fields (kubernetes metadata)
kubernetes.namespace: "ml-scraper" and kubernetes.labels.app: "scraper"

# Múltiples valores (in)
level: ("ERROR" or "WARNING") and producto: ("iphone" or "samsung")

Esqueleto de ADR 0009-stack-de-logging-efk.md

Mismo formato Michael Nygard de los ADRs anteriores. Esqueleto mínimo:

# 0009 — Evaluación de EFK como segundo stack de logging

- Date: 2026-05-20
- Status: Proposed (cierre formal en ADR 0010 de Parte 4)
- Deciders: <equipo>

## Contexto

En Parte 1 adoptamos Loki + Promtail + Grafana (ADR 0007). En Parte 2 desplegamos EFK
en paralelo con dos objetivos: (a) obtener datos comparativos reales sobre el mismo
workload (scraper) y (b) entender los trade-offs antes de cerrar la decisión final
en Parte 4 con OTel.

Restricciones medidas:
- Cluster k3s single-node, ~8 GB RAM totales (con Loki y EFK simultáneos: ~3.5 GB).
- Mismos logs JSON del scraper (Hit #3 de Parte 1) van a los dos stacks.
- Retención 7 días en ambos.
- Licenciamiento: Loki es Apache 2.0; Elasticsearch es Elastic License v2 (source-available,
  NO OSS según OSI). En contexto académico no afecta; en empresa puede ser bloqueante.

## Decisión

NO se decide reemplazar Loki por EFK ni viceversa en esta Parte 2. La decisión se difiere
al ADR 0010 (Parte 4) cuando se haya evaluado también OTel.

Lo que sí se decide acá:
- Mantener los dos stacks corriendo en paralelo durante el TP.
- Documentar las dimensiones de comparación (ver tabla en TP 2 · Parte 2).
- Marcar EFK como "candidato fuerte" cuando el caso de uso requiera full-text search
  pesado, y "candidato descartado" cuando el footprint o licencia importen.

## Consecuencias

- Se gana visibilidad real sobre los trade-offs (no opinión, datos del propio cluster).
- Se gana experiencia con ECK Operator + ILM + KQL — útil aunque no se adopte EFK como
  principal.
- Se pierde RAM (~3.5 GB) durante el desarrollo. Mitigado bajando uno de los dos stacks
  durante coding y subiendo ambos para evaluación.
- Riesgo: introducir dependencia de Elastic License v2 en el repo. Mitigado: sólo se
  usan imágenes oficiales, no se redistribuye Elasticsearch ni se ofrece como servicio.

## Métricas medidas (en NUESTRO cluster)

- RAM Loki stack:    `<medir>` Mi
- RAM EFK stack:     `<medir>` Mi (ratio: `<calcular>`×)
- Latency Q1 (errores por producto 24h):
  - Loki:   `<medir>` ms
  - EFK:    `<medir>` ms
- Latency full-text "encontrar substring de 50 chars" en 7 días de logs:
  - Loki:   `<medir>` seg
  - EFK:    `<medir>` ms

## Referencias

- Tabla comparativa de la cátedra: TP 2 · Parte 2 / Material de apoyo
- Elastic License v2: https://www.elastic.co/licensing/elastic-license
- OpenSearch (alternativa OSS): https://opensearch.org/

Plantilla del ADR comparativo final (Parte 4)

La Parte 4 va a consolidar los ADR 0007 (Loki), 0009 (EFK) y el que produzca la Parte 3 (OTel) en un ADR 0012-stack-de-observabilidad-final.md. La cátedra ya tiene un esqueleto preparado — no se entrega en esta Parte 2, pero escriban el 0009 sabiendo que va a ser revisitado. Si lo escriben honesto y con datos, la Parte 4 va a ser corta. Si lo escriben con “Loki es mejor porque sí”, la Parte 4 los va a obligar a re-medir.


Referencias y Bibliografía

Solo lo directamente vinculado a esta Parte 2. Los libros generales viven en TP 0; las referencias de Parte 1 viven en TP3-1.md. No se repiten.

Hit #1 — ECK Operator + Elasticsearch + Kibana

Hit #2 — Fluent Bit + pipeline Kubernetes

Hit #3 — Index Lifecycle Management

Hit #4 — KQL

Hit #5 — Saved Objects API + dashboards

Hit #6 — Kibana Alerting (bonus)

Licencia y alternativas OSS

Comparativa Loki vs Elasticsearch