v0.1.13: Strategy Recreate + preStop + liveness fix (ERR-SQS-06 root cause)

- ensureDeployment: Strategy Recreate (not RollingUpdate) — prevents H2 file lock
  when two pods mount same PVC simultaneously during rollout restart
- preStop: sleep 3 — graceful H2 shutdown before SIGTERM
- livenessProbe timeoutSeconds: 3 — prevents false positive on GC pause
- terminationGracePeriodSeconds: 15
- doc: thinking log, ERR-SQS-06, progress.md, architecture SVG schema
This commit is contained in:
Naeel
2026-04-09 07:49:11 +03:00
parent 6b7b60db1b
commit d1d1bffd7c
6 changed files with 315 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="700" font-family="Arial, sans-serif" font-size="13">
<!-- Background -->
<rect width="900" height="700" fill="#f8f9fa" rx="10"/>
<!-- Title -->
<text x="450" y="32" text-anchor="middle" font-size="18" font-weight="bold" fill="#1a1a2e">SQS Operator — ресурсы на тенанта</text>
<!-- === QueueService CR === -->
<rect x="340" y="55" width="220" height="55" rx="8" fill="#4a90d9" stroke="#2c6fad" stroke-width="1.5"/>
<text x="450" y="77" text-anchor="middle" fill="white" font-weight="bold">📋 QueueService CR</text>
<text x="450" y="97" text-anchor="middle" fill="#dce9f8" font-size="11">tenant: test001 · enableUI: true</text>
<!-- Arrow CR -> Operator -->
<line x1="450" y1="110" x2="450" y2="145" stroke="#666" stroke-width="1.5" marker-end="url(#arr)"/>
<text x="460" y="132" fill="#666" font-size="11">reconcile</text>
<!-- === Operator === -->
<rect x="310" y="145" width="280" height="50" rx="8" fill="#7c3aed" stroke="#5b21b6" stroke-width="1.5"/>
<text x="450" y="166" text-anchor="middle" fill="white" font-weight="bold">⚙️ Оператор</text>
<text x="450" y="184" text-anchor="middle" fill="#e9d5ff" font-size="11">QueueServiceReconciler (Go)</text>
<!-- === Namespace box === -->
<rect x="30" y="235" width="840" height="430" rx="10" fill="white" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="50" y="258" fill="#64748b" font-size="12" font-weight="bold">Namespace: sless-fn-test001</text>
<!-- Arrow Operator -> Namespace -->
<line x1="450" y1="195" x2="450" y2="235" stroke="#666" stroke-width="1.5" marker-end="url(#arr)"/>
<text x="460" y="220" fill="#666" font-size="11">creates</text>
<!-- === Row 1: Secret, ConfigMap, PVC === -->
<!-- Secret -->
<rect x="55" y="270" width="180" height="60" rx="7" fill="#059669" stroke="#047857" stroke-width="1.5"/>
<text x="145" y="293" text-anchor="middle" fill="white" font-weight="bold">🔑 Secret</text>
<text x="145" y="311" text-anchor="middle" fill="#d1fae5" font-size="11">sqs-creds-test001</text>
<text x="145" y="325" text-anchor="middle" fill="#d1fae5" font-size="10">access_key / secret_key</text>
<!-- ConfigMap -->
<rect x="260" y="270" width="180" height="60" rx="7" fill="#d97706" stroke="#b45309" stroke-width="1.5"/>
<text x="350" y="293" text-anchor="middle" fill="white" font-weight="bold">📄 ConfigMap</text>
<text x="350" y="311" text-anchor="middle" fill="#fef3c7" font-size="11">sqs-cfg-test001</text>
<text x="350" y="325" text-anchor="middle" fill="#fef3c7" font-size="10">elasticmq.conf</text>
<!-- PVC -->
<rect x="465" y="270" width="180" height="60" rx="7" fill="#0891b2" stroke="#0e7490" stroke-width="1.5"/>
<text x="555" y="293" text-anchor="middle" fill="white" font-weight="bold">💾 PVC</text>
<text x="555" y="311" text-anchor="middle" fill="#cffafe" font-size="11">sqs-data-test001</text>
<text x="555" y="325" text-anchor="middle" fill="#cffafe" font-size="10">H2 persistence (остаётся при удалении CR)</text>
<!-- === Deployment === -->
<rect x="55" y="365" width="840" height="0" rx="0" fill="none"/>
<!-- Deployment box -->
<rect x="160" y="360" width="380" height="120" rx="9" fill="#f1f5f9" stroke="#475569" stroke-width="2"/>
<text x="350" y="380" text-anchor="middle" fill="#334155" font-weight="bold" font-size="12">🚀 Deployment: sqs-test001</text>
<!-- Container ElasticMQ -->
<rect x="175" y="390" width="160" height="75" rx="6" fill="#e2e8f0" stroke="#64748b" stroke-width="1"/>
<text x="255" y="410" text-anchor="middle" fill="#1e293b" font-weight="bold" font-size="11">ElasticMQ</text>
<text x="255" y="427" text-anchor="middle" fill="#475569" font-size="10">port 9324</text>
<text x="255" y="442" text-anchor="middle" fill="#475569" font-size="10">Scala / Akka HTTP</text>
<text x="255" y="457" text-anchor="middle" fill="#64748b" font-size="10">elasticmq:1.7.1</text>
<!-- Container UI -->
<rect x="355" y="390" width="170" height="75" rx="6" fill="#e2e8f0" stroke="#64748b" stroke-width="1"/>
<text x="440" y="410" text-anchor="middle" fill="#1e293b" font-weight="bold" font-size="11">elasticmq-ui</text>
<text x="440" y="427" text-anchor="middle" fill="#475569" font-size="10">port 3000</text>
<text x="440" y="442" text-anchor="middle" fill="#475569" font-size="10">Next.js</text>
<text x="440" y="457" text-anchor="middle" fill="#64748b" font-size="10">elasticmq-ui:latest</text>
<!-- Arrows ConfigMap/PVC -> Deployment -->
<line x1="350" y1="330" x2="350" y2="360" stroke="#b45309" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arr)"/>
<line x1="555" y1="330" x2="430" y2="360" stroke="#0e7490" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arr)"/>
<text x="470" y="350" fill="#64748b" font-size="10">mount</text>
<!-- === Service === -->
<rect x="600" y="380" width="200" height="65" rx="7" fill="#6366f1" stroke="#4f46e5" stroke-width="1.5"/>
<text x="700" y="403" text-anchor="middle" fill="white" font-weight="bold">🔌 Service ClusterIP</text>
<text x="700" y="421" text-anchor="middle" fill="#e0e7ff" font-size="11">sqs-svc-test001</text>
<text x="700" y="437" text-anchor="middle" fill="#e0e7ff" font-size="11">9324 (SQS) · 3000 (UI)</text>
<!-- Arrow Deployment -> Service -->
<line x1="540" y1="415" x2="600" y2="415" stroke="#666" stroke-width="1.5" marker-end="url(#arr)"/>
<!-- === Ingresses === -->
<!-- ING1 -->
<rect x="55" y="520" width="185" height="65" rx="7" fill="#db2777" stroke="#be185d" stroke-width="1.5"/>
<text x="147" y="543" text-anchor="middle" fill="white" font-weight="bold">🌐 Ingress SQS API</text>
<text x="147" y="560" text-anchor="middle" fill="#fce7f3" font-size="10">sqs-ing-test001</text>
<text x="147" y="575" text-anchor="middle" fill="#fce7f3" font-size="10">/sqs/test001/... → :9324</text>
<!-- ING2 -->
<rect x="260" y="520" width="185" height="65" rx="7" fill="#db2777" stroke="#be185d" stroke-width="1.5"/>
<text x="352" y="543" text-anchor="middle" fill="white" font-weight="bold">🌐 Ingress UI</text>
<text x="352" y="560" text-anchor="middle" fill="#fce7f3" font-size="10">sqs-ing-ui-test001</text>
<text x="352" y="575" text-anchor="middle" fill="#fce7f3" font-size="10">/sqs-ui/test001/ → :3000</text>
<!-- ING3 -->
<rect x="465" y="520" width="185" height="65" rx="7" fill="#db2777" stroke="#be185d" stroke-width="1.5"/>
<text x="557" y="543" text-anchor="middle" fill="white" font-weight="bold">🌐 Ingress Assets</text>
<text x="557" y="560" text-anchor="middle" fill="#fce7f3" font-size="10">sqs-ing-ui-assets-test001</text>
<text x="557" y="575" text-anchor="middle" fill="#fce7f3" font-size="10">/_next/ → :3000</text>
<!-- ING4 -->
<rect x="670" y="520" width="185" height="65" rx="7" fill="#db2777" stroke="#be185d" stroke-width="1.5"/>
<text x="762" y="543" text-anchor="middle" fill="white" font-weight="bold">🌐 Ingress Routes</text>
<text x="762" y="560" text-anchor="middle" fill="#fce7f3" font-size="10">sqs-ing-ui-queues-test001</text>
<text x="762" y="575" text-anchor="middle" fill="#fce7f3" font-size="10">/queues/ → :3000</text>
<!-- Arrows Service -> Ingresses -->
<line x1="700" y1="445" x2="700" y2="490" stroke="#4f46e5" stroke-width="1" stroke-dasharray="4,2"/>
<line x1="700" y1="490" x2="147" y2="490" stroke="#4f46e5" stroke-width="1" stroke-dasharray="4,2"/>
<line x1="147" y1="490" x2="147" y2="520" stroke="#4f46e5" stroke-width="1" marker-end="url(#arr)"/>
<line x1="352" y1="490" x2="352" y2="520" stroke="#4f46e5" stroke-width="1" marker-end="url(#arr)"/>
<line x1="557" y1="490" x2="557" y2="520" stroke="#4f46e5" stroke-width="1" marker-end="url(#arr)"/>
<line x1="700" y1="490" x2="762" y2="490" stroke="#4f46e5" stroke-width="1" stroke-dasharray="4,2"/>
<line x1="762" y1="490" x2="762" y2="520" stroke="#4f46e5" stroke-width="1" marker-end="url(#arr)"/>
<!-- Client -->
<rect x="340" y="630" width="220" height="45" rx="8" fill="#1a1a2e" stroke="#334155" stroke-width="1.5"/>
<text x="450" y="650" text-anchor="middle" fill="white" font-weight="bold">🖥️ Browser / AWS SDK</text>
<text x="450" y="667" text-anchor="middle" fill="#94a3b8" font-size="11">sqs.kube5s.ru (HTTPS)</text>
<!-- Arrow Client -> Ingresses -->
<line x1="380" y1="630" x2="200" y2="588" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arr)"/>
<line x1="450" y1="630" x2="420" y2="588" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arr)"/>
<!-- Arrow marker -->
<defs>
<marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#666"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

+7
View File
@@ -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).
+1
View File
@@ -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 — финал
+77
View File
@@ -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 одновременно работают? Какая стратегия деплоя?
@@ -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 {
+78
View File
@@ -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.")