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 безвреден. Поэтому
данные прибора записывают идемпотентно: 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), иначе «случайные» задержки у всех совпадут.
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 Примеры распределённых технологий
- BitTorrent — одноранговая раздача: файл режут на куски, каждый проверяют по хешу и берут у разных участников, в первую очередь — самые редкие. Популярность файла здесь не нагружает никого, в отличие от одного сервера. Проверка каждого куска по хешу — та же идея, что проверка образа прошивки при обновлении (раздел 48). Механизм «медленного старта», который часто упоминают рядом, — это управление перегрузкой в самом TCP, а не алгоритм BitTorrent.
- VPN — зашифрованный туннель поверх интернета: удалённый доступ к сети или соединение площадок между собой. Для парка приборов это способ держать их в одной закрытой сети; распространены WireGuard, OpenVPN, IPsec.
- Tor — «луковая» маршрутизация: запрос идёт через несколько узлов, и каждый снимает свой слой шифрования; ни один узел не видит сразу и отправителя, и содержимое.
- Блокчейн — цепочка блоков, каждый из которых хранит хеш предыдущего: изменить старую запись нельзя, не пересчитав все последующие, а копии цепочки есть у всех участников. Приборам он нужен редко, но сама идея журнала, связанного хешами, полезна там, где важно доказать, что записи не правили.
88.9 Грабли
В базе дубликаты измерений. Прибор повторял POST после обрыва;
нужен PUT по своему номеру или ключ запроса.
Сервер не может подняться после сбоя. Все приборы повторяют запросы одновременно — нет случайного разброса или у всех одна затравка.
Сервер отвечает 400 на телеметрию одного прибора. В имени прибора кавычка, и JSON собран без экранирования.
Тело запроса обрезается на кириллице. Content-Length посчитан в
символах, а не в байтах.
Балансировщик шлёт всё на один сервер. Нет общей зоны zone — очередь
у каждого процесса nginx своя.
Токен украли из перехваченного трафика. base64 не шифрует; нужен TLS.
Прибор перестал отправлять данные через сутки. Истёк срок токена, а обновлять его прошивка не умеет.
Отчёт строится минутами. Цепочка синхронных вызовов сервисов, где каждый ждёт следующего.
Счёт за сервер вырос, а данных не прибавилось. Тысячи приборов опрашивают сервер каждые несколько секунд вместо подписки.
88.10 Итоги
- Приборы — клиенты многоуровневых систем; такие системы растут горизонтально, а базы — копиями для чтения и разделением по ключу.
- Монолит проще на старте, микросервисы — при росте; выделяют сервисы по нужде.
- REST:
GET,PUT,DELETEповторять безопасно,POST— нет; повторы прибора делают идемпотентными. 4xx не повторяют, кроме 408 и 429 (и 401 — с новым токеном); 5xx и таймауты — повторяют с задержкой. - Опрос раз в 10 с — 8640 запросов в сутки на прибор; подписка и отправка по событию дешевле.
- JSON собирают с экранированием,
Content-Length— в байтах; опыт с двумя серверами за nginx подтвердил и то и другое. - Повторы — с растущей задержкой и разбросом: 10 000 приборов после сбоя дают в среднем 62 запроса за 0,1 с, в пике около 90, вместо 10 000 в одну секунду.
- JWT защищён от подделки подписью, от подсмотра — только TLS; base64 не шифрует.
Дальше: раздел 89 «Сетевые протоколы и mesh-сети».