Embedded HandbookПриложения
Все разделы › XII. Сети и DevOps для инженера
STM32F103, STM32F407 · уровень средний · 16 мин чтения
Открыть в интерактивном справочнике

112. Наблюдаемость: логи, метрики, оповещения

Стенд из раздела 111 работает — но как узнать, что он работает сейчас, в три часа ночи, и что прибор в дальнем цехе не перегрелся? Наблюдаемость отвечает на три вопроса дежурного: что случилось — журналы, насколько плохо — метрики, когда звать человека — оповещения. Раздел добавляет все три к приёмнику телеметрии и проверяет их сценарием: прибор перегревается, перезагружается, замолкает, а потом падает сам приёмник. Всё собрано в Compose из настоящих Prometheus v3.14.0 и Alertmanager v0.34.0.

Как это называется в документации

Оригинал Перевод
observability наблюдаемость
structured logging журнал со структурой: строка — запись JSON
log rotation ротация журнала: старые файлы удаляются
metric, time series метрика, временной ряд
counter / gauge счётчик (только растёт) / показатель (любое значение)
label метка ряда: dev="A1"
scrape сбор метрик опросом
alerting rule правило оповещения
firing / resolved сработало / снято
on-call дежурный

112.1 Путь данных

Приборы шлют показания приёмнику; Prometheus опрашивает его метрики, Alertmanager шлёт оповещения дежурному; внизу — какие правила сработали на каждом шаге опыта

Каждое звено можно заменить — Loki вместо docker logs, VictoriaMetrics вместо Prometheus, мессенджер вместо notifier, — а устройство останется тем же.


112.2 Журнал: одна строка — одно событие

Строка вида «принято от A1, 21.5» удобна глазу, но её не отфильтровать и не сосчитать. Приёмник пишет запись JSON:

static void log_json(const char *level, const char *event, const char *extra)
{
    printf("{\"ts\":%.3f,\"level\":\"%s\",\"event\":\"%s\"%s%s}\n", now(), level, event, extra[0] ? "," : "", extra);
    fflush(stdout);
}

В журнале это выглядит так:

{"ts":1789230267.718,"level":"info","event":"telemetry","dev":"A1","t":21.5,"up":100}
{"ts":1789230271.973,"level":"warn","event":"alert","alert":"DeviceHot","status":"firing"}

У каждой записи — время, уровень и имя события, дальше — поля события. Такие строки фильтруют по полю (event = telemetry, dev = B2), а не регулярными выражениями. Писать — в стандартный вывод, не в файл внутри контейнера: файл пропадёт вместе с контейнером, а вывод соберёт Docker. fflush нужен, потому что вывод в канал буферизуется блоками, и без него строки придут с опозданием.


112.3 Журнал не должен съесть диск

По умолчанию Docker хранит журнал контейнера без ограничений — приёмник, который пишет строку на каждый запрос, за месяцы заполнит диск сервера. Ограничение задаётся в Compose:

x-logging: &logging
  driver: json-file
  options:
    max-size: "20k"
    max-file: "2"

В опыте приёмник записал 1500 строк телеметрии, а docker logs вернул только последние — около полутора сотен (в опыте — 144): остальные ушли вместе с удалёнными файлами. Последняя строка — с up = 1499, то есть самая свежая. На сервере берут max-size: "10m" и три-пять файлов; то, что нужно хранить дольше, отправляют в сборщик журналов.

Журнал с пределом: записано 1500 строк, осталось последние 144 — старые файлы удалены


112.4 Метрики: счётчики и показатели

Метрика — число с именем и метками, которое Prometheus читает по запросу. Приёмник отдаёт их простым текстом:

telemetry_lines_total 2
telemetry_bad_total 1
device_temperature_celsius{dev="B2"} 65.0
device_uptime_seconds{dev="A1"} 100

Два вида, которые нельзя путать:

В имени — единица измерения (_celsius, _seconds), в метках — то, по чему будут группировать (dev). Метки не должны расти без предела: номер прибора — хорошая метка, время или текст ошибки — плохая, каждое новое значение создаёт новый ряд.

