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

88. Распределённые системы и API

Данные прибора редко заканчиваются на брокере. Дальше их принимает система из многих частей: сервис приёма, база, правила, уведомления, отчёты. Части живут на разных машинах и договариваются через API — программные интерфейсы по сети. Прибор тоже становится участником такой системы: он публикует в брокер (раздел 87) или сам вызывает API сервера.

Раздел — о том, как такие системы устроены и что из этого следует для прошивки: как собрать запрос, чтобы его поняли, как повторять, чтобы не навредить, и что на самом деле защищает токен. Клиентская часть написана на переносимом C и проверена опытом: два сервера nginx за балансировщиком nginx в контейнерах.

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

Оригинал Перевод
file server, client–server, multi-tier файл-сервер, клиент-сервер, многоуровневая архитектура
vertical / horizontal scaling вертикальное / горизонтальное масштабирование
replication, sharding репликация, разделение данных по ключу
monolith, microservices монолит, микросервисы
API, REST, endpoint программный интерфейс, REST, точка доступа
idempotent идемпотентный — повтор не меняет результат
status code код ответа
publish–subscribe, request–reply издатель-подписчик, запрос-ответ
round-robin по очереди
exponential backoff, jitter растущая задержка, случайный разброс
thundering herd «все разом» после сбоя
JWT, bearer token веб-токен, токен в заголовке Authorization
OpenAPI (Swagger) машиночитаемое описание API

88.1 Архитектуры информационных систем

Архитектура Где обработка Сильная сторона Слабая сторона
файл-сервер на клиенте; сервер только хранит файлы проста сеть гоняет целые файлы, одновременные правки портят данные
клиент-сервер на сервере клиенту не нужна мощь, данные в одном месте сервер — центр и точка отказа
многоуровневая по уровням: приём, логика, база — на разных машинах каждый уровень растёт отдельно больше частей — больше настройки

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


88.2 Масштабирование

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

Приборов становится больше, а не мощнее, — поэтому системы для них растут горизонтально.


88.3 Монолит и микросервисы

Монолит Микросервисы
устройство одно приложение, всё внутри набор маленьких сервисов, каждый делает одно
на старте проще написать и проверить целиком больше работы на инфраструктуру
изменения непредсказуемы: всё связано в пределах одного сервиса
нагрузка масштабируется целиком растёт только нагруженный сервис
отказы падает всё падает сервис — остальные живут
цена — сложная сеть сервисов, её надо наблюдать

Для системы приборов разумный путь — начать с монолита и выделять сервисы там, где нагрузка или команда этого требуют: приём сообщений, хранение, правила и тревоги, отчёты.


88.4 Как системы договариваются

Способы объединить системы — от простого к правильному: вручную (копированием), через общие файлы, через общую базу, через шину данных, и через API по HTTP — REST или SOAP. Для приборов сегодня это почти всегда REST или обмен сообщениями.

REST работает с ресурсами по адресам и четырьмя основными методами:

Метод Что делает Повторить безопасно?
GET /devices прочитать список приборов да
POST /devices создать запись о приборе нет — повтор создаст вторую
PUT /devices/42 записать прибор 42 целиком да
DELETE /devices/42 удалить прибор 42 да

Ответ потерян, запрос повторён: POST создал вторую запись, PUT оставил одну

Свойство «повтор не меняет результат» называется идемпотентностью, и для прибора оно главное. Связь рвётся, ответ теряется, прибор повторяет запрос. Повтор POST создаёт дубликат, повтор PUT безвреден. Поэтому данные прибора записывают идемпотентно: PUT по своему номеру или POST с уникальным ключом запроса, по которому сервер узнаёт повтор.

Код ответа решает, что делать дальше: 2xx — готово; 4xx — запрос неверен, повторять его в том же виде бессмысленно; 5xx и отсутствие ответа — повторять, но с растущей задержкой (88.6). Из 4xx есть исключения, и для прибора они важнее правила: 429 «слишком много запросов» и 408 «истекло время ожидания запроса» повторяют с той же растущей задержкой, а если сервер прислал заголовок Retry-After, — не раньше указанного срока; 401 после истечения токена — получить новый токен и повторить (88.7). Прибор, бросивший запрос на 429, теряет данные.

