Dziś nikt już nie ma wątpliwości, po co zbierać metryki serwisów. Kolejnym logicznym krokiem jest skonfigurowanie alertów na zbierane metryki, które będą powiadamiać o wszelkich odchyleniach w danych w wygodnych dla Ciebie kanałach (e-mail, Slack, Telegram). W serwisie rezerwacji online hoteli wszystkie metryki naszych serwisów trafiają do InfluxDB i są wyświetlane w Grafana, gdzie także skonfigurowano podstawowe alerty. Do zadań typu „trzeba coś policzyć i porównać” używamy Kapacitor.

Kapacitor jest częścią stosu TICK, który potrafi przetwarzać metryki z InfluxDB. Może łączyć kilka miar (join), obliczyć coś użytecznego z uzyskanych danych, zapisać wynik z powrotem do InfluxDB i wysłać alert do Slacka/Telegramu/e-maila.
Cały stos ma świetną i szczegółową , ale zawsze można znaleźć przydatne rzeczy, które nie są wprost opisane w dokumentacji. W tym artykule postanowiłem zebrać szereg takich przydatnych, nieoczywistych wskazówek (podstawowa składnia TICKscipt jest opisana ) i pokazać, jak można je zastosować, na przykładzie rozwiązania jednego z naszych zadań.
Zaczynamy!
float & int, błędy obliczeń
Absolutnie standardowy problem, który można rozwiązać przez rzutowanie:
var alert_float = 5.0
var alert_int = 10
data|eval(lambda: float("value") > alert_float OR float("value") < float("alert_int"))
Użycie default()
Jeśli tag/pole nie jest wypełnione, wystąpią błędy w obliczeniach:
|default()
.tag('status', 'empty')
.field('value', 0)
fill w join (inner vs outer)
Domyślnie join odrzuci punkty, gdzie dane są niedostępne (inner).
Przy użyciu fill(‘null’) zostanie wykonany outer join, po czym trzeba będzie zrobić default() i wypełnić puste wartości:
var data = res1
|join(res2)
.as('res1', 'res2')
.fill('null')
|default()
.field('res1.value', 0.0)
.field('res2.value', 100.0)
Był tu jednak pewien niuans. Jeśli w powyższym przykładzie jedna z serii (res1 lub res2) będzie pusta, seria końcowa (data) również będzie pusta. Na ten temat jest kilka zgłoszeń na GitHubie (, , ) – czekamy na poprawki i trochę cierpimy.
Użycie warunków w obliczeniach (if w lambda)
|eval(lambda: if("value" > 0, true, false)
Ostatnie pięć minut z potoku za okres
Na przykład musisz porównać wartości z ostatnich pięciu minut z poprzednim tygodniem. Można wziąć dwie paczki danych dwiema oddzielnymi partiami lub wyciągnąć część danych z większego okresu:
|where(lambda: duration((unixNano(now()) - unixNano("time"))/1000, 1u) < 5m)
Alternatywą na ostatnie pięć minut może być użycie węzła BarrierNode, który odcina dane wcześniej niż podany czas:
|barrier()
.period(5m)
Przykłady użycia szablonów w Go w komunikacie
Szablony odpowiadają formatowi z pakietu , poniżej kilka często występujących zadań.
if-else
Porządkujemy, nie wyzwalając ludzi tekstem niepotrzebnie:
|alert()
...
.message(
'{{ if eq .Level "OK" }}Wszystko jest teraz ok{{ else }}Szefie, wszystko się zepsuło{{end}}'
)
Dwie cyfry po przecinku w komunikacie
Poprawiamy czytelność komunikatu:
|alert()
...
.message(
'aktualna wartość to {{ index .Fields "value" | printf "%0.2f" }}'
)
Rozwijać zmienne w komunikacie
Wyświetlamy więcej informacji w komunikacie, aby odpowiedzieć na pytanie „Dlaczego krzyczy?”
var warnAlert = 10
|alert()
...
.message(
'Dzisiaj wartość jest mniejsza niż '+string(warnAlert)+'%'
)
Unikalny identyfikator alertu
Potrzebna rzecz, gdy w danych jest więcej niż jedna grupa, w przeciwnym razie zostanie wygenerowany tylko jeden alert:
|alert()
...
.id('{{ index .Tags "myname" }}\/{{ index .Tags "myfield" }}')
Niestandardowe handler’y
W dużej liście handlerów znajduje się exec, który pozwala wykonać własny skrypt z przekazanymi parametrami (stdin) – to tylko kreatywność!
Jednym z naszych customów jest mały skrypt w Pythona do wysyłania powiadomień do Slacka.
Na początku chcieliśmy wysyłać w wiadomości obrazek z Grafany, zabezpieczony autoryzacją. Następnie – pisać OK w wątku poprzedniego alertu z tej samej grupy, a nie jako osobnej wiadomości. Jeszcze później – dodawać w komunikacie najczęstszy błąd za ostatnie X minut.
Osobny temat – powiązanie z innymi usługami i jakiekolwiek działania inicjowane przez alert (tylko jeśli twoje monitorowanie działa wystarczająco dobrze).
Przykład opisu handlera, gdzie slack_handler.py to nasz własny skrypt:
topic: slack_graph
id: slack_graph.alert
match: level() != INFO AND changed() == TRUE
kind: exec
options:
prog: \/sbin\/slack_handler.py
args: ["-c", "CHANNELID", "--graph", "--search"]
Jak debugować?
Wariant z wypisaniem do logu
|log()
.level("error")
.prefix("coś")
Sprawdź (cli): kapacitor -url :9092 logs lvl=error
Wariant z httpOut
Pokazuje dane w bieżącym pipeline’ie:
|httpOut('coś')
Sprawdzaj (get): :9092\/kapacitor\/v1\/tasks\/task_name\/something
Schemat wykonania
- Każde zadanie zwraca drzewo wykonania z użytecznymi danymi w formacie .
- Bierzemy blok .
- Wstawiamy do viewer’a, .
Gdzie jeszcze można zdobyć timestamp w influxdb przy odwrotnej zapisie
znacznik czasu w influxdb przy odwrotnej rejestracji
Na przykład, konfigurujemy alert na ilość zapytań na godzinę (groupBy(1h)) i chcemy zapisać powstały alert w influxdb (aby ładnie pokazać fakt wystąpienia problemu na wykresie w grafana).
influxDBOut() zapisze w timestamp wartość time z alertu, odpowiednio, punkt na wykresie będzie zapisany wcześniej/później, niż przyszedł alert.
Gdy wymagana jest dokładność: obejmujemy ten problem przez wywołanie niestandardowego handlera, który zapisze dane w influxdb z bieżącym timestampem.
docker, budowanie i wdrażanie
Podczas uruchamiania kapacitor może ładować zadania, szablony i handlera z katalogu określonego w konfiguracji, w bloku [load].
Do poprawnego stworzenia zadania potrzebne są następujące elementy:
- Nazwa pliku – rozwija się w id/nazwę skryptu
- Typ – stream/batch
- dbrp – słowo kluczowe do wskazania, w której bazie + polityce działa skrypt (dbrp "supplier"."autogen")
Jeśli w jakimkolwiek zadaniu batch nie będzie linii z dbrp, cały serwis odmówi uruchomienia i szczerze napisze o tym w logu.
W chronografie jednak, w przeciwieństwie, tej linii nie powinno być, przez interfejs nie jest akceptowana i zwraca błąd.
Haczyk przy budowie kontenera: Dockerfile kończy się z -1, jeśli znajdują się w nim linie z //.+dbrp, co pozwoli od razu zrozumieć przyczynę niepowodzenia przy budowie.
join jeden do wielu
Przykład zadania: trzeba wziąć 95. percentyl czasu pracy usługi za tydzień, porównać każdą minutę z ostatnich 10 z tą wartością.
Nie można zrobić join jeden do wielu, last/mean/median według grupy punktów przekształca nodę w stream, zwróci błąd „cannot add child mismatched edges: batch -> stream”.
Wynik batcha, jako zmienna w lambda-wyrażeniu, również nie jest podstawiany.
Jest opcja, aby zapisywać potrzebne liczby z pierwszego batcha do pliku przez udf i ładować ten plik przez sideload.
Co tym rozwiązaliśmy?
Mamy około 100 dostawców hoteli, do każdego z nich może być kilka połączeń, nazwijmy to kanałem. Jest ich około 300, każdy kanał może odpaść. Spośród wszystkich rejestrowanych metryk będziemy monitorować wskaźnik błędów (requests i errors).
Dlaczego nie grafana?
Alarmy po błędach skonfigurowane w grafanie mają kilka wad. Niektóre są krytyczne, na inne można przymknąć oko, w zależności od sytuacji.
Grafana nie potrafi obliczeń między pomiarami + alerting, a my potrzebujemy wskaźnika (requests-errors)/requests.
Błędy wyglądają złowrogo:

I mniej złowrogo, jeśli patrzeć na sukcesywnie wykonane zapytania:

OK, możemy wstępnie obliczyć wskaźnik w serwisie do Grafany, i w niektórych przypadkach to będzie odpowiednie. Ale nie w naszym, ponieważ dla każdego kanału oblicza się inne „normalne” proporcje, a alerty działają na podstawie statycznych wartości (szukamy wzrokowo, zmieniamy, jeśli alerty są zbyt częste).
Oto przykłady „normalnych” wartości dla różnych kanałów:


Pomijając poprzedni punkt, zakładajmy, że u wszystkich dostawców „normalny” obraz jest podobny. Teraz wszystko wygląda dobrze i czy możemy polegać na alertach w Grafanie?
Możemy, ale bardzo nie chcemy, bo trzeba wybrać jedną z dwóch opcji:
a) stworzyć wiele wykresów dla każdego kanału osobno (i męczyć się z ich utrzymywaniem)
b) zostawić jeden wykres ze wszystkimi kanałami (i zgubić się w kolorowych liniach oraz ustawionych alertach)

