Главная/Python внутри Container/Урок

6.9. Flask

Цели

После этого материала вы сможете:

  • объяснить, почему flask run не предназначен для production, и назвать конкретные последствия;
  • запустить Flask под Gunicorn с корректной конфигурацией;
  • объяснить, почему приложение должно слушать 0.0.0.0, а не 127.0.0.1;
  • выбрать класс worker'ов и их количество осознанно;
  • настроить логи Gunicorn в едином формате с логами приложения;
  • обеспечить graceful shutdown и уложиться в grace period;
  • разделить конфигурацию разработки и production.

Предварительные знания

Рабочий пример — resources/examples/flask-basic/.

Ключевые термины

ТерминОбъяснение
WSGIСинхронный интерфейс между веб-сервером и Python-приложением
GunicornProduction-сервер 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'ов и следит за ними.

text
   master process (PID 1)
       │  управляет, перезапускает упавших
       ├── worker 1  ──► обрабатывает запросы
       ├── worker 2  ──► обрабатывает запросы
       └── worker N  ──► обрабатывает запросы

Что это даёт:

ВозможностьМеханизм
Параллельная обработкаНесколько процессов
УстойчивостьMaster перезапускает упавшего worker'а
Graceful shutdownMaster ждёт завершения текущих запросов
Защита от утечек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
Постоянный throttling33 процесса конкурируют за долю ядра
Замедление вместо ускоренияНакладные расходы на переключение контекста

Правильный подход — задавать число явно через переменную окружения:

python
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))

Имя WEB_CONCURRENCY — распространённое соглашение, его понимают многие платформы.

Оценка значения: исходить из выделенных CPU и памяти, а не из характеристик хоста. Подробный расчёт — в уроке 6.11.

Привязка к 0.0.0.0

Классическая ошибка: приложение слушает 127.0.0.1, порт опубликован, но сервис недоступен.

text
   слушает 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:

python
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"

Подробно разбирается в разделе 08.

Логи Gunicorn

По умолчанию Gunicorn пишет логи доступа в никуда, а ошибки — в stderr. Для container нужно направить оба потока в стандартные:

python
accesslog = "-"    # "-" означает stdout
errorlog = "-"     # stderr

Без первой строки логов доступа не будет вовсе — частая причина недоумения «почему не видно запросов».

Формат по умолчанию текстовый. Для единого формата с логами приложения настраивается logger Gunicorn (урок 6.6).

Graceful shutdown

Gunicorn обрабатывает SIGTERM: прекращает принимать соединения, ждёт завершения текущих запросов, затем останавливает worker'ов.

Ключевой параметр — graceful_timeout: сколько ждать. Он должен быть меньше grace period Docker (10 секунд по умолчанию), иначе SIGKILL прервёт процесс на середине.

text
   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 запросов. Назначение — ограничить последствия утечек памяти в приложении или зависимостях.

python
max_requests = 1000
max_requests_jitter = 100

Параметр jitter добавляет случайное отклонение, чтобы worker'ы не перезапускались одновременно — иначе возникнет провал в обслуживании.

Это не замена исправлению утечки, а страховка.


Внутренний механизм

Как Gunicorn обрабатывает сигналы

СигналРеакция master
SIGTERMGraceful shutdown: остановить приём, дождаться запросов
SIGINT, SIGQUITБыстрое завершение
SIGHUPПеречитать конфигурацию, плавно перезапустить worker'ов
SIGTTINДобавить одного worker'а
SIGTTOUУбрать одного worker'а

Последние два позволяют настраивать число worker'ов под нагрузкой без перезапуска — способ, рекомендованный документацией для подбора значения.

Почему приложение не должно ловить SIGTERM

Gunicorn как PID 1 получает сигнал и управляет завершением. Обработчик в коде приложения выполнится в worker-процессе и может помешать master завершить его корректно.

Для действий при остановке используйте хуки Gunicorn, а не signal.signal:

python
def worker_exit(server, worker):
    """Вызывается при завершении worker'а."""
    close_connections()

Команды и примеры

Рабочий пример

bash
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
text
образ собран
{
    "pid": 8,
    "service": "flask-basic",
    "worker": "gunicorn"
}

Обратите внимание на pid: 8 — это worker, а не PID 1. Master имеет PID 1.

bash
# 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'
text
    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

bash
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
text
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.

Werkzeug сам предупреждает — в терминале эти строки ещё и подсвечены красным, поэтому в собранных логах вокруг них окажутся ANSI-коды. Проверим, что стоит за предупреждением:

bash
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}')"
text
=== один медленный запрос блокирует остальные ===
  быстрый запрос ждал: 2.7 c

Быстрый запрос ждал завершения медленного — сервер однопоточный.

Сравним с Gunicorn:

bash
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}')"
text
  под Gunicorn быстрый запрос ждал: 0.01 c

Отладчик как уязвимость

bash
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
text
=== ответ на ошибку при FLASK_DEBUG=1 ===
Traceback
Werkzeug Debugger
console

В ответе — интерактивная консоль отладчика. При известном PIN (а он в логах) через неё выполняется произвольный код на сервере.

Для сравнения, под Gunicorn:

bash
curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8000/boom
curl -s localhost:8000/boom | grep -o 'Werkzeug Debugger' || echo "отладчика в ответе нет"
text
HTTP 500
отладчика в ответе нет
bash
docker rm -f f-dev f-debug > /dev/null

Ошибка с 127.0.0.1

bash
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
text
=== снаружи ===
  недоступен
=== изнутри 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

