diff --git a/RukosuevaED/alloy/config.alloy b/RukosuevaED/alloy/config.alloy new file mode 100644 index 0000000..b50fc66 --- /dev/null +++ b/RukosuevaED/alloy/config.alloy @@ -0,0 +1,42 @@ +prometheus.scrape "app" { + targets = [ + {"__address__" = "app:8000"}, + ] + metrics_path = "/metrics" + scrape_interval = "5s" + scrape_timeout = "3s" + forward_to = [prometheus.remote_write.mimir.receiver] +} + +prometheus.remote_write "mimir" { + endpoint { + url = "http://mimir:9009/api/v1/push" + } +} + +discovery.docker "containers" { + host = "unix:///var/run/docker.sock" +} + +discovery.relabel "containers" { + targets = discovery.docker.containers.targets + + rule { + source_labels = ["__meta_docker_container_name"] + regex = "/(.*)" + target_label = "container" + } +} + +loki.source.docker "default" { + host = "unix:///var/run/docker.sock" + targets = discovery.relabel.containers.output + relabel_rules = discovery.relabel.containers.rules + forward_to = [loki.write.default.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} diff --git a/RukosuevaED/app/Dockerfile b/RukosuevaED/app/Dockerfile new file mode 100644 index 0000000..18ed63f --- /dev/null +++ b/RukosuevaED/app/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/RukosuevaED/app/main.py b/RukosuevaED/app/main.py new file mode 100644 index 0000000..faf4274 --- /dev/null +++ b/RukosuevaED/app/main.py @@ -0,0 +1,136 @@ +import logging +import sys +import time +import uuid +from itertools import count + +from fastapi import FastAPI, HTTPException, Request +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest +from pydantic import BaseModel + + +class JsonLogFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for key in ("method", "path", "status_code", "duration_ms", "request_id"): + if hasattr(record, key): + payload[key] = getattr(record, key) + return str(payload).replace("'", '"') + + +def configure_logging() -> logging.Logger: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonLogFormatter()) + + logger = logging.getLogger("notes_app") + logger.setLevel(logging.INFO) + logger.addHandler(handler) + logger.propagate = False + return logger + + +logger = configure_logging() + +app = FastAPI(title="Notes API") + +REQUEST_COUNT = Counter( + "app_requests_total", + "Total number of HTTP requests received by the application", + ["method", "path", "status_code"], +) +REQUEST_LATENCY = Histogram( + "app_request_duration_seconds", + "Duration of HTTP requests in seconds", + ["method", "path"], +) + + +@app.middleware("http") +async def metrics_and_logging_middleware(request: Request, call_next): + request_id = str(uuid.uuid4()) + start = time.perf_counter() + + response = await call_next(request) + + duration = time.perf_counter() - start + route = request.scope.get("route") + path_template = route.path if route is not None else request.url.path + + REQUEST_COUNT.labels( + method=request.method, + path=path_template, + status_code=response.status_code, + ).inc() + REQUEST_LATENCY.labels(method=request.method, path=path_template).observe(duration) + + logger.info( + "request completed", + extra={ + "method": request.method, + "path": path_template, + "status_code": response.status_code, + "duration_ms": round(duration * 1000, 2), + "request_id": request_id, + }, + ) + response.headers["X-Request-ID"] = request_id + return response + + +class NoteIn(BaseModel): + title: str + content: str + + +class Note(NoteIn): + id: int + + +_notes: dict[int, Note] = {} +_id_counter = count(1) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/metrics") +def metrics(): + from starlette.responses import Response + + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + + +@app.get("/notes", response_model=list[Note]) +def list_notes() -> list[Note]: + return list(_notes.values()) + + +@app.post("/notes", response_model=Note, status_code=201) +def create_note(note_in: NoteIn) -> Note: + note_id = next(_id_counter) + note = Note(id=note_id, **note_in.model_dump()) + _notes[note_id] = note + logger.info("note created", extra={"path": "/notes", "method": "POST"}) + return note + + +@app.get("/notes/{note_id}", response_model=Note) +def get_note(note_id: int) -> Note: + note = _notes.get(note_id) + if note is None: + raise HTTPException(status_code=404, detail="Note not found") + return note + + +@app.delete("/notes/{note_id}", status_code=204) +def delete_note(note_id: int) -> None: + if note_id not in _notes: + raise HTTPException(status_code=404, detail="Note not found") + del _notes[note_id] diff --git a/RukosuevaED/app/requirements.txt b/RukosuevaED/app/requirements.txt new file mode 100644 index 0000000..a6a93bd --- /dev/null +++ b/RukosuevaED/app/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +prometheus-client==0.20.0 diff --git a/RukosuevaED/docker-compose.yml b/RukosuevaED/docker-compose.yml new file mode 100644 index 0000000..5292222 --- /dev/null +++ b/RukosuevaED/docker-compose.yml @@ -0,0 +1,70 @@ +services: + app: + build: ./app + container_name: notes-app + ports: + - "8000:8000" + restart: unless-stopped + + mimir: + image: grafana/mimir:2.14.1 + container_name: mimir + command: ["-config.file=/etc/mimir/mimir.yaml"] + volumes: + - ./mimir/mimir.yaml:/etc/mimir/mimir.yaml:ro + - mimir-data:/data + ports: + - "9009:9009" + restart: unless-stopped + + loki: + image: grafana/loki:3.2.1 + container_name: loki + command: ["-config.file=/etc/loki/loki-config.yaml"] + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki-data:/loki + ports: + - "3100:3100" + restart: unless-stopped + + alloy: + image: grafana/alloy:v1.4.3 + container_name: alloy + command: + - "run" + - "--server.http.listen-addr=0.0.0.0:12345" + - "/etc/alloy/config.alloy" + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - "12345:12345" + depends_on: + - app + - loki + - mimir + restart: unless-stopped + + grafana: + image: grafana/grafana:11.2.2 + container_name: grafana + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + depends_on: + - loki + - mimir + restart: unless-stopped + +volumes: + mimir-data: + loki-data: + grafana-data: diff --git a/RukosuevaED/grafana/dashboards/notes-app.json b/RukosuevaED/grafana/dashboards/notes-app.json new file mode 100644 index 0000000..612bb31 --- /dev/null +++ b/RukosuevaED/grafana/dashboards/notes-app.json @@ -0,0 +1,63 @@ +{ + "title": "Notes App", + "uid": "notes-app", + "timezone": "browser", + "schemaVersion": 39, + "refresh": "10s", + "time": { + "from": "now-15m", + "to": "now" + }, + "panels": [ + { + "id": 1, + "title": "Requests per second", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "mimir" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum by (path, status_code) (rate(app_requests_total[1m]))", + "legendFormat": "{{method}} {{path}} {{status_code}}" + } + ] + }, + { + "id": 2, + "title": "Request duration (p95)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "mimir" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, path) (rate(app_request_duration_seconds_bucket[5m])))", + "legendFormat": "{{path}}" + } + ] + }, + { + "id": 3, + "title": "Total requests", + "type": "stat", + "datasource": { "type": "prometheus", "uid": "mimir" }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "sum(app_requests_total)" + } + ] + }, + { + "id": 4, + "title": "Application logs", + "type": "logs", + "datasource": { "type": "loki", "uid": "loki" }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 12 }, + "targets": [ + { + "expr": "{container=\"notes-app\"}" + } + ] + } + ] +} diff --git a/RukosuevaED/grafana/provisioning/dashboards/dashboards.yaml b/RukosuevaED/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000..d056b37 --- /dev/null +++ b/RukosuevaED/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: default + folder: "" + type: file + updateIntervalSeconds: 30 + options: + path: /etc/grafana/dashboards diff --git a/RukosuevaED/grafana/provisioning/datasources/datasources.yaml b/RukosuevaED/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000..13e8649 --- /dev/null +++ b/RukosuevaED/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,17 @@ +apiVersion: 1 + +datasources: + - name: Mimir + uid: mimir + type: prometheus + access: proxy + url: http://mimir:9009/prometheus + isDefault: true + jsonData: + httpMethod: POST + + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:3100 diff --git a/RukosuevaED/loki/loki-config.yaml b/RukosuevaED/loki/loki-config.yaml new file mode 100644 index 0000000..8f518b4 --- /dev/null +++ b/RukosuevaED/loki/loki-config.yaml @@ -0,0 +1,28 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + allow_structured_metadata: true diff --git a/RukosuevaED/mimir/mimir.yaml b/RukosuevaED/mimir/mimir.yaml new file mode 100644 index 0000000..62e8c83 --- /dev/null +++ b/RukosuevaED/mimir/mimir.yaml @@ -0,0 +1,40 @@ +multitenancy_enabled: false + +blocks_storage: + backend: filesystem + filesystem: + dir: /data/blocks + bucket_store: + sync_dir: /data/tsdb-sync + tsdb: + dir: /data/tsdb + +compactor: + data_dir: /data/compactor + sharding_ring: + kvstore: + store: inmemory + +distributor: + ring: + kvstore: + store: inmemory + +ingester: + ring: + kvstore: + store: inmemory + replication_factor: 1 + +ruler_storage: + backend: filesystem + filesystem: + dir: /data/ruler + +store_gateway: + sharding_ring: + kvstore: + store: inmemory + +server: + http_listen_port: 9009 diff --git a/RukosuevaED/readme.md b/RukosuevaED/readme.md new file mode 100644 index 0000000..7ddcde5 --- /dev/null +++ b/RukosuevaED/readme.md @@ -0,0 +1,74 @@ +# Задание 107. Мониторинг + +## Описание + +Микросервис `Notes API` (FastAPI) для хранения заметок в памяти, обвязанный стеком +централизованного сбора логов и метрик: + +- **Grafana Alloy** — собирает логи Docker-контейнеров и метрики приложения; +- **Loki** — хранилище логов; +- **Grafana Mimir** — хранилище метрик (Prometheus remote_write); +- **Grafana** — визуализация логов и метрик в едином дашборде. + +## Приложение + +`app/main.py` — REST API для заметок: + +- `GET /notes`, `POST /notes`, `GET /notes/{id}`, `DELETE /notes/{id}` +- `GET /health` — проверка живости +- `GET /metrics` — метрики в формате Prometheus + +Кастомные метрики: + +- `app_requests_total{method, path, status_code}` — счётчик HTTP-запросов; +- `app_request_duration_seconds{method, path}` — гистограмма длительности запросов. + +Каждый запрос также логируется в stdout в формате JSON (метод, путь, код ответа, +длительность, request id) — эти логи забирает Alloy и отправляет в Loki. + +## Как это работает + +1. `app` отдаёт метрики на `/metrics` и пишет логи в stdout. +2. `alloy`: + - скрейпит `/metrics` приложения и пушит метрики в Mimir (`remote_write`); + - через Docker discovery читает логи всех контейнеров и пушит их в Loki. +3. `mimir` и `loki` хранят метрики и логи на диске (filesystem storage, том Docker). +4. `grafana` подключается к Mimir и Loki как к datasource'ам (автоматически, через + provisioning) и показывает дашборд `Notes App` с графиками RPS, latency (p95), + счётчиком запросов и панелью логов приложения. + +## Запуск + +```bash +docker compose up --build +``` + +После запуска доступны: + +- приложение — http://localhost:8000 (Swagger UI — http://localhost:8000/docs) +- метрики приложения — http://localhost:8000/metrics +- Grafana — http://localhost:3000 (анонимный доступ с правами admin, дашборд + "Notes App" открывается сразу после входа) +- Loki API — http://localhost:3100 +- Mimir API — http://localhost:9009 + +Чтобы в дашборде появились данные, нужно сделать несколько запросов к API, например: + +```bash +curl -X POST http://localhost:8000/notes -H "Content-Type: application/json" \ + -d '{"title": "test", "content": "hello"}' +curl http://localhost:8000/notes +``` + +## Структура проекта + +``` +RukosuevaED/ +├── app/ # исходники и Dockerfile приложения +├── alloy/ # конфигурация Grafana Alloy (сбор логов и метрик) +├── loki/ # конфигурация Loki +├── mimir/ # конфигурация Grafana Mimir +├── grafana/ # provisioning datasource'ов и готовый дашборд +├── docker-compose.yml +└── readme.md +```