18 KiB
Журнал сессии — 2026-10-11 (IoT: внешнее код-ревью и фиксы)
Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24). Предыдущие:
HISTORY/2026-10-07-session-log.md,HISTORY/2026-10-10-session-log.md. Основание: внешнее ревью Claude Sonnet по промпту../CLAUDE-REVIEW-PROMPT.md(40 пунктов на два сервиса; SQS-часть — вSQS-service/HISTORY/2026-10-11-session-log.md).
1. Задача
Разобрать находки ревью по монолиту iot-service и исправить их.
2. Что исправлено
🔴 Критичные
- 🔴8/🔴9 — poison-строка в
flushAll(internal/storage/iotpg/iot_telemetry_store.go:166,177). При постоянной (не retryable) ошибкеflushAllвозвращал ошибку → consumer (consumer.go:159) не удалял батч из SQS → бесконечный redelivery всего батча. Теперь ошибку ставит только retryable-ветка; постоянная ошибка означает «валидные строки уже вставлены построчно, битые отброшены» →Flushвозвращает nil, батч подтверждается. Коммит1fe543f.
🟡 Важные
| Пункт | Файл | Суть | Коммит |
|---|---|---|---|
| 🟡13 | bridge/sender.go |
Ретраил бесконечно любые ошибки SQS; permanent (4xx/размер/нет очереди) занимали воркеры → классификация isPermanentSQSErr (smithy APIError) |
657850d |
| 🟡14 | bridge/handler.go |
MessageID = uuid на каждый колбэк → повторная QoS1-доставка не дедуплицировалась. Теперь SHA256(ns|device|topic|payload) |
657850d |
| 🟡15 | bridge/bridge.go |
После 3 неудач подписки ждали следующего реконнекта (которого не будет при живом соединении) → бесконечный ретрай подписки | 657850d |
| (new) | bridge/handler.go |
Defense-in-depth: валидация формата ns/deviceID из топика (барьер — EMQX authz) |
657850d |
| 🟡17 | consumer/consumer.go |
receiveCount проверялся только для битого envelope → применяем maxReceiveCount ко всем ошибкам |
5114320 |
| 🟡18 | cmd/iot-service/main.go |
run() выходил до завершения server.Shutdown → deferred Close срабатывали до дренажа in-flight. Добавлен shutdownDone |
5114320 |
| 🟡19 | api/handler/devices.go |
Ошибка GetByName после успешного INSERT давала 500 (пароль терялся, повтор → 409) → отдаём 201 с in-memory объектом |
5114320 |
| 🟡11 | iotpg/iot_telemetry_store.go |
getTenantDB/QueryTelemetry не проверяли safeNamespaceRe (a_b ↔ a-b → одна БД) → валидация на всех путях |
f41a9fe |
Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)
- 🟡16 (QoS1 ack): решение — оставить async-ack + метрика дропов. Синхронный
ack (подтверждать только после отправки в SQS) блокировал бы MQTT-колбэк при
недоступности SQS — это уже чинили ранее (потеря ВСЕХ сообщений). Throughput
важнее; дроп при переполнении фиксируется счётчиком
dropped. - 🟡12 (
AUTH_TEST_MODE): решение — оставить как есть. Уже fail-closed: при отсутствииJWT_HMAC_SECRETиAUTH_TEST_MODE=falsemiddleware отказывает в обслуживании (500), а test-mode срабатывает только без секрета.
3. Прочее (перед ревью, в этой же сессии)
193509c— консоль: namespace =sub(убралиsless-{SHA256}, расходившийся с middleware) и удалили несуществующийPOST /v1/namespaces/{ns}/ensure.
4. Верификация
go build ./...,go vet ./...,go test -race ./...— зелёные.- Push:
54fe1f5..f41a9feвorigin/master.
5. Деплой на Nubes (Terraform) — образ EMQX, 2026-10-11
terraform apply упал на nubes_http.iot_emqx через ~70 с (стадия validation,
22.0 с): платформа требует РОВНО ОДИН EXPOSE в образе, иначе
«Не указан applicationUsePort…». У emqx/emqx:5.5.1 — 7 EXPOSE
(18083/1883/4370/5369/8083/8084/8883). Параметра applicationUsePort у
провайдера nubes 3.1.0 нет (проверено: generated/prod/resources_yaml/98_http.yaml
generated/prod/docs-rendered/http.md+ исходники провайдера — совпадений нет), поэтому лечится только пересборкой образа.
Что сделано (образ nail/iot-emqx:v0.2.5):
emqx/Dockerfile— multi-stage:FROM emqx/emqx:5.5.1 AS base→FROM scratch→COPY --from=base / /. EXPOSE/ENV/VOLUME наследуемого образа НЕ переносятся вscratch, поэтому остаётся единственныйEXPOSE 8083. ENVLANG/LC_ALL/PATHобъявлены заново.USER emqx+WORKDIR /opt/emqx- штатные
ENTRYPOINT/CMDбазы.
- ⛔ Попытка
FROM debian:bookworm-slim+COPY --from=base / /НЕ работает:cannot copy to non-directory: /bin— в bookworm/binсимлинк (usrmerge), в emqx-образе (Debian 11)/bin— реальный каталог.
- штатные
emqx/emqx.conf— удалёнsalt_rounds = 10изpassword_hash_algorithm: схема 5.5.1 его НЕ принимает (unknown_fields, unknown => "salt_rounds"→call_hocon_failed, контейнер не стартует). Роль поля нулевая: EMQX только сверяет bcrypt-хеш из БД, cost берётся из самого хеша.
Верификация образа (локально):
docker inspect … --format '{{json .Config.ExposedPorts}}'→{"8083/tcp":{}};- запуск контейнера →
Listener tcp:default on 0.0.0.0:1883 started,Listener ws:default on 0.0.0.0:8083 started,EMQX 5.5.1 is running now!, статусrunning. Ошибкиeconnrefusedк127.0.0.1:5432в тесте ожидаемы (PG рядом нет). - Реестр Gitea: теги
latest,v0.2.4,v0.2.5. terraform.tfvars/terraform.tfvars.example:iot_emqx_version = "v0.2.5".
⚠️ Блокер деплоя: инстанс iot-emqx (3bdaef8e-e880-4419-870a-bd0158826751)
остался в состоянии not created после неудачного apply и держит имя. По
документации платформы такие инстансы через API не удаляются — удалять вручную
в ЛК (deck). terraform state list пуст (в state ничего не записалось).
6. Второй terraform apply — падение iot-service и РАЗБОР через кластер
Инстанс-блокер был удалён в ЛК владельцем (3bdaef8e… → 404). Повторный apply:
EMQX создался и работает, nubes_http.iot_service упал через ~10 мин:
Приложение не запустилось. Производится полный откат установки. Ошибка: jlib.k8s [correctReplicaActive] | Под(ы) не работают: 'containerk8s' (Deployment),
затем таймаут клиента 600 с. Namespace iot-service откатан и удалён.
Разбор (kubeconfig ~/.kube/config → кластер iot-naeel, контекст
tazetdinovn@gmail.com@iot-naeel):
- Причина падения — PG, а не образ. Эфемерный диагностический под
(
kubectl run iot-diag-bad, env — как в tfvars, удалён после проверки):Приложение падает наlevel=ERROR msg="devices store" err="store: init schema: pq: must be owner of table iot_devices (42501)" exit=1 reason=ErrorCREATE UNIQUE INDEX IF NOT EXISTS(internal/service/store/open.go:64- SQL на
:29): таблица уже существует и принадлежит ДРУГОЙ роли.
- SQL на
- Владелец. В БД
iotdbинстанса509145c3…таблицыiot_devices,iot_devices_pkey,idx_iot_devices_ns_device,tenant_credentialsпринадлежали ролиiot_service(создана 2026-08-16, пароль утерян — нет ни в vault платформы, ни вIoT/pg.key). Рольiotserviceиз tfvars — не владелец и без прав:has_table_privilege('iotservice','iot_devices','SELECT')=false. Данные:iot_devices26 строк (в т.ч.__bridge/iot-bridge),tenant_credentials31. - Вторая ошибка (сработала бы следующей).
MQTT_HOSTизlocal.emqx_internal_host=iot-emqx.<uid>.svc.cluster.local— NXDOMAIN. Service в namespace платформы называетсяcontainerk8s:containerk8s.<uid>.svc.cluster.local→ 10.98.42.138 ✅ (провереноgetent hostsизнутри пода EMQX; тот же формат —HISTORY/2026-08-16-session-log.md:824).
Исправление (2026-10-11)
- PG (через
psqlв подеpostgresqlk8s-0, рольpostgres):BEGIN; ALTER TABLE public.iot_devices OWNER TO iotservice; ALTER TABLE public.tenant_credentials OWNER TO iotservice; ALTER INDEX public.iot_devices_pkey OWNER TO iotservice; ALTER INDEX public.idx_iot_devices_ns_device OWNER TO iotservice; COMMIT;Пароли не менялись, БД не создавались, env EMQX не менялся. deploy/terraform/resources.tf—emqx_internal_host→containerk8s.<instanceUid>(былоresource_name).
Перепроверка после правки (обязательна)
- Владельцы в PG: все 4 объекта →
iotservice✅ - DDL приложения под
iotserviceпо TCP (sslmode=require), ровно тот же SQL:CREATE TABLE … / CREATE UNIQUE INDEX IF NOT EXISTS …→exit=0, без 42501 ✅ - Данные целы:
iot_devices26 строк,tenant_credentials31 ✅ - Диагностический под
iot-diag-okсMQTT_HOST=containerk8s.<uid>:devices store ready,bridge device ensured,telemetry store ready,bridge: MQTT connected,bridge: subscribed filter=+/telemetry/+;/health=200,/ready=200{"status":"ready"}✅ terraform validateSuccess,terraform plan= 1 to add, 0 to change, 0 to destroy (EMQX не меняется — его env правки не требует);terraform console local.emqx_internal_host=containerk8s.0d09ee82-c8f9-458f-88a3-89571e5676ed.svc.cluster.local✅- Диагностические поды удалены, в ns EMQX остались только
containerk8sиvmagent.
Правка дефолтов конфига (2026-10-11, после разбора)
internal/service/config/defaults.go+Dockerfile: дефолтSQS_ENDPOINTбылhttp://containerk8s.f1ffb134-…:4100(устаревший namespace — shared-sqs давно на ВМ 213) → заменён наhttps://sqs.sinefo.ru.DefaultMQTTHostбыл"emqx"(не резолвится: EMQX в другом namespace) → пусто,MQTT_HOSTстал обязательным:config.Loadдобавляет его в списокmissing(MQTT_HOST (или MQTT_BROKER_URL)), а не строит кривойws://:8083/mqtt.- В нашем деплое оба значения всё равно задаются Terraform'ом (
resources.tf) — правка убирает ловушку для деплоя без tfvars.go build/vet/testзелёные.
7. Деплой IoT на Nubes — ✅ ГОТОВО (2026-10-11, третий apply)
terraform apply прошёл успешно. Итог:
- EMQX (
nubes_http.iot_emqx): инстанс0d09ee82-c8f9-458f-88a3-89571e5676ed, доменhttps://exqx-iot.containerk8s.dev.nubes.ru, подcontainerk8s1/1 Running. - iot-service (
nubes_http.iot_service): инстанс6d78ebc3-bb90-4af9-8d2f-61186e04ee19, доменhttps://iot-api.containerk8s.dev.nubes.ru, подcontainerk8s1/1 Running, 0 рестартов.
Проверено после apply:
- логи старта:
devices store ready→bridge device ensured→telemetry store ready→iot-service listening :9090→bridge: MQTT connected→bridge: subscribed filter=+/telemetry/+→consumer/bridge: SQS queue resolved queue=iot-telemetry dlq=iot-telemetry-dlq; - публично:
/health=200,/ready=200; в логеGET /=200,GET /static/favicon.svg=200 (консоль). terraform state list=nubes_http.iot_emqx+nubes_http.iot_service; outputs:emqx_url,iot_service_url,iot_mqtt_broker_url.
Осталось (не блокеры): end-to-end телеметрия (устройство → MQTT → SQS → per-tenant PG),
домен EMQX wss://exqx-iot/mqtt для внешних устройств, Terraform на PROD.
8. E2E-тест телеметрии — ✅ ПРОЙДЕН (2026-10-11)
Проверен полный путь снаружи кластера: HTTP API → MQTT (wss) → EMQX → bridge → SQS →
consumer → per-tenant PostgreSQL → HTTP API.
- Устройство через API (
/tmp/e2e_api.py, PyJWT HS256sub=<ns>):POST /v1/namespaces/e2e-nubes/iot/devices {"name":"dev1","device_id":"d-01"}→ 201, выданыmqtt_username=e2e-nubes_d-01,mqtt_password=347425f2…753e,telemetry_topic=e2e-nubes/telemetry/d-01. ⚠GET …/devices/dev1пароль НЕ возвращает (как и задокументировано ранее). - Публикация через внешний MQTT
wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt(/tmp/e2e_mqtt.py; MQTT-over-WS написан вручную на stdlib — локально нет paho/websockets):HTTP/1.1 101 Switching Protocols→CONNACK rc=0(auth прошёл: bcrypt-хеш сверен EMQX напрямую сiot_devices) →PUBLISH e2e-nubes/telemetry/d-01 {"temp":21.5,…}. ACL: разрешён только свой топик — публикация в него не отклонена. - Доставка: лог iot-service —
iotpg: created tenant DB namespace=e2e-nubes db=tenant_e2e_nubes; вiotdbпоявилась строкаe2e-nubes/dev1 (d-01). - Чтение:
GET /v1/namespaces/e2e-nubes/iot/telemetry?device_id=d-01→ 200{"count":1,"items":[{"id":1,"device_id":"d-01","payload":{"ts":1791737028, "src":"e2e-manual","temp":21.5}}]}; в PGtenant_e2e_nubes.iot_telemetry— 1 строка,payloadсовпадает побайтово,message_id=30cf2047a060…(SHA256-дедуп).
Следствия: внешний MQTT-домен EMQX годится для устройств; SQS-креды, PG-DSN, ACL и
дедуп работают. Тестовые артефакты: устройство e2e-nubes/dev1, БД tenant_e2e_nubes.
Осталось (не блокеры): Terraform на PROD; чистка тестовых артефактов e2e (по команде).
9. Проверки безопасности/корректности развёрнутого IoT — PASS=12 FAIL=0
Скрипт /tmp/e2e_neg.py (тест-стенд, namespace e2e-nubes, устройство dev1):
| # | Проверка | Результат |
|---|---|---|
| 1 | MQTT auth: верные креды | CONNACK rc=0 ✅ |
| 2 | MQTT auth: неверный пароль | rc=4 (bad user/password) ✅ |
| 3 | MQTT auth: неизвестный пользователь | rc=5 (not authorized) ✅ |
| 4 | API без токена | 401 ✅ |
| 5 | API: sub ≠ namespace в пути |
403 ✅ |
| 6 | API: подделанная подпись JWT | 401 ✅ |
| 7 | MQTT ACL: публикация в чужой топик other-ns/telemetry/x |
соединение разорвано (deny_action=disconnect) ✅ |
| 8 | Дедуп: два одинаковых payload | iot_telemetry 1→2 (+1, не +2), message_id SHA256 ✅ |
| 9 | PATCH enabled=false |
200 ✅ |
| 10 | MQTT auth выключенного устройства | rc=5 ✅ |
| 11 | PATCH enabled=true |
200 ✅ |
| 12 | MQTT auth после включения | rc=0 ✅ |
Вывод: auth (bcrypt из PG), ACL (own-topic only, disconnect на нарушение), tenant-изоляция API
(namespace == sub), проверка подписи JWT и дедуп повторов — работают на развёрнутом стенде.