В опыте строка «garbage» получила ответ 400 и попала в telemetry_bad_total: счётчик ошибок так же важен, как счётчик успехов.


112.5 Что должен сообщать прибор

Приёмник знает только то, что ему прислали. Минимум, который стоит передавать в каждом сообщении:

Сообщение приходит регулярно, даже когда показания не изменились: молчание тоже сигнал, и правило о нём ниже.


112.6 Prometheus: сбор и запросы

global:
  scrape_interval: 1s
  evaluation_interval: 1s
scrape_configs:
  - job_name: receiver
    static_configs:
      - targets: ["receiver:8080"]

Раз в секунду Prometheus читает /metrics и сохраняет каждое значение с отметкой времени. На сервере хватает 15–30 секунд; секунда в опыте — чтобы не ждать. Для каждой цели Prometheus сам заводит ряд up: 1 — цель ответила, 0 — нет.

Запросы на языке PromQL, которые дали в опыте:

Запрос Что считает Результат
rate(telemetry_lines_total[10s]) строк в секунду около 1
max_over_time(device_temperature_celsius{dev="B2"}[1m]) максимум B2 за минуту 65
resets(device_uptime_seconds{dev="A1"}[1m]) перезагрузок A1 за минуту 1

rate() сам учитывает обнуление счётчика: после перезапуска приёмника счётчик начался заново — 1500 после 1500 новых строк, — а скорость не «провалилась» в минус.


112.7 Правила оповещений

      - alert: DeviceHot
        expr: device_temperature_celsius > 60
        for: 2s
      - alert: DeviceSilent
        expr: time() - device_last_seen_seconds > 10
      - alert: ReceiverDown
        expr: up{job="receiver"} == 0
        for: 3s

expr — условие, for — сколько оно должно держаться, прежде чем правило сработает. Без for одиночный выброс датчика будил бы дежурного; с ним правило сначала «ждёт», потом «срабатывает». DeviceRebooted построен на resets() — оповещение о каждой перезагрузке прибора.

Главное правило — о самом наблюдателе. Когда приёмник остановлен, метрик приборов нет, и правила о текущих значениях молчат: в опыте DeviceSilent исчез в тот же момент — сравнивать стало не с чем. DeviceRebooted ещё держался: он смотрит на минуту истории. Без ReceiverDown упавший приёмник выглядел бы как «всё спокойно».

Но и ReceiverDown — правило того же Prometheus. Упадёт сам Prometheus, Alertmanager, сервер целиком или связь с дежурным — не придёт ни одного оповещения, и тишина снова выглядит как «всё спокойно». Последний уровень — правило, которое горит всегда: expr: vector(1), обычно с именем Watchdog; promtool test rules подтверждает, что оно горит, даже когда метрик нет вовсе. Alertmanager повторяет его, скажем, раз в минуту во внешнюю службу, и она сама будит дежурного, когда повторы перестали приходить. Плюс проверка доступности сервера с другой машины.


112.8 Alertmanager: группы, маршруты, «снято»

route:
  receiver: notifier
  group_by: ["alertname", "dev"]
  group_wait: 1s
receivers:
  - name: notifier
    webhook_configs:
      - url: http://notifier:8080/api/alert
        send_resolved: true

Alertmanager собирает оповещения в группы — одно сообщение о десяти замолчавших приборах вместо десяти, — отправляет по маршруту и повторяет, если проблема не ушла. send_resolved присылает и отбой: в опыте notifier получил DeviceHot со статусом firing, а когда B2 остыл — тот же DeviceHot со статусом resolved.

Оповещения идут в notifier, а не в сам приёмник: ReceiverDown дошёл именно потому, что получатель — другой сервис. Канал оповещений не должен зависеть от того, за чем он следит.


112.9 Опыт по шагам

