PROM 04: Получение метрик из приложений

Описание:

Всем привет!

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

Сегодня же мы наконец переместимся на последний уровень мониторинга - уровень приложения. Итак, сегодня мы напишем и разберем небольшое приложение на golang, определим и опишем метрики, которое оно будет отдавать, и подключим его для мониторинга к Prometheus.

Если продолжить нашу историю с компанией, то в наличии у вас уже имеется сервер Prometheus, подключенные экспортеры для мониторинга системы и сервисов. И все работает отлично, пока в какой-то момент ваше приложение не перестает работать. После некоторых разбирательств вы понимаете, что веб-интерфейс, который отображается у пользователя, обращается к API и через 60 секунд ожидания получает ошибку 502. API написан на golang и запущен на небольшом сервере с Linux, который вы уже мониторите.

Вы идете в Prometheus и начинаете проверять серверы один за другим - смотрите нагрузку, количество свободной памяти и места на дисках, изучаете количество сетевых подключений, но так и не находите ничего необычного. Приложение не работает по неизвестной причине! И именно в этот момент вы понимаете, что вам необходимо настроить мониторинг вашего приложения.

Важно: Как мы уже говорили ранее, в вашей инфраструктуре необходимо мониторить абсолютно ВСЕ компоненты. Это является критически важным для проведения расследований и выяснения истинных причин возникающих проблем. Если вы не мониторите компонент, то он является для вас черным ящиком - в него что-то приходит и он что-то выдает в ответ. Максимум, что вы знаете, - это какие сервисы нужны для работы этого компонента. Этого явно недостаточно для понимания причин сбоев. Поэтому всегда требуйте от своих разработчиков подключение мониторинга для приложений. Подключать мониторинг можно как и с помощью Prometheus, так и с помощью сторонних инструментов - statsd, graphite, zabbix или возможно SaaS-сервисов, таких как datadog или newrelic. Но вы точно должны знать, что происходит внутри приложения.

Написание golang приложения

Весь процесс написания мы разделим на три части:

установка golang,

написание приложения,

тестирование приложения.

Итак, поехали!

Установка golang

Итак, первым шагом нам необходимо написать небольшое приложение на golang. Чтобы не углубляться в область программирования, мы напишем небольшую echo утилиту, которая будет отдавать нам в ответ то, что мы отправили ей в запросе. Мы будем разрабатывать приложение на виртуалке с Linux, поэтому давайте для начала установим нашему пользователю golang:

user@prom:~$ wget https://golang.org/dl/go1.16.4.linux-amd64.tar.gz
user@prom:~$ tar zxf go1.16.4.linux-amd64.tar.gz
user@prom:~$ rm -rf go1.16.4.linux-amd64.tar.gz

В данном примере мы скачали последний стабильный релиз golang для Linux. Список текущих релизов можно найти на официальном сайте. После скачивания мы разархивировали golang в домашнюю директорию (при этом создалась директория ~/go) и удалили скачанный архив. Итак, теперь нам осталось прописать две переменные окружения в .bashrc для нашего пользователя:

user@prom:~$ echo 'export GOROOT=$HOME/go' >> .bashrc
user@prom:~$ echo 'export PATH=$PATH:$GOROOT/bin' >> .bashrc

Первая переменная GOROOT нужна самому golang интерпретатору: благодаря ей он находит путь к своим встроенным библиотекам. Вторая переменная требуется для того, чтобы мы не писали полный путь к бинарнику go, а система находила его сама. Теперь мы можем перелогиниться в систему и проверить, что golang был успешно установлен:

user@prom:~$ go version
go version go1.16.4 linux/amd64

Написание приложения

Теперь мы можем создать директорию для нашего проекта:

user@prom:~$ mkdir -p app/src/app
user@prom:~$ cd app/src/app
user@prom:~/app/src/app$

Внутри нее мы создадим простенький файл main.go, который и будет являться нашим приложением:

package main

// Импортируем основные модули - fmt, log, net/http, time, math/rand
import (
"fmt"
"log"
"net/http"
"time"
"math/rand"
)