Jak to zrobiliśmy?
Ponownie, w dokumentacji jest dobry przykład startowy (), można zajrzeć lub wziąć to za podstawę w podobnych zadaniach.
Co zrobiliśmy ostatecznie:
- połączenie dwóch serii w ciągu kilku godzin, grupowanie według kanałów;
- uzupełniamy serie dla grup, jeśli wcześniej brakowało danych;
- porównujemy medianę ostatnich 10 minut z wcześniejszymi danymi;
- informujemy, jeśli coś wykryliśmy;
- zapisujemy obliczone wskaźniki i zdarzone alerty w influxdb;
- wysyłamy przydatną wiadomość do Slacka.
Moim zdaniem, udało nam się idealnie to, co chcieliśmy osiągnąć (a nawet trochę więcej z niestandardowymi handlerami).
Na github.com można zobaczyć i uzyskanego skryptu.
Przykład otrzymanego kodu:
dbrp "supplier"."autogen"
var name = 'requests.rate'
var grafana_dash = 'pczpmYZWU/mydashboard'
var grafana_panel = '26'
var period = 8h
var todayPeriod = 10m
var every = 1m
var warnAlert = 15
var warnReset = 5
var reqQuery = 'SELECT sum("count") AS value FROM "supplier"."autogen"."requests"'
var errQuery = 'SELECT sum("count") AS value FROM "supplier"."autogen"."errors"'
var prevErr = batch
|query(errQuery)
.period(period)
.every(every)
.groupBy(1m, 'channel', 'supplier')
var prevReq = batch
|query(reqQuery)
.period(period)
.every(every)
.groupBy(1m, 'channel', 'supplier')
var rates = prevReq
|join(prevErr)
.as('req', 'err')
.tolerance(1m)
.fill('null')
// wypełniamy wartości zerami, jeśli ich nie było
|default()
.field('err.value', 0.0)
.field('req.value', 0.0)
// if w lambda: obliczamy wskaźnik, tylko jeśli były błędy
|eval(lambda: if("err.value" > 0, 100.0 * (float("req.value") - float("err.value")) / float("req.value"), 100.0))
.as('rate')
// zapisujemy obliczone wartości w influx
rates
|influxDBOut()
.quiet()
.create()
.database('kapacitor')
.retentionPolicy('autogen')
.measurement('rates')
// wybieramy dane za ostatnie 10 minut, obliczamy medianę
var todayRate = rates
|where(lambda: duration((unixNano(now()) - unixNano("time")) / 1000, 1u) warnAlert)
.warnReset(lambda: ("prev.median" - "today.median") < warnReset)
.flapping(0.25, 0.5)
.stateChangesOnly()
// zbieramy w wiadomości link do wykresu panelu grafany
.message(
'{{ .Level }}: {{ index .Tags "channel" }} stosunek err/req ({{ index .Tags "supplier" }})
{{ if eq .Level "OK" }}Teraz jest ok{{ else }}
'+string(todayPeriod)+' mediana to {{ index .Fields "today.median" | printf "%0.2f" }}%, w porównaniu do poprzedniego '+string(period)+' to {{ index .Fields "prev.median" | printf "%0.2f" }}%{{ end }}
http://grafana.ostrovok.in/d/'+string(grafana_dash)+
'?var-supplier={{ index .Tags "supplier" }}&var-channel={{ index .Tags "channel" }}&panelId='+string(grafana_panel)+'&fullscreen&tz=UTC0300'
)
.id('{{ index .Tags "name" }}{{ index .Tags "channel" }}')
.levelTag('level')
.messageField('message')
.durationField('duration')
.topic('slack_graph')
// "today.median" powielamy jako "value", a także zapisujemy w influx pozostałe pola alertu (keep)
trigger
|eval(lambda: "today.median")
.as('value')
.keep()
|influxDBOut()
.quiet()
.create()
.database('kapacitor')
.retentionPolicy('autogen')
.measurement('alerts')
.tag('alertName', name)
A jaki jest wynik?
Kapacitor doskonale potrafi przeprowadzać monitorowanie i alertowanie z wieloma grupowaniami, wykonywać dodatkowe obliczenia na już zapisanych metrykach, realizować niestandardowe działania oraz uruchamiać skrypty (UDF).
Próg wejścia nie jest zbyt wysoki – spróbuj go, jeśli Grafana lub inne narzędzia nie do końca spełniają Twoje oczekiwania.
Źródło: habr.com