Шаг Что сделали Сработало
1 A1 и B2 шлют показания, у B2 — 65.0 °C DeviceHot для B2
2 A1 перезагрузился и шлёт дальше, B2 остыл и замолчал DeviceRebooted для A1, DeviceSilent для B2; DeviceHot снят
3 приёмник остановлен ReceiverDown; DeviceSilent исчез вместе с метриками, DeviceRebooted держится

До дежурного через Alertmanager дошли DeviceHot и DeviceSilent — оба сработали и сняты, — DeviceRebooted и ReceiverDown. DeviceSilent сняли не потому, что B2 заговорил, а потому, что пропали метрики: отбой не всегда значит «исправлено».


112.10 Грабли

Диск сервера заполнился журналами. Нет max-size у журнала. Задать ограничение в Compose или в настройках Docker.

Строки журнала приходят пачками с опозданием. Вывод буферизуется. fflush(stdout) после записи или построчная буферизация.

Prometheus съел память. В метках — время, текст ошибки или номер запроса: каждое значение — новый ряд. Метки — только с ограниченным набором значений.

Дежурного будят выбросы. Нет for в правиле. Условие должно держаться.

Упал приёмник — и тишина. Правила только о приборах. Нужно правило up == 0 о самом приёмнике.

Оповещение о падении не пришло. Его отправлял сервис, который упал. Канал оповещений — отдельно.

График счётчика «пилой». Показывают сырой счётчик, а он обнуляется при перезапуске. Строить rate().


112.11 Итоги

Этим разделом закончена часть XII «Сети и DevOps для инженера».


Полные исходники

obs.c — приёмник телеметрии с журналом в JSON и метриками для Prometheus.

/* Раздел 112: приёмник телеметрии, за которым можно наблюдать.
 *
 *   POST /api/telemetry   тело «dev=A1 t=21.5 up=120» — показания прибора
 *   POST /api/alert       приём оповещений от Alertmanager (роль notifier)
 *   GET  /metrics         счётчики и показания в текстовом формате Prometheus
 *   GET  /health          проверка здоровья
 *   obs --check           проверка здоровья изнутри образа FROM scratch
 *
 * Каждое событие — одна строка JSON в stdout: её собирает Docker, а читают
 * docker logs и любые сборщики журналов. Один процесс, одно соединение за раз. */
#define _GNU_SOURCE /* strcasestr */
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>

#define MAX_DEV 8

struct device {
    char name[8];
    double temp;
    double uptime;
    double last_seen;
};

static struct device dev[MAX_DEV];
static int n_dev;
static unsigned long lines_total, alerts_total, bad_total;
static unsigned long http_total[6]; /* 200, 400, 404, 413, 500, прочие */

static double now(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    return (double)ts.tv_sec + ts.tv_nsec / 1e9;
}

static void log_json(const char *level, const char *event, const char *extra)
{
    printf("{\"ts\":%.3f,\"level\":\"%s\",\"event\":\"%s\"%s%s}\n", now(), level, event, extra[0] ? "," : "", extra);
    fflush(stdout);
}

static void reply(int fd, int code, const char *type, const char *body)
{
    static const int codes[] = { 200, 400, 404, 413, 500 };
    const char *text = code == 200 ? "OK" : code == 400 ? "Bad Request" : code == 404 ? "Not Found"
                     : code == 413 ? "Payload Too Large" : "Internal Server Error";
    char head[256];
    int k = 5;
    for (int i = 0; i < 5; i++)
        if (codes[i] == code)
            k = i;
    http_total[k]++;
    int n = snprintf(head, sizeof head,
                     "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n",
                     code, text, type, strlen(body));
    if (write(fd, head, (size_t)n) < 0 || write(fd, body, strlen(body)) < 0)
        return;
}

static struct device *find_dev(const char *name)
{
    for (int i = 0; i < n_dev; i++)
        if (strcmp(dev[i].name, name) == 0)
            return &dev[i];
    if (n_dev == MAX_DEV)
        return NULL;
    snprintf(dev[n_dev].name, sizeof dev[n_dev].name, "%s", name);
    return &dev[n_dev++];
}

