Для сохранения трейсов в тестовой среде будем устанавливать контейнер jaeger в варианте "всё-в-одном".
В этом случае трейсы хранятся во внутренней базе данных в
оперативной памяти. Количество хранимых трейсов не
ограничено. Но можно поставить ограничение при помощи параметра --memory.max-traces
.
В файле docker-compose.yaml добавим запуск контейнера jaeger.
jaegertracing:
image: jaegertracing/all-in-one:1.40.0
cpu_count: 2
environment:
COLLECTOR_OTLP_ENABLED: true
ports:
# - "5775:5775/udp" # Agent - deprecated; only used by very old Jaeger clients
- "6831:6831/udp" # Agent - Thrift protocol used by most current Jaeger clients
- "6832:6832/udp" # Agent - protocol for Node.js Jaeger client
- "5778:5778" # Agent - serve configs, sampling strategies
- "16686:16686" # Web UI
- "14268:14268" # Collector - HTTP can accept spans directly from clients in jaeger.thrift format over binary thrift protocol
- "14250:14250" # Collector - used by jaeger-agent to send spans in model.proto format
- "4317:4317" # Collector - OTLP gRPC
- "4318:4318" # Collector - OTLP HTTP
Разрешим поддержку протокола otlp в коллекторе при помощи переменной среды окружения COLLECTOR_OTLP_ENABLED: true
.
Набор публикуемых портов включает в себя порты агента, коллектора и jaeger-query.
Доступ к WEB интерфейсу jaeger http://127.0.0.1:16686.
Поскольку перед нашим приложением установлен nginx, хотелось бы включать в трейсы запросы начиная с самого входа. Т.е. учитывать задержки, возникающие на прокси.
На данный момент трейсинг в Nginx реализуется при помощи модуля opentelemetry-cpp-contrib. Документация по проекту отвратительная. Но попробуем его включить.
Поддерживается только OTLP HTTP, т.е. придется подключаться непосредственно к коллектору.
В базовый контейнер nginx модуль трассировки не включён, поэтому придётся создавать свой контейнер. Для этого будем использовать соответствующий Dockerfile.
Метод, используемый в Dockerfile работает только с nginx версий 1.22.0, 1.23.0, 1.23.1. И только с архитектурой процессоров Intel. На M1 даже не пытайтесь :). Подробности смотрите тут.
Если кратко, то при сборке контейнера используется архив с заранее скомпилированными модулями для определённых версий nginx для архитектуры процессоров Intel. Если возникнет необходимость для сборки контейнера для других версий и архитектур, придётся писать полноценную процедуру сборку nginx.
Запуск nginx реализован следующим образом:
nginx:
image: application_otlp_nginx:1.23.1
build:
context: nginx
ports:
- "8080:80"
volumes:
- ${PWD}/nginx/nginx.conf:/etc/nginx/nginx.conf
- ${PWD}/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
- ${PWD}/nginx/opentelemetry_module.conf:/etc/nginx/conf.d/opentelemetry_module.conf
Включение модуля opentelemetry добавлено в конфигурационный файл nginx.conf
load_module /opt/opentelemetry-webserver-sdk/WebServerModule/Nginx/1.23.1/ngx_http_opentelemetry_module.so;
Обратите внимание на путь к модулю. Там устанавливается несколько вариантов под разные версии nginx.
Так же необходимо подготовить конфигурационный файл opentelemetry_module.conf.
NginxModuleEnabled ON;
NginxModuleOtelSpanExporter otlp;
NginxModuleOtelExporterEndpoint jaegertracing:4317;
NginxModuleServiceName nginx;
NginxModuleServiceNamespace NginxNamespace;
NginxModuleServiceInstanceId Nginx;
NginxModuleResolveBackends ON;
NginxModuleTraceAsError ON;
- Включаем трейсы:
NginxModuleEnabled ON
- Явно указываем какой экспортер будет использоваться:
NginxModuleOtelSpanExporter otlp
- Определяем адрес и порт, куда будут отсылаться span:
NginxModuleOtelExporterEndpoint jaegertracing:4317
. Смотрите определение портов контейнера jaeger. - Определяем имя сервиса, которое будет показываться в span, отсылаемых nginx:
NginxModuleServiceName nginx
В общем проект забавный. Но радует то, что в nginx ingress controller все включено по умолчанию.
Внесём некоторые дополнения в ранее написанное приложение.
Сначала определим базовые параметры:
resource = Resource(attributes={
SERVICE_NAME: "application1"
})
Установим имя сервиса: SERVICE_NAME: "application1"
. В дальнейшем все span будут содержать это имя. Имя сервиса можно
не определять в коде, а воспользоваться переменной среды окружения OTEL_SERVICE_NAME=application1
.
Попытаемся написать "универсальное" приложение, которое умеет отсылать спаны используя разные протоколы. Определить протокол, а следовательно используемый экспортёр, можно при помощи переменных среды окружения.
Это не идеальный вариант. И в дальнейшем мы столкнемся с проблемой конфигурации приложения. По-хорошему, необходимо добавить возможность конфигурации при помощи конфигурационного файла и аргументов командной строки.
def select_processor() -> SpanExporter:
if "OTEL_EXPORTER_OTLP_ENDPOINT" in os.environ:
return OTLPSpanExporter()
elif "OTEL_EXPORTER_JAEGER_AGENT_HOST" in os.environ
and "OTEL_EXPORTER_JAEGER_AGENT_PORT" in os.environ:
return JaegerExporter()
print("Not set exporter in env variable OTEL_EXPORTER_OTLP_ENDPOINT or"
+ " OTEL_EXPORTER_JAEGER_AGENT_HOST| OTEL_EXPORTER_JAEGER_AGENT_PORT]", file=sys.stderr)
exit(1)
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(select_processor())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
Поскольку планируется использовать Jaeger-agent, файле docker-compose.yaml
определим следующие переменные среды
окружения при запуске контейнера:
OTEL_EXPORTER_JAEGER_AGENT_HOST: "jaegertracing"
OTEL_EXPORTER_JAEGER_AGENT_PORT: 6831
В итоге мы получаем объект tracer
, который будем использовать для формирования span.
Добавим трассировку в функцию root().
@app.route("/")
@app.route("/index.html")
def root():
with tracer.start_as_current_span(
"/",
context=extract(request.headers),
kind=trace.SpanKind.SERVER,
attributes=collect_request_attributes(request.environ)
) as span:
span.set_attribute("function", "root")
span.set_attribute(SpanAttributes.HTTP_METHOD, "GET")
span.add_event("The root method")
# Посмотрим заголовки
span.add_event(f"Headers \n {request.headers}")
return render_template("index.html")
tracer.start_as_current_span
- Создаёт span. Обязательным параметром при определении span является только его имя. ё
В нашем случае это будет путь в запросе.
kind
- заранее определенные константы. Как я заметил, на практике их используют крайне редко. Но мы на всякий
случай обозначим наш спан, как созданный на сервере, а не на клиенте.
attributes
можно определить сразу, можно добавить позднее при помощи функции set_attribute
.
Очень важный фрагмент кода:
context = extract(request.headers)
Он отвечает за "связывание" нескольких span в один трейс.
Как я писал раньше, трейс объединяет между собой несколько спанов. Каждый трейс имеет уникальный id.
Что-то типа: 00-23af8b3bbcab3eec236f11d649d3a516-f5c4561d7e9d3764-01
.
При формировании span можно явным образом указать к какому trace_id он относится. Если необходимо связать несколько
span из различных приложений в один трейс, в нашем случае nginx и application1. Мы должны каким то образом передать
trace_id из nginx в aplication1. В случае HTTP запросов, id помещают в заголовок (header) запроса. Модуль nginx
при передаче запроса в application1 так и поступает. Нам остаётся получить из заголовков запроса этот id и поместить
его в контекст span. За получения id из запроса отвечает функция extract
.
Если при создании спан не указывать trace_id, создаётся новый трейс. Т.е. генерируется новый trace_id и добавляется в существующий спан.
Таким образом, наш span будет соотнесён с трейсом, который начался при обращении пользователя к nginx.
add_event("The root method")
- добавляем в span событие. В терминах jaeger - лог.
span.add_event(f"Headers \n {request.headers}")
- добавляем в лог span ещё одно событие. Тут мог быть, например
запрос к БД. Ну а так, просто смотрим какие заголовки мы получили от предыдущего приложения.
В данном примере tracer.start_as_current_span
вызывался внутри оператора with
, поэтому он будет закрыт автоматически
после завершения работы функции root()
.
Дополнения в функции base().
# Добавим информацию о нашем трейсе в запрос ко второму сервису
headers = {}
TraceContextTextMapPropagator().inject(headers)
# Пошлем запрос во второе приложение
resp = requests.get(f"{os.getenv('APP2')}/api/v1/data", headers=headers)
В этой функции span формируется аналогично функции root()
. Но внутри мы посылаем HTTP запрос к application2 и неплохо
бы добавить в заголовок этого запроса trace_id текущего спан, что бы связать в одном трейсе все три приложения:
nginx, application1 и application2.
Делается это при помощи функции TraceContextTextMapPropagator().inject(headers)
. В дальнейшем эти заголовки
передаются при вызове requests.get
. В application2 мы их получим и добавим span приложения к этому трейсу.
В этом приложении у нас только одна функция эмулирующая запрос к базе данных. В ней есть две рандомных задержки. И два span.
На примере второго span показано как можно создать дочерний span. Он формируется такой же функцией, как и родительский.
@app.route("/api/v1/data")
def db_request_emulation():
with tracer.start_as_current_span(
"/api/v1/data",
context=extract(request.headers),
kind=trace.SpanKind.SERVER,
attributes=collect_request_attributes(request.environ)
) as span:
span.set_attribute("function", "db_request_emulation")
span.set_attribute(SpanAttributes.HTTP_METHOD, "GET")
# Посмотрим заголовки
span.add_event(f"Headers \n {request.headers}")
# generate 1-st delay and value
delay: float = random.uniform(0.1, 0.9)
span.add_event(f"1-s request, delay - {delay}")
time.sleep(delay)
data[0]['value'] = delay
with tracer.start_as_current_span("/api/v1/data sub_request") as rspan:
# generate 2-nd delay and value
delay: float = random.uniform(0.1, 0.9)
rspan.add_event(f"2-d request, delay - {delay}")
time.sleep(delay)
data[1]['value'] = delay
return jsonify({'data': data})
Запустите приложение при помощи docker-compose.
Сделайте пару запросов к приложению на http://120.0.0.1:8080/. Нажмите на внутреннюю ссылку.
Затем посмотрите на трейсы, который стали доступны в WEB интерфейсе jaeger - http://127.0.0.1:16686.