Проблема

API стал получать всё больше и больше нагрузки, необходимо было проводить анализ работы, и после оптимизировать работу большого backend'а. Обычно процесс оптимизации типичного backend'а на php включает:

  • оптимизации SQL-запросов в БД;

  • оптимизации работы с кэшем (повышение hitrate, увеличение объёма кэшируемых данных);

  • оптимизация внутренних процессов в backend (вынесение тяжёлых процессов в асинхронный режим, оптимизация внутренних структур данных)

Всё это уже было проведено, но не давало достаточного эффекта - API был большим, в нём было много сервисов с разной логикой, сложностью и связями. Точечные улучшения давали эффект, но было желание посмотреть на весь backend сверху - оценить потоки данных, оценить создаваемую нагрузку на каждый из внешних хранилищ (БД, кэш, сервисы) и оптимизировать исходя из полученных данных.

Уже существующие реализации APM (newrelic, datadog, elastic apm agent, dynatrace, open tracing) были рассмотрены, но не были выбраны так как подразумевалось подключать в проект внешние расширения php, покупать лицензию и/или поднимать в инфраструктуре дополнительные узлы.

Соглашусь, что для целей APM больше подходит не анализ логов, а отдельное хранилище с агрегированными данными - метриками, на основе которых и будут строится графики - это быстрее, дольше живёт (по периоду доступ к данным) и вообще правильнее. Однако мониторинг реализовывался поступательно, в течение нескольких месяцев, шаг за шагом меняя акценты в данных, без каких-либо изменений инфраструктуры. Отчасти это был ещё и эксперимент: насколько удачно может быть мониторинг на логах минимальными усилиями.

Решение

Решено было использовать логи приложения из nginx и сервера приложения (php-fpm в данном случае), отправляемые в loki, для складирования агрегированной информации по запросам-ответам, которые потому будут агрегированы grafana+loki и по которым будут построены графики.

В плане:

  1. Меняем формат логов nginx и php-fpm - добавлен вывод дополнительных полей и меняем формат на json;
    Это довольно просто: всё берётся из документации: подправляется формат с помощью log_format и access.format соответственно.

  2. Добавляем логгирование подключения и выполнения запросов/команд с таймингом - к БД и кэшу в приложении
    Это было не сложно, ведь используемый фреймворк позволяет переопределить базовые классы для команд и запросов. Реализация очень зависит от фреймворка, но чаще всего заключается в переопределении базовых классов взаимодействия (pdo, cache) с простыми $dbConnectionTime += $start_connection_time - microtime(true).

  3. Оборачиваем все вызовы внешних сервисов в прокладку (названную коннектором), которая считает время обращения, выполняет валидацию (в том числе и структуры) ответа и логгирует успешность вызова;
    Это было даже полезно, ведь теперь у нас все обращения к внешним системам (а их уже около 15) единообразно выполняются, валидируются и логгируются.

Формат логов nginx и приложения

Логи nginx выглядят довольно просто:

{
    "body_bytes_sent": "3186",
    "client_ip": "10.***.***.***",
    "client_req_id": "cad148c1-****-4819-****-035a4a9ace14",
    "connection": "10186566",
    "http_user_agent": "***",
    "pid": "16",
    "request_method": "POST",
    "request_time": "0.102",
    "request_uri": "/***/***/***?***=***",
    "request_uri_path": "/***/***/***",
    "status": "200",
    "time_local": "10/Apr/2023:23:27:15 +0300",
    "upstream_connect_time": "0.000",
    "upstream_header_time": "0.101",
    "upstream_response_time": "0.101",
    "uri": "/index.php"
}
Настройки nginx
// общие
http {
    // ...
    log_format json_upstream_logs escape=json '{"time_local":"$time_local","client_req_id":"$client_req_id","client_ip":"$client_ip","request_method":"$request_method","request_uri":"$request_uri","request_uri_path":"$request_uri_path","status":"$status","body_bytes_sent":"$body_bytes_sent","http_user_agent":"$http_user_agent","request_time":"$request_time","uri":"$uri","upstream_connect_time":"$upstream_connect_time","upstream_header_time":"$upstream_header_time","upstream_response_time":"$upstream_response_time","pid":"$pid","connection":"$connection"}';
    // ...
    map $request_uri $request_uri_path {
        "~^(?P<path>[^?]*)(\?.*)?$"  $path;
    }
}