/* «dev=A1 t=21.5 up=120»: 0 — принято, -1 — строка не разобрана. */
static int take_telemetry(const char *body)
{
    char name[8];
    double t, up;
    if (sscanf(body, "dev=%7[A-Za-z0-9] t=%lf up=%lf", name, &t, &up) != 3)
        return -1;
    struct device *d = find_dev(name);
    if (!d)
        return -1;
    d->temp = t;
    d->uptime = up;
    d->last_seen = now();
    lines_total++;
    char extra[96];
    snprintf(extra, sizeof extra, "\"dev\":\"%s\",\"t\":%.1f,\"up\":%.0f", name, t, up);
    log_json("info", "telemetry", extra);
    return 0;
}

static void metrics(char *out, size_t cap)
{
    static const char *const code_name[] = { "200", "400", "404", "413", "500", "other" };
    size_t n = 0;
    n += (size_t)snprintf(out + n, cap - n,
                          "# HELP telemetry_lines_total Lines of telemetry accepted.\n"
                          "# TYPE telemetry_lines_total counter\ntelemetry_lines_total %lu\n"
                          "# TYPE telemetry_bad_total counter\ntelemetry_bad_total %lu\n"
                          "# TYPE alerts_received_total counter\nalerts_received_total %lu\n"
                          "# TYPE http_requests_total counter\n",
                          lines_total, bad_total, alerts_total);
    for (int i = 0; i < 6; i++)
        n += (size_t)snprintf(out + n, cap - n, "http_requests_total{code=\"%s\"} %lu\n", code_name[i], http_total[i]);
    n += (size_t)snprintf(out + n, cap - n, "# TYPE device_temperature_celsius gauge\n");
    for (int i = 0; i < n_dev; i++)
        n += (size_t)snprintf(out + n, cap - n, "device_temperature_celsius{dev=\"%s\"} %.1f\n", dev[i].name, dev[i].temp);
    n += (size_t)snprintf(out + n, cap - n, "# TYPE device_uptime_seconds gauge\n");
    for (int i = 0; i < n_dev; i++)
        n += (size_t)snprintf(out + n, cap - n, "device_uptime_seconds{dev=\"%s\"} %.0f\n", dev[i].name, dev[i].uptime);
    n += (size_t)snprintf(out + n, cap - n, "# TYPE device_last_seen_seconds gauge\n");
    for (int i = 0; i < n_dev; i++)
        n += (size_t)snprintf(out + n, cap - n, "device_last_seen_seconds{dev=\"%s\"} %.3f\n", dev[i].name,
                              dev[i].last_seen);
}

/* Из тела оповещения Alertmanager берём статус и имя правила — для журнала. */
static void take_alert(const char *body)
{
    char status[16] = "?", name[48] = "?";
    const char *p = strstr(body, "\"status\":\"");
    if (p)
        sscanf(p + 10, "%15[a-z]", status);
    p = strstr(body, "\"alertname\":\"");
    if (p)
        sscanf(p + 13, "%47[A-Za-z0-9_]", name);
    alerts_total++;
    char extra[96];
    snprintf(extra, sizeof extra, "\"alert\":\"%s\",\"status\":\"%s\"", name, status);
    log_json("warn", "alert", extra);
}

static void serve(int fd)
{
    static char req[16384], out[8192];
    ssize_t got = 0, r;
    char *end = NULL;
    long want = 0;

    while (got < (ssize_t)sizeof req - 1 && (r = read(fd, req + got, sizeof req - 1 - (size_t)got)) > 0) {
        got += r;
        req[got] = 0;
        if (!end && (end = strstr(req, "\r\n\r\n")) != NULL) {
            const char *cl = strcasestr(req, "\r\nContent-Length:");
            want = cl ? atol(cl + 17) : 0;
        }
        if (end && got - (end + 4 - req) >= want)
            break;
    }
    if (!end) {
        reply(fd, 400, "text/plain", "bad request\n");
        return;
    }
    const char *body = end + 4;
    if (strncmp(req, "POST /api/telemetry ", 20) == 0) {
        if (want > 256) {
            reply(fd, 413, "text/plain", "too large\n");
        } else if (take_telemetry(body) == 0) {
            reply(fd, 200, "text/plain", "ok\n");
        } else {
            bad_total++;
            log_json("error", "bad_telemetry", "");
            reply(fd, 400, "text/plain", "bad telemetry\n");
        }
    } else if (strncmp(req, "POST /api/alert ", 16) == 0) {
        take_alert(body);
        reply(fd, 200, "text/plain", "ok\n");
    } else if (strncmp(req, "GET /metrics ", 13) == 0) {
        metrics(out, sizeof out);
        reply(fd, 200, "text/plain; version=0.0.4", out);
    } else if (strncmp(req, "GET /health ", 12) == 0) {
        reply(fd, 200, "text/plain", "ok\n");
    } else {
        reply(fd, 404, "text/plain", "not found\n");
    }
}

