feat: Gunicorn instead of Werkzeug dev server, bump 1.0.7→1.0.8
Deploy loadtest / validate (push) Waiting to run
Deploy loadtest / validate (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# 2026-07-10 — Ответ Соннета: Werkzeug dev server + chunked TE = гонка
|
||||||
|
|
||||||
|
## Диагноз
|
||||||
|
Werkzeug dev server не умеет корректно работать с chunked transfer encoding от nginx.
|
||||||
|
При `proxy_request_buffering: off` nginx шлёт тело чанками.
|
||||||
|
Flask отвечает `Connection: close` → nginx ещё шлёт чанк → RST → таймаут.
|
||||||
|
|
||||||
|
nginx:alpine держит keep-alive → проблемы нет.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Gunicorn вместо Werkzeug dev server.
|
||||||
|
Правильно де-чанкует тело, держит persistent connections.
|
||||||
|
|
||||||
|
## Способ
|
||||||
|
Shturval запускает `python app.py`. Вместо `app.run()` — Gunicorn через Python API:
|
||||||
|
```python
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import gunicorn.app.base
|
||||||
|
class StandaloneApp(gunicorn.app.base.BaseApplication):
|
||||||
|
def __init__(self, app, options=None):
|
||||||
|
self.application = app
|
||||||
|
self.options = options or {}
|
||||||
|
super().__init__()
|
||||||
|
def load_config(self):
|
||||||
|
for k, v in self.options.items():
|
||||||
|
self.cfg.set(k, v)
|
||||||
|
def load(self):
|
||||||
|
return self.application
|
||||||
|
StandaloneApp(app, {
|
||||||
|
'bind': '0.0.0.0:5000',
|
||||||
|
'workers': 2,
|
||||||
|
'timeout': 300,
|
||||||
|
}).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
requirements.txt уже содержит gunicorn. Dockerfile CMD можно убрать (Shturval всё равно переопределяет).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Sonnet: Flask за ingress'ом дропает большие POST — nginx echo нет
|
||||||
|
|
||||||
|
## Новый решающий тест
|
||||||
|
Nginx:alpine через тот же VIP/ingress — 100KB POST = 10/10 успех.
|
||||||
|
Flask через тот же VIP/ingress — 100KB POST = 2-5/10.
|
||||||
|
|
||||||
|
VIP, ingress, kube-vip, сеть исключены. Проблема в связке «ingress → Flask».
|
||||||
|
|
||||||
|
## Что уже проверили на Flask
|
||||||
|
- debug=True → debug=False: 5/10 (незначительно)
|
||||||
|
- request.get_data() перед request.form: 2/10 (ухудшило, откачено)
|
||||||
|
- proxy_request_buffering off: 5/10 (лучше чем on = 3/10)
|
||||||
|
- proxy_request_buffering on: 3/10
|
||||||
|
- Внутри кластера (pod→pod, pod→ingress): 10/10 всегда
|
||||||
|
|
||||||
|
## Инфра
|
||||||
|
- Flask 3.1.3, Werkzeug 3.1.8, Python 3.12
|
||||||
|
- Запуск: app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
||||||
|
- Ingress: nginx, proxy_http_version 1.1, proxy_request_buffering off
|
||||||
|
- Flask отвечает Connection: close (dev server не держит keepalive)
|
||||||
|
- Content-Type: application/x-www-form-urlencoded, тело ~136KB (base64)
|
||||||
|
- Код: request.form.get('data', '') → парсинг формы
|
||||||
|
|
||||||
|
## Код /upload
|
||||||
|
```python
|
||||||
|
@app.route('/upload', methods=['POST'])
|
||||||
|
def upload():
|
||||||
|
t0 = time.time()
|
||||||
|
data = request.form.get('data', '')
|
||||||
|
if data:
|
||||||
|
if ',' in data:
|
||||||
|
data = data.split(',', 1)[1]
|
||||||
|
data = data.replace(' ', '+')
|
||||||
|
missing = len(data) % 4
|
||||||
|
if missing:
|
||||||
|
data += '=' * (4 - missing)
|
||||||
|
raw = base64.b64decode(data)
|
||||||
|
elapsed = round((time.time() - t0) * 1000)
|
||||||
|
return {'ok': True, 'bytes': len(raw), 'elapsed_ms': elapsed}
|
||||||
|
for f in request.files.values():
|
||||||
|
...
|
||||||
|
return {'ok': True, 'bytes': total, 'elapsed_ms': elapsed}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
Почему Flask за nginx ingress'ом дропает connection на большом POST, а nginx:alpine — нет?
|
||||||
|
Может ли Werkzeug dev server иметь проблему с chunked transfer encoding от ingress'а?
|
||||||
|
Или проблема в парсинге URL-encoded формы большого размера?
|
||||||
|
Что попробовать В КОДЕ (не в инфраструктуре)?
|
||||||
+26
-2
@@ -4,7 +4,7 @@ from flask import Flask, render_template, request
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
VERSION = '1.0.7'
|
VERSION = '1.0.8'
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -48,4 +48,28 @@ def upload():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
import gunicorn.app.base
|
||||||
|
|
||||||
|
class StandaloneApp(gunicorn.app.base.BaseApplication):
|
||||||
|
def __init__(self, app, options=None):
|
||||||
|
self.application = app
|
||||||
|
self.options = options or {}
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
for k, v in self.options.items():
|
||||||
|
self.cfg.set(k, v)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
return self.application
|
||||||
|
|
||||||
|
StandaloneApp(app, {
|
||||||
|
'bind': '0.0.0.0:5000',
|
||||||
|
'workers': 2,
|
||||||
|
'timeout': 300,
|
||||||
|
'worker_class': 'sync',
|
||||||
|
'keepalive': 5,
|
||||||
|
'limit_request_field_size': 0,
|
||||||
|
'limit_request_line': 0,
|
||||||
|
'limit_request_fields': 0,
|
||||||
|
}).run()
|
||||||
|
|||||||
Reference in New Issue
Block a user