diff --git a/doc/architecture/sqs-operator-schema.svg b/doc/architecture/sqs-operator-schema.svg new file mode 100644 index 0000000..dff1411 --- /dev/null +++ b/doc/architecture/sqs-operator-schema.svg @@ -0,0 +1,135 @@ + + + + + + + SQS Operator — ресурсы на тенанта + + + + 📋 QueueService CR + tenant: test001 · enableUI: true + + + + reconcile + + + + ⚙️ Оператор + QueueServiceReconciler (Go) + + + + Namespace: sless-fn-test001 + + + + creates + + + + + 🔑 Secret + sqs-creds-test001 + access_key / secret_key + + + + 📄 ConfigMap + sqs-cfg-test001 + elasticmq.conf + + + + 💾 PVC + sqs-data-test001 + H2 persistence (остаётся при удалении CR) + + + + + + + 🚀 Deployment: sqs-test001 + + + + ElasticMQ + port 9324 + Scala / Akka HTTP + elasticmq:1.7.1 + + + + elasticmq-ui + port 3000 + Next.js + elasticmq-ui:latest + + + + + mount + + + + 🔌 Service ClusterIP + sqs-svc-test001 + 9324 (SQS) · 3000 (UI) + + + + + + + + 🌐 Ingress SQS API + sqs-ing-test001 + /sqs/test001/... → :9324 + + + + 🌐 Ingress UI + sqs-ing-ui-test001 + /sqs-ui/test001/ → :3000 + + + + 🌐 Ingress Assets + sqs-ing-ui-assets-test001 + /_next/ → :3000 + + + + 🌐 Ingress Routes + sqs-ing-ui-queues-test001 + /queues/ → :3000 + + + + + + + + + + + + + 🖥️ Browser / AWS SDK + sqs.kube5s.ru (HTTPS) + + + + + + + + + + + + + diff --git a/doc/errors/log.md b/doc/errors/log.md index b9a77b6..cb76b74 100644 --- a/doc/errors/log.md +++ b/doc/errors/log.md @@ -1662,3 +1662,10 @@ if errors.IsInvalid(err) { **Симптом:** Клик на очередь в UI → браузер переходит на `/queues/1234` → nginx 404. **Причина:** Next.js в образе `elasticmq-ui` собран с `basePath=""`. Внутренние переходы идут по абсолютным путям без prefix `/sqs-ui/tenantID/`. Ingress не знал о маршруте `/queues`. **Решение (v0.1.12):** `ensureIngressUIQueues` — третий ingress `/queues` PathTypePrefix → UI service:3000. Без rewrite-target. + +### ERR-SQS-06: H2 file lock при rollout restart (повторяющийся) + +**Симптом:** После `kubectl rollout restart` ElasticMQ стартует, SQS API отвечает, но SendMessage зависает навсегда. В логах: `MVStoreException: The file is locked: /data/elasticmq.mv.db`. +**Ложный фикс (v0.1.10):** `FILE_LOCK=NO` в JDBC URI — не помогает, т.к. lock на уровне `FileChannel.lock()`, не JDBC. +**Настоящая причина:** `Deployment strategy: RollingUpdate` + `PVC: ReadWriteOnce`. При rollout новый pod поднимается ДО убийства старого. Оба монтируют один PVC, ElasticMQ-1 держит lock → ElasticMQ-2 не может открыть H2 → persistence actor падает → write-операции зависают (dead letters). +**Решение (v0.1.13):** Strategy `Recreate` (старый pod убивается до создания нового), `preStop: sleep 3` (graceful H2 shutdown), `livenessProbe timeoutSeconds: 3` (защита от GC pause false positive). diff --git a/doc/progress.md b/doc/progress.md index fef7403..933fc24 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -16,6 +16,7 @@ | v0.1.10 | FILE_LOCK=NO в H2 JDBC URL (SendMessage зависал после rollout restart) | — | | v0.1.11 | ensureService добавляет port 3000 при enableUI=true (503 после self-healing) | a04d720 | | v0.1.12 | Ingress /queues -> elasticmq-ui:3000 (404 при навигации) | 3adc0d8 | +| v0.1.13 | Strategy Recreate + preStop + liveness fix (H2 lock root cause) | pending | ### Тест-сьют v0.1.10 — финал diff --git a/doc/thinking/2026-04-09.md b/doc/thinking/2026-04-09.md new file mode 100644 index 0000000..b9c77fc --- /dev/null +++ b/doc/thinking/2026-04-09.md @@ -0,0 +1,77 @@ +# 2026-04-09 — Thinking Log + +**Агент:** GitHub Copilot (Claude Opus 4) + +--- + +## Анализ H2 file lock — корневая причина + +### Симптом +При каждом `kubectl rollout restart` ElasticMQ стартует, но `SendMessage` зависает навсегда. +В логах: `The file is locked: /data/elasticmq.mv.db [2.2.224/7]`, затем `dead letters` и `AskTimeoutException` на SendMessage. + +### Ошибочная гипотеза (v0.1.10) +Предположил что JVM не освобождает JDBC-level lock при crash → добавил `FILE_LOCK=NO` в JDBC URI. +**Это было НЕПРАВИЛЬНО.** `FILE_LOCK=NO` отключает только JDBC soft-lock. H2 MVStore использует `java.nio.FileChannel.lock()` — это OS-level file lock, не зависящий от JDBC параметров. + +### Почему "работало" после каждой чистки +После `rm /data/elasticmq.mv.db` + restart — файл создаётся заново, lock отсутствует. Но при следующем rollout restart проблема возвращается. + +### Корневая причина (найдена 2026-04-09) + +**Deployment strategy: `RollingUpdate` + PVC: `ReadWriteOnce`** + +Цепочка событий при `kubectl rollout restart`: +1. Kubernetes добавляет аннотацию `restartedAt` → меняется template → начинается rollout +2. Стратегия `RollingUpdate` (maxSurge=25%, maxUnavailable=25%) → для replicas=1: + - maxSurge=1 (ceil 0.25) → Kubernetes поднимает НОВЫЙ pod + - maxUnavailable=0 (floor 0.25) → старый pod ЕЩЁ ЖИВА +3. PVC `ReadWriteOnce` — допускает mount с нескольких pod на ОДНОЙ НОДЕ (это не ReadWriteOncePod) +4. Оба pod монтируют один PVC → оба пытаются открыть `/data/elasticmq.mv.db` +5. Старый ElasticMQ держит `FileChannel.lock()` → новый ElasticMQ получает `MVStoreException: The file is locked` +6. Persistence actor (SqlQueuePersistenceActor) в новом pod падает → dead letters +7. Старый pod убивается (readinessProbe eventual fail) → lock освобождается — но поздно +8. SQS REST server работает (port 9324 слушает), но WRITE-операции (SendMessage) зависают — actor мёртв + +### Решение — 3 изменения в ensureDeployment + +1. **Strategy: Recreate** (вместо RollingUpdate) + - Kubernetes СНАЧАЛА убивает старый pod, ПОТОМ поднимает новый + - Два pod НИКОГДА не работают одновременно → lock невозможен + - Downtime ~25-30 секунд (JVM startup) — допустимо для мультитенант SQS + +2. **preStop hook: sleep 3** + - При SIGTERM JVM начинает shutdown + - `sleep 3` даёт H2 время на `fsync` + `FileChannel.close()` + - Без preStop: Kubernetes может убить pod раньше чем H2 закончит flush + +3. **livenessProbe timeoutSeconds: 1 → 3** + - JVM стартует за 20-23 секунды + - initialDelaySeconds=5 + failureThreshold=5 × period=10 = 55 сек запас — хватает для старта + - НО: `timeoutSeconds=1` — если GC pause > 1 сек → liveness fail → unnecessary restart → CrashLoopBackOff + - Поднимаем до 3 секунд. GC pause > 3 сек — это уже реальная проблема которую стоит рестартить + +## Дополнительные обнаруженные проблемы + +### /_next/ и /queues/ ingress — глобальные (архитектурная) +Пути `/_next/` и `/queues/` на хосте `sqs.kube5s.ru` общие. При двух тенантах с `enableUI=true` — конфликт ingress. +**Решение отложено** — пока один тенант с UI. При мультитенант UI → нужен отдельный хост per tenant. + +### imagePullPolicy: Always на UI +`softwaremill/elasticmq-ui:latest` + `Always` → upstream может сломать при обновлении. +**Пока оставляем** — будем пинить версию когда стабилизируем. + +### memory limit 512Mi vs Xmx 384m +`-Xmx384m` + JVM overhead ~150 МБ = ~534 МБ > limit 512 Mi. OOMKill возможен при нагрузке. +**Пока оставляем** — в idle не стреляет. Учтём при нагрузочном тестировании. + +## Самоанализ ошибки + +Почему неправильно решил в v0.1.10: +- Увидел `The file is locked` → сразу искал H2-настройки → нашёл `FILE_LOCK=NO` +- НЕ проверил deployment strategy (RollingUpdate — default в Kubernetes) +- НЕ проверил ReadWriteOnce behavior (допускает multi-pod на одной ноде) +- НЕ проверил что происходит при rollout (два pod одновременно) +- Лечил симптом (lock message) вместо причины (concurrent access) + +**Вывод:** при любой ошибке связанной с persistence/lock/state — ПЕРВЫМ делом проверять: кто ещё имеет доступ к файлу? Сколько pod одновременно работают? Какая стратегия деплоя? diff --git a/sqs-operator/internal/controller/queueservice_controller.go b/sqs-operator/internal/controller/queueservice_controller.go index d722061..56ab2b7 100644 --- a/sqs-operator/internal/controller/queueservice_controller.go +++ b/sqs-operator/internal/controller/queueservice_controller.go @@ -533,6 +533,12 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1 "sqs.kube5s.ru/tenant": qs.Spec.TenantID, }, }, + // Recreate: старый pod убивается ДО создания нового. + // H2 MVStore держит FileChannel.lock() на /data/elasticmq.mv.db — + // при RollingUpdate два pod лезут в один PVC одновременно = ERR-SQS-06. + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RecreateDeploymentStrategyType, + }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: sqsLabels(qs.Spec.TenantID), @@ -543,6 +549,8 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1 SecurityContext: &corev1.PodSecurityContext{ FSGroup: func() *int64 { v := int64(999); return &v }(), }, + // 15 сек — достаточно для graceful shutdown JVM + H2 fsync. + TerminationGracePeriodSeconds: func() *int64 { v := int64(15); return &v }(), Containers: func() []corev1.Container { ctrs := []corev1.Container{ { @@ -605,8 +613,17 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1 }, InitialDelaySeconds: 5, PeriodSeconds: 10, + TimeoutSeconds: 3, FailureThreshold: 5, }, + // preStop: 3 сек на graceful shutdown H2 перед SIGTERM. + Lifecycle: &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"sh", "-c", "sleep 3"}, + }, + }, + }, }, } if qs.Spec.EnableUI { diff --git a/sqs-operator/patch_v0113_recreate.py b/sqs-operator/patch_v0113_recreate.py new file mode 100644 index 0000000..88c352a --- /dev/null +++ b/sqs-operator/patch_v0113_recreate.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# 2026-04-09 — v0.1.13: Strategy Recreate + preStop hook + liveness timeout fix +import sys + +FILE = "/home/naeel/terra/sless/sqs-operator/internal/controller/queueservice_controller.go" + +with open(FILE, "r") as f: + code = f.read() + +# === PATCH 1: Strategy Recreate === +old_1 = "\t\t\t},\n\t\t\tTemplate: corev1.PodTemplateSpec{" +new_1 = """\t\t\t}, +\t\t\t// Recreate: старый pod убивается ДО создания нового. +\t\t\t// H2 MVStore держит FileChannel.lock() на /data/elasticmq.mv.db — +\t\t\t// при RollingUpdate два pod лезут в один PVC одновременно = ERR-SQS-06. +\t\t\tStrategy: appsv1.DeploymentStrategy{ +\t\t\t\tType: appsv1.RecreateDeploymentStrategyType, +\t\t\t}, +\t\t\tTemplate: corev1.PodTemplateSpec{""" + +count = code.count(old_1) +if count != 1: + print(f"FAIL patch 1: found {count} matches for Selector close + Template") + sys.exit(1) +code = code.replace(old_1, new_1, 1) +print("PATCH 1 OK: Strategy Recreate") + +# === PATCH 2: terminationGracePeriodSeconds === +old_2 = "\t\t\t\t\tSecurityContext: &corev1.PodSecurityContext{\n\t\t\t\t\t\tFSGroup: func() *int64 { v := int64(999); return &v }(),\n\t\t\t\t\t},\n\t\t\t\t\tContainers: func() []corev1.Container {" +new_2 = """\t\t\t\t\tSecurityContext: &corev1.PodSecurityContext{ +\t\t\t\t\t\tFSGroup: func() *int64 { v := int64(999); return &v }(), +\t\t\t\t\t}, +\t\t\t\t\t// 15 сек — достаточно для graceful shutdown JVM + H2 fsync. +\t\t\t\t\tTerminationGracePeriodSeconds: func() *int64 { v := int64(15); return &v }(), +\t\t\t\t\tContainers: func() []corev1.Container {""" + +count = code.count(old_2) +if count != 1: + print(f"FAIL patch 2: found {count} matches for SecurityContext + Containers") + sys.exit(1) +code = code.replace(old_2, new_2, 1) +print("PATCH 2 OK: terminationGracePeriodSeconds 15") + +# === PATCH 3: LivenessProbe TimeoutSeconds=3 + preStop hook === +old_3 = "\t\t\t\t\t\t\t\t\tInitialDelaySeconds: 5,\n\t\t\t\t\t\t\t\t\tPeriodSeconds: 10,\n\t\t\t\t\t\t\t\t\tFailureThreshold: 5,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}" +new_3 = """\t\t\t\t\t\t\t\t\tInitialDelaySeconds: 5, +\t\t\t\t\t\t\t\t\tPeriodSeconds: 10, +\t\t\t\t\t\t\t\t\tTimeoutSeconds: 3, +\t\t\t\t\t\t\t\t\tFailureThreshold: 5, +\t\t\t\t\t\t\t\t}, +\t\t\t\t\t\t\t\t// preStop: 3 сек на graceful shutdown H2 перед SIGTERM. +\t\t\t\t\t\t\t\tLifecycle: &corev1.Lifecycle{ +\t\t\t\t\t\t\t\t\tPreStop: &corev1.LifecycleHandler{ +\t\t\t\t\t\t\t\t\t\tExec: &corev1.ExecAction{ +\t\t\t\t\t\t\t\t\t\t\tCommand: []string{"sh", "-c", "sleep 3"}, +\t\t\t\t\t\t\t\t\t\t}, +\t\t\t\t\t\t\t\t\t}, +\t\t\t\t\t\t\t\t}, +\t\t\t\t\t\t\t}, +\t\t\t\t\t\t}""" + +count = code.count(old_3) +if count != 1: + print(f"FAIL patch 3: found {count} matches for LivenessProbe block") + sys.exit(1) +code = code.replace(old_3, new_3, 1) +print("PATCH 3 OK: timeoutSeconds=3 + preStop sleep 3") + +# === PATCH 4: version === +if "0.1.12" in code: + code = code.replace("0.1.12", "0.1.13") + print("PATCH 4 OK: version -> 0.1.13") +else: + print("PATCH 4 SKIP: version string not found") + +with open(FILE, "w") as f: + f.write(code) +print("\nAll patches applied.")