8.0 KiB
Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок
Отправлено: 27.07.2026
Ты — senior архитектор. Разбери проблему в Flask-приложении на Nubes pythonk8s (gunicorn, 1 worker).
Контекст
Приложение app-autotest — веб-интерфейс для автотестов операций облачных сервисов Nubes. Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git Версия: v1.0.47
Файлы для изучения
| Файл | Назначение |
|---|---|
site/app.py |
Точка входа Flask, VERSION, blueprints |
site/routes/api_test.py |
POST /api/test (запуск), GET /api/test/status (поллинг), _finish_op (фоновый поток) |
site/routes/main.py |
Главная страница, /api/operations/ (список инстансов из трекера) |
site/operations/tracker.py |
Запись/чтение /tmp/instances.json (_INITIAL, add, remove, list_all) |
site/templates/index.html |
UI: executeOp(), showStages(), поллинг, refreshInstances() |
site/api/http_client.py |
HTTP-клиент (Bearer auth, User-Agent) |
Полные пути в репозитории:
app-autotest/site/app.py
app-autotest/site/routes/api_test.py
app-autotest/site/routes/main.py
app-autotest/site/operations/tracker.py
app-autotest/site/templates/index.html
app-autotest/site/api/http_client.py
Поток CREATE (как работает сейчас)
- UI: пользователь жмёт «Создать» → showParams(18, 'create') → заполняет форму → executeOp(params)
- UI отправляет POST /api/test
{serviceId:1, operation:"create", svcOperationId:18, displayName, params, instanceUid:""} - Бэкенд api_test():
client.post("/instances", {serviceId, displayName})→ получает instanceUidclient.post("/instanceOperations", {instanceUid, operation:"create"})→ получает opUidclient.post("/instanceOperationCfsParams", ...)— для каждого параметраclient.post("/instanceOperations/{opUid}/run")— запускthreading.Thread(target=_finish_op, args=(client, opUid, instanceUid, ...), daemon=True).start()- возвращает
{status:"RUNNING", opUid, instanceUid}
- UI начинает поллинг:
GET /api/test/status/<opUid>каждые 2 секунды - _finish_op (фоновый daemon-поток):
- поллит
GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages - обновляет
_op_results[opUid](in-memory dict) - когда
dtFinish != nullиisSuccessful == true:- вызывает tracker_add(instanceUid, svcId, displayName) ← ПРОБЛЕМА ЗДЕСЬ
- устанавливает
_op_results[opUid] = {status:"OK", stages, duration}
- таймаут 300 секунд
- поллит
- UI при status=="OK": вызывает
selectService(SVC_ID)→GET /api/operations/1→ читает tracker → показывает список
ПРОБЛЕМА
Инстанс создаётся в Nubes (виден в UI платформы), НО в списке инстансов приложения НЕ появляется.
tracker_add не вызывается → /tmp/instances.json не обновляется → инстанс не в списке.
Происходило 2 раза подряд (v1.0.46 и v1.0.47). Оба раза инстанс в Nubes есть, в трекере — нет.
Моя гипотеза
tracker_add вызывается внутри daemon-потока _finish_op. В gunicorn:
- Воркер может быть перезапущен платформой в любой момент
- Daemon-потоки умирают вместе с воркером — молча, без логов
- Python не пишет traceback при смерти daemon-потока
Текущий код _finish_op (api_test.py, строки 153-195)
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, is_delete=False):
"""Фоном ждать dtFinish и сохранить результат."""
import time
t0 = time.time()
deadline = t0 + 300
while time.time() < deadline:
try:
data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages")
except Exception:
time.sleep(5)
continue
op = data.get("instanceOperation", {})
dt_finish = op.get("dtFinish")
_op_results[op_uid] = {
"status": "RUNNING",
"stages": op.get("stages", []),
"duration": round(time.time() - t0, 1),
}
if dt_finish and str(dt_finish).strip():
is_ok = op.get("isSuccessful")
err = op.get("errorLog") or ""
# Сначала трекер — чтобы UI при poll уже видел инстанс
if is_ok:
if is_create:
tracker_add(instance_uid, svc_id, display_name)
elif is_delete:
tracker_remove(instance_uid)
_op_results[op_uid] = {
"status": "OK" if is_ok else "FAIL",
"error": str(err) if err else "",
"stages": op.get("stages", []),
"duration": round(time.time() - t0, 1),
}
return
time.sleep(5)
_op_results[op_uid] = {"status": "TIMEOUT", "duration": round(time.time() - t0, 1)}
Вопросы
1. Где вызывать tracker_add?
Вариант А: синхронно в api_test(), сразу после получения instanceUid от API (шаг 3), до запуска потока.
- Плюс: гарантированная запись, инстанс в трекере мгновенно
- Минус: если операция потом упадёт — в трекере «мусорный» инстанс (но это лучше чем отсутствие)
Вариант Б: subprocess.Popen вместо threading.Thread.
- Плюс: процесс живёт независимо от gunicorn worker
- Минус: сложнее, нужен IPC для _op_results
Вариант В: sqlite3 с WAL-режимом.
- Плюс: атомарная запись, конкурентный доступ
- Минус: без persistent volume данные теряются при редеплое (как и /tmp/)
2. Что делать с _op_results?
Сейчас это module-level dict. При нескольких gunicorn workers — каждый worker имеет свой dict. Нужен ли переход на sqlite/file-based storage для _op_results?
3. Надёжность под gunicorn
Как правильно организовать фоновую работу под gunicorn на pythonk8s (1 worker, нет PV, нет Redis/RabbitMQ)?
Дополнительно: UI кнопок
Нужен CSS чтобы кнопки операций шли горизонтально в 1-2 ряда, как в Nubes UI:
delete | modify | suspend | resume | redeploy | reconcile
Сейчас они в vertical списке внутри div.inst-ops. Предложи стиль.
Ограничения платформы
site/— НЕ пакет (без __init__.py, конфликт с stdlib site.py)- Импорты без префикса site.:
from api.http_client import ... app.run(host="0.0.0.0", port=5000)— обязательно- Gunicorn запускается платформой, предположительно 1 worker
- Нет persistent volume — данные в /tmp/ теряются при редеплое
- Деплой: git push → managed service подхватывает → редеплой