bash
cd resources/examples/flask-basic
cat gunicorn.conf.py
text
"""Конфигурация 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'ов:

bash
# В 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
text
WEB_CONCURRENCY=1 → процессов gunicorn: 2
WEB_CONCURRENCY=4 → процессов gunicorn: 5

Процессов на один больше — это master.

Почему не os.cpu_count()

bash
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'
text
=== 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

bash
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
text
=== остановка без активных запросов ===
  время: 0.34 c, код: 0

Быстро и с кодом 0 — Gunicorn корректно обработал SIGTERM, свой обработчик не потребовался.

Проверим дозавершение активного запроса:

bash
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
text
  время stop: 2.6 c (ждал завершения запроса)
  ответ клиенту: {"done":true}

Клиент получил корректный ответ — запрос не был оборван.

Что будет при слишком большом graceful_timeout

bash
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
text
  время stop: 10.3 c, код: 137

Gunicorn ждал бы 60 секунд, но Docker убил его через 10. Код 137 вместо 0, запрос оборван.

Правило: graceful_timeout меньше grace period Docker. Если запросы действительно долгие, увеличивайте stop_grace_period в Compose, а не только graceful_timeout.

Логи в едином формате

bash
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
text
=== логи 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

bash
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
text
dev    размер: 168MB   UID: 0
prod   размер: 168MB   UID: 10001

Один Dockerfile, два образа под разные задачи. Различия существенны: в dev включён отладчик и автоперезагрузка, в prod — Gunicorn, non-root и healthcheck.

Подробно разделение разбирается в разделе 10.

Уборка

bash
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-приложение, удовлетворяющее восьми требованиям.

  1. Запуск под Gunicorn, не через flask run.
  2. Привязка к 0.0.0.0 с портом из переменной окружения.
  3. Число worker'ов из WEB_CONCURRENCY, а не из os.cpu_count().
  4. Логи доступа и ошибок в stdout и stderr.
  5. graceful_timeout меньше grace period Docker.
  6. Работа от непривилегированного пользователя.
  7. Healthcheck без установки дополнительных пакетов.
  8. docker stop завершает сервис за секунды с кодом 0, дозавершая активные запросы.

Приведите проверку каждого требования командой.

Подсказки

Подсказка 1

Требование 8 проверяется запуском медленного запроса перед docker stop и проверкой, что клиент получил ответ.

Подсказка 2

Число процессов Gunicorn на единицу больше числа worker'ов — master тоже процесс.

Подсказка 3

Для требования 7 подойдёт python -c с urllib из стандартной библиотеки.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
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

Ожидаемый вывод:

text
═══ 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.

Проверка результата

bash
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 без нагрузкиБыстрая остановка кажется доказательствомПроверять с активным запросом

Контрольные вопросы

На понимание:

  1. Назовите три конкретных ограничения flask run для production.
  2. Почему FLASK_DEBUG=1 — это уязвимость, а не просто неудобство?
  3. Почему приложение должно слушать 0.0.0.0, а не 127.0.0.1?
  4. Почему формула 2 * os.cpu_count() + 1 неверна внутри container?
  5. Почему graceful_timeout должен быть меньше grace period Docker?

На применение:

  1. Как задать число worker'ов без пересборки образа?
  2. Как направить логи доступа Gunicorn в docker logs?
  3. Как проверить, что graceful shutdown действительно работает?

На диагностику:

  1. Порт опубликован, docker ps показывает 0.0.0.0:8000->8000/tcp, но curl не отвечает. Что проверить?
  2. docker stop занимает 10 секунд и возвращает 137, хотя Gunicorn обрабатывает SIGTERM. Причина?

Краткое резюме

  1. flask run — сервер разработки: однопоточный, без управления worker'ами, с опасным отладчиком.
  2. Gunicorn реализует модель pre-fork: master следит за worker'ами и обрабатывает сигналы.
  3. Приложение обязано слушать 0.0.0.0127.0.0.1 внутри container недоступен снаружи.
  4. Число worker'ов задаётся переменной WEB_CONCURRENCY, а не вычисляется из os.cpu_count().
  5. Класс sync — правильное значение по умолчанию; переход на другие делается по измерению.
  6. accesslog = "-" и errorlog = "-" направляют логи в стандартные потоки.
  7. graceful_timeout должен быть меньше grace period Docker, ориентир — 8 при 10.
  8. max_requests с jitter — страховка от накопления утечек памяти.
  9. Свой обработчик SIGTERM в приложении под Gunicorn не нужен и вреден.
  10. Graceful shutdown проверяется с активным запросом, а не по времени остановки простаивающего сервиса.

Официальные источники

ИсточникСсылкаЧто подтверждает
Flask: deployinghttps://flask.palletsprojects.com/en/stable/deploying/Встроенный сервер не для production
Flask: Gunicornhttps://flask.palletsprojects.com/en/stable/deploying/gunicorn/Запуск Flask под Gunicorn, привязка адреса
Flask: debug modehttps://flask.palletsprojects.com/en/stable/debugging/Опасность отладчика в production
Gunicorn: settingshttps://docs.gunicorn.org/en/stable/settings.htmlbind, workers, worker_class, accesslog, graceful_timeout, max_requests
Gunicorn: designhttps://docs.gunicorn.org/en/stable/design.htmlМодель pre-fork, классы worker'ов
Gunicorn: FAQhttps://docs.gunicorn.org/en/stable/faq.htmlРекомендация 2–4 worker'а на ядро, подбор под нагрузкой
Gunicorn: signal handlinghttps://docs.gunicorn.org/en/stable/signals.htmlРеакция на SIGTERM, SIGHUP, SIGTTIN, SIGTTOU
Runtime options with Memory, CPUshttps://docs.docker.com/engine/containers/resource_constraints/Ограничения CPU и их видимость из container
Docker networking: port publishinghttps://docs.docker.com/engine/network/Почему 127.0.0.1 в container недоступен снаружи

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → FastAPI
Главное оглавление

Markdown на GitHub ↗