Перейти к основному содержимому

Шаг 11. Loki и Promtail

Grafana Loki — это система для сбора, хранения и анализа логов, которая использует методы индексации на основе меток и предоставляет возможности запросов и визуализации логов облачных серверов через веб-интерфейс Grafana.

Loki был спроектирован с целью упрощения реализации в соответствии со следующими принципами:

  • быть простым для старта;
  • потреблять мало ресурсов;
  • работать самостоятельно без какого-либо специального обслуживания;
  • служить дополнением к Prometheus для помощи в расследовании багов.

Однако эта простота достигается за счет некоторых компромиссов. Один из них — не индексировать контент. Поэтому поиск по тексту не очень эффективен или богат и не позволяет вести статистику по содержимому текста. Но поскольку Loki хочет быть эквивалентом grep и дополнением к Prometheus, то это не является недостатком.

Согласно официальной документации, у Loki есть разные инструменты для сборки журналов. Один из инструментов — это Promtail. Самый простой способ отправлять журналы в Loki из обычных текстовых файлов (например, из файлов, которые регистрируются в /var/log/*.log). Promtail это делает хорошо! Этим мы и воспользуемся.

Реализуем следующую схему:

  • Приложение на Python будет писать логи в директорию /log в файлы *.log. Эта директорию будет примонтированным томом Docker app-log.
  • Поднимем контейнер с Promtail, к которому примонтируем томом Docker app-log. Он Promtail будет собирать журналы из примонтированной директории и отправлять в Loki.
  • Loki также будет поднят в отдельном контейнере.
  • В завершении этого, мы укажем источники datasources у Grafana, чтобы мы могли работать с данными из Jaeger, Prometheus и Loki.

Начнем с того, что уточним структуру нашей директории:

observability
├── app
│ ├── .gitignore
│ ├── log
│ │ └── .gitkeep
│ ├── app.py
│ ├── Dockerfile
│ └── requirements.txt
├── grafana
│ └── grafana.yml
├── prometheus
│ └── prometheus.yml
├── promtail
│ └── promtail.yaml
├── .gitignore
└── docker-compose.yaml

Приступим к адаптации нашего приложения к извлечению журнала.

app/app.py будет выглядеть следующим образом:

import os
import logging
from logging.config import dictConfig

from typing import Iterable
from prometheus_client import generate_latest
from flask import Flask
from random import randint


from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.metrics import Observation, CallbackOptions
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.resources import SERVICE_NAME, Resource


app = Flask(__name__)


def do_roll():
with tracer.start_as_current_span("do_roll"):
res = randint(1, 7)
current_span = trace.get_current_span()
current_span.set_attribute("roll.value", res)
current_span.add_event("This is a span event")
return res


def do_important_job():
with tracer.start_as_current_span("do_important_job"):
randint(1, 10000)


@app.route("/rolldice")
def roll_dice():
request_counter.add(1)
result = do_roll()
do_important_job()
if (result < 0 or result > 6):
logging.getLogger().error("An incorrect number was received on the dice!")
return 'something went wrong!', 500
return str(result)


@app.route('/metrics')
def get_metrics():
return generate_latest()


def cpu_time_callback(options: CallbackOptions) -> Iterable[Observation]:
observations = []
with open("/proc/stat") as procstat:
procstat.readline() # skip the first line
for line in procstat:
if not line.startswith("cpu"):
break
cpu, *states = line.split()
observations.append(Observation(
int(states[0]) // 100, {"cpu": cpu, "state": "user"}))
observations.append(Observation(
int(states[1]) // 100, {"cpu": cpu, "state": "system"}))
return observations


def init_traces(resource):
tracer_provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(OTLPSpanExporter(
endpoint=os.environ.get('TRACE_ENDPOINT', "http://localhost:4317")))
tracer_provider.add_span_processor(processor)
trace.set_tracer_provider(tracer_provider)
tracer = trace.get_tracer(__name__)
return tracer


def init_metrics(resource):
metric_reader = PrometheusMetricReader()
meter_provider = MeterProvider(
resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)

meter = metrics.get_meter_provider().get_meter(__name__)
request_counter = meter.create_counter(
name="request_counter", description="Number of requests", unit="1")
meter.create_observable_counter(
"system.cpu.time",
callbacks=[cpu_time_callback],
unit="s",
description="CPU time"
)
return request_counter


def init_logs():
LoggingInstrumentor().instrument(set_logging_format=True)
dictConfig({
'version': 1,
'formatters': {'default': {
'format': '%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s resource.service.name=%(otelServiceName)s trace_sampled=%(otelTraceSampled)s] - %(message)s',
}},
'handlers': {
'wsgi': {
'class': 'logging.StreamHandler',
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
},
"file": {
"class": "logging.FileHandler",
"filename": "log/flask.log",
"formatter": "default",
}},
'root': {
'level': 'INFO',
'handlers': ['wsgi', 'file']
}
})


resource = Resource.create({SERVICE_NAME: os.environ.get(
'APP_SERVICE_NAME', "my-python-service")})
tracer = init_traces(resource)
request_counter = init_metrics(resource)
init_logs()

FlaskInstrumentor().instrument_app(app)

if __name__ == "__main__":
host = os.environ.get('APP_HOST_NAME', "0.0.0.0")
port = int(os.environ.get('APP_PORT', 5000))
app.run(host=host, port=port)

Здесь мы уточнили то, куда необходимо собирать логи, а именно в файл log/flask.log.

Файл app/log/.gitkeep нам нужен для того, чтобы директория log была уже создана и могли записываться туда журналы при локальной отладке.

Нам также следует обновить app/Dockerfile:

FROM python:3.12-slim-bookworm
COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install -r requirements.txt
RUN mkdir -p /app/log
COPY app.py /app
EXPOSE 5000
CMD ["python", "app.py" ]

Файл promtail/promtail.yaml описывает то, как мы будем осуществлять сборку журналов и куда будем отправлять их:

server:
http_listen_port: 9080
grpc_listen_port: 0

positions:
filename: /tmp/positions.yaml

clients:
- url: http://iu5devops-loki:3100/loki/api/v1/push

scrape_configs:
- job_name: flask-log-scraper
static_configs:
- targets:
- localhost
labels:
__path__: "/log/*.log"
app: app

Файл grafana/grafana.yml описывает то, как источники данных у нас имеются:

apiVersion: 1

datasources:
- name: Loki
type: loki
uid: iu5devops-loki
access: proxy
url: http://iu5devops-loki:3100
basicAuth: false
isDefault: false
version: 1
editable: true
jsonData:
derivedFields:
- datasourceUid: iu5devops-jaeger
name: JaegerTraceID
matcherRegex: trace_id=(\w+)
url: '$${__value.raw}'
- name: TraceID
matcherRegex: trace_id=(\w+)
url: 'http://localhost:16686/trace/$${__value.raw}'
- name: Prometheus
type: prometheus
access: proxy
uid: iu5devops-prometheus
url: http://iu5devops-prometheus:9090
- name: Jaeger
type: jaeger
uid: iu5devops-jaeger
url: http://iu5devops-jaeger:16686
access: proxy
readOnly: false
isDefault: false

Подробная информация о том, как этот файл описывать, содержится в официальной документации:

В завершении этого доработаем нашу конфигурацию в docker-compose.yaml:

services:
app:
image: iu5devops/app
build:
context: ./app
dockerfile: Dockerfile
container_name: iu5devops-app
networks:
- iu5devops
ports:
- 8080:5000
environment:
- FLASK_DEBUG=1
- APP_SERVICE_NAME=iu5devops-app
- TRACE_ENDPOINT=http://iu5devops-jaeger:4317
- OTEL_PYTHON_LOG_CORRELATION=true
- OTEL_PYTHON_LOG_LEVEL=info
volumes:
- app-log:/app/log


jaeger:
image: jaegertracing/all-in-one
container_name: iu5devops-jaeger
networks:
- iu5devops
ports:
- 16686:16686

prometheus:
image: prom/prometheus
container_name: iu5devops-prometheus
networks:
- iu5devops
ports:
- 9090:9090
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

grafana:
image: grafana/grafana
container_name: iu5devops-grafana
networks:
- iu5devops
ports:
- 4000:3000
volumes:
- grafana-storage:/var/lib/grafana
- ./grafana/grafana.yml:/etc/grafana/provisioning/datasources/datasources.yaml

loki:
image: grafana/loki:latest
container_name: iu5devops-loki
networks:
- iu5devops
ports:
- 3100:3100
command: -config.file=/etc/loki/local-config.yaml

promtail:
image: grafana/promtail:latest
container_name: iu5devops-promtail
depends_on:
- loki
networks:
- iu5devops
volumes:
- app-log:/log
- ./promtail/promtail.yaml:/etc/promtail/docker-config.yaml
command: -config.file=/etc/promtail/docker-config.yaml

volumes:
grafana-storage:
name: iu5devops-grafana-storage
prometheus-data:
name: iu5devops-prometheus-data
app-log:
name: iu5devops-app-log

networks:
iu5devops:
name: iu5devops

Здесь мы добавили конкретные имена томам, подключили том для журналирования к приложению, и описали как поднимать контейнеры с Promtail и Loki.

к сведению

Как уже ранее упоминалось, что контейнер с Jaeger основывается на образе jaegertracing/all-in-one для демонстрации работы с MLT. Для хранения данных используются такие СУБД, как ClickHouse и Cassandra. Рассмотрение использования данных хранилищ выходит за рамки данного пособия.

Чтобы полюбоваться всем великолепием, мы должны переподнять Docker compose:

docker compose down
docker compose build
docker compose up -d