Observability стек: Prometheus + Grafana + Loki
В production у тебя 5+ контейнеров: Airflow webserver, scheduler, Postgres, Kafka, ClickHouse. Один из них тормозит — какой? Почему OOM Killer убил scheduler в 3 ночи? Сколько raw events в час уходит в Kafka?
Эти вопросы решает observability — три столпа:
- Metrics — цифры во времени (CPU, RAM, queue depth, requests/sec). Хранятся как time-series.
- Logs — текстовые события.
INFO,ERROR, stacktraces. - Traces — путь запроса через сервисы. Где задержка — в API, БД, или в очереди?
Стандарт 2026 — стек на open-source: Prometheus (метрики) + Grafana (UI) + Loki (логи) + Tempo (traces) + cAdvisor (per-container metrics). Все они живут в compose-файле, который ты можешь скопировать и поднять за 5 минут.
Архитектура стека
Шаг 1: Prometheus + cAdvisor
Как /proc и cgroups экспортируют метрики процессов# compose.observability.yml
services:
prometheus:
image: prom/prometheus:v3.0.1
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
- '--web.enable-lifecycle' # для reload через POST /-/reload
networks:
- obs-net
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: cadvisor
ports:
- "8081:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
devices:
- /dev/kmsg
networks:
- obs-net
node-exporter:
image: prom/node-exporter:v1.8.2
container_name: node-exporter
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
networks:
- obs-net
volumes:
prom-data:
networks:
obs-net:
prometheus.yml:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'cadvisor'
static_configs:
- targets: ['cadvisor:8080']
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'airflow'
metrics_path: '/admin/metrics/'
static_configs:
- targets: ['airflow-webserver:8080']
# Airflow требует enable-Prometheus metrics через провайдер
- job_name: 'clickhouse'
static_configs:
- targets: ['clickhouse:9363'] # ClickHouse Prometheus port
Что мы получаем:
- cAdvisor — метрики каждого контейнера (CPU, RAM, network I/O). Самое полезное для DE: видеть, какой контейнер ест больше всех.
- node-exporter — метрики хоста (load avg, disk usage, NIC).
- Airflow / ClickHouse — application metrics (DAG-run-count, query-duration, и т.д.).
Шаг 2: Grafana
services:
grafana:
image: grafana/grafana:11.3.1
container_name: grafana
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: 'false'
volumes:
- grafana-data:/var/lib/grafana
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
depends_on:
- prometheus
networks:
- obs-net
volumes:
grafana-data:
Provisioning datasource через файл ./observability/grafana/provisioning/datasources/prometheus.yml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
access: proxy
isDefault: true
- name: Loki
type: loki
url: http://loki:3100
access: proxy
- name: Tempo
type: tempo
url: http://tempo:3200
access: proxy
При старте Grafana прочитает этот файл и автоматически создаст data sources — ничего руками настраивать не нужно.
Доступ: http://localhost:3000, login admin/admin.
Шаг 3: Loki + Promtail для логов
services:
loki:
image: grafana/loki:3.3.1
container_name: loki
ports:
- "3100:3100"
volumes:
- ./observability/loki-config.yaml:/etc/loki/local-config.yaml:ro
- loki-data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- obs-net
promtail:
image: grafana/promtail:3.3.1
container_name: promtail
volumes:
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./observability/promtail-config.yaml:/etc/promtail/config.yml:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
networks:
- obs-net
volumes:
loki-data:
promtail-config.yaml:
server:
http_listen_port: 9080
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'stream'
Promtail отписывается на Docker events через docker.sock и автоматически собирает логи всех контейнеров. В Loki они приходят с label’ами container=..., stream=stdout|stderr.
В Grafana -> Explore -> Loki:
{container="airflow-scheduler"} |~ "ERROR"
Найдёт все ERROR-логи scheduler’а за последний час.
Loki НЕ индексирует содержимое логов (как ElasticSearch). Индексируется только labels (container, stream, namespace). Поэтому поиск по тексту — линейный scan, но запись и storage в десятки раз дешевле. Стратегия: label = metadata, |~ “regex” — для поиска.
Шаг 4: Tempo для traces
services:
tempo:
image: grafana/tempo:2.6.1
container_name: tempo
ports:
- "3200:3200" # query
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./observability/tempo-config.yaml:/etc/tempo.yaml:ro
- tempo-data:/tmp/tempo
command: ["-config.file=/etc/tempo.yaml"]
networks:
- obs-net
volumes:
tempo-data:
tempo-config.yaml:
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
storage:
trace:
backend: local
local:
path: /tmp/tempo/traces
wal:
path: /tmp/tempo/wal
Tempo принимает spans через OTLP (OpenTelemetry Protocol). Твой Python-код может слать spans:
# pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://tempo:4317", insecure=True)))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("my-etl")
with tracer.start_as_current_span("etl-job") as span:
span.set_attribute("rows_processed", 100000)
# ... твой код
В Grafana traces ищутся по trace ID или service name.
Application metrics: prometheus_client в Python
В твоём ETL-коде хочешь видеть бизнес-метрики (rows-processed, lag, error count):
# pip install prometheus_client
from prometheus_client import Counter, Histogram, Gauge, start_http_server
ROWS_PROCESSED = Counter('etl_rows_processed_total', 'Total rows processed', ['job_name'])
JOB_DURATION = Histogram('etl_job_duration_seconds', 'ETL job duration', ['job_name'])
ACTIVE_WORKERS = Gauge('etl_active_workers', 'Active worker count')
# Запускаем HTTP-сервер на 9000 для Prometheus scrape
start_http_server(9000)
def etl_job():
with JOB_DURATION.labels(job_name='users').time():
ACTIVE_WORKERS.inc()
try:
rows = process_users()
ROWS_PROCESSED.labels(job_name='users').inc(rows)
finally:
ACTIVE_WORKERS.dec()
В Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 9000
CMD ["python", "main.py"]
В Prometheus config:
- job_name: 'etl-app'
static_configs:
- targets: ['my-etl:9000']
Теперь в Grafana:
rate(etl_rows_processed_total[5m])
— покажет rows/sec в реальном времени.
Дашборды: что важно мониторить
Для DE-стека минимальный дашборд должен показывать:
-
Container resources (CPU, memory) — из cAdvisor. Шаблон 14282 в grafana.com — готовый дашборд.
-
Host resources (disk, network) — из node-exporter. Шаблон 1860.
-
Application metrics — сколько rows прошло, lag consumer’а, время DAG-run’а.
-
Logs — count ERROR за последний час, top sources of error.
-
Alerts — через Grafana Alerting:
- memory > 90% за 5 min
- DAG run failed
- Kafka consumer lag > 10000
ВНИМАНИЕ: observability-stack сам потребляет ресурсы. Prometheus с 15-day retention на 10 контейнеров: ~5GB диска. Loki с логами: 10-30GB/неделя. На маленьком стенде это сравнимо с самой data-нагрузкой. Используй retention настройки агрессивно.
Полный compose-overlay
# compose.observability.yml -- запускается поверх основного стека
services:
prometheus:
image: prom/prometheus:v3.0.1
profiles: ["observability"]
ports: ["9090:9090"]
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
networks: [de-net]
grafana:
image: grafana/grafana:11.3.1
profiles: ["observability"]
ports: ["3000:3000"]
volumes:
- grafana-data:/var/lib/grafana
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
networks: [de-net]
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
profiles: ["observability"]
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
privileged: true
networks: [de-net]
loki:
image: grafana/loki:3.3.1
profiles: ["observability"]
networks: [de-net]
promtail:
image: grafana/promtail:3.3.1
profiles: ["observability"]
volumes:
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [de-net]
volumes:
grafana-data:
networks:
de-net:
external: true
Поднимать:
docker compose --profile observability up -d
# Стек с Airflow + observability одновременно
# Только основной (без observability)
docker compose up -d
# Только observability (если основной стек уже live)
docker compose --profile observability up -d
Попробуй сам
# 1. Создай ./observability/prometheus.yml с минимальной конфой
mkdir -p observability
cat > observability/prometheus.yml <<EOF
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: [localhost:9090]
- job_name: cadvisor
static_configs:
- targets: [cadvisor:8080]
EOF
# 2. Подними небольшую часть
docker compose -f compose.observability.yml --profile observability up -d prometheus grafana cadvisor
# 3. Открой Prometheus
open http://localhost:9090
# Status -> Targets -- должны быть UP
# 4. Открой Grafana
open http://localhost:3000 # admin/admin
# 5. Импортируй дашборд:
# Dashboards -> New -> Import -> 14282 (cAdvisor exporter)
# 6. Симулируй нагрузку:
docker run --rm -d --name stress alpine sh -c "yes > /dev/null"
# 7. В дашборде увидишь, как растёт CPU stress-контейнера
# 8. Cleanup
docker rm -f stress
docker compose -f compose.observability.yml --profile observability down -v