// сервера
server {
    #Определение идентификатора запроса. если его нет в запросе - создаём
    if ($arg_client_req_id !~ $arg_client_req_id) {
        set $client_req_id $request_id;
    }
    if ($arg_request_id) {
        set $client_req_id $arg_request_id;
    }
    if ($arg_client_req_id) {
        set $client_req_id $arg_client_req_id;
    }
    #Определение ip адреса клиента
    # запросы приходят с проксирующего сервера, так что HTTP-заголовки приходят доверенные
    if ($http_x_real_ip !~ $http_x_real_ip) {
        set $client_ip $remote_addr;
    }
    if ($http_x_real_ip) {
        set $client_ip $http_x_real_ip;
    }


    location ~ ^/index\.php$ {
        // ..
        access_log  /var/log/nginx/access.log  json_upstream_logs;
        // ..
    }
}

Заменённые логи php-fpm выглядят тоже довольно просто:

{
    "C": "43.21",
    "M": "2097152",
    "P": "9",
    "Q": "",
    "R": "-",
    "RequestId": "f894d70d5910***3772a94261f7c40ca",
    "T": "20/Apr/2023:16:17:19 +0300",
    "d": "0.023",
    "f": "/var/www/html/web//index.php",
    "l": "42",
    "m": "POST",
    "n": "www",
    "p": "17",
    "q": "",
    "r": "/index.php",
    "s": "200",
    "t": "20/Apr/2023:16:17:19 +0300",
    "u": ""
}
Настройки php-fpm
access.format = '{"RequestId":"%{REQUEST_ID}e","C":"%C","d":"%d","f":"%f","l":"%l","m":"%m","M":"%M","n":"%n","P":"%P","p":"%p","q":"%q","Q":"%Q","r":"%r","R":"%R","s":"%s","T":"%T","t":"%t","u":"%u"}'

Тайминги в приложении

Ко всем логам из приложения добавляем мета-информацию (для возможности трассировки логов между разными сервисами):

  • id запроса;

  • вызываемый сервис;

  • id авторизованного пользователя, сессия;

Вначале оборачиваем всё: коннекты/выполнение запросов к БД, коннекты/выполнение команд в кэше, считаем время/объём данных.

Из получаемых данных формируем первую категорую сообщений - timings, о ходе выполнения запроса. Формируется по одному сообщению на каждый запрос в конце выполнения:

{
    "cacheCommands": 14,
    "cacheCommandsTime": 0.005800008773803711,
    "cacheConnectionTime": 0.0006201267242431641,
    "cacheReads": 7,
    "cacheReadsSize": 7989,
    "cacheWrites": 4,
    "cacheWritesSize": 1480,
    "dbConnectionTime": 0.0017120838165283203,
    "dbQueries": 5,
    "dbQueriesTime": 0.0563662052154541,
    "externalCalls": 1,
    "externalCallsTime": 0.03611111640930176
}

Вызовы внешних сервисов

Об успешности и времени выполнения запросов к внешним сервисам формируем вторую категория - external_calls - на каждый вызов по сообщению в логе.

  • В случае успешного вызова:

{
    "requestTime": 0.03611111640930176,
    "requestUrl": "http://10.***.***.***/***/***/***/***",
    "systemId": "***"
}
  • В случае ошибки вызова:

{
    "error": "cURL error 28: Connection timeout after 1001 ms (see https://curl.haxx.se/libcurl/c/libcurl-errors.html): 0",
    "request":
    {
        "body": "",
        "url": "http://***.***.***:8280/***/***/***?***=***"
    },
    "requestTime": 1.0022687911987305,
    "response": null,
    "systemId": "***"
}

