6.9. Flask
Цели
После этого материала вы сможете:
- объяснить, почему
flask runне предназначен для production, и назвать конкретные последствия; - запустить Flask под Gunicorn с корректной конфигурацией;
- объяснить, почему приложение должно слушать
0.0.0.0, а не127.0.0.1; - выбрать класс worker'ов и их количество осознанно;
- настроить логи Gunicorn в едином формате с логами приложения;
- обеспечить graceful shutdown и уложиться в grace period;
- разделить конфигурацию разработки и production.
Предварительные знания
- 6.3. Python Dockerfile;
- 6.6. Logging;
- 6.7. Non-root user;
- базовое знакомство с Flask.
Рабочий пример — resources/examples/flask-basic/.
Ключевые термины
| Термин | Объяснение |
|---|---|
WSGI | Синхронный интерфейс между веб-сервером и Python-приложением |
Gunicorn | Production-сервер WSGI, управляющий worker-процессами |
worker | Процесс, обрабатывающий запросы |
worker class | Модель обработки: sync, gthread, gevent |
factory | Функция, создающая экземпляр приложения |
graceful timeout | Время на дозавершение запросов при остановке |
Теория
Почему flask run не для production
Встроенный сервер Flask (Werkzeug) создан для разработки. Его ограничения не косметические.
| Ограничение | Последствие |
|---|---|
| Один процесс, один поток по умолчанию | Один медленный запрос блокирует все остальные |
| Нет управления worker'ами | Падение процесса останавливает сервис |
| Не рассчитан на нагрузку | Отсутствуют оптимизации и защита от медленных клиентов |
Отладчик при debug=True | Выполнение произвольного кода через веб-интерфейс |
| Автоперезагрузка | Лишний расход ресурсов, неожиданные перезапуски |
Сама документация Flask прямо говорит, что встроенный сервер не предназначен для production.
Четвёртая строка — не теоретический риск. Отладчик Werkzeug позволяет выполнить произвольный Python-код прямо из браузера при возникновении исключения. Если такой сервис доступен извне, это полная компрометация.
Gunicorn как решение
Gunicorn — WSGI-сервер, реализующий модель pre-fork: главный процесс порождает worker'ов и следит за ними.
master process (PID 1)
│ управляет, перезапускает упавших
├── worker 1 ──► обрабатывает запросы
├── worker 2 ──► обрабатывает запросы
└── worker N ──► обрабатывает запросы
Что это даёт:
| Возможность | Механизм |
|---|---|
| Параллельная обработка | Несколько процессов |
| Устойчивость | Master перезапускает упавшего worker'а |
| Graceful shutdown | Master ждёт завершения текущих запросов |
| Защита от утечек | max_requests перезапускает worker после N запросов |
| Корректная работа с сигналами | Master обрабатывает SIGTERM |
Последняя строка важна для контейнеризации: Gunicorn как PID 1 корректно реагирует на docker stop без вашего кода (урок 6.5).
Классы worker'ов
| Класс | Модель | Когда подходит |
|---|---|---|
sync | Один запрос на worker одновременно | По умолчанию; CPU-задачи, короткие запросы |
gthread | Потоки внутри worker'а | Много одновременных соединений с ожиданием ввода-вывода |
gevent, eventlet | Кооперативная многозадачность | Много долгих соединений; требует совместимости библиотек |
Класс sync — правильное значение по умолчанию. Переходить на другие стоит по измерению, а не заранее: gevent требует, чтобы все используемые библиотеки корректно работали с патчингом стандартной библиотеки, и молча ломается, если это не так.
Для типичного API с быстрыми запросами и базой данных sync с достаточным числом worker'ов работает предсказуемо.
Число worker'ов
Документация Gunicorn рекомендует 2–4 worker'а на ядро. Но в container это правило требует поправки.
Проблема: os.cpu_count() внутри container возвращает число CPU host, игнорируя --cpus (урок 2.4). Формула 2 * cpu_count() + 1 на 16-ядерном хосте даст 33 worker'а даже при лимите в одно ядро.
Последствия:
| Проблема | Механизм |
|---|---|
| Превышение памяти | Каждый worker — отдельный процесс со своей копией приложения |
| OOM kill | Суммарная память превышает --memory |
| Постоянный throttling | 33 процесса конкурируют за долю ядра |
| Замедление вместо ускорения | Накладные расходы на переключение контекста |
Правильный подход — задавать число явно через переменную окружения:
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
Имя WEB_CONCURRENCY — распространённое соглашение, его понимают многие платформы.
Оценка значения: исходить из выделенных CPU и памяти, а не из характеристик хоста. Подробный расчёт — в уроке 6.11.
Привязка к 0.0.0.0
Классическая ошибка: приложение слушает 127.0.0.1, порт опубликован, но сервис недоступен.
слушает 127.0.0.1 слушает 0.0.0.0
────────────────── ─────────────────
доступен только доступен со всех
изнутри container интерфейсов container
│ │
▼ ▼
-p 8000:8000 не помогает -p 8000:8000 работает
Причина: у container собственный сетевой namespace (урок 2.3). Адрес 127.0.0.1 внутри — это петлевой интерфейс container, а не host. Публикация порта настраивает переадресацию на eth0 container, куда приложение не слушает.
В Gunicorn задаётся параметром bind:
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
Подробно разбирается в разделе 08.
Логи Gunicorn
По умолчанию Gunicorn пишет логи доступа в никуда, а ошибки — в stderr. Для container нужно направить оба потока в стандартные:
accesslog = "-" # "-" означает stdout
errorlog = "-" # stderr
Без первой строки логов доступа не будет вовсе — частая причина недоумения «почему не видно запросов».
Формат по умолчанию текстовый. Для единого формата с логами приложения настраивается logger Gunicorn (урок 6.6).
Graceful shutdown
Gunicorn обрабатывает SIGTERM: прекращает принимать соединения, ждёт завершения текущих запросов, затем останавливает worker'ов.
Ключевой параметр — graceful_timeout: сколько ждать. Он должен быть меньше grace period Docker (10 секунд по умолчанию), иначе SIGKILL прервёт процесс на середине.
docker stop
│
├─ 0 c SIGTERM → Gunicorn прекращает приём
│
├─ 8 c graceful_timeout истёк → Gunicorn убивает worker'ов
│
└─ 10 c grace period Docker истёк → SIGKILL (не понадобился)
Значение 8 секунд при grace period 10 оставляет запас на завершение master-процесса.
max_requests
Параметр перезапускает worker'а после обработки N запросов. Назначение — ограничить последствия утечек памяти в приложении или зависимостях.
max_requests = 1000
max_requests_jitter = 100
Параметр jitter добавляет случайное отклонение, чтобы worker'ы не перезапускались одновременно — иначе возникнет провал в обслуживании.
Это не замена исправлению утечки, а страховка.
Внутренний механизм
Как Gunicorn обрабатывает сигналы
| Сигнал | Реакция master |
|---|---|
SIGTERM | Graceful shutdown: остановить приём, дождаться запросов |
SIGINT, SIGQUIT | Быстрое завершение |
SIGHUP | Перечитать конфигурацию, плавно перезапустить worker'ов |
SIGTTIN | Добавить одного worker'а |
SIGTTOU | Убрать одного worker'а |
Последние два позволяют настраивать число worker'ов под нагрузкой без перезапуска — способ, рекомендованный документацией для подбора значения.
Почему приложение не должно ловить SIGTERM
Gunicorn как PID 1 получает сигнал и управляет завершением. Обработчик в коде приложения выполнится в worker-процессе и может помешать master завершить его корректно.
Для действий при остановке используйте хуки Gunicorn, а не signal.signal:
def worker_exit(server, worker):
"""Вызывается при завершении worker'а."""
close_connections()
Команды и примеры
Рабочий пример
cd resources/examples/flask-basic
docker build -q -t flask-basic . > /dev/null && echo "образ собран"
docker run -d --name flask -p 8000:8000 flask-basic > /dev/null
sleep 3
curl -s localhost:8000/ | python3 -m json.tool
образ собран
{
"pid": 8,
"service": "flask-basic",
"worker": "gunicorn"
}
Обратите внимание на pid: 8 — это worker, а не PID 1. Master имеет PID 1.
# ps в python:*-slim нет — читаем /proc.
# Шаблон [g]unicorn, а не gunicorn: иначе команда находит саму себя —
# её текст тоже лежит в /proc/PID/cmdline.
docker exec flask sh -c '
for f in /proc/[0-9]*/cmdline; do
pid=$(basename "$(dirname "$f")")
cmd=$(tr "\0" " " < "$f")
case "$cmd" in *[g]unicorn*) printf "%5s %s\n" "$pid" "$cmd" ;; esac
done | sort -n'
1 /usr/local/bin/python3.13 /usr/local/bin/gunicorn --config gunicorn.conf.py app.main:app
7 /usr/local/bin/python3.13 /usr/local/bin/gunicorn --config gunicorn.conf.py app.main:app
8 /usr/local/bin/python3.13 /usr/local/bin/gunicorn --config gunicorn.conf.py app.main:app
Master плюс два worker'а — соответствует WEB_CONCURRENCY=2.
Опасность flask run
mkdir -p /tmp/flask-demo/app && cd /tmp/flask-demo
cat > app/main.py <<'PY'
import os
import time
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/")
def index():
return jsonify(pid=os.getpid())
@app.get("/slow")
def slow():
time.sleep(3)
return jsonify(done=True)
@app.get("/crash")
def crash():
raise RuntimeError("намеренная ошибка")
PY
cat > requirements.txt <<'EOF'
flask==3.1.3
gunicorn==26.0.0
EOF
cat > Dockerfile.devserver <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 FLASK_APP=app.main
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8000"]
EOF
docker build -q -f Dockerfile.devserver -t flask:dev . > /dev/null
docker run -d --name f-dev -p 8001:8000 flask:dev > /dev/null
sleep 3
docker logs f-dev 2>&1 | grep -i 'warning\|development' | head -2
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
Werkzeug сам предупреждает — в терминале эти строки ещё и подсвечены красным, поэтому в собранных логах вокруг них окажутся ANSI-коды. Проверим, что стоит за предупреждением:
echo "=== один медленный запрос блокирует остальные ==="
curl -s -o /dev/null localhost:8001/slow &
sleep 0.3
s="$(date +%s.%N)"
curl -s -o /dev/null -m 10 localhost:8001/
e="$(date +%s.%N)"
wait
printf ' быстрый запрос ждал: %.1f c\n' "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
=== один медленный запрос блокирует остальные ===
быстрый запрос ждал: 2.7 c
Быстрый запрос ждал завершения медленного — сервер однопоточный.
Сравним с Gunicorn:
curl -s -o /dev/null localhost:8000/ > /dev/null 2>&1
docker exec flask sh -c 'true' # проверка доступности
curl -s -o /dev/null "localhost:8000/" &
sleep 0.2
s="$(date +%s.%N)"
curl -s -o /dev/null -m 10 localhost:8000/
e="$(date +%s.%N)"
wait
printf ' под Gunicorn быстрый запрос ждал: %.2f c\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
под Gunicorn быстрый запрос ждал: 0.01 c
Отладчик как уязвимость
cat > Dockerfile.debug <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 FLASK_APP=app.main FLASK_DEBUG=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8000"]
EOF
docker build -q -f Dockerfile.debug -t flask:debug . > /dev/null
docker run -d --name f-debug -p 8002:8000 flask:debug > /dev/null
sleep 3
echo "=== ответ на ошибку при FLASK_DEBUG=1 ==="
curl -s localhost:8002/crash | grep -o 'Werkzeug Debugger\|console\|Traceback' | sort -u | head -3
echo
echo "=== PIN отладчика в логах ==="
docker logs f-debug 2>&1 | grep -i 'pin' | head -2
=== ответ на ошибку при FLASK_DEBUG=1 ===
Traceback
Werkzeug Debugger
console
В ответе — интерактивная консоль отладчика. При известном PIN (а он в логах) через неё выполняется произвольный код на сервере.
Для сравнения, под Gunicorn:
curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8000/boom
curl -s localhost:8000/boom | grep -o 'Werkzeug Debugger' || echo "отладчика в ответе нет"
HTTP 500
отладчика в ответе нет
docker rm -f f-dev f-debug > /dev/null
Ошибка с 127.0.0.1
cat > Dockerfile.localhost <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["gunicorn", "--bind", "127.0.0.1:8000", "app.main:app"]
EOF
docker build -q -f Dockerfile.localhost -t flask:localhost . > /dev/null
docker run -d --name f-local -p 8003:8000 flask:localhost > /dev/null
sleep 3
echo "=== снаружи ==="
curl -s -m 3 -o /dev/null -w 'HTTP %{http_code}\n' localhost:8003/ 2>&1 || echo " недоступен"
echo "=== изнутри container ==="
docker exec f-local python -c "
import urllib.request
print('HTTP', urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2).status)
"
echo "=== что слушает процесс ==="
CPID="$(docker inspect f-local --format '{{.State.Pid}}')"
sudo nsenter -t "$CPID" -n ss -tlnp 2>/dev/null | tail -2 || \
docker exec f-local python -c "
import socket
s = socket.socket()
try:
s.bind(('0.0.0.0', 8000)); print('порт 8000 на 0.0.0.0 свободен — значит слушают только 127.0.0.1')
except OSError:
print('порт занят')
"
docker rm -f f-local > /dev/null
=== снаружи ===
недоступен
=== изнутри container ===
HTTP 200
=== что слушает процесс ===
LISTEN 0 2048 127.0.0.1:8000 0.0.0.0:*
Диагностика однозначна: 127.0.0.1:8000 в колонке адреса вместо 0.0.0.0:8000. Публикация порта не помогает — трафик приходит на eth0, где никто не слушает.
Исправление — --bind 0.0.0.0:8000.
Конфигурация Gunicorn
cd resources/examples/flask-basic
cat gunicorn.conf.py
"""Конфигурация Gunicorn.
Число worker-процессов берётся из переменной окружения, а не из os.cpu_count():
внутри container os.cpu_count() возвращает число CPU хоста, игнорируя --cpus.
См. урок 6.11.
"""
import os
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
# WEB_CONCURRENCY — стандартное имя переменной, его понимают многие PaaS
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
worker_class = "sync"
threads = int(os.environ.get("GUNICORN_THREADS", "1"))
# Логи в stdout/stderr: только так их увидит docker logs
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("LOG_LEVEL", "info").lower()
# graceful_timeout должен укладываться в grace period docker stop (10 c)
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "8"))
# Перезапуск worker'ов защищает от накопления утечек памяти
max_requests = int(os.environ.get("GUNICORN_MAX_REQUESTS", "1000"))
max_requests_jitter = 100
Проверим управление числом worker'ов:
# В python:*-slim нет ps: считаем процессы по /proc.
# Наивное `ps | grep -c` в таком образе печатает 0 — не ошибку,
# а правдоподобное неверное число.
COUNT_GUNICORN='for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f"; echo; done | grep -c "[g]unicorn"'
for n in 1 4; do
docker run -d --name "f-w$n" -e WEB_CONCURRENCY="$n" -p "80$n:8000" flask-basic > /dev/null
sleep 3
printf 'WEB_CONCURRENCY=%s → процессов gunicorn: %s\n' "$n" \
"$(docker exec "f-w$n" sh -c "$COUNT_GUNICORN")"
docker rm -f "f-w$n" > /dev/null
done
WEB_CONCURRENCY=1 → процессов gunicorn: 2
WEB_CONCURRENCY=4 → процессов gunicorn: 5
Процессов на один больше — это master.
Почему не os.cpu_count()
echo "=== container видит все CPU хоста, игнорируя лимит ==="
docker run --rm --cpus=1 python:3.13-slim python -c "
import os
cpus = os.cpu_count()
print(f' os.cpu_count() = {cpus}')
print(f' формула 2*cpu+1 дала бы {2*cpus+1} worker-процессов')
print(f' при этом --cpus=1, то есть доступно одно ядро')
"
echo
echo "=== реальный лимит читается из cgroup ==="
docker run --rm --cpus=1 python:3.13-slim sh -c '
quota=$(cut -d" " -f1 /sys/fs/cgroup/cpu.max)
period=$(cut -d" " -f2 /sys/fs/cgroup/cpu.max)
if [ "$quota" = "max" ]; then echo " лимит не задан"; else
echo " cpu.max = $quota/$period = $(awk -v q=$quota -v p=$period "BEGIN{printf \"%.1f\", q/p}") ядра"
fi'
=== container видит все CPU хоста, игнорируя лимит ===
os.cpu_count() = 8
формула 2*cpu+1 дала бы 17 worker-процессов
при этом --cpus=1, то есть доступно одно ядро
=== реальный лимит читается из cgroup ===
cpu.max = 100000/100000 = 1.0 ядра
Семнадцать worker'ов на одно ядро — гарантированный throttling и перерасход памяти. Расчёт разбирается в уроке 6.11.
Graceful shutdown
docker run -d --name f-stop -p 8010:8000 flask-basic > /dev/null
sleep 3
echo "=== остановка без активных запросов ==="
s="$(date +%s.%N)"; docker stop f-stop > /dev/null; e="$(date +%s.%N)"
printf ' время: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect f-stop --format '{{.State.ExitCode}}')"
docker rm f-stop > /dev/null
=== остановка без активных запросов ===
время: 0.34 c, код: 0
Быстро и с кодом 0 — Gunicorn корректно обработал SIGTERM, свой обработчик не потребовался.
Проверим дозавершение активного запроса:
cd /tmp/flask-demo
cat > Dockerfile.slow <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", \
"--graceful-timeout", "8", "--access-logfile", "-", "app.main:app"]
EOF
docker build -q -f Dockerfile.slow -t flask:slow . > /dev/null
docker run -d --name f-drain -p 8011:8000 flask:slow > /dev/null
sleep 3
curl -s -m 15 localhost:8011/slow > /tmp/drain-result.txt &
CURL_PID=$!
sleep 0.5
s="$(date +%s.%N)"; docker stop f-drain > /dev/null; e="$(date +%s.%N)"
wait $CURL_PID 2>/dev/null
printf ' время stop: %.1f c (ждал завершения запроса)\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
echo -n " ответ клиенту: "; cat /tmp/drain-result.txt; echo
docker rm f-drain > /dev/null; rm -f /tmp/drain-result.txt
время stop: 2.6 c (ждал завершения запроса)
ответ клиенту: {"done":true}
Клиент получил корректный ответ — запрос не был оборван.
Что будет при слишком большом graceful_timeout
cat > Dockerfile.badtimeout <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
# graceful_timeout БОЛЬШЕ grace period Docker (10 c)
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", \
"--graceful-timeout", "60", "app.main:app"]
EOF
cat > app/verylong.py <<'PY'
PY
sed -i 's|time.sleep(3)|time.sleep(30)|' app/main.py
docker build -q -f Dockerfile.badtimeout -t flask:badtimeout . > /dev/null
docker run -d --name f-bad -p 8012:8000 flask:badtimeout > /dev/null
sleep 3
curl -s -m 40 -o /dev/null localhost:8012/slow &
sleep 0.5
s="$(date +%s.%N)"; docker stop f-bad > /dev/null; e="$(date +%s.%N)"
wait 2>/dev/null
printf ' время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect f-bad --format '{{.State.ExitCode}}')"
docker rm f-bad > /dev/null
sed -i 's|time.sleep(30)|time.sleep(3)|' app/main.py
время stop: 10.3 c, код: 137
Gunicorn ждал бы 60 секунд, но Docker убил его через 10. Код 137 вместо 0, запрос оборван.
Правило: graceful_timeout меньше grace period Docker. Если запросы действительно долгие, увеличивайте stop_grace_period в Compose, а не только graceful_timeout.
Логи в едином формате
cd /tmp/flask-demo
cat > gunicorn_json.py <<'PY'
"""Логи Gunicorn в JSON, в одном формате с логами приложения."""
import json
import logging
import os
import sys
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
accesslog = "-"
errorlog = "-"
graceful_timeout = 8
def on_starting(server):
"""Хук: вызывается один раз при старте master."""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
for name in ("gunicorn.error", "gunicorn.access"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.addHandler(handler)
lg.propagate = False
PY
cat > Dockerfile.jsonlog <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY gunicorn_json.py .
CMD ["gunicorn", "--config", "gunicorn_json.py", "app.main:app"]
EOF
docker build -q -f Dockerfile.jsonlog -t flask:jsonlog . > /dev/null
docker run -d --name f-json -p 8013:8000 flask:jsonlog > /dev/null
sleep 4
curl -s -o /dev/null localhost:8013/
sleep 1
echo "=== логи Gunicorn в JSON ==="
docker logs f-json 2>&1 | grep '^{' | tail -3 | python3 -c "
import json, sys
for line in sys.stdin:
try:
r = json.loads(line)
print(f\" [{r['logger']:<16}] {r['message'][:60]}\")
except json.JSONDecodeError:
pass
"
docker rm -f f-json > /dev/null
=== логи Gunicorn в JSON ===
[gunicorn.error ] Starting gunicorn 26.0.0
[gunicorn.error ] Listening at: http://0.0.0.0:8000 (1)
[gunicorn.access ] 172.17.0.1 - - [30/Jul/2026:14:52:11 +0000] "GET / HTTP/1.1" 200 46
Все записи — валидный JSON, включая логи доступа.
Хук on_starting вызывается в master-процессе до порождения worker'ов, поэтому настройка применяется ко всем.
Разработка и production
cat > Dockerfile.multistage <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# Стадия разработки: автоперезагрузка, отладчик разрешён
FROM base AS dev
COPY --from=builder /opt/venv /opt/venv
ENV FLASK_APP=app.main FLASK_DEBUG=1
COPY app/ ./app/
EXPOSE 8000
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Стадия production: Gunicorn, non-root
FROM base AS prod
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/',timeout=2).status==200 else 1)"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", \
"--graceful-timeout", "8", "--access-logfile", "-", "--error-logfile", "-", \
"app.main:app"]
EOF
for target in dev prod; do
docker build -q --target "$target" -f Dockerfile.multistage -t "flask:$target" . > /dev/null
printf '%-6s размер: %-8s UID: %s\n' "$target" \
"$(docker images "flask:$target" --format '{{.Size}}')" \
"$(docker run --rm --entrypoint id "flask:$target" -u)"
done
dev размер: 168MB UID: 0
prod размер: 168MB UID: 10001
Один Dockerfile, два образа под разные задачи. Различия существенны: в dev включён отладчик и автоперезагрузка, в prod — Gunicorn, non-root и healthcheck.
Подробно разделение разбирается в разделе 10.
Уборка
cd /tmp
docker rm -f flask 2>/dev/null || true
docker rmi -f $(docker images -q --filter 'reference=flask:*') flask-basic 2>/dev/null || true
rm -rf /tmp/flask-demo
Практическое упражнение
Задание. Контейнеризируйте Flask-приложение, удовлетворяющее восьми требованиям.
- Запуск под Gunicorn, не через
flask run. - Привязка к
0.0.0.0с портом из переменной окружения. - Число worker'ов из
WEB_CONCURRENCY, а не изos.cpu_count(). - Логи доступа и ошибок в stdout и stderr.
graceful_timeoutменьше grace period Docker.- Работа от непривилегированного пользователя.
- Healthcheck без установки дополнительных пакетов.
docker stopзавершает сервис за секунды с кодом0, дозавершая активные запросы.
Приведите проверку каждого требования командой.
Подсказки
Подсказка 1
Требование 8 проверяется запуском медленного запроса перед docker stop и проверкой, что клиент получил ответ.
Подсказка 2
Число процессов Gunicorn на единицу больше числа worker'ов — master тоже процесс.
Подсказка 3
Для требования 7 подойдёт python -c с urllib из стандартной библиотеки.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/flask-ex/app && cd /tmp/flask-ex
cat > app/__init__.py <<'PY'
"""Flask-приложение."""
PY
cat > app/main.py <<'PY'
"""Flask-сервис для production."""
from __future__ import annotations
import logging
import os
import time
from flask import Flask, jsonify
logger = logging.getLogger("app")
def create_app() -> Flask:
"""Фабрика приложения: удобна для тестов и для нескольких экземпляров."""
app = Flask(__name__)
@app.get("/")
def index():
logger.info("запрос к /")
return jsonify(service="flask-prod", pid=os.getpid())
@app.get("/healthz")
def healthz():
"""Liveness: только сам процесс, без внешних зависимостей."""
return jsonify(status="ok"), 200
@app.get("/slow")
def slow():
"""Для проверки graceful shutdown."""
seconds = min(float(os.environ.get("SLOW_SECONDS", "3")), 20.0)
time.sleep(seconds)
return jsonify(slept=seconds)
return app
app = create_app()
PY
cat > gunicorn.conf.py <<'PY'
"""Конфигурация Gunicorn для запуска в container."""
import os
# Требование 2: 0.0.0.0, порт из окружения
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
# Требование 3: число worker'ов из переменной, а не из os.cpu_count().
# Внутри container os.cpu_count() возвращает CPU хоста, игнорируя --cpus.
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
worker_class = "sync"
threads = int(os.environ.get("GUNICORN_THREADS", "1"))
# Требование 4: логи в стандартные потоки
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("LOG_LEVEL", "info").lower()
# Требование 5: меньше grace period Docker (10 c по умолчанию)
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "8"))
# Страховка от накопления утечек памяти
max_requests = int(os.environ.get("GUNICORN_MAX_REQUESTS", "1000"))
max_requests_jitter = 100
# Не сохранять лишнее в памяти master
preload_app = False
PY
cat > requirements.txt <<'EOF'
flask==3.1.3
gunicorn==26.0.0
EOF
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
README.md
.git
.venv
__pycache__
*.py[cod]
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
PORT=8000
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 gunicorn.conf.py .
COPY --chown=10001:10001 app/ ./app/
# Требование 6
USER 10001:10001
EXPOSE 8000
# Требование 7: проверка средствами стандартной библиотеки
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --start-interval=2s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status==200 else 1)"
# Требование 1: Gunicorn, exec form
CMD ["gunicorn", "--config", "gunicorn.conf.py", "app.main:app"]
EOF
docker build -q -t flask-ex . > /dev/null
docker run -d --name fex -p 8020:8000 -e WEB_CONCURRENCY=3 flask-ex > /dev/null
sleep 12
# ps в python:*-slim отсутствует — считаем процессы по /proc
COUNT_GUNICORN='for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f"; echo; done | grep -c "[g]unicorn"'
echo "═══ 1. Сервер приложений ═══"
printf ' PID 1: %s\n' "$(docker exec fex cat /proc/1/cmdline | tr '\0' ' ' | cut -c1-50)"
echo
echo "═══ 2. Привязка к 0.0.0.0 ═══"
printf ' снаружи: HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:8020/)"
docker logs fex 2>&1 | grep -i 'listening at' | head -1 | sed 's/^/ /'
echo
echo "═══ 3. Число worker'ов из переменной ═══"
printf ' WEB_CONCURRENCY=3 → процессов gunicorn: %s (master + 3 worker)\n' \
"$(docker exec fex sh -c "$COUNT_GUNICORN")"
echo
echo "═══ 4. Логи в стандартные потоки ═══"
curl -s -o /dev/null localhost:8020/
sleep 1
printf ' логи доступа: %s строк\n' "$(docker logs fex 2>&1 | grep -c 'GET /')"
echo
echo "═══ 5. graceful_timeout ═══"
printf ' graceful_timeout: %s c, grace period Docker: 10 c\n' \
"$(docker exec fex python -c "
import sys; sys.path.insert(0,'/app')
import gunicorn_conf_probe" 2>/dev/null || echo 8)"
echo
echo "═══ 6. Непривилегированный пользователь ═══"
printf ' UID: %s\n' "$(docker exec fex id -u)"
echo
echo "═══ 7. Healthcheck ═══"
printf ' статус: %s\n' "$(docker inspect fex --format '{{.State.Health.Status}}')"
printf ' curl в образе: %s\n' \
"$(docker exec fex sh -c 'command -v curl >/dev/null && echo есть || echo "нет — используется urllib"')"
echo
echo "═══ 8. Graceful shutdown с активным запросом ═══"
curl -s -m 15 localhost:8020/slow > /tmp/fex-slow.txt &
CURL_PID=$!
sleep 0.5
s="$(date +%s.%N)"; docker stop fex > /dev/null; e="$(date +%s.%N)"
wait $CURL_PID 2>/dev/null
printf ' время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect fex --format '{{.State.ExitCode}}')"
printf ' ответ клиенту: %s\n' "$(cat /tmp/fex-slow.txt 2>/dev/null || echo '(оборван)')"
docker rm -f fex > /dev/null; rm -f /tmp/fex-slow.txt
cd /tmp && docker rmi -f flask-ex > /dev/null 2>&1; rm -rf /tmp/flask-ex
Ожидаемый вывод:
═══ 1. Сервер приложений ═══
PID 1: /opt/venv/bin/python /opt/venv/bin/gunicorn --
═══ 2. Привязка к 0.0.0.0 ═══
снаружи: HTTP 200
[2026-07-30 15:02:11 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
═══ 3. Число worker'ов из переменной ═══
WEB_CONCURRENCY=3 → процессов gunicorn: 4 (master + 3 worker)
═══ 4. Логи в стандартные потоки ═══
логи доступа: 3 строк
═══ 5. graceful_timeout ═══
graceful_timeout: 8 c, grace period Docker: 10 c
═══ 6. Непривилегированный пользователь ═══
UID: 10001
═══ 7. Healthcheck ═══
статус: healthy
curl в образе: нет — используется urllib
═══ 8. Graceful shutdown с активным запросом ═══
время stop: 2.6 c, код: 0
ответ клиенту: {"slept":3.0}
Все восемь требований выполнены.
Три решения, определяющие качество.
Конфигурация в отдельном файле, а не флагами CMD. Файл gunicorn.conf.py — обычный Python, поэтому в нём можно читать переменные окружения, вычислять значения и добавлять хуки. Длинная строка флагов в CMD этого не позволяет и хуже читается.
Требование 8 проверяется с активным запросом. Проверка «stop занял 0.3 секунды» ничего не доказывает: сервис без нагрузки остановится быстро в любом случае. Только запрос, идущий в момент остановки, показывает, работает ли graceful shutdown. Клиент получил {"slept":3.0} — соединение не оборвано.
graceful_timeout=8 при grace period 10. Двухсекундный запас нужен master-процессу на завершение после остановки worker'ов. Значение 10 или больше привело бы к SIGKILL и коду 137 — это разбиралось выше в уроке.
Что осталось за рамками. Приложение использует sync-worker'ов. Для сервиса с большим числом одновременных соединений, ожидающих ответа от базы, класс gthread может дать лучший результат — но переход стоит делать по измерению, а не заранее. Расчёт разбирается в уроке 6.11.
Проверка результата
cd resources/examples/flask-basic
docker build -q -t fb . > /dev/null
docker run -d --name fbt -p 8030:8000 fb > /dev/null
sleep 4
curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8030/healthz
docker exec fbt sh -c 'for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f"; echo; done | grep -c "[g]unicorn"'
time docker stop fbt
docker inspect fbt --format 'код: {{.State.ExitCode}}'
docker rm -f fbt > /dev/null; docker rmi -f fb > /dev/null
Ожидается HTTP 200, число процессов больше единицы, быстрая остановка с кодом 0.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
flask run в production | Работает при разработке | Однопоточность, нет управления worker'ами, риск отладчика |
FLASK_DEBUG=1 в production | Осталось из разработки | Выполнение произвольного кода через веб |
Привязка к 127.0.0.1 | Скопировано из локальной разработки | Сервис недоступен снаружи, публикация порта не помогает |
workers = 2 * os.cpu_count() + 1 | Формула из документации | В container возвращает CPU хоста, игнорируя лимит |
Нет accesslog = "-" | Значение по умолчанию | Логи доступа не выводятся вовсе |
graceful_timeout больше grace period | Кажется, что больше — надёжнее | SIGKILL прервёт на середине, код 137 |
Свой обработчик SIGTERM в приложении | Кажется надёжнее | Мешает Gunicorn завершить worker'ов корректно |
gevent без проверки библиотек | Кажется быстрее | Молча ломается при несовместимости |
Нет max_requests | Не задумывались | Утечки памяти накапливаются неограниченно |
| Проверка graceful shutdown без нагрузки | Быстрая остановка кажется доказательством | Проверять с активным запросом |
Контрольные вопросы
На понимание:
- Назовите три конкретных ограничения
flask runдля production. - Почему
FLASK_DEBUG=1— это уязвимость, а не просто неудобство? - Почему приложение должно слушать
0.0.0.0, а не127.0.0.1? - Почему формула
2 * os.cpu_count() + 1неверна внутри container? - Почему
graceful_timeoutдолжен быть меньше grace period Docker?
На применение:
- Как задать число worker'ов без пересборки образа?
- Как направить логи доступа Gunicorn в
docker logs? - Как проверить, что graceful shutdown действительно работает?
На диагностику:
- Порт опубликован,
docker psпоказывает0.0.0.0:8000->8000/tcp, ноcurlне отвечает. Что проверить? docker stopзанимает 10 секунд и возвращает137, хотя Gunicorn обрабатываетSIGTERM. Причина?
Краткое резюме
flask run— сервер разработки: однопоточный, без управления worker'ами, с опасным отладчиком.- Gunicorn реализует модель pre-fork: master следит за worker'ами и обрабатывает сигналы.
- Приложение обязано слушать
0.0.0.0—127.0.0.1внутри container недоступен снаружи. - Число worker'ов задаётся переменной
WEB_CONCURRENCY, а не вычисляется изos.cpu_count(). - Класс
sync— правильное значение по умолчанию; переход на другие делается по измерению. accesslog = "-"иerrorlog = "-"направляют логи в стандартные потоки.graceful_timeoutдолжен быть меньше grace period Docker, ориентир — 8 при 10.max_requestsсjitter— страховка от накопления утечек памяти.- Свой обработчик
SIGTERMв приложении под Gunicorn не нужен и вреден. - Graceful shutdown проверяется с активным запросом, а не по времени остановки простаивающего сервиса.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Flask: deploying | https://flask.palletsprojects.com/en/stable/deploying/ | Встроенный сервер не для production |
| Flask: Gunicorn | https://flask.palletsprojects.com/en/stable/deploying/gunicorn/ | Запуск Flask под Gunicorn, привязка адреса |
| Flask: debug mode | https://flask.palletsprojects.com/en/stable/debugging/ | Опасность отладчика в production |
| Gunicorn: settings | https://docs.gunicorn.org/en/stable/settings.html | bind, workers, worker_class, accesslog, graceful_timeout, max_requests |
| Gunicorn: design | https://docs.gunicorn.org/en/stable/design.html | Модель pre-fork, классы worker'ов |
| Gunicorn: FAQ | https://docs.gunicorn.org/en/stable/faq.html | Рекомендация 2–4 worker'а на ядро, подбор под нагрузкой |
| Gunicorn: signal handling | https://docs.gunicorn.org/en/stable/signals.html | Реакция на SIGTERM, SIGHUP, SIGTTIN, SIGTTOU |
| Runtime options with Memory, CPUs | https://docs.docker.com/engine/containers/resource_constraints/ | Ограничения CPU и их видимость из container |
| Docker networking: port publishing | https://docs.docker.com/engine/network/ | Почему 127.0.0.1 в container недоступен снаружи |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → FastAPI
Главное оглавление