feat: improve session limit handling and add k6 load testing

This commit is contained in:
2026-04-23 05:17:53 +00:00
parent 47f46d5c5b
commit 1438dee21a
6 changed files with 687 additions and 156 deletions
+263 -128
View File
@@ -1,6 +1,7 @@
import datetime as dt import datetime as dt
import enum import enum
import fcntl import fcntl
import json
import re import re
import logging import logging
import os import os
@@ -9,13 +10,14 @@ import secrets
import threading import threading
import time import time
import uuid import uuid
import contextvars
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from typing import Optional from typing import Optional
import docker import docker
import requests import requests
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeTimedSerializer from itsdangerous import BadSignature, URLSafeTimedSerializer
@@ -44,6 +46,7 @@ COOKIE_MAX_AGE = 8 * 60 * 60
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300")) SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300"))
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru") PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0")) UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
@@ -63,6 +66,22 @@ logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s %(message)s", format="%(asctime)s %(levelname)s %(name)s %(message)s",
) )
logger = logging.getLogger("portal") logger = logging.getLogger("portal")
request_id_ctx = contextvars.ContextVar("request_id", default="-")
def _normalize_log_value(value):
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, dt.datetime):
return value.isoformat()
return str(value)
def log_event(event: str, level: int = logging.INFO, **fields) -> None:
payload = {"event": event, "req_id": request_id_ctx.get()}
for key, value in fields.items():
payload[key] = _normalize_log_value(value)
logger.log(level, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32)) SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
serializer = URLSafeTimedSerializer(SIGNING_KEY, salt="portal-auth") serializer = URLSafeTimedSerializer(SIGNING_KEY, salt="portal-auth")
@@ -79,22 +98,51 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.middleware("http") @app.middleware("http")
async def request_logging_middleware(request: Request, call_next): async def request_logging_middleware(request: Request, call_next):
req_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8]) req_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
token = request_id_ctx.set(req_id)
started = time.time() started = time.time()
client_ip = request.client.host if request.client else "-"
user_agent = request.headers.get("user-agent", "-")
try: try:
response = await call_next(request) response = await call_next(request)
except Exception: except Exception:
logger.exception("request_failed req_id=%s method=%s path=%s", req_id, request.method, request.url.path) log_event(
"request_failed",
level=logging.ERROR,
method=request.method,
path=request.url.path,
client_ip=client_ip,
user_agent=user_agent,
)
request_id_ctx.reset(token)
raise raise
duration_ms = int((time.time() - started) * 1000) duration_ms = int((time.time() - started) * 1000)
logger.info( level = logging.INFO
"request req_id=%s method=%s path=%s status=%s duration_ms=%s", if response.status_code >= 500:
req_id, level = logging.ERROR
request.method, elif response.status_code >= 400:
request.url.path, level = logging.WARNING
response.status_code, log_event(
duration_ms, "request",
level=level,
method=request.method,
path=request.url.path,
query=str(request.url.query or ""),
status=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
user_agent=user_agent,
) )
if duration_ms >= LOG_SLOW_REQUEST_MS:
log_event(
"slow_request",
level=logging.WARNING,
method=request.method,
path=request.url.path,
duration_ms=duration_ms,
threshold_ms=LOG_SLOW_REQUEST_MS,
)
response.headers["X-Request-ID"] = req_id response.headers["X-Request-ID"] = req_id
request_id_ctx.reset(token)
return response return response
@@ -112,6 +160,7 @@ class SessionStatus(str, enum.Enum):
ACTIVE = "ACTIVE" ACTIVE = "ACTIVE"
EXPIRED = "EXPIRED" EXPIRED = "EXPIRED"
TERMINATED = "TERMINATED" TERMINATED = "TERMINATED"
ROTATED = "ROTATED"
class User(Base): class User(Base):
@@ -196,6 +245,28 @@ def now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc) return dt.datetime.now(dt.timezone.utc)
def session_closed_reason(sess: SessionModel, db: Session) -> str:
if not sess:
return "idle"
if sess.status == SessionStatus.EXPIRED:
return "idle"
if sess.status == SessionStatus.ROTATED:
return "limit"
if sess.status == SessionStatus.TERMINATED:
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
active_rows = db.scalars(
select(SessionModel).where(
SessionModel.user_id == sess.user_id,
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
).all()
active_service_ids = {row.service_id for row in active_rows}
if len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER and sess.service_id not in active_service_ids:
return "limit"
return "idle"
def normalize_web_target(url: str) -> str: def normalize_web_target(url: str) -> str:
raw = (url or "").strip() raw = (url or "").strip()
if not raw: if not raw:
@@ -981,11 +1052,24 @@ def terminate_session_record(
) -> None: ) -> None:
if not sess or sess.status != SessionStatus.ACTIVE: if not sess or sess.status != SessionStatus.ACTIVE:
return return
old_status = sess.status
cid = sess.container_id or "" cid = sess.container_id or ""
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")): if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")):
stop_runtime_container(cid) stop_runtime_container(cid)
sess.status = new_status sess.status = new_status
sess.last_access_at = now_utc() sess.last_access_at = now_utc()
log_event(
"session_closed",
level=logging.INFO,
session_id=sess.id,
user_id=sess.user_id,
service_id=sess.service_id,
container_id=cid,
old_status=old_status.value if isinstance(old_status, SessionStatus) else str(old_status),
new_status=new_status.value,
reason=session_closed_reason(sess, db),
stop_container=stop_container,
)
def ensure_schema_compatibility() -> None: def ensure_schema_compatibility() -> None:
@@ -1005,6 +1089,20 @@ def ensure_schema_compatibility() -> None:
""" """
) )
) )
conn.execute(
text(
"""
DO $$
BEGIN
BEGIN
ALTER TYPE sessionstatus ADD VALUE IF NOT EXISTS 'ROTATED';
EXCEPTION WHEN undefined_object THEN
NULL;
END;
END $$;
"""
)
)
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
@@ -1430,6 +1528,11 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
session_notice = "" session_notice = ""
if session_closed == "idle": if session_closed == "idle":
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново." session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
elif session_closed == "limit":
session_notice = (
f"Сессия была закрыта из-за лимита в {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
"Освободите один сервис и попробуйте снова."
)
elif launch_error == "max_services": elif launch_error == "max_services":
session_notice = ( session_notice = (
f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). " f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
@@ -1675,6 +1778,7 @@ def logout(request: Request):
@app.get("/go/{slug}") @app.get("/go/{slug}")
def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)): def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
log_event("session_open_requested", user_id=user.id, service_slug=slug)
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True)) service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
if not service: if not service:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
@@ -1682,142 +1786,149 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
raise HTTPException(status_code=410, detail="VNC services are deprecated") raise HTTPException(status_code=410, detail="VNC services are deprecated")
if not has_access(db, user.id, service.id): if not has_access(db, user.id, service.id):
raise HTTPException(status_code=403, detail="ACL denied") raise HTTPException(status_code=403, detail="ACL denied")
with allocator_lock(db, 92000 + int(user.id)):
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
if existing_user_session:
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
existing_user_session = find_active_session_for_user_service(db, user.id, service.id) cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
if existing_user_session: active_rows = db.scalars(
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303) select(SessionModel).where(
SessionModel.user_id == user.id,
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) SessionModel.status == SessionStatus.ACTIVE,
active_rows = db.scalars( SessionModel.last_access_at >= cutoff,
select(SessionModel).where(
SessionModel.user_id == user.id,
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
).all()
active_rows = sorted(active_rows, key=lambda row: row.created_at)
active_service_ids = {row.service_id for row in active_rows}
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
oldest = next((row for row in active_rows if row.service_id != service.id), None)
if oldest:
terminate_session_record(db, oldest, SessionStatus.TERMINATED, stop_container=True)
db.commit()
logger.info(
"session_rotated user_id=%s closed_session=%s old_service_id=%s new_service_id=%s",
user.id,
oldest.id,
oldest.service_id,
service.id,
) )
else: ).all()
return RedirectResponse(url="/?launch_error=max_services", status_code=303) active_rows = sorted(active_rows, key=lambda row: row.created_at)
active_service_ids = {row.service_id for row in active_rows}
if service.type == ServiceType.RDP: if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
active_owner = find_active_session_for_service(db, service.id) oldest = next((row for row in active_rows if row.service_id != service.id), None)
if active_owner: if oldest:
if active_owner.user_id != user.id: terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
owner = db.get(User, active_owner.user_id)
owner_name = owner.username if owner else f"id={active_owner.user_id}"
raise HTTPException(
status_code=409,
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
)
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
session_id = str(uuid.uuid4())
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
try:
with allocator_lock(db, 91001):
ensure_web_pool()
slot = acquire_web_pool_slot(db)
slot_cid = f"WEBPOOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_web_pool_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=slot_cid,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit() db.commit()
except Exception as exc: log_event(
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) "session_rotated",
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
if service_uses_universal_pool(service):
try:
with allocator_lock(db, 91002):
ensure_universal_pool()
slot = acquire_universal_slot(db)
slot_cid = f"POOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_universal_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id, user_id=user.id,
service_id=service.id, closed_session_id=oldest.id,
container_id=slot_cid, closed_service_id=oldest.service_id,
status=SessionStatus.ACTIVE, new_service_id=service.id,
created_at=now_utc(),
last_access_at=now_utc(),
) )
db.add(session_obj) else:
db.commit() return RedirectResponse(url="/?launch_error=max_services", status_code=303)
except Exception as exc:
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) if service.type == ServiceType.RDP:
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id) active_owner = find_active_session_for_service(db, service.id)
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target") if active_owner:
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) if active_owner.user_id != user.id:
return RedirectResponse(url=f"/s/{session_id}/", status_code=303) owner = db.get(User, active_owner.user_id)
owner_name = owner.username if owner else f"id={active_owner.user_id}"
raise HTTPException(
status_code=409,
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
)
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
session_id = str(uuid.uuid4())
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
try:
with allocator_lock(db, 91001):
ensure_web_pool()
slot = acquire_web_pool_slot(db)
slot_cid = f"WEBPOOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_web_pool_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=slot_cid,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
except Exception as exc:
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
if service_uses_universal_pool(service):
try:
with allocator_lock(db, 91002):
ensure_universal_pool()
slot = acquire_universal_slot(db)
slot_cid = f"POOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_universal_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=slot_cid,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
except Exception as exc:
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
ensure_warm_pool(service)
open_warm_web_url(service, service.target)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=f"POOL:{service.slug}",
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
try:
container_id = create_runtime_container(service, session_id)
except Exception as exc:
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="Session runtime failed to start")
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
ensure_warm_pool(service)
open_warm_web_url(service, service.target)
session_obj = SessionModel( session_obj = SessionModel(
id=session_id, id=session_id,
user_id=user.id, user_id=user.id,
service_id=service.id, service_id=service.id,
container_id=f"POOL:{service.slug}", container_id=container_id,
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
created_at=now_utc(), created_at=now_utc(),
last_access_at=now_utc(), last_access_at=now_utc(),
) )
db.add(session_obj) db.add(session_obj)
db.commit() db.commit()
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id) log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
ready = wait_for_session_route(session_id)
log_event("session_route_ready", session_id=session_id, ready=ready)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303) return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
try:
container_id = create_runtime_container(service, session_id)
except Exception as exc:
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="Session runtime failed to start")
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=container_id,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
ready = wait_for_session_route(session_id)
logger.info("session_route_ready session_id=%s ready=%s", session_id, ready)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
@app.get("/svc/{slug}/", response_class=HTMLResponse) @app.get("/svc/{slug}/", response_class=HTMLResponse)
def service_wait_page(slug: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)): def service_wait_page(slug: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
@@ -1985,7 +2096,23 @@ def touch_session(session_id: str, user: User = Depends(require_user), db: Sessi
if not sess or sess.user_id != user.id: if not sess or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
if sess.status != SessionStatus.ACTIVE: if sess.status != SessionStatus.ACTIVE:
raise HTTPException(status_code=410, detail="Session expired") reason = session_closed_reason(sess, db)
log_event(
"session_touch_rejected",
level=logging.WARNING,
session_id=session_id,
user_id=user.id,
status=sess.status.value,
reason=reason,
)
return JSONResponse(
status_code=410,
content={
"ok": False,
"reason": reason,
"status": sess.status.value,
},
)
sess.last_access_at = now_utc() sess.last_access_at = now_utc()
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -1997,9 +2124,17 @@ def close_session(session_id: str, user: User = Depends(require_user), db: Sessi
if not sess or sess.user_id != user.id: if not sess or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
if sess.status != SessionStatus.ACTIVE: if sess.status != SessionStatus.ACTIVE:
log_event(
"session_close_already_closed",
session_id=session_id,
user_id=user.id,
status=sess.status.value,
reason=session_closed_reason(sess, db),
)
return {"ok": True, "status": sess.status.value} return {"ok": True, "status": sess.status.value}
terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True) terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True)
db.commit() db.commit()
log_event("session_closed_by_user", session_id=session_id, user_id=user.id)
return {"ok": True, "status": "TERMINATED"} return {"ok": True, "status": "TERMINATED"}
+1 -1
View File
@@ -30,7 +30,7 @@ services:
api: api:
build: build:
context: ./app context: ./app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"] command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
environment: environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY} SIGNING_KEY: ${SIGNING_KEY}
+70
View File
@@ -0,0 +1,70 @@
# Load Testing (k6)
Готовый скрипт: `scripts/load/portal_k6.js`.
## 1) Установка k6
Ubuntu/Debian:
```bash
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt update && sudo apt install -y k6
```
## 2) Smoke тест (быстрая проверка)
```bash
k6 run scripts/load/portal_k6.js \
-e BASE_URL=https://stend.4mont.ru \
-e USERNAME=ruslan \
-e PASSWORD='YOUR_PASSWORD' \
-e SERVICE_SLUGS=vmmanager,termidesk \
-e PROFILE=smoke
```
## 3) Нормальная нагрузка
```bash
k6 run scripts/load/portal_k6.js \
-e BASE_URL=https://stend.4mont.ru \
-e USERS_CSV='user1:pass1;user2:pass2;user3:pass3' \
-e SERVICE_SLUGS=vmmanager,termidesk,sayt-mont5 \
-e PROFILE=load \
-e HEARTBEATS=3 \
-e THINK_TIME=1
```
## 4) Стресс
```bash
k6 run scripts/load/portal_k6.js \
-e BASE_URL=https://stend.4mont.ru \
-e USERS_CSV='user1:pass1;user2:pass2;user3:pass3;user4:pass4' \
-e SERVICE_SLUGS=vmmanager,termidesk,sayt-mont5 \
-e PROFILE=stress
```
## 5) Ключевые параметры
- `BASE_URL` — адрес портала.
- `USERNAME`/`PASSWORD` — одиночный пользователь.
- `USERS_CSV` — набор пользователей `u1:p1;u2:p2`.
- `SERVICE_SLUGS` — список slug через запятую.
- `PROFILE``smoke|load|stress`.
- `HEARTBEATS` — сколько `touch` в итерации.
- `THINK_TIME` — пауза между итерациями.
- `CLOSE_SESSION=0` — не закрывать сессию в конце итерации.
## 6) Что смотреть в результате
- `http_req_duration` (`p95/p99`)
- `http_req_failed`
- `flow_errors`
- `open_success`, `open_rejected`, `limit_redirects`, `touch_rejected`
## 7) Важное
- Запускайте сначала `smoke`, потом `load`, и только затем `stress`.
- Для реалистичного теста используйте несколько пользователей, иначе упретесь в лимиты по сессиям одного пользователя.
+14 -5
View File
@@ -59,22 +59,31 @@ cat > /opt/portal/index.html <<HTML
rfb.scaleViewport = true; rfb.scaleViewport = true;
rfb.resizeSession = true; rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL_BASE = '/?session_closed=';
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close'); const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
function goSessionClosed() { function goSessionClosed(reason = 'idle') {
const safeReason = reason === 'limit' ? 'limit' : 'idle';
const target = `${SESSION_CLOSED_URL_BASE}${safeReason}`;
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
window.top.location.href = SESSION_CLOSED_URL; window.top.location.href = target;
return; return;
} }
} catch (e) {} } catch (e) {}
window.location.href = SESSION_CLOSED_URL; window.location.href = target;
} }
async function touch() { async function touch() {
try { try {
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'}); const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
if (!res.ok) { if (!res.ok) {
goSessionClosed(); let reason = 'idle';
try {
const payload = await res.json();
if (payload && typeof payload.reason === 'string') {
reason = payload.reason;
}
} catch (e) {}
goSessionClosed(reason);
} }
} catch(e) {} } catch(e) {}
} }
+220
View File
@@ -0,0 +1,220 @@
import http from 'k6/http';
import { check, fail, sleep } from 'k6';
import exec from 'k6/execution';
import { Counter, Rate, Trend } from 'k6/metrics';
const BASE_URL = (__ENV.BASE_URL || 'https://stend.4mont.ru').replace(/\/$/, '');
const SERVICE_SLUGS = (__ENV.SERVICE_SLUGS || 'vmmanager')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const HEARTBEATS = Number(__ENV.HEARTBEATS || 3);
const THINK_TIME = Number(__ENV.THINK_TIME || 1);
const CLOSE_SESSION = (__ENV.CLOSE_SESSION || '1') !== '0';
const PROFILE = (__ENV.PROFILE || 'smoke').toLowerCase();
const usersFromCsv = parseUsersCsv(__ENV.USERS_CSV || '');
const singleUser = {
username: __ENV.USERNAME || '',
password: __ENV.PASSWORD || '',
};
const openAttempts = new Counter('open_attempts');
const openSuccess = new Counter('open_success');
const openRejected = new Counter('open_rejected');
const limitRedirects = new Counter('limit_redirects');
const touchRejected = new Counter('touch_rejected');
const closeCalls = new Counter('close_calls');
const loginFailures = new Counter('login_failures');
const flowErrors = new Rate('flow_errors');
const openLatency = new Trend('open_latency_ms');
const PROFILE_OPTIONS = {
smoke: {
vus: 5,
duration: '1m',
},
load: {
vus: 30,
duration: '10m',
},
stress: {
stages: [
{ duration: '2m', target: 30 },
{ duration: '5m', target: 80 },
{ duration: '2m', target: 0 },
],
},
};
const selected = PROFILE_OPTIONS[PROFILE] || PROFILE_OPTIONS.smoke;
export const options = {
...selected,
insecureSkipTLSVerify: (__ENV.INSECURE_TLS || '0') === '1',
thresholds: {
http_req_failed: ['rate<0.02'],
http_req_duration: ['p(95)<2000'],
flow_errors: ['rate<0.05'],
},
};
let loggedInKey = '';
function parseUsersCsv(raw) {
if (!raw.trim()) return [];
return raw
.split(';')
.map((pair) => pair.trim())
.filter(Boolean)
.map((pair) => {
const idx = pair.indexOf(':');
if (idx <= 0) return null;
return {
username: pair.slice(0, idx).trim(),
password: pair.slice(idx + 1).trim(),
};
})
.filter((u) => u && u.username && u.password);
}
function currentCredentials() {
if (usersFromCsv.length > 0) {
const vu = exec.vu.idInTest || 1;
return usersFromCsv[(vu - 1) % usersFromCsv.length];
}
return singleUser;
}
function ensureLoggedIn() {
const creds = currentCredentials();
if (!creds.username || !creds.password) {
fail('Set USERNAME/PASSWORD or USERS_CSV (username:password;username2:password2)');
}
const userKey = `${creds.username}:${creds.password}`;
if (loggedInKey === userKey) return creds;
const jar = http.cookieJar();
jar.clear(BASE_URL);
const landing = http.get(`${BASE_URL}/`, { redirects: 0, tags: { name: 'GET /' } });
const csrfFromCookie = (jar.cookiesForURL(BASE_URL).csrf_token || [])[0] || '';
const csrf = csrfFromCookie || ((landing.cookies.csrf_token || [])[0] || {}).value || '';
if (!csrf) {
loginFailures.add(1);
fail('CSRF cookie not found on GET /');
}
const payload = [
`username=${encodeURIComponent(creds.username)}`,
`password=${encodeURIComponent(creds.password)}`,
`csrf_token=${encodeURIComponent(csrf)}`,
].join('&');
const loginRes = http.post(`${BASE_URL}/login`, payload, {
redirects: 0,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
tags: { name: 'POST /login' },
});
const ok = check(loginRes, {
'login status is redirect': (r) => r.status === 303,
});
const hasAuth = (jar.cookiesForURL(BASE_URL).portal_auth || []).length > 0;
if (!ok || !hasAuth) {
loginFailures.add(1);
fail(`Login failed for ${creds.username}: status=${loginRes.status}`);
}
loggedInKey = userKey;
return creds;
}
function pickSlug() {
return SERVICE_SLUGS[Math.floor(Math.random() * SERVICE_SLUGS.length)];
}
function extractSessionId(location) {
const match = (location || '').match(/\/s\/([0-9a-fA-F-]{36})\//);
return match ? match[1] : '';
}
export default function () {
ensureLoggedIn();
const slug = pickSlug();
openAttempts.add(1);
const openRes = http.get(`${BASE_URL}/go/${slug}`, {
redirects: 0,
tags: { name: 'GET /go/:slug' },
});
openLatency.add(openRes.timings.duration);
const location = openRes.headers.Location || '';
const sessionId = extractSessionId(location);
const opened = check(openRes, {
'open returns redirect': (r) => r.status === 303,
});
if (!opened) {
openRejected.add(1);
flowErrors.add(1);
sleep(THINK_TIME);
return;
}
if (location.includes('launch_error=max_services')) {
limitRedirects.add(1);
flowErrors.add(1);
sleep(THINK_TIME);
return;
}
if (!sessionId) {
openRejected.add(1);
flowErrors.add(1);
sleep(THINK_TIME);
return;
}
openSuccess.add(1);
for (let i = 0; i < HEARTBEATS; i += 1) {
const touchRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/touch`, null, {
redirects: 0,
tags: { name: 'POST /api/sessions/:id/touch' },
});
if (touchRes.status === 410) {
touchRejected.add(1);
break;
}
const touchOk = check(touchRes, {
'touch status 200': (r) => r.status === 200,
});
if (!touchOk) {
flowErrors.add(1);
break;
}
sleep(0.5);
}
if (CLOSE_SESSION) {
const closeRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/close`, null, {
redirects: 0,
tags: { name: 'POST /api/sessions/:id/close' },
});
closeCalls.add(1);
check(closeRes, {
'close status 200': (r) => r.status === 200,
});
}
flowErrors.add(0);
sleep(THINK_TIME);
}
+119 -22
View File
@@ -65,8 +65,15 @@ cat > /opt/portal/index.html <<'HTML'
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const XK_ALT_L = 0xffe9; const XK_ALT_L = 0xffe9;
const XK_LEFT = 0xff51; const XK_LEFT = 0xff51;
let rfb = null;
let connected = false; let connected = false;
let connectTimer = null; let connectTimer = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 12;
const RECONNECT_DELAYS_MS = [1000, 2000, 3000, 5000, 8000];
let manualDisconnect = false;
function showStatus(text, isError = false) { function showStatus(text, isError = false) {
statusEl.textContent = text; statusEl.textContent = text;
@@ -78,73 +85,160 @@ cat > /opt/portal/index.html <<'HTML'
statusEl.classList.add('hidden'); statusEl.classList.add('hidden');
} }
showStatus('Подключение к слоту...'); function clearConnectTimer() {
connectTimer = setTimeout(() => { if (connectTimer) {
if (!connected) { clearTimeout(connectTimer);
showStatus('Нет подключения к экрану слота. Откройте сервис заново из дашборда.', true); connectTimer = null;
} }
}, 8000); }
const rfb = new RFB(document.getElementById('screen'), wsUrl); function clearReconnectTimer() {
rfb.viewOnly = false; if (reconnectTimer) {
rfb.scaleViewport = true; clearTimeout(reconnectTimer);
rfb.resizeSession = true; reconnectTimer = null;
rfb.addEventListener('connect', () => { }
connected = true; }
if (connectTimer) clearTimeout(connectTimer);
hideStatus(); function reconnectDelay(attemptNumber) {
}); const idx = Math.min(Math.max(attemptNumber - 1, 0), RECONNECT_DELAYS_MS.length - 1);
rfb.addEventListener('disconnect', () => { return RECONNECT_DELAYS_MS[idx];
}
function scheduleConnectTimeout() {
clearConnectTimer();
connectTimer = setTimeout(() => {
if (!connected && !manualDisconnect) {
scheduleReconnect('Нет подключения к экрану слота. Пробуем переподключиться...');
}
}, 8000);
}
function scheduleReconnect(reasonText) {
if (manualDisconnect) return;
clearConnectTimer();
clearReconnectTimer();
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showStatus('Соединение со слотом потеряно. Переподключение не удалось. Откройте сервис заново.', true);
return;
}
const nextAttempt = reconnectAttempts + 1;
const delayMs = reconnectDelay(nextAttempt);
const delaySec = Math.ceil(delayMs / 1000);
showStatus(`${reasonText} Повтор ${nextAttempt}/${MAX_RECONNECT_ATTEMPTS} через ${delaySec} сек.`, true);
reconnectTimer = setTimeout(() => {
reconnectAttempts = nextAttempt;
connectRfb('Переподключение к слоту...');
}, delayMs);
}
function attachRfb() {
if (rfb) {
try { rfb.disconnect(); } catch (e) {}
}
rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
reconnectAttempts = 0;
clearConnectTimer();
clearReconnectTimer();
hideStatus();
});
rfb.addEventListener('disconnect', () => {
connected = false;
if (manualDisconnect) return;
scheduleReconnect('Соединение со слотом потеряно.');
});
}
function connectRfb(statusText) {
if (manualDisconnect) return;
connected = false; connected = false;
showStatus('Соединение со слотом потеряно. Запустите сервис заново.', true); showStatus(statusText || 'Подключение к слоту...');
}); attachRfb();
scheduleConnectTimeout();
}
const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0'; const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
const sid = new URLSearchParams(location.search).get('sid'); const sid = new URLSearchParams(location.search).get('sid');
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL_BASE = '/?session_closed=';
const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : ''; const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : '';
function goSessionClosed() {
function goSessionClosed(reason = 'idle') {
const safeReason = reason === 'limit' ? 'limit' : 'idle';
const target = `${SESSION_CLOSED_URL_BASE}${safeReason}`;
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
window.top.location.href = SESSION_CLOSED_URL; window.top.location.href = target;
return; return;
} }
} catch (e) {} } catch (e) {}
window.location.href = SESSION_CLOSED_URL; window.location.href = target;
} }
async function touch() { async function touch() {
if (!sid) return; if (!sid) return;
try { try {
const res = await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'}); const res = await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'});
if (!res.ok) { if (!res.ok) {
goSessionClosed(); let reason = 'idle';
try {
const payload = await res.json();
if (payload && typeof payload.reason === 'string') {
reason = payload.reason;
}
} catch (e) {}
goSessionClosed(reason);
} }
} catch (e) {} } catch (e) {}
} }
let closing = false; let closing = false;
async function closeSessionNow() { async function closeSessionNow() {
if (!CLOSE_PATH || closing) return; if (!CLOSE_PATH || closing) return;
closing = true; closing = true;
manualDisconnect = true;
clearConnectTimer();
clearReconnectTimer();
try {
if (rfb) rfb.disconnect();
} catch (e) {}
try { try {
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true}); await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
} catch (e) {} } catch (e) {}
} }
if (enableHeartbeat) { if (enableHeartbeat) {
setInterval(touch, 15000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow); window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow); window.addEventListener('beforeunload', closeSessionNow);
} }
function keyTap(keysym, code) { function keyTap(keysym, code) {
if (!rfb) return;
rfb.sendKey(keysym, code, true); rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false); rfb.sendKey(keysym, code, false);
} }
function chord(mod, key, modCode, keyCode) { function chord(mod, key, modCode, keyCode) {
if (!rfb) return;
rfb.sendKey(mod, modCode, true); rfb.sendKey(mod, modCode, true);
keyTap(key, keyCode); keyTap(key, keyCode);
rfb.sendKey(mod, modCode, false); rfb.sendKey(mod, modCode, false);
} }
function goHome() { function goHome() {
manualDisconnect = true;
clearConnectTimer();
clearReconnectTimer();
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
window.top.location.href = '/'; window.top.location.href = '/';
@@ -153,9 +247,12 @@ cat > /opt/portal/index.html <<'HTML'
} catch (e) {} } catch (e) {}
window.location.href = '/'; window.location.href = '/';
} }
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft')); document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome); document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
connectRfb('Подключение к слоту...');
</script> </script>
</body> </body>
</html> </html>