Визуализация

Далее остаётся лишь вооружиться logql, графаной и написать запросы верные к хранилищу.

Заводим две переменные, которые выбираются в обоих дашбордах (таким образом реализована смена исследуемого контура):

  • $nginx_source - logql-селектор для логов nginx

  • $fpm_source - logql-селектор для логов php-fpm

Весь APM поделён на две дашборды:

  1. Performance Status. Для оценки "насколько всё хорошо справляется".

  2. Remote Calls. Для изучения взаимодействия с другими сервисами.

Performance Status

Состоит из следующих блоков:

Статистика ответов API в разрезе отдельных сервисов/кодов ответа.

  1. RPS. Счётчик запросов ко всему API, с разделением по кодам ответа nginx.

  2. Summary for {code}. Выбираем из выпадающего списка grafana любые коды ответа, на основе которых будет построены:

    1. Average Response time (nginx / fpm).

    2. Response time. Перцентили ответов по данным nginx.

  3. Response count by path for {code}. Количество ответов по сервисам для выбранного кода ответа.

  4. Response size by path. Средний размер ответа сервисов.

  5. Response time by path. Среднее время ответа сервисов.

Как выглядит
RPS по всем запросам к API
RPS по всем запросам к API
Как формируется

{{status}}: sum by (status) (rate($nginx_source | json | error != "JSONParserErr"[1s]))

Среднее время ответа API
Среднее время ответа API
Как формируется

avg by nginx - {{status}}

avg_over_time( $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s] ) by (status)

connect_time - {{status}}

avg_over_time($nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap upstream_connect_time | __error__=""[1s]) by (status)

header_time - {{status}}

avg_over_time($nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap upstream_header_time | __error__=""[1s]) by (status)

response_time - {{status}}

avg_over_time($nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap upstream_response_time | __error__=""[1s]) by (status)

avg by php-fpm - {{s}}

avg_over_time($fpm_source | json | __error__ != "JSONParserErr" | s=$http_code | unwrap d | __error__=""[1s]) by (s)

Перцентили времени ответа
Перцентили времени ответа
Как формируется

max by nginx - {{status}}

max_over_time($nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

95% by nginx - {{status}}

quantile_over_time(0.95, $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

90% by nginx - {{status}}

quantile_over_time(0.9, $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

70% by nginx - {{status}}

quantile_over_time(0.7, $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

50% by nginx - {{status}}

quantile_over_time(0.5, $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

30% by nginx - {{status}}

quantile_over_time(0.3, $nginx_source | json | __error__ != "JSONParserErr" | status=$http_code | unwrap request_time | __error__=""[1s]) by (status)

Количество ответов по сервису
Количество ответов по сервису
Как формируется

{{request_uri_path}}: sum by (request_uri_path) (rate($nginx_source | json | __error__ != "JSONParserErr" | status=$http_code [1s]))

Размер ответа по сервису
Размер ответа по сервису
Как формируется

{{request_uri_path}}: avg_over_time($nginx_source | json | __error__ != "JSONParserErr" | unwrap body_bytes_sent | __error__=""[1s]) by (request_uri_path)

Среднее время ответа по сервисам
Среднее время ответа по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($nginx_source | json | __error__ != "JSONParserErr" | unwrap request_time | __error__=""[1s]) by (request_uri_path)

Сводная статистика приложения в разрезе отдельных хранилищ (по данным fpm).

  1. DB/cache/calls summary. Траты времени приложением на разные активности: на работу с БД, кэшем и другими сервисами.

  2. queries and commands. Количество запросов, чтений/записей из кэша.

  3. cache size. Объём считанных/записанных данных в кэш.

  4. External calls summary. Распределение времени работы с внешними сервисами.

Как выглядит
Сводка по затраченному времени
Сводка по затраченному времени
Как формируется

connect to db time

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_dbConnectionTime | __error__=""[1s]))

db queries time

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_dbQueriesTime | __error__=""[1s]))

connect to cache time

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheConnectionTime | __error__=""[1s]))

