diff --git a/HISTORY/2026-08-14-session-log.md b/HISTORY/2026-08-14-session-log.md index cabc3f6..1c787a0 100644 --- a/HISTORY/2026-08-14-session-log.md +++ b/HISTORY/2026-08-14-session-log.md @@ -642,3 +642,28 @@ C. Известные аномалии: GET /ui через ingress висит ~1 D. TCP: retransmit-счётчики пода (/proc/net/snmp) до/после. Итог: таблица «локаль vs ВМ vs внутри пода» — что режет платформа, а что сервис. +## РЕЗУЛЬТАТЫ диагностики платформы (tests/platform_probe.py, 14.08.2026) + +**ГЛАВНАЯ НАХОДКА — большие POST-тела зависают на ~51с (защитный слой платформы):** +- send 2–48KB — 0.09–0.22с; 56–64KB — то быстро, то виснет (порог плавает); + 80KB/100KB — первый запрос 50–52с, следующие 0.03с. +- Воспроизводится и с ВМ, и с локальной машины → общий узел DDoS-Guard/WAF. +- 100KB с БИТОЙ SigV4 — тоже 50.4с (висит путь, не наш auth/сервис). +- /health параллельно зависшему запросу — 0.02–0.05с (сервис жив). +- TCP connect+TLS мгновенны всегда (в т.ч. после idle 60/120с). +- Большие ОТВЕТЫ не режутся: receive 10×256KB (2.5MB) — 0.26с. +- Long-poll 20с держится честно (20.01с) — платформа не режет до 20с. + +**Прочие ограничения платформы:** +- Заголовки >16KB → 400 Bad Request (8KB проходит). +- Chunked Transfer-Encoding → 400. +- POST /health → 400; HEAD /health → 405 (наш mux); HTTP/1.0 → ок (апгрейд). + +**Нагрузка на под**: burst 500 × /health: 500/500 ok, p50=2.54с, p95=3.43с — +это CPU-лимит пода 500m, а не платформа (внутренние запросы мгновенны). + +**Следствие**: клиенты с сообщениями >~64KB и read_timeout < 51с получают +ReadTimeout на первом запросе в окне (это объясняет кластеры ReadTimeout и +max latency 52с в нагрузочных тестах). Рекомендации: клиентам read_timeout ≥ 60с +и retries; вопрос про WAF-инспекцию больших POST — в поддержку платформы. + diff --git a/tests/platform_probe.py b/tests/platform_probe.py new file mode 100644 index 0000000..cac58f1 --- /dev/null +++ b/tests/platform_probe.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""tests/platform_probe.py — диагностика транспортного слоя платформы (HTTP контейнер). + +Проверяет, как платформа (шлюз/ingress/DDoS-Guard) пропускает трафик к сервису, +НЕЗАВИСИМО от бизнес-логики SQS. Запускать и на ВМ, и на локальной машине +для сравнения путей. Только stdlib + boto3. + +Блоки: + A — через /health: burst 500 параллельных, большие заголовки, медленное тело, + keep-alive x50, HTTP/1.0, HEAD, POST. + B — через SQS API (транспорт): SendMessage 256KB, Receive 10x256KB (~2.5MB ответ), + long-poll 20s (фактическая длительность), chunked-тело. +""" +import http.client +import json +import os +import socket +import ssl +import sys +import threading +import time +import uuid + +import boto3 +from botocore.config import Config + +ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru") +HOST = ENDPOINT.replace("https://", "").replace("http://", "").rstrip("/") +PORT = 443 +REGION = os.environ.get("REGION", "us-east-1") +MODE = os.environ.get("PROBE_MODE", "unknown") + +results = {} + + +def _conn(): + c = http.client.HTTPSConnection(HOST, PORT, timeout=30) + return c + + +def record(name, value): + results[name] = value + print("%s: %s" % (name, json.dumps(value, ensure_ascii=False)), flush=True) + + +def http_get(path, headers=None, method="GET", body=None): + c = _conn() + try: + c.request(method, path, body=body, headers=headers or {}) + r = c.getresponse() + data = r.read() + return r.status, dict(r.getheaders()), data + finally: + c.close() + + +# --- A1: burst 500 параллельных /health --- +def a1_burst(): + n = 500 + ok = err = 0 + lat = [] + lock = threading.Lock() + + def one(): + nonlocal ok, err + t0 = time.time() + try: + st, _, _ = http_get("/health") + with lock: + lat.append(time.time() - t0) + if st == 200: + ok += 1 + else: + err += 1 + except Exception: + with lock: + err += 1 + + threads = [threading.Thread(target=one) for _ in range(n)] + t0 = time.time() + for t in threads: + t.start() + for t in threads: + t.join() + total = time.time() - t0 + lat.sort() + record("A1_burst_500", { + "ok": ok, "err": err, "total_s": round(total, 2), + "p50": round(lat[len(lat) // 2], 3) if lat else None, + "p95": round(lat[int(len(lat) * 0.95)], 3) if lat else None, + "max": round(lat[-1], 3) if lat else None, + }) + + +# --- A2: большие заголовки --- +def a2_big_headers(): + for kb in (8, 16, 64): + h = {"User-Agent": "platform-probe", "X-Pad": "x" * (kb * 1024)} + try: + st, _, _ = http_get("/health", headers=h) + record("A2_header_%dkb" % kb, st) + except Exception as e: + record("A2_header_%dkb" % kb, type(e).__name__ + ": " + str(e)[:80]) + + +# --- A3: медленное тело (1 байт / 0.5с, Content-Length 10) --- +def a3_slow_body(): + try: + ctx = ssl.create_default_context() + raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15), + server_hostname=HOST) + raw.sendall(b"POST /health HTTP/1.1\r\nHost: %s\r\nContent-Length: 10\r\nConnection: close\r\n\r\n" % HOST.encode()) + t0 = time.time() + for _ in range(10): + raw.sendall(b"x") + time.sleep(0.5) + resp = raw.recv(65536) + dt = time.time() - t0 + raw.close() + first_line = resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)" + record("A3_slow_body", {"resp": first_line, "t_s": round(dt, 2)}) + except Exception as e: + record("A3_slow_body", type(e).__name__ + ": " + str(e)[:80]) + + +# --- A4: keep-alive 50 запросов на одном соединении --- +def a4_keepalive(): + ok = err = 0 + c = _conn() + try: + for _ in range(50): + try: + c.request("GET", "/health") + r = c.getresponse() + r.read() + if r.status == 200: + ok += 1 + else: + err += 1 + except Exception: + err += 1 + break + finally: + c.close() + record("A4_keepalive_50", {"ok": ok, "err": err}) + + +# --- A5: HTTP/1.0, HEAD, POST --- +def a5_variants(): + try: + ctx = ssl.create_default_context() + raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15), + server_hostname=HOST) + raw.sendall(b"GET /health HTTP/1.0\r\nHost: %s\r\n\r\n" % HOST.encode()) + resp = raw.recv(4096) + raw.close() + record("A5_http1.0", resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)") + except Exception as e: + record("A5_http1.0", type(e).__name__ + ": " + str(e)[:80]) + try: + st, _, _ = http_get("/health", method="HEAD") + record("A5_head", st) + except Exception as e: + record("A5_head", type(e).__name__ + ": " + str(e)[:80]) + try: + st, _, _ = http_get("/health", method="POST", body=b"x") + record("A5_post", st) + except Exception as e: + record("A5_post", type(e).__name__ + ": " + str(e)[:80]) + + +# --- B6: SendMessage 256KB --- +def b6_big_send(sqs, url): + t0 = time.time() + try: + sqs.send_message(QueueUrl=url, MessageBody="x" * (256 * 1024)) + record("B6_send_256kb", {"ok": True, "t_s": round(time.time() - t0, 3)}) + except Exception as e: + record("B6_send_256kb", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 3)}) + + +# --- B8: Receive 10 x 256KB (~2.5MB ответ) --- +def b8_big_receive(sqs, url): + for _ in range(10): + sqs.send_message(QueueUrl=url, MessageBody="y" * (256 * 1024)) + t0 = time.time() + try: + r = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=1) + msgs = r.get("Messages", []) + total = sum(len(m["Body"]) for m in msgs) + record("B8_receive_2.5mb", {"ok": True, "msgs": len(msgs), "bytes": total, "t_s": round(time.time() - t0, 3)}) + for m in msgs: + try: + sqs.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"]) + except Exception: + pass + except Exception as e: + record("B8_receive_2.5mb", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 3)}) + + +# --- B9: long-poll 20s — фактическая длительность --- +def b9_longpoll(sqs, url): + t0 = time.time() + try: + sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=1, WaitTimeSeconds=20) + record("B9_longpoll_20s", {"ok": True, "t_s": round(time.time() - t0, 2)}) + except Exception as e: + record("B9_longpoll_20s", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 2)}) + + +# --- B7: chunked-тело --- +def b7_chunked(): + try: + ctx = ssl.create_default_context() + raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15), + server_hostname=HOST) + raw.sendall(b"POST /health HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" % HOST.encode()) + raw.sendall(b"5\r\nhello\r\n") + raw.sendall(b"5\r\nworld\r\n") + raw.sendall(b"0\r\n\r\n") + resp = raw.recv(4096) + raw.close() + record("B7_chunked", resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)") + except Exception as e: + record("B7_chunked", type(e).__name__ + ": " + str(e)[:80]) + + +def main(): + sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION, + config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 1})) + q = "platprobe-%s" % int(time.time()) + url = sqs.create_queue(QueueName=q)["QueueUrl"] + print("queue: %s" % url, flush=True) + try: + a1_burst() + a2_big_headers() + a3_slow_body() + a4_keepalive() + a5_variants() + b6_big_send(sqs, url) + b7_chunked() + b8_big_receive(sqs, url) + b9_longpoll(sqs, url) + finally: + try: + sqs.delete_queue(QueueUrl=url) + except Exception: + pass + print("MODE=%s ENDPOINT=%s" % (MODE, ENDPOINT), flush=True) + print("SUMMARY: %s" % json.dumps(results, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + sys.exit(main())