static int self_check(void)
{
    int s = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
    const char q[] = "GET /health HTTP/1.1\r\nHost: self\r\n\r\n";
    char ans[64] = { 0 };
    if (s < 0 || connect(s, (struct sockaddr *)&a, sizeof a) < 0 || write(s, q, sizeof q - 1) < 0
        || read(s, ans, sizeof ans - 1) <= 0)
        return 1;
    close(s);
    return strncmp(ans, "HTTP/1.1 200", 12) == 0 ? 0 : 1;
}

int main(int argc, char **argv)
{
    if (argc > 1 && strcmp(argv[1], "--check") == 0)
        return self_check();
    int s = socket(AF_INET, SOCK_STREAM, 0), one = 1;
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = htonl(INADDR_ANY) };
    setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    if (bind(s, (struct sockaddr *)&a, sizeof a) < 0 || listen(s, 64) < 0) {
        perror("bind");
        return 1;
    }
    log_json("info", "start", "");
    for (;;) {
        int c = accept(s, NULL, NULL);
        if (c < 0)
            continue;
        serve(c);
        close(c);
    }
}

prometheus.yml — сбор метрик и подключение правил и Alertmanager.

# Раздел 112: сбор метрик раз в секунду (в опыте — чтобы не ждать; на сервере — 15–30 с).
global:
  scrape_interval: 1s
  evaluation_interval: 1s

rule_files:
  - /etc/prometheus/alerts.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

scrape_configs:
  - job_name: receiver
    static_configs:
      - targets: ["receiver:8080"]

alerts.yml — правила оповещений.

# Раздел 112: правила оповещений. Порог и длительность — в правиле, а не в голове.
groups:
  - name: devices
    rules:
      - alert: ReceiverDown
        expr: up{job="receiver"} == 0
        for: 3s
        labels:
          severity: critical
        annotations:
          summary: "Приёмник телеметрии не отвечает"

      - alert: DeviceHot
        expr: device_temperature_celsius > 60
        for: 2s
        labels:
          severity: warning
        annotations:
          summary: "Прибор {{ $labels.dev }}: {{ $value }} °C"

      - alert: DeviceSilent
        expr: time() - device_last_seen_seconds > 10
        labels:
          severity: warning
        annotations:
          summary: "Прибор {{ $labels.dev }} молчит больше 10 с"

      - alert: DeviceRebooted
        expr: resets(device_uptime_seconds[1m]) > 0
        labels:
          severity: info
        annotations:
          summary: "Прибор {{ $labels.dev }} перезагрузился"

alertmanager.yml — куда и как группируются оповещения.

# Раздел 112: куда уходят оповещения. В опыте — в отдельный сервис notifier;
# на сервере — в почту, мессенджер или телефон дежурного.
route:
  receiver: notifier
  group_by: ["alertname", "dev"]
  group_wait: 1s
  group_interval: 2s
  repeat_interval: 1h

receivers:
  - name: notifier
    webhook_configs:
      - url: http://notifier:8080/api/alert
        send_resolved: true

Дальше: раздел 113 «Ada и SPARK: Alire, GNAT для Cortex-M, доказательство».

Открыть в интерактивном справочнике