cache time

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheCommandsTime | __error__=""[1s]))

external calls time

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_externalCallsTime | __error__=""[1s]))

Запросы в БД и в кэш
Запросы в БД и в кэш
Как формируется

db queries

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_dbQueries | __error__=""[1s]))

cache commands

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheCommands | __error__=""[1s]))

cache reads

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheReads | __error__=""[1s]))

cache writes

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheWrites | __error__=""[1s]))

Объём записи и чтения из кэша
Объём записи и чтения из кэша
Как формируется

read from cache

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheReadsSize | __error__=""[1s]))

write to cache

sum(sum_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheWritesSize | __error__=""[1s]))

Потраченное время на вызовы внешних сервисов
Потраченное время на вызовы внешних сервисов
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"external_calls"` | json | __error__ != "JSONParserErr" | unwrap message_requestTime | __error__=""[1s]) by (message_systemId)

Статистика работы с БД в разрезе отдельных сервисов.

  1. DB time. Время работы с БД среднее.

  2. DB queries count. Среднее количество запросов к БД.

Как выглядит
Время работы с БД по сервисам
Время работы с БД по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_dbQueriesTime | __error__=""[1s]) by (method)

Количество запросов к БД по сервисам
Количество запросов к БД по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_dbQueries | __error__=""[1s]) by (method)

Статистика работы с кэшем в разрезе отдельных сервисов.

  1. Cache commands time. Среднее время работы с кэшем.

  2. Cache commands count. Среднее количество команд к кэшу.

  3. Cache writes size. Средний размер записи в кэш.

  4. Cache reads size. Средний размер записи в кэш.

Как выглядит
Время работы с кэшем по сервисам
Время работы с кэшем по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheCommandsTime | __error__=""[1s]) by (method)

Количество команд в кэш по сервисам
Количество команд в кэш по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheCommands | __error__=""[1s]) by (method)

Размер записи в кэш по сервисам
Размер записи в кэш по сервисам
Как формируется

{{request_uri_path}}: quantile_over_time(0.9, $fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheWritesSize | __error__=""[1s]) by (method)

Размер записи в кэш по сервисам
Размер чтения из кэша по сервисам
Как формируется

{{request_uri_path}}: quantile_over_time(0.9, $fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_cacheReadsSize | __error__=""[1s]) by (method)

Статистика работы с другими API в разрезе отдельных сервисов.

  1. External calls time. Время работы с внешними сервисами.

Как выглядит
Потрачено время на вызовы внешних сервисов по сервисам
Потрачено время на вызовы внешних сервисов по сервисам
Как формируется

{{request_uri_path}}: avg_over_time($fpm_source |=`:"timings"` | json | __error__ != "JSONParserErr" | unwrap message_externalCallsTime | __error__=""[1s]) by (method)

Утилизация CPU и памяти воркерами.

  1. PHP avg memory & CPU.

Как выглядит
Как формируется

php cpu cons. % - {{s}}

avg_over_time($fpm_source | json | __error__ != "JSONParserErr" | unwrap C | __error__=""[1s]) by (s)

php mem cons. - {{s}}

avg_over_time($fpm_source | json | __error__ != "JSONParserErr" | unwrap M | __error__=""[1s]) by (s)

Нестандартные логи nginx / fpm.

  1. nginx messages. Все сообщения из nginx, не являющиеся access-логами.

  2. php-fpm messages. То же самое, только для php.

Как выглядит
Как формируется

логи nginx

$nginx_source !=`client_req_id`

логи php-fpm

$fpm_source !=`RequestId` !=`category` !=`executing too slow` !=`failed to ptrace`

Сводка по исключениям приложения.

  1. Exceptions. Все строки из логов приложения, в которых есть Exception.

  2. Exceptions messages. Детально строки логов с Exceptions, чтобы изучить прямо на месте.

Как выглядит
Как формируется

{{category}}: sum by (category) (rate($fpm_source |=`Exception` | json | __error__ != "JSONParserErr" [1s]))

External calls

Состоит из изменяемого количества блоков, каждый из которых содержит сводку по взаимодействию с одним внешним сервисом, в который обращается монолит.

  1. График RPS & avg. time + ошибок. График, который показывает:

    1. Количество успешных ответов от сервиса.

    2. Среднее и максимальное время ответа сервиса.

    3. Ошибки (с группировкой по типу) при обращении к сервису, при парсинге ответа, при анализе ответа.

  2. Логи обращения к сервису. Отображаются списком все обращения в обратном хронологическом порядке.

Как выглядит
График запросов - успешных и не очень, и время ответа среднее/максимальное
График запросов - успешных и не очень, и время ответа среднее/максимальное
Как формируется

Успешные запросы

{{message_systemId}}

sum by (message_systemId) (count_over_time($fpm_source |=`external_calls` |=`systemId":"$external_system` |=`info` | json | __error__ != "JSONParserErr" [10s]))

Неуспешные запросы

errored - {{message_systemId}} : {{message_error}}

sum by (message_systemId, message_error) (count_over_time($fpm_source |=`external_calls` |=`systemId":"$external_system` |=`error` | json | __error__ != "JSONParserErr" [10s]))

Среднее время выполнения

avg request time - {{message_systemId}}

avg_over_time($fpm_source |=`external_calls` |=`systemId":"$external_system` | json | __error__ != "JSONParserErr" | unwrap message_requestTime | __error__=""[10s]) by (message_systemId)

Максимальное время выполнения

max request time - {{message_systemId}}

max_over_time($fpm_source |=`external_calls` |=`systemId":"$external_system` | json | __error__ != "JSONParserErr" | unwrap message_requestTime | __error__=""[10s]) by (message_systemId)

Время ответа, чтобы погрузиться в детали конкретных запросов
Время ответа, чтобы погрузиться в детали конкретных запросов
Как формируется

$fpm_source |=`external_calls` |=`systemId":"$external_system` | json | __error__ != "JSONParserErr" | line_format "{{.timestamp}}: {{ printf "%s" .message_requestTime }} {{ .message_error }}"

Выводы

Построить свой APM из подручных средств получилось.

У него есть минусы:

  • Малый срок хранения данных; заполняем и без того тяжёлое хранилище логов метриками;

  • Медленная агрегация данных;

  • Необходимо вручную менять конфигурации либо переопределять (встраиваться) в работу с БД/кэшем/внешними сервисами.
    Также стоит не забывать о "скрытых" обращениях к внешним хранилищам: разного рода библиотеки для интеграций (s3, oauth2.0), которые ходят во внешние системы и на которые тоже стоило бы повесить мониторинг. Например, у нас на обращение к oauth2 серверу данных тоже висит мониторинг (да ещё и запрос проксируется с двухкратным ретраем) - пришлось только отнаследовать пару базовых классов библиотеки и добавить логгирование данных.

Все минусы, кроме последнего, можно нивелировать, перейдя на другое хранилище или уже готовую реализацию APM.

Есть и плюсы:

  • Всё работает на текущем установленном софте; Не нужно ничего нового устанавливать.

  • При изучении пользовательских проблем (особенно жалоб на большое время ответа) можно сразу же, не отходя от логов бизнес-логики проверить и тайминги выполнения разного рода обращений;
    Например, зачастую проблемы медленного API у нас появлялись именно из-за долгих обращений к внешним сервисам. Благодаря установке nginx-прокси перед этими сервисами мы уменьшили сетевые издержки.

  • При разработке можно сразу же по логам контейнера оценить сложность создаваемого сервиса - видно сколько раз он сходил в БД, в кэш, сколько выполнил запросов и какие из них были тяжелее остальных;

Комментарии (0)