Files
contracts/History/connection-reset-analysis.md
T

6.5 KiB
Raw Blame History

Запрос на анализ: случайные обрывы соединения при HTTP POST

Дата: 2026-06-17 Проект: Contracts App (Flask + PostgreSQL на managed-хостинге) Версия: v1.21


Симптом

Любой HTTP POST на managed-хостинг pythonk8s.services.ngcloud.ru случайно обрывается:

100KB #1: HTTP:000
100KB #2: HTTP:000
100KB #3: HTTP:200
100KB #4: HTTP:000
100KB #5: HTTP:000
  • HTTP 000 — curl не может установить TCP-соединение (Connection reset? Connection refused?)
  • Не зависит от: размера (17KB падает, 35KB работает), типа файла, кодировки
  • FormData и JSON — одинаково
  • Health check (GET /health, без БД) — всегда 200 (5/5)
  • Прямой доступ к БД через /test (JSON) — всегда работает

Инфраструктура

Клиент → ddos-guard → nginx/Ingress → gunicorn → Flask (1 worker?)
                                              ↓
                                         PostgreSQL 17.6
  • Managed-хостинг: pythonk8s.services.ngcloud.ru
  • Деплой: git push master → автосборка
  • БД: внутренний кластер postgresqlk8s-master...svc.cluster.local
  • Код: Flask (app.run, host=0.0.0.0:5000)

Что пробовали

  1. FormData/multipart — POST / с файлом → ✗
  2. Чанки 50KB (FormData) → только первый чанк доходит
  3. Чанки в PostgreSQL (таблица chunks) → только первый чанк
  4. Пул соединений psycopg2 (ThreadedConnectionPool min=1 max=3) → ✗
  5. JSON/base64 (POST /upload, Content-Type: application/json) → ✗
  6. Прямой INSERT в БД через /test endpoint (JSON) → ✓ всегда

Код

Сервер: простое сохранение файла в БД

# app.py
def _upload_json(self):
    data = request.get_json(silent=True)
    if not data:
        return jsonify({"error": "Нет JSON"}), 400

    filename = data.get("filename", "")
    b64 = data.get("data", "")
    if not filename or not b64:
        return jsonify({"error": "Нет filename или data"}), 400

    import base64 as b64mod
    file_bytes = b64mod.b64decode(b64)
    mime = mimeutil.guess_mime(filename) or "application/octet-stream"
    cid = data.get("cid")

    conn = None
    if cid:
        contract_id = cid
    else:
        conn, err = db.connect()
        if err:
            return jsonify({"error": f"БД: {err}"}), 500
        cur = conn.cursor()
        cur.execute(
            "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
            ("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
        )
        contract_id = cur.fetchone()[0]
        conn.commit()

    doc_id = _save_file_to_db(filename, file_bytes, mime, str(contract_id), conn=conn)
    if conn:
        cur.close()
        db.put_conn(conn)
    return jsonify({"contract_id": str(contract_id), "doc_id": str(doc_id) if doc_id else None})

Пул соединений (db.py)

import os
from psycopg2 import pool as _pgpool

_pool = None

def _get_pool():
    global _pool
    if _pool is None:
        _pool = _pgpool.ThreadedConnectionPool(
            minconn=1,
            maxconn=3,
            host=os.getenv("DB_HOST"),
            port=os.getenv("DB_PORT", 5432),
            dbname=os.getenv("DB_NAME"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASS"),
            sslmode=os.getenv("DB_SSLMODE", "disable"),
        )
    return _pool

def connect():
    try:
        return _get_pool().getconn(), None
    except Exception as e:
        return None, str(e)

def put_conn(conn):
    try:
        _get_pool().putconn(conn)
    except Exception:
        pass

def execute(sql_text, params=None):
    conn, err = connect()
    if err:
        return None, f"connect: {err}"
    try:
        cur = conn.cursor()
        cur.execute(sql_text, params)
        conn.commit()
        rowcount = cur.rowcount
        cur.close()
        return rowcount, None
    except Exception as e:
        try: conn.rollback()
        except: pass
        return None, str(e)
    finally:
        put_conn(conn)

Клиент (JavaScript)

function readFileAsBase64(file, onProgress) {
    return new Promise(function(resolve, reject) {
      var reader = new FileReader();
      reader.onload = function() {
        var b64 = reader.result.split(',')[1];
        resolve(b64);
      };
      reader.onerror = function() { reject(new Error('Read error')); };
      reader.readAsDataURL(file);
    });
  }

  function uploadFileJSON(file, cid, onProgress) {
    return new Promise(function(resolve, reject) {
      readFileAsBase64(file, onProgress).then(function(b64) {
        var xhr = new XMLHttpRequest();
        xhr.open('POST', '/upload');
        xhr.setRequestHeader('Content-Type', 'application/json');
        xhr.timeout = 120000;
        xhr.onload = function() {
          if (xhr.status === 200) {
            try {
              var resp = JSON.parse(xhr.responseText);
              if (resp.error) { reject(new Error(resp.error)); return; }
              resolve(resp);
            } catch(e) { reject(new Error('Bad JSON')); }
          } else {
            reject(new Error('HTTP ' + xhr.status));
          }
        };
        xhr.onerror = function() { reject(new Error('Сеть')); };
        xhr.ontimeout = function() { reject(new Error('Таймаут')); };
        xhr.send(JSON.stringify({
          filename: file.name,
          data: b64,
          cid: cid || null
        }));
      }).catch(reject);
    });
  }

Вопросы

  1. Почему health check (GET /health, просто return "OK") всегда работает, а POST с любым телом — случайно обрывается?
  2. Может ли проблема быть в ddos-guard, который считает частые POST за флуд?
  3. Может ли gunicorn с 1 worker'ом не справляться с последовательными запросами (keepalive, таймауты)?
  4. Почему curl с локалхоста показывает HTTP 000 (нет TCP-соединения), а не HTTP 502/504?
  5. Может ли managed PostgreSQL обрывать соединения пула, вызывая каскадный отказ?