14 KiB
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:
- Kubernetes добавляет аннотацию
restartedAt→ меняется template → начинается rollout - Стратегия
RollingUpdate(maxSurge=25%, maxUnavailable=25%) → для replicas=1:- maxSurge=1 (ceil 0.25) → Kubernetes поднимает НОВЫЙ pod
- maxUnavailable=0 (floor 0.25) → старый pod ЕЩЁ ЖИВА
- PVC
ReadWriteOnce— допускает mount с нескольких pod на ОДНОЙ НОДЕ (это не ReadWriteOncePod) - Оба pod монтируют один PVC → оба пытаются открыть
/data/elasticmq.mv.db - Старый ElasticMQ держит
FileChannel.lock()→ новый ElasticMQ получаетMVStoreException: The file is locked - Persistence actor (SqlQueuePersistenceActor) в новом pod падает → dead letters
- Старый pod убивается (readinessProbe eventual fail) → lock освобождается — но поздно
- SQS REST server работает (port 9324 слушает), но WRITE-операции (SendMessage) зависают — actor мёртв
Решение — 3 изменения в ensureDeployment
-
Strategy: Recreate (вместо RollingUpdate)
- Kubernetes СНАЧАЛА убивает старый pod, ПОТОМ поднимает новый
- Два pod НИКОГДА не работают одновременно → lock невозможен
- Downtime ~25-30 секунд (JVM startup) — допустимо для мультитенант SQS
-
preStop hook: sleep 3
- При SIGTERM JVM начинает shutdown
sleep 3даёт H2 время наfsync+FileChannel.close()- Без preStop: Kubernetes может убить pod раньше чем H2 закончит flush
-
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 одновременно работают? Какая стратегия деплоя?
Анализ shared-sqs — форк GoAWS для multi-tenant SQS
Агент: GitHub Copilot (Claude Opus 4) Время: 2026-04-09, вечер
Контекст
Пользователь решил делать shared multi-tenant SQS сервис (вариант А — форк GoAWS). Нужен детальный план для другого агента (Sonnet).
Исследование GoAWS
Скачал и проанализировал исходники:
- router.go — gorilla/mux, единый
actionHandlerдиспатчит по Action name из routingTableV1 - globals.go —
SyncQueues= один map[string]*Queue с RWMutex. Это ВЕСЬ state. - models.go — Queue struct: Name, URL, ARN, Messages []SqsMessage, VisibilityTimeout и т.д.
- create_queue.go — создаёт очередь, ключ в map = queueName, URL =
http://host:port/accountID/queueName - send_message.go — извлекает queueName из последнего сегмента URL, ищет в SyncQueues
- gosqs.go — PeriodicTasks каждую секунду: visibility timeout reset, DLQ routing, dedup cleanup
- configuration.go — Environment struct с Host, Port, Region, AccountID (глобальная, ОДНА на всех)
Ключевое наблюдение
GoAWS УЖЕ имеет /{account}/{queueName} маршрут в роутере. AccountID используется в URL/ARN.
Это значит: tenantID = accountID — естественное отображение.
Queue URL для тенанта: http://host:port/{tenantID}/{queueName} — совпадает с route pattern.
Архитектурное решение: tenant isolation
Ключ в SyncQueues: {accessKey}:{queueName} (вместо просто {queueName})
Почему AccessKey: уже есть в auth context, уникален, не надо лишний lookup по TenantID→AccessKey.
Идентифицированные ловушки (17 штук)
Задокументировал в PLAN.md все точки где могут быть баги:
- crypto/rand vs math/rand для генерации ключей
- Удаление из двух индексов при delete tenant
- Парсинг AWS Signature V4 Authorization header
- Long polling таймаут (до 20 сек)
- Игнорирование X-Amz-Security-Token
- URL parsing — последний сегмент = queueName (НЕ tenantID)
- getQueueFromPath() — совместимость с новым URL форматом
- FIFO queue naming (.fifo суффикс)
- DLQ ARN parsing для tenant-scoped lookup
- QueueUrl в ответах ОБЯЗАН содержать tenantID
- DELETE tenant → удалить все очереди (memory leak)
- Admin API — отдельная auth (bearer token, не tenant credentials)
- Graceful shutdown (GoAWS не обрабатывает signals)
- Deployment strategy: Recreate (урок из ERR-SQS-06)
- .gitignore для бинарника
- go mod tidy после удаления SNS
- Import paths: goaws → shared-sqs
Результат
Создан shared-sqs/PLAN.md — ~400 строк, 9 этапов, 17 ловушек, примеры кода.
Достаточно подробный для Sonnet чтобы реализовать без дополнительных вопросов.
Этап 1 — Клон GoAWS + чистка (GitHub Copilot / Claude Sonnet 4.6)
Время: 2026-04-09
Анализ перед началом
Директория shared-sqs/ содержит только PLAN.md и doc/. Нужно:
- Клонировать GoAWS upstream в
_upstream/, скопироватьapp/,go.mod,go.sum,Dockerfile - Сменить module на
shared-sqs, Go 1.22 - Заменить все import paths
github.com/Admiral-Piett/goaws/app/→shared-sqs/app/ - Удалить
gosns/и все SNS-связанные места - Удалить тестовые директории
Риски
- GoAWS может иметь скрытые зависимости на SNS внутри SQS-handlers (например, DeadLetter → SNS upsert)
router.goсодержит SNS-actions в routingTableV1 — нужно аккуратно вычистить- После удаления SNS могут остаться неиспользуемые imports — нужен
go mod tidy
Стратегия
Все команды — по SSH. Файлы go модифицировать через sed (замена import paths).
После удаления SNS — go build, смотреть на ошибки, фиксить одну за одной.
Результат Этапа 1
- GoAWS склонирован, module =
shared-sqs, Go 1.22 - Все import paths заменены (sed по всем .go)
- gosns/, mocks/, fixtures/, servertest/, test/ — удалены
- SNS-код убран из: models.go, configuration.go, globals.go, helpers.go, requests.go, responses.go, router.go, conf/config.go
- Нюанс: responses.go был обрезан слишком агрессивно — DeleteMessageBatch типы потерялись. Добавил их вручную.
- go build OK, /health → "OK"
- Коммит:
f4352a1
Этап 2 — TenantStore (GitHub Copilot / Claude Sonnet 4.6)
Анализ
Нужно создать app/tenant/tenant_store.go:
- Tenant struct: ID, Name, AccessKey, SecretKey, MaxQueues, CreatedAt, Active
- TenantStore: два индекса (byID + byAccessKey), RWMutex
- Ловушка #1: crypto/rand для ключей
- Ловушка #2: Delete из ОБОИХ индексов
- AccessKey формат:
SSAK-{12 random bytes hex}= SSAK- + 24 hex chars - SecretKey: 32 bytes → 64 hex chars
Сессия 2 (продолжение) — Claude Sonnet 4.6
Продолжение с момента прерывания
Предыдущая сессия была прервана на Этапе 4 (все 13 handlers переписаны, but go build not yet run).
Немедленное действие: go build → чисто. Этап 4 пройден.
Этап 4 — результат
- go build: OK
- Бинарник: 10MB
- Коммит:
08053ca"shared-sqs: Этап 4 — изоляция очередей по тенанту"
Этап 5 — Admin API
Решение: Создан app/admin/admin.go с handlers:
POST /admin/tenants— create, возвращает secret_key ОДИН РАЗGET /admin/tenants— список без secret_keyGET /admin/tenants/{id}— деталь без secret_keyDELETE /admin/tenants/{id}— удаляет тенанта + все его очереди из SyncQueues (Trap #11)GET /admin/health— детальный health с counts
Trap #12 реализован: Bearer token auth в bearerAuthMiddleware, полностью изолирован от tenant auth.
Trap #11 реализован: DELETE итерирует SyncQueues, удаляет все ключи с prefix {accessKey}:. Без этого — memory leak.
Этап 6 — Entry Point
Обновлён goaws.go:
- Флаги: --config, --admin-token, --port, --debug, --loglevel
- Admin token: flag > env SHARED_SQS_ADMIN_TOKEN >
log.Fatal(Trap #13) - TenantStore инициализируется при старте
router.New(tenantStore, adminToken)— передача зависимостей- HTTP сервер с таймаутами (WriteTimeout = 35s > max WaitTimeSeconds 20s для long polling)
- Graceful shutdown: SIGTERM/SIGINT → close(quit) → srv.Shutdown(10s)
Trap #13 реализован: SIGTERM → quit channel → PeriodicTasks останавливается корректно.
Этап 7 — Dockerfile + K8s
Dockerfile: multi-stage (golang:1.22-alpine → alpine:3.19), CGO_ENABLED=0 K8s manifests:
- namespace.yaml, deployment.yaml, service.yaml, secret.yaml
strategy: Recreate— НЕ RollingUpdate (Trap #14: in-memory state, split brain risk)
Этап 8 — Makefile
Таргеты: build, docker-build, docker-push, test, run, clean. Фикс: Makefile через heredoc потерял табы → пересоздан через Python с \t.
Итоговое состояние
go build → OK (все этапы 1-8) Коммиты:
Остался Этап 9 — bash тесты. Ждём указания пользователя.