Кто кого ждёт. Цепочка синхронных вызовов «сервис → сервис → сервис» складывает задержки, и отказ одного звена рвёт всю цепочку. Асинхронный запрос-ответ отвечает «принято» и присылает результат потом. Издатель-подписчик — MQTT (разделы 37.2 и 87): прибор публикует, а кто и сколько получателей подписано, не знает и знать не должен.

Почему приборы не опрашивают сервер каждые несколько секунд: опрос раз в 10 с — 8640 запросов в сутки на один прибор, 8,64 миллиона на тысячу приборов, и почти все — впустую. Подписка или отправка по событию тратит сеть и батарейку только когда есть что сказать.


88.5 Прибор как клиент REST

Запрос из прошивки — это текст (раздел 78.6), и две вещи в нём ломаются чаще всего: тело JSON и его длина.

JSON собирают так, чтобы кавычка в строке не порвала документ:

/* Строка в JSON: кавычка, обратная косая и управляющие символы — через «\» */
size_t json_escape(char *out, size_t cap, const char *s)
{
    static const char hex[] = "0123456789abcdef";
    size_t k = 0;
    for (; *s; s++) {
        unsigned char c = (unsigned char)*s;
        char tmp[7];
        size_t n = 0;
        if (c == '"' || c == '\\') {
            tmp[n++] = '\\';
            tmp[n++] = (char)c;
        } else if (c < 0x20) {
            tmp[n++] = '\\';
            tmp[n++] = 'u';
            tmp[n++] = '0';
            tmp[n++] = '0';
            tmp[n++] = hex[c >> 4];
            tmp[n++] = hex[c & 15];
        } else {
            tmp[n++] = (char)c;
        }
        size_t need = n;
        if (c >= 0xC0u)                     /* первый байт буквы UTF-8: она должна влезть целиком */
            need = c >= 0xF0u ? 4u : c >= 0xE0u ? 3u : 2u;
        if (k + need + 1 > cap)
            break;                          /* не влезло — обрезаем, букву UTF-8 не разрезая */
        memcpy(out + k, tmp, n);
        k += n;
    }
    out[k] = '\0';
    return k;
}

/* Телеметрия: температура в сотых долях градуса — без float на приборе */
int json_telemetry(char *out, size_t cap, const char *device, int32_t temp_centi, uint32_t uptime_s)
{
    char name[64];
    json_escape(name, sizeof name, device);
    int32_t a = temp_centi < 0 ? -temp_centi : temp_centi;
    return snprintf(out, cap, "{\"device\":\"%s\",\"temp\":%s%ld.%02ld,\"uptime\":%lu}",
                    name, temp_centi < 0 ? "-" : "", (long)(a / 100), (long)(a % 100),
                    (unsigned long)uptime_s);
}

Для прибора с именем stm32 "цех-1", температурой −1234 сотых градуса и временем работы 86 400 с получилось {"device":"stm32 \"цех-1\"","temp":-12.34,"uptime":86400} — Python разобрал его без ошибок. Дробь печатается из целых, без вещественной арифметики (раздел 69).

Длина тела — в байтах, а не в символах: буква кириллицы в UTF-8 — два байта. strlen считает именно байты:

int http_request(char *out, size_t cap, const char *method, const char *host,
                 const char *path, const char *body)
{
    if (body)
        return snprintf(out, cap,
                        "%s %s HTTP/1.1\r\nHost: %s\r\n"
                        "Content-Type: application/json\r\nContent-Length: %zu\r\n"
                        "Connection: close\r\n\r\n%s",
                        method, path, host, strlen(body), body);
    return snprintf(out, cap, "%s %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
                    method, path, host);
}

Опыт. В сети контейнеров — два сервера nginx, backend-1 и backend-2, и перед ними nginx-балансировщик под именем lb. «Прибор» — тот же код, собранный для компьютера:

Что прибор отправил Что получил
четыре GET /whoami backend-1, backend-2, backend-1, backend-2 — по очереди
POST /telemetry с JSON 201; сервер увидел Content-Length 60 — ровно длину тела в байтах

Балансировщик описан несколькими строками:

upstream backends {
    zone backends 64k;
    server b1:80;
    server b2:80;
}
server {
    listen 80;
    location / {
        proxy_pass http://backends;
    }
}

⚠️ Строка zone здесь не для красоты. Очередь «по кругу» каждый рабочий процесс nginx ведёт сам, а соединения от приборов короткие и приходят в разные процессы. В первой пробе, без общей зоны, все четыре запроса ушли на первый сервер, и балансировщик выглядел сломанным. zone делает состояние группы общим для всех процессов.