// Функция, которая будет обрабатывать все запросы на наш веб сервер
func handler(w http.ResponseWriter, r *http.Request) {
// Засыпаем случайное количество секунд - от 0 до 2х
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
// В ответ отправляем путь, к которому обратился пользователь
fmt.Fprintf(w, "%s\n", r.URL.Path)
}

// Главная функция - точка старта нашей программы
func main() {
// Инициализируем генератор случайных чисел
rand.Seed(time.Now().UnixNano())
// Определяем, что при запросах на "/" - то есть по сути на любой http путь, необходимо вызывать функцию handler
http.HandleFunc("/", handler)
// Запускаем наш сервер на порту 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}

Как вы можете видеть, приложение очень простое. Оно слушает входящие http-запросы на порту 8080 и при любом обращении вызывает функцию handler, которая в свою очередь засыпает на 2 секунды, а потом выводит в ответ путь, указанный пользователем. Давайте попробуем скомпилировать и запустить наше приложение.

Тестирование приложения

Итак, для запуска нашего мегапроекта нам нужно получить бинарный файл из исходного кода. Для этого мы воспользуемся компилятором go, который мы установили на первом шаге:

user@prom:~/app/src/app$ export GOPATH=$HOME/app
user@prom:~/app/src/app$ go build -o app main.go

В начале мы выставили переменную окружения GOPATH, которая требуется компилятору для того, чтобы найти все файлы нашего проекта. После чего запустили компилятор и указали ему два параметра. Первый -o app - имя выходного файла, который мы получим. Второй - main.go - имя нашего файла с исходным кодом. Давайте теперь попробуем запустить наше приложение:

user@prom:~/app/src/app$ ./app

После запуска вывод будет пустым и если вы не увидили ошибок - то все отлично, наше приложение работает. Давайте попробуем открыть вторую консольку и отправить пару запросов на наш сервис:

user@prom:~/app/src/app$ curl localhost:8080/test
/test
user@prom:~/app/src/app$ curl localhost:8080/test/user/path
/test/user/path
user@prom:~/app/src/app$

Как видно из вывода, наше приложение просто отдает путь, который запросил пользователь. А также вносит задержку в ответ от 0 до 2х секунд. Собственно наше приложение готово! Переходим к подключению мониторинга.

Подключаем приложение к мониторингу

Перед тем, как мы начнем подключать мониторинг к нашему приложению, нам стоит определиться с метриками, которые мы хотим получать.

Best Practice: На самом деле список метрик для приложений может быть очень велик - количество входящих запросов, время выполнения одного запроса, количество отправленных и полученных данные от внешнего хранилища и так далее. Идеально было бы конечно выдавать их все, но самый минимум, который вы должны попросить внедрить разработчиков в систему, - это подсчет времени ответа от внешних сервисов. То есть вы должны видеть тайминги по ответам от баз данных, от внешних http ресурсов и так далее. В 95% случаев сервис начинает тормозить и тупить, когда какой-то внешний ресурс недоступен или отвечает слишком медленно. Благодаря тайминг метрикам, вы сможете это увидеть очень быстро. Остальные 4% случаев приходятся на слишком сложные алгоритмы внутри приложения - например, когда ваше приложение обрабатывает 5 строк данных за 1 секунду, а 10 строк данных - за 20 секунд. В этом случае количество данных увеличилось в два раза, а время обработки в 20 раз. На это влияет сложность алгоритма. В общем, мониторинг времени выполнения внешних запросов и внутренних функций раскроет вам внутренности приложения на 99%. Поэтому старайтесь внедрить их в первую очередь.

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

Количество запросов. Мы просто хотим видеть сколько входящих запросов обрабатывает наше приложение.

Время обработки наших запросов - здесь мы будем смотреть на распределение наших запросов. Например, что 90% всех запросов выполняется менее, чем за 50 ms. Подробнее об этой метрике мы поговорим в задании про promql.

Итак, для начала сбора наших метрик нам необходимо подключить golang библиотеку Prometheus к нашему приложению. Это делается практически так, как мы подключали модули fmt, net/http и другие. Для этого модифицируем наш код и добавим нужную строку:

// Импортируем основные модули - fmt, log, net/http, time, math/rand
import (
"fmt"
"log"
"net/http"
"time"
"math/rand"

// Добавляем импорт клиентской библиотеки Prometheus
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

После этого мы определим наши метрики в специальном блоке var:

var (
// Метрика для подсчета времени обработки запроса
apiDurations = prometheus.NewSummary(
prometheus.SummaryOpts{
Name: "app_api_durations_seconds",
Help: "API latency distributions.",
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
}
)

// Метрика для подсчета количества входящих запросов
apiProcessed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "app_api_processed_ops_total",
Help: "The total number of processed requests",
})
)

