28 KiB
Thinking Log — 2026-04-06
Агент: GitHub Copilot (Claude Sonnet 4.6)
Архитектурные обсуждения перед началом Kafka
Контекст
Пользователь обсуждал будущую prod-архитектуру IoT сервиса. Никакого кода не менялось — чистое планирование.
Итоги обсуждений
Три отдельных кластера (принято):
- IoT кластер — EMQX, bridge, Kafka, iot-consumer, Postgres, REST API
- Serverless кластер — operator, builder, event-dispatcher, Functions
- Infra/Control кластер — Terraform для provisioning кластеров 1 и 2, DNS, TLS, auth, billing
Это классическая схема "control plane отдельно от data plane".
Kafka — выбор подтверждён:
- Сейчас: bridge → Postgres напрямую (синхронно, без буфера)
- Prod: bridge → Kafka → {consumer → Postgres, event-dispatcher → Functions}
- Dev/test: Kafka через Helm (bitnami, KRaft mode, 1 нод, PVC)
- Prod: managed Kafka (Confluent/Aiven) — только меняется KAFKA_BROKERS в Secret
Postgres → managed облачный: легко
- bridge и API используют DATABASE_URL из env
- Для переключения: только заменить Secret в кластере
- Код не трогается
Состояние RabbitMQ для IoT (важное открытие):
- Bridge сейчас пишет в RabbitMQ очередь
iot.{namespace}.telemetry - НО event-dispatcher эту очередь не читает — он настроен на serverless functions triggers
- То есть IoT-сообщения в RabbitMQ лежат мёртвым грузом — никто не читает
- Kafka заменяет RabbitMQ для IoT-части полностью
Что проверяли в кластере:
- 2026-04-05: только один активный тенант
sless-16367aacb67a4a01(созданный после инцидента) - Устройство
device2, одно сообщение:{"msg":"hello1dddd1777"}от 14:34 UTC - 2026-04-06: kubeconfig истёк → обновил → тот же один тенант, никто новый не входил
План интеграции Kafka
Анализ текущего bridge
Читал iot/cmd/mqtt-bridge/main.go. Текущая логика в buildMQTTMessageHandler:
- Получает MQTT сообщение
- Публикует в RabbitMQ (бесполезно — никто не читает)
- Пишет напрямую в Postgres через iotpg.Store
С Kafka нужно:
- Получает MQTT сообщение
- Публикует в Kafka топик
iot.telemetry(единый топик, namespace в payload) - Убрать прямой INSERT в Postgres из bridge
Что создаётся заново
iot/cmd/kafka-consumer/main.go — новый сервис:
- Читает из Kafka топика
iot.telemetry - Пишет в Postgres (та же логика что сейчас в bridge)
- Consumer group:
iot-pg-consumer
Изменения в bridge:
- Убрать RabbitMQ
- Добавить Kafka producer (библиотека
github.com/segmentio/kafka-go) - Env var:
KAFKA_BROKERSвместоRABBITMQ_URL
Новые env vars:
- bridge:
KAFKA_BROKERS=kafka.sless.svc.cluster.local:9092 - consumer:
KAFKA_BROKERS=...,IOT_PG_DSN=...
Что НЕ меняется
- EMQX, operator, REST API, IoT Console — не трогаются
iotpgstorage package — используется consumer-ом напрямую- ACL, auth, namespace-изоляция — не меняются
Порядок работы
- Документация + коммит (сейчас)
- Ветка
iot-kafka - Helm: установить Kafka в namespace
sless - Переписать bridge: убрать RabbitMQ, добавить Kafka producer
- Создать
iot/cmd/kafka-consumer/main.go - Обновить Dockerfile (добавить сборку consumer)
- Обновить deployment манифесты
- Сборка v0.1.67, деплой, тест
Риски
kafka-govsconfluent-kafka-go— выбираемsegmentio/kafka-go(pure Go, без CGO, совместим с alpine)- KRaft mode в Helm bitnami — убедиться что включён (без Zookeeper)
- Topic
iot.telemetry— создаётся автоматически при первой публикации (auto.create.topics.enable=true по умолчанию)
Сессия (продолжение) — реализация Kafka pipeline
Что было сделано
Ветка: iot-kafka
1. Kafka StatefulSet (deployments/k8s/kafka.yaml)
Установка через Helm bitnami провалилась — образ bitnami/kafka:4.0.0 заблокирован (paywall с Aug 2025).
Переключились на официальный apache/kafka:3.7.0 — бесплатный, полнофункциональный.
Написан кастомный kafka.yaml:
- KRaft mode (без Zookeeper) — node.id=1, roles=broker+controller
- ConfigMap монтируется в
/tmp/kafka-config(не/etc/kafka— read-only в образе) securityContext.fsGroup=1000— kafka user (UID 1000) может писать в PVC- PVC 1Gi на
vcd-disk-ext4(local-path отказал: not enough disk space) - Два Service:
kafka:9092и headlesskafka-headless
2. bridge переписан (iot/cmd/mqtt-bridge/main.go)
- Убран RabbitMQ (
amqp091-go) - Убрана прямая запись в Postgres через
iotpg - Добавлен Kafka writer (
segmentio/kafka-go) - Топик:
iot.telemetry, ключ = namespace (партиционирование по тенанту) Async: false, RequiredAcks: RequireOne— синхронная запись, подтверждение от лидера
3. kafka-consumer создан (iot/cmd/kafka-consumer/main.go)
- Consumer group:
iot-pg-consumer - Читает из
iot.telemetry, пишет в Postgres черезiotpg.Store - Offset коммитится ТОЛЬКО после успешной записи (at-least-once)
- Retry loop при недоступности Kafka
4. Dockerfile обновлён
- Добавлена сборка
iot-kafka-consumerбинаря COPY --from=builder /workspace/iot-kafka-consumer .- Итого в образе 3 бинаря:
manager,iot-mqtt-bridge,iot-kafka-consumer
5. Манифесты обновлены
iot-mqtt-bridge.yaml: убранRABBITMQ_URL, добавленKAFKA_BROKERSiot-kafka-consumer.yaml: новый deployment
Баги которые встретили и решили
Bug 1: дублирующий package main
create_file вставил package main дважды — в начале и перед import.
Фикс: replace_string_in_file удалил дубликат.
Bug 2: kafka-go помечен как // indirect в go.mod
gopls не видел пакет как доступный. Причина: зависимость добавлена без прямого импорта в момент добавления.
Фикс: go mod tidy убрал // indirect.
Bug 3: Race condition — consumer зависал при холодном старте
Когда: consumer стартовал одновременно с Kafka (первый деплой, топика нет).
Что происходило: consumer JOIN-ил group → Kafka auto-создавала топик в момент JOIN → kafka-go зависал на FetchMessage навсегда.
Гипотеза №1: postStart lifecycle hook на Kafka — создать топик сразу после старта брокера.
Проблема с гипотезой: kafka-topics.sh --list без таймаута зависает бесконечно → pod застрял в PodInitializing. Попытка с nc — nc не установлен в образе. Попытка с request.timeout.ms через properties — postStart возвращал exit code 1 → Kubernetes убивал контейнер → CrashLoopBackOff.
Итоговое решение: ensureKafkaTopic() в consumer — создаёт топик через kafka.DialContext + conn.CreateTopics() ДО создания Reader и JOIN группы. Retry 30 раз × 3 сек = 90 сек макс ожидания.
// Порядок в consumer:
// 1. Connect IoT Postgres
// 2. ensureKafkaTopic() ← создаём топик, ждём брокер
// 3. kafka.NewReader() ← только теперь join group
// 4. FetchMessage() loop
Почему это решение правильное: race исключён на уровне приложения, не инфраструктуры. Даже если kafka.yaml не имеет никакого init — consumer сам дождётся Kafka и создаст топик.
Bug 4: CrashLoopBackOff после force delete pod-а
Force delete оставил .lock файл на PVC. Kafka падала с:
Failed to acquire lock on file .lock in /var/kafka-data/logs
Фикс: удалить StatefulSet + PVC (kubectl delete statefulset kafka && kubectl delete pvc kafka-data-kafka-0), пересоздать.
Урок: НИКОГДА не делать kubectl delete pod --force для stateful pod-ов. Только graceful (kubectl delete pod, подождать). Force delete = гарантированная поломка PVC.
Результаты тестирования (v0.1.68)
| Тест | Условие | Результат |
|---|---|---|
| Cold start | consumer стартует раньше Kafka | ✅ ensureKafkaTopic ретраится, дожидается |
| 5 рестартов consumer | Kafka работает | ✅ каждый раз kafka topic ready |
| MQTT → Pipeline | device2, 1 сообщение | ✅ offset=0 в Postgres |
| Рестарт Kafka | consumer живёт | ✅ ретраится с ERROR fetch, восстанавливается |
| 10 сообщений параллельно | 10 pod-ов mosquitto | ✅ offsets 2-11 все в Postgres |
Что НЕ тестировалось:
- Полный холодный старт с нуля (
kubectl apply -fна чистый кластер) - Consumer стартует одновременно с Kafka (оба новые) — race condition исправлен кодом, но на новом кластере не проверялся
Текущее состояние кластера (2026-04-06 ~17:30 МСК)
sless-operator:v0.1.68 — Running
kafka-0 — Running (после удаления PVC и пересоздания)
iot-mqtt-bridge — Running, подключён к EMQX и Kafka
iot-kafka-consumer — Running, waiting for messages
iot-postgres — Running
Тенант: sless-16367aacb67a4a01, устройство device2.
В IoT Postgres: 12+ записей телеметрии (offsets 0-11).
Что нужно сделать ещё
- Тест: полный холодный старт — удалить kafka + consumer + PVC, применить всё одновременно, убедиться что race не вылезает
- Helm chart — параметризовать
KAFKA_BROKERS,IOT_PG_DSN, тег образа, StorageClass дляvalues-dev.yaml/values-prod.yaml - Managed Kafka/Postgres — при переходе только менять
values-prod.yaml - Merge
iot-kafkaвmain— после тестов
Архитектурные выводы сессии
Будущая prod-архитектура (принято):
- 3 кластера: IoT / Serverless / Infra-Control
- Managed Kafka + Managed Postgres (переключение через env vars, код не меняется)
- Helm chart для параметризации per-environment
Текущий статус пути данных:
IoT Device
→ MQTT PUBLISH
→ EMQX (sless namespace)
→ iot-mqtt-bridge (подписан на +/telemetry/+)
→ Kafka топик iot.telemetry (key=namespace)
→ iot-kafka-consumer (group iot-pg-consumer)
→ IoT Postgres (per-tenant schema через EnsureTenantDB)
→ GET /v1/{ns}/iot/telemetry (IoT Console)
Полное суровое тестирование IoT pipeline (2026-04-06, вечер)
Агент: GitHub Copilot (Claude Sonnet 4.6)
Исходное состояние
- Все поды Running: kafka-0, iot-kafka-consumer, iot-mqtt-bridge, iot-postgres, emqx
- Baseline: 18 строк в
iot_telemetry(tenant_sless_16367aacb67a4a01) - Образ: v0.1.68, ветка iot-kafka
Тест-окружение
MQTT broker: emqx.sless.svc.cluster.local:1883
MQTT user: sless-16367aacb67a4a01_device2
MQTT topic: sless-16367aacb67a4a01/telemetry/device2
Kafka topic: iot.telemetry
Consumer group: iot-pg-consumer
Postgres DB: tenant_sless_16367aacb67a4a01, таблица iot_telemetry
TEST 1: Cold Start — удаление ВСЕХ IoT подов одновременно
Сценарий: kubectl delete pod kafka-0 iot-kafka-consumer iot-mqtt-bridge
Ожидание: consumer дождётся Kafka через ensureKafkaTopic(), поднимется без паники.
Что произошло:
- kafka-0 поднялся через ~40с (StatefulSet, PVC сохранился)
- consumer запустился, попал в retry loop
ensureKafkaTopic():- 16 попыток × 3с = ~48с ждал пока Kafka полностью инициализируется
- Logged: "kafka not reachable yet, retrying..." attempt=1..16
- На попытке 16: "kafka topic ready" → "kafka reader ready, waiting for messages..."
- bridge поднялся за <5с (stateless)
Верификация E2E: отправлен 1 MQTT сообщение → id=19 с {"test":"cold_start"} появился в Postgres
Результат: ✅ PASS
TEST 2: Restart resilience — 3 принудительных рестарта consumer
Сценарий: 3 раза kubectl delete pod iot-kafka-consumer --grace-period=0 подряд
Результат каждого рестарта:
- Restart 1: pod recreated, logged "starting iot-kafka-consumer"
- Restart 2: "connected to IoT Postgres" + "kafka topic ready" + "kafka reader ready" — <1с
- Restart 3: "starting iot-kafka-consumer" — <1с
Ключевое наблюдение: когда Kafka уже running, ensureKafkaTopic() проходит мгновенно (first attempt succeeds). Никакого зависания.
Результат: ✅ PASS — начало работы после рестарта: <1с
TEST 3: Load 100 сообщений — КРИТИЧЕСКОЕ ОТКРЫТИЕ
Сценарий: for i in 1..100; do mosquitto_pub ...; done из ephemeral pod
Ожидание: ≥100 строк в Postgres за ~2 мин
Что произошло:
- Цикл mosquitto_pub завершился быстро (каждый вызов QoS 0: connect+publish+disconnect)
- Все 100 сообщений упали в EMQX
- Bridge начал доставку в Kafka — при этом каждый
WriteMessagesСИНХРОННЫЙ блокирует ~1с - Bridge обрабатывает 1 сообщение/сек (throughput bottleneck!)
- После 27 доставок (25с): EMQX keepalive timeout → bridge потерял MQTT-соединение (pingresp not received)
- Bridge переподключился через 28мс (CleanSession=false)
- НО: устройства публиковали QoS 0 → EMQX не хранит un-ACK сообщения QoS 0 → 73 сообщения ПОТЕРЯНЫ безвозвратно
Итог: в Postgres попало только 27/100 сообщений
Корень проблемы — архитектурный недостаток:
Kafka.Writer{Async: false} ← каждый WriteMessages блокирует на ACK от Kafka
mosquitto_pub QoS 0 ← EMQX не хранит для оффлайн подписчиков
= при burst load потери гарантированы
Что нужно исправить (FIX backlog):
kafka.Writer{Async: true}в bridge — не блокировать MQTT loop- Устройства должны публиковать QoS ≥ 1 для гарантированной доставки
- Или увеличить keepalive timeout в bridge
Результат: ⚠️ PARTIAL FAIL — 27/100 msg. Функционально работает, но не масштабируется без фикса.
TEST 4: Burst при оффлайн consumer (Kafka buffering)
Сценарий:
kubectl scale deploy iot-kafka-consumer --replicas=0(consumer offline)- Отправить 10 сообщений через MQTT
- Проверить что в Postgres 0 новых строк (Kafka буферизует)
kubectl scale --replicas=1→ consumer поднялся- Проверить что все 10 дошли
Что произошло:
- Consumer scaled to 0 ✅
- Sent 10 msgs → bridge forwarded все 10 в Kafka (bridge работает независимо от consumer)
- Postgres: 0 новых строк (consumer offline, данные в Kafka) ✅
- Consumer поднялся → "kafka topic ready" в <1с
- Все 10 сообщений обработаны за <300мс (offsets 39-48 в одном flush)
Ключевое наблюдение: когда Kafka имеет накопленные сообщения, consumer читает их пачками (не 1/сек). Bottleneck 1/сек — только при live доставке через bridge.
Результат: ✅ PASS — Kafka держит сообщения при оффлайн consumer, доставка после старта мгновенная.
TEST 5: Невалидные сообщения
Сценарий: отправить 3 типа "невалидного" payload:
{not:valid:json— невалидный JSON- Пустое сообщение (
-nflag) plain text payload— просто строка
Что произошло:
- Bridge получил все 3 через MQTT
- Bridge код:
if !json.Valid(payload) { quotedBytes, _ := json.Marshal(string(payload)) }— оборачивает non-JSON в JSON строку - Конверсия:
{not:valid:json→"{not:valid:json"(JSON string)- пустое →
""(пустая JSON строка) plain text payload→"plain text payload"(JSON string)
- Consumer получил 3 валидных envelope, не увидел WARNов, все 3 записи сохранились в Postgres
- Consumer: статус Running, никаких крашей, никаких ошибок
Что записалось в Postgres (id=56,57,58):
56 | "{not:valid:json"
57 | ""
58 | "plain text payload"
Результат: ✅ PASS — система gracefully обрабатывает любой payload, не крашится.
TEST 6: Дублированные сообщения (at-least-once delivery)
Сценарий: отправить одно и то же сообщение {test:duplicate, value:42} 3 раза
Ожидание: 3 отдельные записи (at-least-once, нет дедупликации)
Что произошло: ровно 3 строки id=59,60,61 с одинаковым payload в Postgres
Это ожидаемое поведение. Система не deduplicate по умолчанию.
Результат: ✅ PASS (ожидаемое поведение)
TEST 7: Kafka недоступна — убить kafka-0
Сценарий:
kubectl delete pod kafka-0 --grace-period=0- Отправить 2 сообщения:
a.
kafka_down— пока Kafka недоступна b.after_kafka_restart— после восстановления
Что произошло:
Bridge реакция на Kafka downtime:
- При попытке WriteMessages →
dial tcp 10.104.151.227:9092: connect: operation not permitted - 1 ERROR в логе, сообщение
kafka_downПОТЕРЯНО (нет retry, нет local buffer) - kafka-go Writer автоматически переподключается
Consumer реакция:
- При попытке FetchMessage → серия ERROR:
connection refused, затемoperation not permitted - Retry через
continueв цикле (немедленный retry, не exponential backoff) - Kafka запустилась через ~2 мин — consumer начал получать ошибки "operation not permitted" (KRaft init)
- Через ~3 мин total: consumer переподключился автоматически
Сообщение after_kafka_restart:
- Bridge успешно forwarded в Kafka (15:11:11)
- Consumer прочитал и сохранил в Postgres (offset=55, 15:11:12) ✅
Результат: ✅ PASS с замечаниями:
- 1 сообщение потеряно при bridge Kafka error (нет retry — это FIX backlog)
- Recovery time: ~3 мин (Kafka init ~2мин + consumer reconnect ~1мин)
- После recovery: система работает нормально
Итоговая таблица тестов
| # | Тест | Статус | Примечание |
|---|---|---|---|
| 1 | Cold start (все поды) | ✅ PASS | 48с ожидание Kafka (16 retry × 3с) |
| 2 | Restart resilience (3×) | ✅ PASS | <1с при running Kafka |
| 3 | Load 100 msgs | ⚠️ PARTIAL FAIL | 27/100 доставлено. Архит. баг: Async=false + QoS 0 |
| 4 | Burst при offline consumer | ✅ PASS | Kafka держит, consumer обработал 10 за <300мс |
| 5 | Невалидные сообщения (3 типа) | ✅ PASS | Bridge оборачивает, consumer не крашится |
| 6 | Дубликаты | ✅ PASS | at-least-once, 3×identical→3 rows |
| 7 | Kafka restart (network drop) | ✅ PASS | Recovery ~3мин автоматически, 1 msg lost |
Критические находки (требуют fix)
FINDING #1: Bridge throughput bottleneck — ~1 msg/сек
Причина: kafka.Writer{Async: false} = каждый WriteMessages ждёт ACK от Kafka (~1с/msg)
Симптом: MQTT keepalive timeout → disconnect → QoS 0 loss
Fix: kafka.Writer{Async: true, ErrorLogger: ...} c обработкой ошибок
Приоритет: HIGH (потеря данных при burst)
FINDING #2: QoS 0 от устройств = no durability при bridge disconnect
Причина: mosquitto_pub без флага -q = QoS 0 = EMQX fire-and-forget
Симптом: при кратком bridge disconnect (28мс!) теряются непрочитанные сообщения
Fix: устройства должны публиковать с QoS 1 (-q 1 в mosquitto_pub)
Приоритет: HIGH (потеря данных)
FINDING #3: Bridge не retry при Kafka error
Причина: нет retry logic в buildMQTTMessageHandler
Симптом: 1 сообщение потеряно при Kafka restart
Fix: local message buffer + retry с exponential backoff
Приоритет: MEDIUM
FINDING #4: Consumer retry на Kafka error — немедленный (no backoff)
Причина: continue в цикле после ошибки = busy-wait
Симптом: срабатывает редко, но при длительном Kafka downtime = CPU waste
Fix: time.Sleep(min(retryCount*100ms, 30s)) перед continue
Приоритет: LOW
Состояние системы после тестов
Postgres: 62 строки в iot_telemetry (было 18)
Kafka offset: 55 (последний обработанный)
All pods: Running
Consumer: iot-kafka-consumer-577f7ff88d-pkqd8, Running, 0 restarts
Bridge: iot-mqtt-bridge-7dc87c46bc-tqjgz, Running, 0 restarts
kafka-0: Running, 4 мин (перезапускался в TEST 7)
Fix: v0.1.69 — Kafka write async (2026-04-06, после тестирования)
Агент: GitHub Copilot (Claude Sonnet 4.6)
Проблема, выявленная тестом #3
При load test 100 сообщений выяснилось: 27/100 доставлено.
Первичная диагностика показала throughput ~1 msg/сек — я объяснил это "bottleneck bridge" и записал в backlog. Но пользователь указал: это не backlog, это архитектурная ошибка. Между звеньями pipeline не должно быть ничего синхронного.
Анализ root cause
MQTT callback (paho.mqtt.golang) вызывается синхронно в своём goroutine.
Если callback долго выполняется — следующие входящие MQTT сообщения накапливаются.
При Async=false: WriteMessages блокируется до получения ACK от Kafka (~1-10мс в норме,
но при burst + latency spike → сотни мс → EMQX keepalive timeout = disconnect).
Цепочка событий при burst:
- 100 сообщений за <100мс влетают в EMQX
- Bridge получает первое, вызывает WriteMessages (blocking ~1с)
- Пока bridge заблокирован — EMQX keepalive не получает pingresp
- После 30с (keepalive): EMQX разрывает соединение
- Сообщения QoS 0, которые не были получены bridge — испаряются
Решение
kafka.Writer{Async: true} — WriteMessages возвращается немедленно, Kafka batching
работает в фоновом goroutine внутри kafka-go. Ошибки доставки идут в ErrorLogger,
который логирует без блокировки MQTT loop.
Почему не нужен отдельный channel/goroutine в handler:
kafka-go с Async: true уже внутри держит буфер и горутину записи.
Добавлять ещё один слой buffering — overengineering без причины.
Что изменено в коде (v0.1.69)
iot/cmd/mqtt-bridge/main.go:
// ДО (v0.1.68) — НЕПРАВИЛЬНО:
kafkaWriter := &kafka.Writer{
Async: false, // блокирует MQTT callback до ACK Kafka
}
// в handler:
err = w.WriteMessages(ctx, ...) // блокировка ~1с/msg
// ПОСЛЕ (v0.1.69) — ПРАВИЛЬНО:
kafkaWriter := &kafka.Writer{
Async: true, // WriteMessages возвращается немедленно
ErrorLogger: kafka.LoggerFunc(func(msg string, args ...interface{}) {
log.Error("kafka async write error", ...) // ошибки не блокируют MQTT
}),
}
// в handler:
_ = w.WriteMessages(ctx, ...) // немедленный возврат, доставка в фоне
Deployment manifests
Оба yaml обновлены: v0.1.68 → v0.1.69:
deployments/k8s/iot-mqtt-bridge.yamldeployments/k8s/iot-kafka-consumer.yaml
Что ожидаем после фикса
- MQTT callback завершается за <1мс (только marshal JSON + WriteMessages enqueue)
- Bridge не теряет keepalive с EMQX при burst
- Throughput: лимитируется сетью/Kafka, а не синхронным write (~тысячи msg/сек)
- Load test 100 сообщений: должны дойти все 100