feat: замена Kafka → shared-SQS в IoT pipeline
- mqtt-bridge: kafka.Writer → SQS SendMessage (AWS SDK Go v2) - kafka-consumer → sqs-consumer: polling loop с ReceiveMessage/DeleteMessage - admin stats: Kafka lag → SQS GetQueueAttributes - Удалён kafka-consumer, добавлен sqs-consumer - go.mod: убран segmentio/kafka-go, добавлен aws-sdk-go-v2 - Dockerfile, Makefile: kafka-consumer → sqs-consumer - .gitignore: исправлен чтобы не игнорировать cmd/ директории - deployments: новый iot-sqs-consumer.yaml, обновлён mqtt-bridge - doc/decisions: задокументировано решение
This commit is contained in:
+5
-4
@@ -1,7 +1,8 @@
|
|||||||
# Бинарники
|
# Бинарники (только в корне проекта, не директории cmd/)
|
||||||
iot-operator
|
/iot-operator
|
||||||
mqtt-bridge
|
/mqtt-bridge
|
||||||
kafka-consumer
|
/kafka-consumer
|
||||||
|
/sqs-consumer
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,7 @@
|
|||||||
# Создано: 2026-04-12
|
# Создано: 2026-04-12
|
||||||
|
# Изменено: 2026-04-12 (kafka-consumer → sqs-consumer)
|
||||||
# Dockerfile для IoT managed service.
|
# Dockerfile для IoT managed service.
|
||||||
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, kafka-consumer).
|
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, sqs-consumer).
|
||||||
# Образ: naeel/iot-operator (Docker Hub).
|
# Образ: naeel/iot-operator (Docker Hub).
|
||||||
|
|
||||||
FROM golang:1.25 AS builder
|
FROM golang:1.25 AS builder
|
||||||
@@ -19,11 +20,11 @@ COPY internal/ internal/
|
|||||||
# iot-operator — controller-manager + REST API
|
# iot-operator — controller-manager + REST API
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
|
||||||
|
|
||||||
# mqtt-bridge — MQTT (EMQX) → Kafka
|
# mqtt-bridge — MQTT (EMQX) → shared-SQS
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
|
||||||
|
|
||||||
# kafka-consumer — Kafka → Postgres
|
# sqs-consumer — shared-SQS → Postgres
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
|
||||||
|
|
||||||
# --- Runtime ---
|
# --- Runtime ---
|
||||||
FROM gcr.io/distroless/static:nonroot
|
FROM gcr.io/distroless/static:nonroot
|
||||||
@@ -33,7 +34,7 @@ WORKDIR /
|
|||||||
# Какой запускать — определяется command в k8s Deployment.
|
# Какой запускать — определяется command в k8s Deployment.
|
||||||
COPY --from=builder /workspace/iot-operator .
|
COPY --from=builder /workspace/iot-operator .
|
||||||
COPY --from=builder /workspace/mqtt-bridge .
|
COPY --from=builder /workspace/mqtt-bridge .
|
||||||
COPY --from=builder /workspace/kafka-consumer .
|
COPY --from=builder /workspace/sqs-consumer .
|
||||||
|
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
ENTRYPOINT ["/iot-operator"]
|
ENTRYPOINT ["/iot-operator"]
|
||||||
|
|||||||
@@ -15,16 +15,7 @@ build-bridge:
|
|||||||
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
||||||
|
|
||||||
build-consumer:
|
build-consumer:
|
||||||
CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/
|
CGO_ENABLED=0 go build -o bin/sqs-consumer ./cmd/sqs-consumer/
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker-build:
|
|
||||||
docker build -t $(IMG) .
|
|
||||||
|
|
||||||
docker-push:
|
|
||||||
docker push $(IMG)
|
|
||||||
|
|
||||||
# Тесты и go mod
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Создано: 2026-04-12
|
||||||
|
// main.go — точка входа IoT managed service.
|
||||||
|
// Запускает controller-manager (IoTDevice CRD) и REST API сервер (порт 9090) в одном процессе.
|
||||||
|
// Перенесено из sless/main.go — только IoT-специфичная часть.
|
||||||
|
//
|
||||||
|
// Компоненты:
|
||||||
|
// 1. Controller-Manager (controller-runtime) — reconcile IoTDevice CRD
|
||||||
|
// 2. REST API Server (gorilla/mux) — CRUD устройств, телеметрия, MQTT auth, admin
|
||||||
|
//
|
||||||
|
// Конфигурация через env vars:
|
||||||
|
// IOT_PG_DSN — DSN для IoT Postgres (опционально)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||||
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||||
|
|
||||||
|
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
|
||||||
|
iotcontrollers "gitea.services.ngcloud.ru/Nail/IoT/controllers"
|
||||||
|
iotapi "gitea.services.ngcloud.ru/Nail/IoT/internal/api"
|
||||||
|
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/handler"
|
||||||
|
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scheme = runtime.NewScheme()
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||||
|
// IoT API group iot.kube5s.ru/v1alpha1
|
||||||
|
utilruntime.Must(iotv1alpha1.AddToScheme(scheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
|
||||||
|
|
||||||
|
log.Info("starting IoT managed service operator")
|
||||||
|
|
||||||
|
// Controller Manager — управляет reconcile loop для IoTDevice CRD
|
||||||
|
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
|
||||||
|
Scheme: scheme,
|
||||||
|
HealthProbeBindAddress: ":8081",
|
||||||
|
LeaderElection: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("unable to create controller manager", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем IoTDevice controller
|
||||||
|
if err = (&iotcontrollers.IoTDeviceReconciler{
|
||||||
|
Client: mgr.GetClient(),
|
||||||
|
Scheme: mgr.GetScheme(),
|
||||||
|
}).SetupWithManager(mgr); err != nil {
|
||||||
|
log.Error("unable to create IoTDevice controller", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health/ready пробы для k8s
|
||||||
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||||
|
log.Error("unable to set up health check", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
||||||
|
log.Error("unable to set up ready check", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoT Postgres — опционален (если IOT_PG_DSN не задан — телеметрия отключена)
|
||||||
|
iotPGStore, err := iotpg.NewFromEnv(log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to init IoT Postgres", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if iotPGStore != nil {
|
||||||
|
defer iotPGStore.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API handler
|
||||||
|
h := &handler.Handler{
|
||||||
|
K8s: mgr.GetClient(),
|
||||||
|
Scheme: mgr.GetScheme(),
|
||||||
|
IoTPG: iotPGStore,
|
||||||
|
Log: log,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := iotapi.NewRouter(h, log)
|
||||||
|
|
||||||
|
// HTTP API сервер на порту 9090
|
||||||
|
apiServer := &http.Server{
|
||||||
|
Addr: ":9090",
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем API сервер в горутине
|
||||||
|
go func() {
|
||||||
|
log.Info("starting REST API server", "addr", ":9090")
|
||||||
|
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Error("API server failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Запускаем controller-manager (блокирует до ctx.Done)
|
||||||
|
go func() {
|
||||||
|
log.Info("starting controller manager")
|
||||||
|
if err := mgr.Start(ctx); err != nil {
|
||||||
|
log.Error("controller manager failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Info("shutting down IoT operator")
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
if err := apiServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Error("API server shutdown error", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("IoT operator stopped")
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Создано: 2026-04-04
|
||||||
|
// Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
||||||
|
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → shared-SQS.
|
||||||
|
//
|
||||||
|
// Роль в архитектуре:
|
||||||
|
// IoT Device → MQTT PUBLISH → EMQX → [mqtt-bridge подписан на "+/telemetry/+"]
|
||||||
|
// → SQS очередь "iot-telemetry"
|
||||||
|
// → iot-sqs-consumer → Postgres (история телеметрии)
|
||||||
|
// → event-dispatcher → Serverless Functions (триггеры)
|
||||||
|
//
|
||||||
|
// Логика:
|
||||||
|
// 1. Подключиться к EMQX как MQTT клиент (credentials из env)
|
||||||
|
// 2. Подписаться на топик "+/telemetry/+" (any namespace / telemetry / any device)
|
||||||
|
// 3. При получении сообщения — отправить в SQS очередь "iot-telemetry"
|
||||||
|
// 4. Payload оборачивается в envelope с метаданными (namespace, device_id, ts)
|
||||||
|
//
|
||||||
|
// Конфигурация через env vars:
|
||||||
|
// MQTT_BROKER_URL — tcp://emqx.sless.svc:1883
|
||||||
|
// MQTT_USERNAME — username для подключения bridge к EMQX
|
||||||
|
// MQTT_PASSWORD — пароль bridge клиента
|
||||||
|
// SQS_ENDPOINT — https://qu.kube5s.ru (или внутрикластерный endpoint)
|
||||||
|
// SQS_ACCESS_KEY — Access Key для shared-SQS tenant
|
||||||
|
// SQS_SECRET_KEY — Secret Key для shared-SQS tenant
|
||||||
|
// SQS_QUEUE_NAME — имя очереди (default: iot-telemetry)
|
||||||
|
// SQS_REGION — регион (default: us-east-1)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mqttBridgeConfig — конфигурация сервиса из env vars.
|
||||||
|
type mqttBridgeConfig struct {
|
||||||
|
MQTTBrokerURL string
|
||||||
|
MQTTUsername string
|
||||||
|
MQTTPassword string
|
||||||
|
SQSEndpoint string
|
||||||
|
SQSAccessKey string
|
||||||
|
SQSSecretKey string
|
||||||
|
SQSQueueName string
|
||||||
|
SQSRegion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// iotTelemetryMessage — envelope сообщения публикуемого в SQS.
|
||||||
|
// Потребители (iot-sqs-consumer, event-dispatcher) читают этот формат.
|
||||||
|
type iotTelemetryMessage struct {
|
||||||
|
// Namespace — k8s namespace тенанта (из MQTT topic, первый сегмент)
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
// DeviceID — идентификатор устройства (из MQTT topic, третий сегмент)
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
// Topic — оригинальный MQTT topic
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
// Payload — данные от устройства (JSON as-is, или строка если не JSON)
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
// ReceivedAt — время получения сообщения мостом (UTC, RFC3339)
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
cfg := loadBridgeConfig()
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Info("starting iot-mqtt-bridge",
|
||||||
|
"mqtt_broker", cfg.MQTTBrokerURL,
|
||||||
|
"mqtt_username", cfg.MQTTUsername,
|
||||||
|
"sqs_endpoint", cfg.SQSEndpoint,
|
||||||
|
"sqs_queue", cfg.SQSQueueName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQS клиент через AWS SDK Go v2. Endpoint переопределяем на shared-SQS.
|
||||||
|
sqsClient := sqs.New(sqs.Options{
|
||||||
|
Region: cfg.SQSRegion,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(
|
||||||
|
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
|
||||||
|
),
|
||||||
|
BaseEndpoint: aws.String(cfg.SQSEndpoint),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем QueueUrl по имени — SQS API требует URL, а не имя очереди.
|
||||||
|
// Делаем это один раз при старте.
|
||||||
|
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
||||||
|
QueueName: aws.String(cfg.SQSQueueName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get SQS queue URL — очередь должна существовать", "queue", cfg.SQSQueueName, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
queueURL := *queueUrlOut.QueueUrl
|
||||||
|
log.Info("SQS queue resolved", "queue_url", queueURL)
|
||||||
|
|
||||||
|
// Создаём MQTT клиент
|
||||||
|
mqttClient, err := connectMQTT(cfg, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to connect to MQTT broker", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer mqttClient.Disconnect(500)
|
||||||
|
|
||||||
|
// Функция-обработчик MQTT сообщений — отправляет envelope в SQS
|
||||||
|
messageHandler := buildMQTTMessageHandler(ctx, sqsClient, queueURL, log)
|
||||||
|
|
||||||
|
// Подписываемся на все telemetry топики всех namespace
|
||||||
|
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
|
||||||
|
const telemetryTopicFilter = "+/telemetry/+"
|
||||||
|
token := mqttClient.Subscribe(telemetryTopicFilter, 1, messageHandler)
|
||||||
|
token.Wait()
|
||||||
|
if token.Error() != nil {
|
||||||
|
log.Error("mqtt subscribe failed", "topic", telemetryTopicFilter, "err", token.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Info("subscribed to MQTT topic", "filter", telemetryTopicFilter)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Info("shutting down iot-mqtt-bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBridgeConfig читает конфигурацию из env vars.
|
||||||
|
func loadBridgeConfig() mqttBridgeConfig {
|
||||||
|
required := func(key string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
slog.Error("required env var not set", "key", key)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
return mqttBridgeConfig{
|
||||||
|
MQTTBrokerURL: getEnvOrDefault("MQTT_BROKER_URL", "tcp://emqx.sless.svc:1883"),
|
||||||
|
MQTTUsername: required("MQTT_USERNAME"),
|
||||||
|
MQTTPassword: required("MQTT_PASSWORD"),
|
||||||
|
SQSEndpoint: required("SQS_ENDPOINT"),
|
||||||
|
SQSAccessKey: required("SQS_ACCESS_KEY"),
|
||||||
|
SQSSecretKey: required("SQS_SECRET_KEY"),
|
||||||
|
SQSQueueName: getEnvOrDefault("SQS_QUEUE_NAME", "iot-telemetry"),
|
||||||
|
SQSRegion: getEnvOrDefault("SQS_REGION", "us-east-1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectMQTT устанавливает подключение к EMQX брокеру.
|
||||||
|
func connectMQTT(cfg mqttBridgeConfig, log *slog.Logger) (mqtt.Client, error) {
|
||||||
|
opts := mqtt.NewClientOptions()
|
||||||
|
opts.AddBroker(cfg.MQTTBrokerURL)
|
||||||
|
opts.SetClientID("sless-iot-bridge")
|
||||||
|
opts.SetUsername(cfg.MQTTUsername)
|
||||||
|
opts.SetPassword(cfg.MQTTPassword)
|
||||||
|
opts.SetAutoReconnect(true)
|
||||||
|
opts.SetConnectRetry(true)
|
||||||
|
opts.SetConnectRetryInterval(5 * time.Second)
|
||||||
|
opts.SetKeepAlive(30 * time.Second)
|
||||||
|
opts.SetCleanSession(false)
|
||||||
|
|
||||||
|
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||||
|
log.Warn("MQTT connection lost, reconnecting...", "err", err)
|
||||||
|
})
|
||||||
|
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
|
||||||
|
log.Info("MQTT reconnecting...")
|
||||||
|
})
|
||||||
|
opts.SetOnConnectHandler(func(_ mqtt.Client) {
|
||||||
|
log.Info("MQTT connected to broker")
|
||||||
|
})
|
||||||
|
|
||||||
|
client := mqtt.NewClient(opts)
|
||||||
|
token := client.Connect()
|
||||||
|
if !token.WaitTimeout(30 * time.Second) {
|
||||||
|
return nil, fmt.Errorf("MQTT connect timeout")
|
||||||
|
}
|
||||||
|
if token.Error() != nil {
|
||||||
|
return nil, fmt.Errorf("MQTT connect: %w", token.Error())
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMQTTMessageHandler возвращает обработчик MQTT сообщений.
|
||||||
|
// При получении — формирует envelope и отправляет в SQS очередь.
|
||||||
|
// MessageGroupId = namespace (для FIFO очередей — группировка по тенанту).
|
||||||
|
func buildMQTTMessageHandler(ctx context.Context, sqsClient *sqs.Client, queueURL string, log *slog.Logger) mqtt.MessageHandler {
|
||||||
|
return func(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
topic := msg.Topic()
|
||||||
|
payload := msg.Payload()
|
||||||
|
|
||||||
|
// Топик: "{namespace}/telemetry/{deviceId}"
|
||||||
|
parts := strings.SplitN(topic, "/", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
log.Warn("unexpected MQTT topic format, skipping", "topic", topic)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ns := parts[0]
|
||||||
|
deviceID := parts[2]
|
||||||
|
|
||||||
|
// Нормализуем payload: если не JSON — оборачиваем в строку
|
||||||
|
rawPayload := json.RawMessage(payload)
|
||||||
|
if !json.Valid(payload) {
|
||||||
|
quotedBytes, _ := json.Marshal(string(payload))
|
||||||
|
rawPayload = json.RawMessage(quotedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope := iotTelemetryMessage{
|
||||||
|
Namespace: ns,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
Topic: topic,
|
||||||
|
Payload: rawPayload,
|
||||||
|
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(envelope)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("marshal telemetry message", "topic", topic, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем в SQS. SendMessage — синхронный, но HTTP round-trip быстрый.
|
||||||
|
// При ошибке логируем и продолжаем — не блокируем MQTT callback надолго.
|
||||||
|
_, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
MessageBody: aws.String(string(body)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("SQS SendMessage failed", "queue_url", queueURL, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("forwarded IoT telemetry to SQS",
|
||||||
|
"mqtt_topic", topic,
|
||||||
|
"namespace", ns,
|
||||||
|
"device", deviceID,
|
||||||
|
"sqs_queue", queueURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Создано: 2026-04-12 (замена kafka-consumer → sqs-consumer)
|
||||||
|
// sqs-consumer/main.go — iot-sqs-consumer: читает IoT телеметрию из shared-SQS → пишет в Postgres.
|
||||||
|
//
|
||||||
|
// Роль в архитектуре:
|
||||||
|
// SQS очередь "iot-telemetry" → iot-sqs-consumer → IoT Postgres (per-tenant DB)
|
||||||
|
//
|
||||||
|
// Long polling (WaitTimeSeconds=20) — минимизирует пустые запросы к SQS.
|
||||||
|
// При временной недоступности Postgres — сообщения остаются в очереди (visibility timeout).
|
||||||
|
// DeleteMessage вызывается только после успешной записи в Postgres.
|
||||||
|
//
|
||||||
|
// Конфигурация через env vars:
|
||||||
|
// SQS_ENDPOINT — https://qu.kube5s.ru (или внутрикластерный endpoint)
|
||||||
|
// SQS_ACCESS_KEY — Access Key для shared-SQS tenant
|
||||||
|
// SQS_SECRET_KEY — Secret Key для shared-SQS tenant
|
||||||
|
// SQS_QUEUE_NAME — имя очереди (default: iot-telemetry)
|
||||||
|
// SQS_REGION — регион (default: us-east-1)
|
||||||
|
// IOT_PG_DSN — postgres://user:pass@host:5432/iotdb (master DSN для IoT Postgres)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||||
|
|
||||||
|
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqsConsumerConfig — конфигурация из env vars.
|
||||||
|
type sqsConsumerConfig struct {
|
||||||
|
SQSEndpoint string
|
||||||
|
SQSAccessKey string
|
||||||
|
SQSSecretKey string
|
||||||
|
SQSQueueName string
|
||||||
|
SQSRegion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// iotTelemetryMessage — envelope из SQS (идентичен bridge).
|
||||||
|
type iotTelemetryMessage struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
cfg := loadConsumerConfig()
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Info("starting iot-sqs-consumer",
|
||||||
|
"sqs_endpoint", cfg.SQSEndpoint,
|
||||||
|
"sqs_queue", cfg.SQSQueueName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// IoT Postgres — обязательный компонент для этого сервиса
|
||||||
|
iotStore, err := iotpg.NewFromEnv(log)
|
||||||
|
if err != nil || iotStore == nil {
|
||||||
|
log.Error("failed to connect to IoT Postgres — IOT_PG_DSN required", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer iotStore.Close()
|
||||||
|
log.Info("connected to IoT Postgres")
|
||||||
|
|
||||||
|
// SQS клиент
|
||||||
|
sqsClient := sqs.New(sqs.Options{
|
||||||
|
Region: cfg.SQSRegion,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(
|
||||||
|
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
|
||||||
|
),
|
||||||
|
BaseEndpoint: aws.String(cfg.SQSEndpoint),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем QueueUrl по имени один раз при старте
|
||||||
|
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
||||||
|
QueueName: aws.String(cfg.SQSQueueName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get SQS queue URL — очередь должна существовать", "queue", cfg.SQSQueueName, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
queueURL := *queueUrlOut.QueueUrl
|
||||||
|
log.Info("SQS queue resolved", "queue_url", queueURL)
|
||||||
|
|
||||||
|
log.Info("sqs consumer ready, starting polling loop...")
|
||||||
|
|
||||||
|
// Polling loop — long polling (WaitTimeSeconds=20) минимизирует пустые запросы.
|
||||||
|
// При ошибке SQS — backoff 5 сек и продолжаем.
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
MaxNumberOfMessages: 10,
|
||||||
|
WaitTimeSeconds: 20, // long polling — SQS держит соединение до 20 сек
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break // штатное завершение
|
||||||
|
}
|
||||||
|
log.Error("SQS ReceiveMessage failed", "err", err)
|
||||||
|
// Backoff при ошибках SQS — не спамим запросами
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sqsMsg := range resp.Messages {
|
||||||
|
if sqsMsg.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := processSQSTelemetry(ctx, *sqsMsg.Body, iotStore, log); err != nil {
|
||||||
|
log.Error("process telemetry message", "err", err, "message_id", derefStr(sqsMsg.MessageId))
|
||||||
|
// НЕ удаляем сообщение — оно вернётся в очередь после visibility timeout
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем сообщение из SQS только после успешной записи в Postgres
|
||||||
|
_, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
ReceiptHandle: sqsMsg.ReceiptHandle,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("SQS DeleteMessage failed", "err", err, "message_id", derefStr(sqsMsg.MessageId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("shutting down iot-sqs-consumer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSQSTelemetry десериализует envelope из SQS и записывает в Postgres.
|
||||||
|
func processSQSTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
|
||||||
|
var envelope iotTelemetryMessage
|
||||||
|
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
||||||
|
// Битое сообщение — логируем и пропускаем (не блокируем очередь)
|
||||||
|
log.Warn("failed to unmarshal telemetry envelope, skipping", "err", err, "raw", body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTenantDB идемпотентен — кэшируется после первого вызова
|
||||||
|
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.Payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("telemetry saved to Postgres",
|
||||||
|
"namespace", envelope.Namespace,
|
||||||
|
"device", envelope.DeviceID,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConsumerConfig читает конфигурацию из env vars.
|
||||||
|
func loadConsumerConfig() sqsConsumerConfig {
|
||||||
|
required := func(key string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
slog.Error("required env var not set", "key", key)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqsConsumerConfig{
|
||||||
|
SQSEndpoint: required("SQS_ENDPOINT"),
|
||||||
|
SQSAccessKey: required("SQS_ACCESS_KEY"),
|
||||||
|
SQSSecretKey: required("SQS_SECRET_KEY"),
|
||||||
|
SQSQueueName: getEnvOrDefault("SQS_QUEUE_NAME", "iot-telemetry"),
|
||||||
|
SQSRegion: getEnvOrDefault("SQS_REGION", "us-east-1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// derefStr — безопасная разыменовка строкового указателя.
|
||||||
|
func derefStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
# Создано: 2026-04-04
|
# Создано: 2026-04-04
|
||||||
# Изменено: 2026-04-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67)
|
# Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
||||||
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT.
|
# Deployment iot-mqtt-bridge — MQTT→SQS мост для IoT.
|
||||||
#
|
#
|
||||||
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
||||||
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace).
|
# и отправляет в shared-SQS очередь "iot-telemetry" через AWS SDK.
|
||||||
#
|
#
|
||||||
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
||||||
# Этот Secret нужно создать вручную ДО деплоя:
|
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
||||||
#
|
#
|
||||||
# # 1. Создать IoTDevice для bridge через API:
|
# Создание SQS Secret (один раз):
|
||||||
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \
|
# kubectl create secret generic iot-sqs-credentials -n sless \
|
||||||
# -d '{"name":"bridge","device_id":"bridge","enabled":true}'
|
# --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
||||||
#
|
# --from-literal=SQS_ACCESS_KEY="<access_key>" \
|
||||||
# # 2. Получить credentials:
|
# --from-literal=SQS_SECRET_KEY="<secret_key>"
|
||||||
# MQTT_USERNAME=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-username}' | base64 -d)
|
|
||||||
# MQTT_PASSWORD=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-password}' | base64 -d)
|
|
||||||
#
|
|
||||||
# # 3. Создать Secret для bridge Deployment (один раз):
|
|
||||||
# kubectl create secret generic iot-bridge-credentials -n sless \
|
|
||||||
# --from-literal=MQTT_USERNAME="$MQTT_USERNAME" \
|
|
||||||
# --from-literal=MQTT_PASSWORD="$MQTT_PASSWORD"
|
|
||||||
#
|
#
|
||||||
# Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
# Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
||||||
|
|
||||||
@@ -47,19 +40,20 @@ spec:
|
|||||||
# При смене версии оператора — менять тег и здесь.
|
# При смене версии оператора — менять тег и здесь.
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
command: ["/iot-mqtt-bridge"]
|
command: ["/mqtt-bridge"]
|
||||||
env:
|
env:
|
||||||
- name: MQTT_BROKER_URL
|
- name: MQTT_BROKER_URL
|
||||||
value: "tcp://emqx.sless.svc:1883"
|
value: "tcp://emqx.sless.svc:1883"
|
||||||
- name: KAFKA_BROKERS
|
- name: SQS_QUEUE_NAME
|
||||||
value: "kafka.sless.svc.cluster.local:9092"
|
value: "iot-telemetry"
|
||||||
|
- name: SQS_REGION
|
||||||
|
value: "us-east-1"
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-bridge-credentials
|
name: iot-bridge-credentials
|
||||||
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
|
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-postgres-secret
|
name: iot-sqs-credentials
|
||||||
optional: true
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "32Mi"
|
memory: "32Mi"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Создано: 2026-04-12
|
||||||
|
# Deployment iot-sqs-consumer — читает IoT телеметрию из shared-SQS → пишет в IoT Postgres.
|
||||||
|
#
|
||||||
|
# Long polling (WaitTimeSeconds=20) — минимизирует пустые запросы.
|
||||||
|
# Сообщение удаляется из SQS только после успешной записи в Postgres (at-least-once).
|
||||||
|
#
|
||||||
|
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
||||||
|
# IOT_PG_DSN берётся из Secret iot-postgres-secret.
|
||||||
|
#
|
||||||
|
# Создание SQS Secret (один раз, если ещё не создан):
|
||||||
|
# kubectl create secret generic iot-sqs-credentials -n sless \
|
||||||
|
# --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
||||||
|
# --from-literal=SQS_ACCESS_KEY="<access_key>" \
|
||||||
|
# --from-literal=SQS_SECRET_KEY="<secret_key>"
|
||||||
|
#
|
||||||
|
# Применение: kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: iot-sqs-consumer
|
||||||
|
namespace: sless
|
||||||
|
labels:
|
||||||
|
app: iot-sqs-consumer
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: iot-sqs-consumer
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: iot-sqs-consumer
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: sqs-consumer
|
||||||
|
# Тот же образ что и оператор — все IoT бинари в одном образе.
|
||||||
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command: ["/sqs-consumer"]
|
||||||
|
env:
|
||||||
|
- name: SQS_QUEUE_NAME
|
||||||
|
value: "iot-telemetry"
|
||||||
|
- name: SQS_REGION
|
||||||
|
value: "us-east-1"
|
||||||
|
envFrom:
|
||||||
|
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||||
|
- secretRef:
|
||||||
|
name: iot-sqs-credentials
|
||||||
|
# IOT_PG_DSN — master DSN для IoT Postgres (per-tenant DB)
|
||||||
|
- secretRef:
|
||||||
|
name: iot-postgres-secret
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "32Mi"
|
||||||
|
cpu: "25m"
|
||||||
|
limits:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: sless-registry-auth
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Решение: замена Kafka на shared-SQS
|
||||||
|
|
||||||
|
**Дата:** 2026-04-12
|
||||||
|
**Статус:** в работе
|
||||||
|
**Ветка:** `feature/replace-kafka-with-sqs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
IoT-сервис использует Kafka (библиотека `segmentio/kafka-go`) как промежуточную очередь между MQTT-bridge и Postgres consumer.
|
||||||
|
Kafka — тяжёлый компонент: требует отдельный деплоймент в кластере (`deployments/k8s/kafka.yaml`), ZooKeeper/KRaft, настройку топиков, партиций.
|
||||||
|
|
||||||
|
У нас есть собственный сервис **shared-SQS** — AWS SQS-совместимая очередь сообщений:
|
||||||
|
- Репа: https://gitea.services.ngcloud.ru/Nail/shared-SQS
|
||||||
|
- Endpoint: `https://qu.kube5s.ru`
|
||||||
|
- 17 SQS-операций, multi-tenant, Redis persistence, billing
|
||||||
|
- Работает через стандартные AWS SDK
|
||||||
|
|
||||||
|
RabbitMQ в коде IoT **не используется** (только в старых документах как план MVP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заменить Kafka → shared-SQS во всём IoT pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что меняется
|
||||||
|
|
||||||
|
### 1. mqtt-bridge (`cmd/mqtt-bridge/main.go`)
|
||||||
|
- **Было:** `kafka.Writer` → `WriteMessages()` в топик `iot.telemetry`
|
||||||
|
- **Стало:** AWS SDK Go v2 → `sqs.SendMessage()` в очередь `iot-telemetry`
|
||||||
|
- Env: `KAFKA_BROKERS` → `SQS_ENDPOINT`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`, `SQS_QUEUE_NAME`
|
||||||
|
|
||||||
|
### 2. kafka-consumer → sqs-consumer (`cmd/kafka-consumer/` → `cmd/sqs-consumer/`)
|
||||||
|
- **Было:** `kafka.NewReader` с consumer group, `FetchMessage()` + `CommitMessages()`
|
||||||
|
- **Стало:** polling loop: `sqs.ReceiveMessage(WaitTimeSeconds=20)` + `sqs.DeleteMessage()`
|
||||||
|
- Переименовать директорию и бинарник
|
||||||
|
|
||||||
|
### 3. admin stats handler (`internal/api/handler/iot_admin_stats_handler.go`)
|
||||||
|
- **Было:** Kafka lag (ListOffsets + OffsetFetch)
|
||||||
|
- **Стало:** `sqs.GetQueueAttributes(ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible)`
|
||||||
|
- Env: `KAFKA_BROKERS` → SQS credentials в handler
|
||||||
|
|
||||||
|
### 4. go.mod
|
||||||
|
- Убрать: `github.com/segmentio/kafka-go`
|
||||||
|
- Добавить: `github.com/aws/aws-sdk-go-v2`, `github.com/aws/aws-sdk-go-v2/service/sqs`
|
||||||
|
|
||||||
|
### 5. Deployments
|
||||||
|
- Удалить: `deployments/k8s/kafka.yaml`
|
||||||
|
- Обновить: `deployments/k8s/iot-mqtt-bridge.yaml` — новые env vars
|
||||||
|
- Обновить: `deployments/k8s/iot-kafka-consumer.yaml` → `iot-sqs-consumer.yaml`
|
||||||
|
|
||||||
|
### 6. Dockerfile / Makefile
|
||||||
|
- Переименовать бинарник `kafka-consumer` → `sqs-consumer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Плюсы
|
||||||
|
|
||||||
|
- Убираем Kafka из кластера (экономия ресурсов)
|
||||||
|
- Используем свой managed сервис (единая инфраструктура)
|
||||||
|
- AWS SDK — стандартная библиотека, код проще
|
||||||
|
- Billing и мониторинг из коробки в shared-SQS
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- SQS — pull-based (polling latency до 20s long poll vs Kafka push). Для IoT телеметрии приемлемо.
|
||||||
|
- At-least-once delivery — нужно учитывать idempotency (уже есть в текущем коде: INSERT ON CONFLICT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQS endpoint
|
||||||
|
|
||||||
|
Пока используем публичный `https://qu.kube5s.ru`. Если есть внутрикластерный сервис — обновим.
|
||||||
@@ -3,11 +3,13 @@ module gitea.services.ngcloud.ru/Nail/IoT
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.11.2
|
||||||
github.com/segmentio/kafka-go v0.4.50
|
|
||||||
k8s.io/api v0.26.0
|
k8s.io/api v0.26.0
|
||||||
k8s.io/apimachinery v0.26.0
|
k8s.io/apimachinery v0.26.0
|
||||||
k8s.io/client-go v0.26.0
|
k8s.io/client-go v0.26.0
|
||||||
@@ -15,6 +17,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -36,13 +41,11 @@ require (
|
|||||||
github.com/imdario/mergo v0.3.6 // indirect
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.15.9 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
|
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.14.0 // indirect
|
github.com/prometheus/client_golang v1.14.0 // indirect
|
||||||
github.com/prometheus/client_model v0.3.0 // indirect
|
github.com/prometheus/client_model v0.3.0 // indirect
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
|||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 h1:8Bv3TQ1Cob6HLlpUbAnWxeHhAkYScJO9RIHh2WPXaxw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs=
|
||||||
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
@@ -188,8 +200,6 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
|||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
|
||||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
@@ -225,8 +235,6 @@ github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc=
|
|||||||
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
|
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
|
||||||
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
|
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
|
||||||
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
|
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
|
||||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -260,8 +268,6 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
|
|||||||
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
||||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
|
|
||||||
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
|
||||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||||
@@ -278,12 +284,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
|
||||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ K8s client.Client
|
|||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||||
IoTPG *iotpg.IoTPostgresStore
|
IoTPG *iotpg.IoTPostgresStore
|
||||||
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
|
|
||||||
KafkaBrokers string
|
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Создано: 2026-04-06
|
// Создано: 2026-04-06
|
||||||
|
// Изменено: 2026-04-12 (замена Kafka lag → SQS queue stats)
|
||||||
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
||||||
//
|
//
|
||||||
// Endpoints:
|
// Endpoints:
|
||||||
@@ -6,8 +7,8 @@
|
|||||||
//
|
//
|
||||||
// Источники данных:
|
// Источники данных:
|
||||||
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
||||||
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
|
// - SQS: approximate message count (ApproximateNumberOfMessages)
|
||||||
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
|
// - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
|
||||||
//
|
//
|
||||||
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
||||||
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
||||||
@@ -19,10 +20,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
kafka "github.com/segmentio/kafka-go"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||||
|
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
)
|
||||||
@@ -36,12 +41,11 @@ type iotAdminPodStatus struct {
|
|||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
|
// iotAdminSQSStats — информация о SQS очереди для мониторинга.
|
||||||
type iotAdminKafkaStats struct {
|
type iotAdminSQSStats struct {
|
||||||
LatestOffset int64 `json:"latest_offset"`
|
ApproximateMessages int64 `json:"approximate_messages"`
|
||||||
CommittedOffset int64 `json:"committed_offset"`
|
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
|
||||||
ConsumerLag int64 `json:"consumer_lag"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminStats обрабатывает GET /iot-admin/stats.
|
// AdminStats обрабатывает GET /iot-admin/stats.
|
||||||
@@ -78,8 +82,8 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
|
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
|
// SQS: approximate message count для очереди iot-telemetry
|
||||||
result["kafka"] = h.collectIotKafkaLag(ctx)
|
result["sqs"] = h.collectIotSQSStats(ctx)
|
||||||
|
|
||||||
// K8s: статус подов bridge и consumer
|
// K8s: статус подов bridge и consumer
|
||||||
result["pods"] = h.collectIotPodStatuses(ctx)
|
result["pods"] = h.collectIotPodStatuses(ctx)
|
||||||
@@ -87,72 +91,59 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
|
// collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
|
||||||
// вычисляет lag = latest - committed.
|
// Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
|
||||||
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
|
func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
|
||||||
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
endpoint := os.Getenv("SQS_ENDPOINT")
|
||||||
if h.KafkaBrokers == "" {
|
accessKey := os.Getenv("SQS_ACCESS_KEY")
|
||||||
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
|
secretKey := os.Getenv("SQS_SECRET_KEY")
|
||||||
|
queueName := os.Getenv("SQS_QUEUE_NAME")
|
||||||
|
if queueName == "" {
|
||||||
|
queueName = "iot-telemetry"
|
||||||
|
}
|
||||||
|
region := os.Getenv("SQS_REGION")
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
brokers := strings.Split(h.KafkaBrokers, ",")
|
if endpoint == "" || accessKey == "" || secretKey == "" {
|
||||||
brokerAddr := kafka.TCP(brokers...)
|
return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
|
||||||
|
|
||||||
kc := &kafka.Client{
|
|
||||||
Addr: brokerAddr,
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topic = "iot.telemetry"
|
sqsClient := sqs.New(sqs.Options{
|
||||||
const group = "iot-pg-consumer"
|
Region: region,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(
|
||||||
|
accessKey, secretKey, "",
|
||||||
|
),
|
||||||
|
BaseEndpoint: aws.String(endpoint),
|
||||||
|
})
|
||||||
|
|
||||||
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
|
// Получаем URL очереди
|
||||||
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
|
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
||||||
Addr: brokerAddr,
|
QueueName: aws.String(queueName),
|
||||||
Topics: map[string][]kafka.OffsetRequest{
|
})
|
||||||
topic: {kafka.LastOffsetOf(0)},
|
if err != nil {
|
||||||
|
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueUrl: %v", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрашиваем атрибуты очереди — approximate message counts
|
||||||
|
attrsOut, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
|
||||||
|
QueueUrl: queueUrlOut.QueueUrl,
|
||||||
|
AttributeNames: []sqstypes.QueueAttributeName{
|
||||||
|
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
|
||||||
|
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
|
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestOffset int64
|
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
|
||||||
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
|
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
|
||||||
if partitions[0].Error == nil {
|
|
||||||
latestOffset = partitions[0].LastOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем committed offset consumer group (что consumer уже обработал)
|
return iotAdminSQSStats{
|
||||||
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
|
ApproximateMessages: approxMsg,
|
||||||
Addr: brokerAddr,
|
ApproximateMessagesNotVisible: approxNotVisible,
|
||||||
GroupID: group,
|
|
||||||
Topics: map[string][]int{topic: {0}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return iotAdminKafkaStats{
|
|
||||||
LatestOffset: latestOffset,
|
|
||||||
Error: fmt.Sprintf("offset fetch: %v", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var committedOffset int64
|
|
||||||
if partitions, ok := fetchResp.Topics[topic]; ok && len(partitions) > 0 {
|
|
||||||
if partitions[0].Error == nil {
|
|
||||||
committedOffset = partitions[0].CommittedOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lag := latestOffset - committedOffset
|
|
||||||
if lag < 0 {
|
|
||||||
lag = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return iotAdminKafkaStats{
|
|
||||||
LatestOffset: latestOffset,
|
|
||||||
CommittedOffset: committedOffset,
|
|
||||||
ConsumerLag: lag,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +151,7 @@ func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
|||||||
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
|
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
|
||||||
result := map[string]any{}
|
result := map[string]any{}
|
||||||
|
|
||||||
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-kafka-consumer"} {
|
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-sqs-consumer"} {
|
||||||
podList := &corev1.PodList{}
|
podList := &corev1.PodList{}
|
||||||
if err := h.K8s.List(ctx, podList,
|
if err := h.K8s.List(ctx, podList,
|
||||||
client.InNamespace("sless"),
|
client.InNamespace("sless"),
|
||||||
|
|||||||
Reference in New Issue
Block a user