В функции main мы сделаем два изменения. Во-первых заставим клиентскую библиотеку Prometheus зарегистрировать наши новые метрики, а во-вторых добавим обработчик для пути /metrics - при обращении к нему наше приложение будет отдавать метрики в формате Prometheus:

func main() {
// Регистрируем наши метрики
prometheus.MustRegister(apiDurations)
prometheus.MustRegister(apiProcessed)

// Инициализируем генератор случайных чисел
rand.Seed(time.Now().UnixNano())
// Определяем, что при запросах на / - то есть по сути на любой http путь, необходимо вызывать функцию handler
http.HandleFunc("/", handler)

// При запросе пути /metrics будем выдавать метрики в формате Prometheus
http.Handle("/metrics", promhttp.Handler())

// Запускаем наш сервер на порту 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}

Теперь всё, что нам осталось, - это изменить функцию handler, которая обрабатывает пользовательские запросы и научить ее считать наши метрики:

// Функция, которая будет обрабатывать все запросы на наш веб-сервер
func handler(w http.ResponseWriter, r *http.Request) {
// Увеличиваем счетчик количества входящих запросов
apiProcessed.Inc()

// Засекаем время начала обработки запроса
start := time.Now()

// Засыпаем на случайное количество секунд - от 0 до 2х
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
// В ответ отправляем путь к которому обратился пользователь
fmt.Fprintf(w, "%s\n", r.URL.Path)

// После окончания обработки считаем, сколько времени прошло с начала обработки
duration := time.Since(start)
// Сохраняем время обработки в метрику
apiDurations.Observe(duration.Seconds())
}

Теперь можем свести все воедино и попробовать запустить наше приложение:

package main

// Импортируем основные модули - fmt, log, net/http, time, math/rand
import (
"fmt"
"log"
"net/http"
"time"
"math/rand"

// Добавляем импорт клиентской библиотеки Prometheus
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
// Метрика для подсчета времени обработки запроса
apiDurations = prometheus.NewSummary(
prometheus.SummaryOpts{
Name: "app_api_durations_seconds",
Help: "API latency distributions.",
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
})

// Метрика для подсчета количества входящих запросов
apiProcessed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "app_api_processed_ops_total",
Help: "The total number of processed requests",
})
)

// Функция, которая будет обрабатывать все запросы на наш веб-сервер
func handler(w http.ResponseWriter, r *http.Request) {
// Увеличиваем счетчик количества входящих запросов
apiProcessed.Inc()

// Засекаем время начала обработки запроса
start := time.Now()

// Засыпаем на случайное количество секунд - от 0 до 2х
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
// В ответ отправляем путь, к которому обратился пользователь
fmt.Fprintf(w, "%s\n", r.URL.Path)

// После окончания обработки считаем сколько времени прошло с начала обработки
duration := time.Since(start)
// Сохраняем время обработки в метрику
apiDurations.Observe(duration.Seconds())
}

