Files
sless/doc/thinking/2026-04-09.md
T

14 KiB
Raw Blame History

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 одновременно работают? Какая стратегия деплоя?


Анализ 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.goSyncQueues = один 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 все точки где могут быть баги:

  1. crypto/rand vs math/rand для генерации ключей
  2. Удаление из двух индексов при delete tenant
  3. Парсинг AWS Signature V4 Authorization header
  4. Long polling таймаут (до 20 сек)
  5. Игнорирование X-Amz-Security-Token
  6. URL parsing — последний сегмент = queueName (НЕ tenantID)
  7. getQueueFromPath() — совместимость с новым URL форматом
  8. FIFO queue naming (.fifo суффикс)
  9. DLQ ARN parsing для tenant-scoped lookup
  10. QueueUrl в ответах ОБЯЗАН содержать tenantID
  11. DELETE tenant → удалить все очереди (memory leak)
  12. Admin API — отдельная auth (bearer token, не tenant credentials)
  13. Graceful shutdown (GoAWS не обрабатывает signals)
  14. Deployment strategy: Recreate (урок из ERR-SQS-06)
  15. .gitignore для бинарника
  16. go mod tidy после удаления SNS
  17. 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/. Нужно:

  1. Клонировать GoAWS upstream в _upstream/, скопировать app/, go.mod, go.sum, Dockerfile
  2. Сменить module на shared-sqs, Go 1.22
  3. Заменить все import paths github.com/Admiral-Piett/goaws/app/shared-sqs/app/
  4. Удалить gosns/ и все SNS-связанные места
  5. Удалить тестовые директории

Риски

  • 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_key
  • GET /admin/tenants/{id} — деталь без secret_key
  • DELETE /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 тесты. Ждём указания пользователя.