Кадр стека у json_telemetry на Cortex-M3 — 136 байт, из них 64 — буфер имени; остальные функции обходятся 48 байтами и меньше. Для задачи RTOS это немного, но стек задачи связи считают с запасом (раздел 26).


88.6 Повторять, но не все сразу

Сервер упал и поднялся. Тысячи приборов, которые ждали, повторят запрос — и если все через ровно одну секунду, они снова положат сервер. Это «все разом» (thundering herd). Лечится растущей задержкой со случайным разбросом:

/* Задержка перед попыткой attempt (0, 1, 2…): случайная в пределах
 * 1 с · 2^attempt, но не больше 5 минут. */
uint32_t backoff_ms(unsigned attempt, uint32_t *seed)
{
    uint32_t cap = attempt >= 9 ? 300000u : 1000u << attempt;
    if (cap > 300000u)
        cap = 300000u;
    *seed = *seed * 1103515245u + 12345u;
    return (*seed >> 8) % (cap + 1);
}

Предел задержки растёт — 1, 2, 4, 8… 256 с — и с десятой попытки упирается в 5 минут; внутри предела задержка случайна. Посчитано на 10 000 приборах после сбоя: без разброса все 10 000 приходят в одну и ту же секунду, а с разбросом на пятой попытке запросы растягиваются на 16 с: в среднем 62 за 0,1 секунды, в самые плотные 0,1 с — около 90. Затравку генератора берут у каждого прибора свою — например, из уникального номера камня (раздел 79.7), иначе «случайные» задержки у всех совпадут.

Повторять, но не все сразу: предел растёт вдвое до 5 минут, а внутри него задержка случайна


88.7 Токены: что они защищают, а что нет

Сервер должен знать, кто к нему пришёл. Прибор предъявляет токен в заголовке Authorization: Bearer …. Распространённый формат — JWT: три части через точку — заголовок, содержимое и подпись, каждая закодирована base64url:

/* base64url без «=» в конце — так кодируют части токена JWT */
size_t b64url(const uint8_t *in, size_t n, char *out)
{
    static const char tab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    size_t k = 0;
    for (size_t i = 0; i < n; i += 3) {
        uint32_t v = (uint32_t)in[i] << 16;
        if (i + 1 < n)
            v |= (uint32_t)in[i + 1] << 8;
        if (i + 2 < n)
            v |= in[i + 2];
        out[k++] = tab[v >> 18 & 63];
        out[k++] = tab[v >> 12 & 63];
        if (i + 1 < n)
            out[k++] = tab[v >> 6 & 63];
        if (i + 2 < n)
            out[k++] = tab[v & 63];
    }
    out[k] = '\0';
    return k;
}

Заголовок {"alg":"HS256","typ":"JWT"} превращается в eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 — начало любого такого токена.

Главное, что нужно помнить: base64 — это кодирование, а не шифр. Содержимое токена читает любой, у кого он есть. Токен защищён от подделки подписью, а от подсмотра — только шифрованием канала (TLS, раздел 37.4). В содержимом есть срок действия: истёкший токен сервер не примет, а прибор должен вовремя получить новый. Токен в прошивке открытым текстом виден в её образе (раздел 77.9).

Прежде чем писать клиентскую часть, API пробуют руками: описание в формате OpenAPI (Swagger) показывает все методы, а программы для отправки запросов позволяют увидеть ответы сервера до первой строки прошивки.


88.8 Примеры распределённых технологий


88.9 Грабли

В базе дубликаты измерений. Прибор повторял POST после обрыва; нужен PUT по своему номеру или ключ запроса.

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

Сервер отвечает 400 на телеметрию одного прибора. В имени прибора кавычка, и JSON собран без экранирования.

Тело запроса обрезается на кириллице. Content-Length посчитан в символах, а не в байтах.

Балансировщик шлёт всё на один сервер. Нет общей зоны zone — очередь у каждого процесса nginx своя.

Токен украли из перехваченного трафика. base64 не шифрует; нужен TLS.

Прибор перестал отправлять данные через сутки. Истёк срок токена, а обновлять его прошивка не умеет.

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

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


88.10 Итоги

Дальше: раздел 89 «Сетевые протоколы и mesh-сети».

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