func main() {
// Регистрируем наши метрики
prometheus.MustRegister(apiDurations)
prometheus.MustRegister(apiProcessed)

// Инициализируем генератор случайных чисел
rand.Seed(time.Now().UnixNano())
// Определяем, что при запросах на / - то есть по сути на любой http путь, необходимо вызывать функцию handler
http.HandleFunc("/", handler)

// При запросе пути /metrics будем выдавать метрики в формате Prometheus
http.Handle("/metrics", promhttp.Handler())

// Запускаем наш сервер на порту 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}

Скачиваем дополнительные модули (библиотеку Prometheus) и запускаем наше приложение:

user@prom:~/app/src/app$ export GOPATH=$HOME/app
user@prom:~/app/src/app$ go mod init
user@prom:~/app/src/app$ go get
user@prom:~/app/src/app$ go build -o app-v1 main.go
user@prom:~/app/src/app$ ./app-v1

В данном случае мы инициализировали модуль в понимании golang, после этого скачали все зависимости и снова скомпилировали, но с другим именем - app-v1. Итак, запускаем его снова и переходим на вторую консольку и проверяем полученные результаты:

user@prom:~/app$ curl localhost:8080/test/user/path
/test/user/path
user@prom:~/app$ curl localhost:8080/test/user
/test/user
user@prom:~/app$ curl localhost:8080/test
/test
user@prom:~/app$ curl localhost:8080/metrics
# HELP app_api_durations_seconds API latency distributions.
# TYPE app_api_durations_seconds summary
app_api_durations_seconds{quantile="0.5"} 1.0479e-05
app_api_durations_seconds{quantile="0.9"} 6.2502e-05
app_api_durations_seconds{quantile="0.99"} 6.2502e-05
app_api_durations_seconds_sum 8.312600000000002e-05
app_api_durations_seconds_count 3
# HELP app_api_processed_ops_total The total number of processed requests
# TYPE app_api_processed_ops_total counter
app_api_processed_ops_total 3
# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 0
go_gc_duration_seconds{quantile="0.25"} 0
go_gc_duration_seconds{quantile="0.5"} 0
go_gc_duration_seconds{quantile="0.75"} 0
go_gc_duration_seconds{quantile="1"} 0
go_gc_duration_seconds_sum 0
go_gc_duration_seconds_count 0
... skipped ...

В самом начале мы сделали три запроса к нашему приложению. После чего обратились по специальному пути /metrics, который выдал нам все собранные метрики в формате Prometheus. Как видно у нас появилась метрика app_api_processed_ops_total равная 3 - это то количество запросов, которое обработало наше приложение. Помимо этого мы видим странную метрику app_api_durations_seconds, которая должна показывать нам время обработки запроса. На самом деле она именно это и делает, но немного хитро. Но об этом позднее, в следующих заданиях.

Подключение приложения к Prometheus

Итак, наше приложение научилось выдавать Prometheus-метрики. Осталось всего ничего - подключить его к Prometheus. Сделать это можно точно так же, как с другими экспортерами. Отредактируем prometheus.yml:

global:
scrape_interval: 15s

scrape_configs:
- job_name: 'app'
static_configs:
- targets: ['localhost:8080']

Этой конфигурации будет достаточно для получения метрик с нашего приложения.

Итоги

Итак, сегодня мы освоили мониторинг третьего уровня - уровня приложения. Как видите, подключить сбор метрик со своего приложения не так уж и сложно. Тем более, что делать это будут скорее всего разработчики. Главное не забывайте, что мониторить по большей части нужно время ответов от внешних сервисов и время обработки внутренних функций и запросов. Тогда с вероятностью 99% вы сможете локализовать возникшую проблему.

Приложения, написанные на других языках программирования, подключаются абсолютно аналогично. Достаточно подключить библиотеку Prometheus к вашему проекту и начать собирать метрики.

Полезные ссылки:

Сложность алгоритмов

Monitoring apps with prometheus

Пишем собственный экспортер для Prometheus

Альтернативный способ установки Go с помощью gvm

Задание:

В этом задании вам будет необходимо запустить приложение, которое умеет экспортировать метрики в формате Prometheus, и подключить его к мониторингу. На подготовленной виртуальной машине уже установлен Prometheus.

Критерии оценки выполнения задания:

Запустите приложение whoami:

1.1. Скомпилируйте приложение whoami по аналогии с тем, как мы собирали свое собственное приложение на golang. (вместо компиляции вы можете запустить docker контейнер с уже собранным приложением)

1.2. Приложение должно слушать порт 9292.

Настройте Prometheus:

2.1. Prometheus должен собирать все метрики c приложения whoami.