Gunicorn кажется простым, пока не сталкиваешься с эксплуатацией: внезапные ошибки 502, зависшие воркеры и странное поведение при перезапусках. За этими симптомами стоят вполне конкретные причины — от медленных клиентов и отсутствия буферизации до особенностей реализации GThread и механики Graceful Shutdown.

В этой статье разберём реальные сценарии отказов, посмотрим, как менялась архитектура GThread в разных версиях Gunicorn, и соберём практичную конфигурацию с Nginx, Docker и Kubernetes, которая ведёт себя предсказуемо под нагрузкой.

Проблема медленных клиентов

По умолчанию Gunicorn использует синхронные рабочие процессы (sync) по модели «один процесс на запрос». Это означает, что каждый процесс обслуживает одного клиента в каждый момент времени. Если число одновременно поступивших запросов превышает количество доступных процессов, то новые клиенты ожидают освобождения любого из них.

Длительность обработки запроса в синхронной модели определяется не только скоростью выполнения внутренней логики приложения, но и характеристиками самого клиента. Медленные клиенты представляют собой наиболее серьёзное ограничение такой архитектуры: они могут медленно отправлять тело запроса или медленно считывать ответ. В результате синхронный процесс остаётся занятым с момента установки соединения до отправки последнего байта тела ответа. Накопление подобных клиентов быстро исчерпывает пул процессов и приводит к отказу сервера в обработке новых соединений.

Для защиты приложения от медленных клиентов используется обратный прокси-сервер, чаще всего Nginx. Его размещение между клиентом и сервером приложений позволяет развязать скорость клиента и скорость обработки запроса. Эффективность такого подхода напрямую зависит от правильной настройки буферизации. Перед передачей запроса на Upstream прокси-сервер должен полностью прочитать все заголовки и тело запроса от клиента и лишь затем направить его серверу приложений.

Аналогичный принцип действует и в обратном направлении: Nginx быстро получает ответ от Gunicorn, а затем с требуемой скоростью отдаёт его клиенту, даже если клиент медленно читает. В итоге Gunicorn освобождается раньше и сразу может приступить к обработке следующего запроса.

Таким образом, ключевым элементом защиты выступает именно буферизация, а не просто проксирование. Корректный выбор параметров буферизации позволяет минимизировать дисковые операции, избежать нерационального расходования оперативной памяти и сохранить предсказуемое поведение под нагрузкой.

Буферизация запросов клиентов

Буферизация тела клиентского запроса управляется директивой proxy_request_buffering (по умолчанию on). В активном состоянии Nginx полностью считывает тело запроса до его передачи на бэкенд.

Основные параметры, влияющие на процесс:

  • client_body_buffer_size — размер буфера в оперативной памяти для чтения тела запроса (если размер тела превышает заданное значение, Nginx сохраняет его (или избыточную часть) во временный файл на диске)

  • client_body_temp_path — путь к каталогу для временных файлов, содержащих тело запросов

  • client_max_body_size — максимально допустимый размер тела запроса. Запросы с большим телом отклоняются с кодом 413 (Request Entity Too Large)

Для предсказуемой работы и полного исключения дискового ввода‑вывода при обработке запросов, рекомендуется настроить размер буфера так, чтобы типичное тело запроса помещалось в память целиком. Установка client_body_buffer_size равным client_max_body_size гарантирует запрет записи на диск: тело запроса будет либо полностью буферизироваться в памяти, либо запрос будет отвергнут при превышении лимита.

Обе директивы можно определять не только на уровнях http и server, но и в отдельных секциях location. Это открывает возможность гибкого выделения ресурсов для разных Endpoint.

http {
    client_max_body_size      1m;
    client_body_buffer_size   64k;
    client_body_temp_path     /tmp/nginx/body;

    server {
        # Легковесные endpoint’ы с небольшими POST-запросами
        location /api/ {
          client_max_body_size     64k;      
          client_body_buffer_size  64k;
        }

        # Endpoint приёма файлов, требующий увеличенных лимитов
        location /upload/ {
            client_max_body_size     50m;
            client_body_buffer_size  50m;
        }
    }
}

Буферизация ответов от Upstream-серверов

Для включения буферизации ответов используется директива proxy_buffering (по умолчанию on). В этом режиме Nginx максимально быстро получает ответ от проксируемого сервера и сохраняет его в буферах, задаваемых следующими директивами:

  • proxy_buffer_size — размер буфера для чтения первой части ответа. Как правило, эта часть содержит только заголовки, поэтому значение может быть относительно небольшим.

  • proxy_buffers number size — количество (number) и размер (size) буферов, используемых для чтения тела ответа в рамках одного соединения. Общий объём памяти, выделяемой под один ответ, составляет number × size.

  • proxy_busy_buffers_size ограничивает суммарный размер буферов, которые заняты отправкой ответа клиенту, пока ответ ещё не прочитан полностью. Оставшиеся буферы в это время могут использоваться для продолжения чтения данных от Upstream-сервера. По умолчанию это значение равно размеру двух буферов, задаваемых proxy_buffer_size и proxy_buffers. В большинстве случаев это исключает необходимость ручной корректировки.

Значения proxy_buffers целесообразно подбирать с учётом профиля нагрузки. Например, если половина ответов приложения не превышает 64 Кб, а максимальный размер ответа достигает 512 Кб, то нерационально использовать большое количество мелких буферов (к примеру, 128 буферов по 4 Кб). Более эффективным решением будет установка восьми буферов по 64 Кб, что позволит покрыть весь диапазон ответов без избыточной фрагментации.

location /api/ {
    proxy_buffering on;
    proxy_buffer_size 8k;
    proxy_buffers 8 64k;
    proxy_busy_buffers_size 128k;   
}

Если общий объём ответа превышает размер буферов, избыточные данные сохраняются во временный файл на диске. Запись во временные файлы регулируется следующими директивами:

  • proxy_temp_path — каталог для временных файлов

  • proxy_max_temp_file_size — максимальный размер одного временного файла (нулевое значение (0) полностью отключает буферизацию ответов во временных файлах)

  • proxy_temp_file_write_size — размер блока, которым производится запись во временный файл

Отключение дисковых временных файлов гарантирует, что ответы не будут сохраняться на диск. Однако это требует корректного расчёта размеров буферов, чтобы ответ полностью помещался в оперативную память. В противном случае, если все буферы заполнены, а запись на диск запрещена, рабочий процесс Nginx будет заблокирован до тех пор, пока клиент не прочитает хотя бы один буфер и не освободит место для следующего чанка от Upstream. Такая блокировка может негативно повлиять на обслуживание других соединений.

Мультипроцессинг и мультитрединг: чем повышать конкурентность

Архитектура Gunicorn построена вокруг главного процесса-арбитра и набора дочерних процессов-воркеров. Масштабирование путём увеличения числа процессов может привести к значительному потреблению оперативной памяти, поскольку каждый воркер имеет собственное адресное пространство. Использование флага --preload и механизма copy-on-write несколько смягчает ситуацию, но общий расход памяти может оставаться существенным.

Что такое --preload?

По умолчанию каждый воркер загружает приложение в память независимо. Параметр --preload позволяет однократно загрузить код приложения в процессе арбитра до создания дочерних процессов. В этом случае воркеры наследуют уже загруженное приложение. Такой подход сокращает общее время запуска сервера и ускоряет перезапуск отдельного воркера как при плановой ротации, так и в случае аварийного завершения.

Включать эту опцию следует с осторожностью. Перед этим необходимо убедиться, что приложение корректно работает в такой модели. Характерный пример: ситуация, когда в синхронном коде вынужденно вызывается loop.run_until_complete().Это может быть продиктовано асинхронной природой используемой библиотеки или особенностями реализации бизнес-логики. Если в подобном приложении цикл событий инициализируется на этапе загрузки, то возникает известная проблема (issue 21998), проявляющаяся в невозможности использования родительского Event Loop в дочерних процессах.

Необходимо учитывать проблему гонки процессов за приём соединений, известную как Thundering Herd. Все воркеры прослушивают один и тот же сокет. При поступлении нового соединения одновременно пробуждаются все свободные процессы. Соединение получает тот, кто успеет его захватить первым, остальные же получают ошибку EAGAIN (или EWOULDBLOCK). Следовательно, с ростом числа процессов увеличивается и нагрузка на процессор, вызванная ложными пробуждениями.

Может показаться, что флаг --reuse-port решает эту проблему, однако здесь есть неочевидный нюанс: опция SO_REUSEPORT устанавливается на слушающий сокет в процессе арбитра, а не в каждом воркере. Иными словами, воркеры продолжают использовать единственный унаследованный от арбитра сокет, и проблема остаётся нерешённой.

Зачем же тогда нужен --reuse-port?

Gunicorn располагает штатными возможностями для развёртывания Zero-Downtime. Можно перезапустить конфигурацию сервера «на лету» (см. Reload the configuration), а при отключённом --preload воркеры подхватывают новую версию кода. Однако гораздо удобнее параллельно запустить независимый экземпляр Gunicorn с обновлённой конфигурацией и новым кодом на том же порте. В таком сценарии можно подготовить отдельное виртуальное окружение, где одним действием обновляются и сам Gunicorn, и все зависимости приложения.

Опция --reuse-port как раз позволяет запустить новый арбитр с новыми воркерами, не освобождая порт. После этого старый экземпляр Gunicorn можно безопасно остановить. Такой подход обсуждался, в частности, в issue #598.

Воркеры sync не поддерживают Keep-Alive соединения. После обработки запроса воркер отправляет заголовок Connection: Close и принудительно разрывает соединение. В результате каждый новый запрос требует установки отдельного соединения с полным прохождением процедур TCP- и TLS-рукопожатий. При высокой нагрузке эти операции отнимают значительную долю процессорного времени и увеличивают задержку.

Переход на воркер gthread позволяет устранить перечисленные трудности. Если код вашего WSGI-приложения потокобезопасен, то gthread будет оптимальным решением.

Архитектура GThread. Настройка Keep Alive

Воркер gthread использует пул потоков внутри каждого процесса, размер которого задаётся параметром --threads. Главный поток лишь принимает соединения, а обработка запросов выполняется потоками из пула. Такой подход с меньшими затратами памяти повышает конкурентность и, что особенно важно, позволяет обслуживать Keep-Alive соединения.

Управление Keep-Alive соединениями со стороны Gunicorn выполняется опцией --keep-alive , которая задаёт время в секундах, в течение которого неактивное соединение остаётся открытым. Тайм-аут обновляется после каждого обработанного запроса, что позволяет повторно использовать то же соединение.

Поскольку Nginx по умолчанию проксирует запросы по HTTP/1.0, который не поддерживает Keep-Alive, для корректной работы с Gunicorn необходимо явно переключиться на HTTP/1.1 и очистить заголовок Connection, чтобы клиентские настройки не попадали к бэкенду.

location / {
    proxy_http_version  1.1;
    proxy_set_header    Connection "";

    proxy_pass http://app;
}

Управление пулом Keep-Alive соединений с Upstream-серверами выполняется в блоке upstream с помощью следующих директив:

  • keepalive — задаёт максимальное количество неактивных Keep-Alive соединений, которые сохраняются в кеше каждого рабочего процесса. При превышении лимита наименее востребованные соединения закрываются. Эта директива не ограничивает общее количество соединений к бэкенду, поэтому её значение стоит выбирать достаточно небольшим, чтобы Upstream-серверы могли принимать и новые входящие соединения.

  • keepalive_timeout — устанавливает тайм-аут неактивности. По истечении тайм-аута Keep-Alive соединение к Upstream закрывается.

  • keepalive_requests — максимальное количество запросов, обрабатываемых через одно Keep-Alive соединение. После достижения этого порога соединение закрывается. Периодическое закрытие необходимо для освобождения памяти, связанной с каждым соединением, поэтому чрезмерно большое значение может привести к нежелательному расходу памяти.

  • keepalive_time — ограничивает суммарное время обработки запросов через одно Keep-Alive соединение. По истечении указанного интервала соединение закрывается после завершения очередного запроса.

Пример сбалансированной конфигурации с учётом всех перечисленных параметров:

http {
    upstream app {
        server 127.0.0.1:8000;

        keepalive           10;
        keepalive_timeout   60s;
        keepalive_requests  1000;
        keepalive_time      10m;
    }

    server {
        location / {
            proxy_http_version  1.1;
            proxy_set_header    Connection "";

            proxy_pass http://app;
        }
    }
}

Ключевое правило согласования тайм-аутов: значение keepalive_timeout на стороне Nginx должно быть меньше, чем --keep-alive в Gunicorn. Нарушив это условие, Nginx будет считать соединение пригодным для повторного использования, в то время как Gunicorn уже мог закрыть его.

Проблема утечки памяти приложения

В идеальном мире утечки памяти в приложении отсутствуют. Однако на практике вы можете столкнуться с одной из следующих ситуаций:

  • Используются сторонние библиотеки, на поведение которых вы не можете повлиять

  • Проект большой и давно разрабатывается, поэтому накопились скрытые дефекты

  • Утечки проявляются только в редко воспроизводимых сценариях

  • Отсутствие времени и ресурсов для полноценной диагностики

В таких ситуациях возникает неприятный, но сугубо утилитарный вопрос: как минимизировать ущерб здесь и сейчас? Самый простой способ — периодически перезапускать воркеры после обработки заданного числа запросов. В Gunicorn для этого предназначен параметр --max-requests.

Перезапуск воркера неизбежно связан с простоем: пока процесс останавливается и запускается вновь, новые запросы продолжают поступать. Чтобы свести этот эффект к минимуму, обычно применяют несколько воркеров. Пока один перезапускается, остальные продолжают принимать запросы. Чтобы избежать одновременного перезапуска всех воркеров, в Gunicorn предусмотрен параметр --max-requests-jitter, который добавляет случайный разброс к значению --max-requests. В коде базового класса Worker это реализовано так:

class Worker:    

    def __init__(self, age, ppid, sockets, app, timeout, cfg, log):
        
        ...
        
        if cfg.max_requests > 0:
            jitter = randint(0, cfg.max_requests_jitter)
            self.max_requests = cfg.max_requests + jitter
        else:
            self.max_requests = sys.maxsize

Приведём пример: при трёх воркерах перезапуск одного из них означает, что оставшиеся два временно принимают на себя треть обычной нагрузки, пока не будет готов новый процесс.

Многие справедливо относятся к регулярным перезапускам со скепсисом, поскольку это не устраняет утечку. Тем не менее на практике такая мера часто становится единственным доступным и оперативным способом стабилизировать сервис, пока команда не добралась до полноценной диагностики. Некоторые включают перезапуск превентивно, чтобы защититься от внезапных проблем в новых релизах.

Если вы используете подобную стратегию, обратите внимание на версию Gunicorn. Внутренняя архитектура gthread претерпела значительные изменения, и в некоторых версиях перезапуск приводил к временной недоступности сервера.

Однако есть более фундаментальный аспект: автоматический перезапуск воркера по логике завершения ничем не отличается от штатного Graceful Shutdown при плановой остановке сервера. А мы всегда ожидаем, что старая версия приложения корректно завершит свою работу время развёртывания. Поэтому стоит детально, шаг за шагом, разобраться в архитектуре gthread , чтобы понять первопричины возможных проблем и найти пути их решения.

Эволюция GThread

Версия 20.1.0: проблема блокировки потоков

На представленной схеме показана архитектура воркера gthread версии v20.1.0. Её можно условно разделить на три части: цикл событий, обработка запроса и Graceful Shutdown.

В начале работы Listen-сокет переводится в неблокирующий режим. Затем в poller регистрируется Callback accept. После этого запускается бесконечный цикл, который ожидает событий от poller с таймаутом в 1 сек. Если за это время никаких событий не поступило, цикл выполняет завершающие операции и вновь переходит в режим ожидания.

При поступлении запроса на установку соединения срабатывает Сallback accept. Внутри него клиентский сокет становится блокирующим. Задача на обработку запроса помещается в пул потоков, а в полученном экземпляре Future регистрируется Сallback finish_request.

Когда свободный поток приступает к работе, начинается чтение данных от клиента в блокирующем режиме. После получения всех данных от клиента, счётчик запросов сравнивается со значением --max-requests. Если счётчик превышает пороговое значение, статус воркера alive установится в значение False. Это приводит к выходу основного потока из цикла событий, после чего запускается процедура Graceful Shutdown. Важно отметить, что обработка самого запроса при этом продолжает выполняться в выделенном потоке пула.

По окончании обработки в том же потоке вызывается Callback finish_request. Его задача — определить, следует ли сохранить соединение или закрыть его немедленно. Если принято решение сохранить соединение, клиентский сокет переводится в неблокирующий режим, устанавливается таймаут, и в poller регистрируется другой Сallback — reuse_connection. Отличие reuse_connection от accept заключается лишь в отсутствии логики установки нового соединения, в остальном их поведение идентично.

Основной вывод, который можно сделать из этой схемы: чтение данных от клиента блокирует поток. Как только сервер принимает соединение, задача немедленно попадает в пул. Если клиент устанавливает соединение, но не передаёт данные, поток будет заблокирован в ожидании пакетов. Именно это стало причиной проблемы «Gunicorn GThread deadlock #2917». Несмотря на то, что такое поведение сервера было ожидаемым, и даже официальный deployment guide рекомендует «прятать» сервер приложений за обратным прокси, автор принял Pull Request «GThread: only read sockets when they are readable #2918», который вошёл в релиз v21.0.0.

Версия 21.0.0: борьба со спекулятивными соединениями

Основная цель изменений — защита от спекулятивных соединений. Для этого было решено помещать задачу на обработку запроса в пул потоков только после того, как клиент действительно начнёт передавать данные. Архитектура версии v21.0.0 выглядит следующим образом:

Да, это решило проблему спекулятивных соединений, но вместе с новой архитектурой пришли новые «баги». Пользователи массово столкнулись с проблемой «Connection reset during max-requests auto-restart with gthread #3038», которая для клиента проявлялась как ошибка 502. Причины и способы устранения мы рассмотрим далее.

Эта проблема сохранялась вплоть до версии v23.0.0 (10 августа 2024 г.) включительно. Спустя некоторое время вышла версия v24.0.0 (23 января 2026 г.), включающая значительные архитектурные изменения воркера gthread.

Версия 24.0.0: переработка Graceful Shutdown

Трудно не заметить появление новой сущности PollableMethodQueue. Несмотря на то, что она не оказывает прямого влияния на доступность сервера, мимо неё пройти невозможно.

Какую задачу решает PollableMethodQueue?

Класс PollableMethodQueue позволяет через метод defer положить в очередь некоторый Callback, который впоследствии будет обработан основным потоком воркера через метод run_callbacks. Класс содержит внутри два дескриптора read_fd и write_fd, а также экземпляр очереди SimpleQueue. Отрывок исходного кода:

class PollableMethodQueue:

    ...

    def init(self):
        """Initialize the pipe and queue."""
        self._read_fd, self._write_fd = os.pipe()
        # Set both ends to non-blocking:
        # - Write: prevents worker threads from blocking if buffer is full
        # - Read: allows run_callbacks to drain without blocking
        os.set_blocking(self._read_fd, False)
        os.set_blocking(self._write_fd, False)
        self._queue = queue.SimpleQueue()

Метод defer принимает Callback и позиционные аргументы, оборачивает их в partial, кладёт в очередь и затем пишет один байт b'\x00' в write_fd. Таким образом, на каждый Callback в очереди приходится один байт в дескрипторе.

class PollableMethodQueue:

    ...

    def defer(self, callback, *args):
        """Queue a callback to be run on the main thread.

        The callback is added to the queue first, then a wake-up byte
        is written to the pipe. If the pipe write fails (buffer full),
        it's safe to ignore because the main thread will eventually
        drain the queue when it reads other wake-up bytes.
        """
        self._queue.put(partial(callback, *args))
        try:
            os.write(self._write_fd, b'\x00')
        except OSError:
            # Pipe buffer full (EAGAIN/EWOULDBLOCK) - safe to ignore
            # The main thread will still process the queue
            pass

Дескриптор read_fd зарегистрирован в poller, поэтому появление данных пробуждает главный поток и вызывает run_callbacks. Метод читает до max_callbacks байтов из read_fd, оценивая тем самым объём накопившихся Callback, а затем в цикле извлекает их из очереди и выполняет.

class PollableMethodQueue:

    ...

    def run_callbacks(self, _fileobj, max_callbacks=50):
        """Run queued callbacks. Called when the pipe is readable.

        Drains all available wake-up bytes and runs corresponding callbacks.
        The max_callbacks limit prevents starvation of other event sources.
        """
        # Read all available wake-up bytes (up to limit)
        try:
            data = os.read(self._read_fd, max_callbacks)
        except OSError:
            return

        # Run callbacks for each byte read, plus any extras in queue
        # (extras can accumulate if pipe writes were dropped)
        callbacks_run = 0
        while callbacks_run < len(data) + 10:  # +10 to drain dropped writes
            try:
                callback = self._queue.get_nowait()
                callback()
                callbacks_run += 1
            except queue.Empty:
                break

Зачем это нужно? Все обратные вызовы теперь выполняются строго в основном потоке воркера. Это устраняет необходимость в синхронизации с пулом потоков через RLock. Раньше poller, который не является потокобезопасным, частично управлялся и в рабочих потоках. Теперь вся работа с poller вынесена в главный поток. Попутно, по оценке автора, производительность выросла примерно на 8%.

Тест

До

После

Простые запросы

2,980 req/s

3,268 req/s

Высокая конкуренция

2,923 req/s

3,176 req/s

Большой ответ

2,783 req/s

3,089 req/s

Этот релиз принёс два главных изменения:

  • Возврат прежней логики accept: задача на обработку запроса снова ставится в пул сразу после установки соединения

  • Полная переработка Graceful Shutdown

Акцент на логике accept обусловлен тем, что она вместе с реализацией Graceful Shutdown определяет доступность сервера в следующих сценариях:

Но чтобы осознать ценность нововведений, необходимо рассмотреть устройство Graceful Shutdown в предыдущих версиях.

Graceful Shutdown: v20.1.0, v21.0.0—v23.0.0

class ThreadWorker(base.Worker):

    def run(self):
        ...
        
        self.tpool.shutdown(False)
        self.poller.close()

        for s in self.sockets:
            s.close()

        futures.wait(
            self.futures,
            timeout=self.cfg.graceful_timeout,
        )

После выхода из цикла последовательно выполняются следующие шаги:

  • Пул потоков завершает работу без ожидания оставшихся задач

  • Закрывается poller

  • Закрываются Listen-сокеты

  • Ожидается завершение задач, уже помещённых в пул

Закрытие poller приводит к закрытию всех зарегистрированных в нём сокетов. Именно это стало причиной ошибок 502, описанных в issue #3038. Как упоминалось ранее, в версиях с v21.0.0 по v23.0.0 после успешного accept задача на обработку не ставилась в пул. Вместо этого клиентский сокет регистрировался в poller для ожидания данных. Если в этот момент воркер менял статус alive на False, то, согласно логике Graceful Shutdown, только что принятый клиентский сокет немедленно закрывался. Клиент, находящийся в процессе отправки запроса или чтения ответа, получал ошибку Connection Reset by Peer.

В сети рассматривалось несколько решений issue #3038, не считая отката до v20.1.0. Рассмотрим некоторые из них.

Issue #3038: решение средствами Nginx

Для начала посмотрим, что по этому поводу говорит RFC 2616:

Client software SHOULD reopen the transport connection and retransmit the aborted sequence of requests without user interaction so long as the request sequence is idempotent.

Другими словами, клиентское ПО обязано автоматически пересоздавать соединение и повторять последовательность запросов без участия пользователя, но только при условии их идемпотентности.

Правила, по которым Nginx принимает решение о повторной попытке, зависит от нескольких факторов. Максимальное количество попыток для одного запроса в Nginx определяется наименьшим из двух значений:

  • next_upstream_tries (если задано и не равно 0)

  • Количество доступных серверов в группе Upstream

В случае разрыва соединения после accept , повторная попытка возможна только при наличии в блоке upstream как минимум одного доступного сервера. Если указан только один сервер, то клиенту вернётся ошибка 502, независимо от идемпотентности запроса. Исходя из этого, встречалось довольно экзотическое решение: заставить Gunicorn слушать несколько интерфейсов:

gunicorn wsgi:app \
  --bind 127.0.0.1:8001 \
  --bind 127.0.0.1:8002 \
  -w 2 \
  -k gthread \
  --threads 2 \ 
  --max-requests 1000 \
  --max-requests-jitter 1000

В блоке upstream вместо одного сервера можно прописать каждый порт (или Unix-сокет). Таким образом, при возникновении описанного сценария будет предпринята повторная попытка по другому серверу в надежде, что другой активный воркер обработает запрос.

upstream app {
    server 127.0.0.1:8001 max_fails=0 fail_timeout=0;
    server 127.0.0.1:8002 max_fails=0 fail_timeout=0;
}

Важно помнить, что в такой конфигурации вступают в силу параметры max_fails и fail_timeout. max_fails задаёт количество неудачных попыток связи с сервером в течение времени fail_timeout (по умолчанию 10 сек.), после чего сервер считается недоступным на то же время fail_timeout. По умолчанию max_fails равен 1. Нулевое значение отключает учёт попыток.

Описанную идею можно развить, запустив несколько экземпляров Gunicorn на одном или разных хостах с разными IP. Однако в таком случае Nginx маскирует проблему, а не решает её. Более того, для методов POST или PATCH повторные попытки не применяются, поскольку это нарушает требование идемпотентности. Хотя это требование можно проигнорировать, указав в директиве proxy_next_upstream параметр non_idempotent, это следует делать с большой осторожностью.

Issue #3038: решение кастомным воркером

На мой взгляд, лучшее решение — не допускать закрытия соединения после accept. Однако это возможно только в случае, когда задача на обработку запроса сразу помещается в пул. Именно это я предложил для версий v21.0.0-v23.0.0. По сути, я унаследовал воркер и отменил нововведения v21.0.0. Для внедрения потребуется указать --worker-class.

import errno

from gunicorn import SERVER_SOFTWARE, version_info
from gunicorn.workers.gthread import TConn, ThreadWorker

COMPATIBLE = False

if (21, 0, 0) <= version_info <= (23, 0, 0):
    COMPATIBLE = True

if not COMPATIBLE:
    raise RuntimeError(f'{SERVER_SOFTWARE} is not supported')


class TConnSync(TConn):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.initialized = True


class ThreadWorkerSync(ThreadWorker):

    def accept(self, server, listener):
        try:
            sock, client = listener.accept()
            conn = TConnSync(self.cfg, sock, client, server)

            self.nr_conns += 1
            self.enqueue_req(conn)

        except OSError as e:
            if e.errno not in (
                errno.EAGAIN,
                errno.ECONNABORTED,
                errno.EWOULDBLOCK,
            ):
                raise

С переходом на версии v21.0.0v23.0.0 временное решение помогло устранить ошибки 502, но оно оказалось эффективным только для свежеустановленных соединений. Обсуждаемая нами проблема актуальна и для Keep-Alive соединений. Сервер устанавливает для них таймаут и регистрирует в poller в ожидании новых данных. Клиент, отправляя запрос по такому соединению, предполагает, что оно ещё активно, однако сервер мог закрыть его раньше, даже при корректной настройке таймаутов. Эта проблема существовала еще до версии v21.0.0.

В подобной ситуации Nginx демонстрирует более гибкое поведение: он условно «отменяет» попытку и пробует снова. Это работает даже в том случае, если в Upstream прописан только один сервер. Другими словами, для этого не требуется дополнительная настройка. Тем не менее правило идемпотентности запросов остаётся в силе.

Переход на версию v24.0.0 не только решит issue #3038, вернув логику accept, но и позволит избавиться от гонки закрытия Keep-Alive соединений.

Graceful Shutdown: v24.0.0

class ThreadWorker(base.Worker):

    def run(self):
        
        ...

        # Graceful shutdown: stop accepting but handle existing connections
        self.set_accept_enabled(False)

        # Wait for in-flight connections within grace period
        graceful_timeout = time.monotonic() + self.cfg.graceful_timeout
        while self.nr_conns > 0:
            time_remaining = max(graceful_timeout - time.monotonic(), 0)
            if time_remaining == 0:
                break
            self.wait_for_and_dispatch_events(timeout=time_remaining)
            self.murder_keepalived()

        # Cleanup
        self.tpool.shutdown(wait=False)
        self.poller.close()
        self.method_queue.close()

        for s in self.sockets:
            s.close()

После выхода из основного цикла слушающие сокеты снимаются с регистрации в Poller. Это гарантирует, что в следующем «завершающем» цикле новые соединения приниматься не будут. Однако пока у сервера остаются активные соединения, он ожидает появления новых запросов по ним.

Фактически это и есть решение проблемы повторного использования соединений. Gunicorn теперь соблюдает контракт: если соединению был установлен таймаут, то воркер будет ждать новый запрос до его истечения. Когда таймауты клиента и сервера согласованы, гонка закрытия исчезает.

Побочным эффектом этой реализации является прямое влияние параметра --keep-alive на время остановки сервера: чем он больше, тем дольше может длиться Graceful Shutdown. Поэтому при переходе на v24.0.0 следует внимательно отнестись к этому параметру. Нет практического смысла устанавливать большие значения.

В результате получилась самая стабильная и предсказуемая версия. Однако ситуация по своей природе противоречива. С одной стороны, пользователи, не применяющие обратный прокси, сталкиваются с блокировками. С другой стороны, предыдущие попытки борьбы с «паразитными» подключениями приводили к ошибке 502 при перезапуске или развёртывании, даже с обратным прокси. Поэтому, на мой взгляд, решение вернуть старую механику было абсолютно верным. Но я уверен, что найдутся пользователи, которые после обновления вновь столкнутся с Deadlock. И такой случай уже зафиксирован: см. дискуссию «Switching from Gunicorn 23 to 25 breaks Django + k8s workload #3516».

Версия 25.2.0: очередная борьба со cпекулятивными соединениями

Суть дискуссии #3516 та же: медленные и/или спекулятивные клиенты блокируют потоки. Проваливается Readiness-проба — K8s снимает трафик с пода. Проваливается Liveness-проба — перезапуск пода.

На этом фоне автор Gunicorn предложил интересный Pull Request «fix(gthread): prevent thread pool exhaustion from slow clients #3519», вошедший в состав релиза v25.2.0 (25 марта 2026 г.).

У класса TConn появился новый метод wait_for_data:

class TConn:

    def wait_for_data(self, timeout):
        """Wait for data to be available on the socket.

        Uses selectors to wait for the socket to become readable within
        the given timeout. This prevents slow clients from blocking
        thread pool slots indefinitely.

        Args:
            timeout: Maximum time to wait in seconds.

        Returns:
            True if data is available, False if timeout expired.
        """
        if self.data_ready:
            return True

        # Use a temporary selector to wait for data
        sel = selectors.DefaultSelector()
        try:
            sel.register(self.sock, selectors.EVENT_READ)
            events = sel.select(timeout=timeout)
            if events:
                self.data_ready = True
                return True
            return False
        except (OSError, ValueError):
            # Socket closed or invalid
            return False
        finally:
            sel.close()

Этот метод создаёт временный селектор, регистрирует клиентский сокет и в блокирующем режиме ожидает событие EVENT_READ в течение 5 сек. (DEFAULT_WORKER_DATA_TIMEOUT). Если за это время данные поступают, то метод возвращает True. Если же соединение было спекулятивным, то по истечении таймаута возвращается False. Вызов происходит в потоке из пула до начала непосредственной обработки запроса.

class ThreadWorker(base.Worker):
    
    def handle(self, conn):
        """Handle a request on a connection. Runs in a worker thread."""
        req = None
        try:
            # For new connections (not yet initialized), wait for data with timeout
            # to prevent slow clients from blocking thread pool slots indefinitely.
            # Skip this for already-initialized connections (keepalive, deferred)
            # since they're coming from the poller and data is already available.
            if not conn.initialized and not conn.data_ready:
                # Wait for data with timeout before committing this thread
                if not conn.wait_for_data(DEFAULT_WORKER_DATA_TIMEOUT):
                    # No data within timeout - defer to poller
                    return _DEFER
        ...

Для различения штатного завершения обработки и сигнала «вернуть соединение в Poller» вводится специальная переменная _DEFER:

# Sentinel value to indicate connection should be deferred back to poller
_DEFER = object()

В finish_request проверяется результат: если он равен _DEFER и воркер всё ещё жив, то соединение снова переводится в неблокирующий режим, устанавливается Keep-Alive таймаут и регистрируется в poller для ожидания поступления данных.

class ThreadWorker(base.Worker):

    def finish_request(self, conn, fs):
        """Handle completion of a request (called via method_queue on main thread)."""
        try:
            result = fs.result() if not fs.cancelled() else False

            if result is _DEFER and self.alive:
                # Connection deferred - no data arrived within timeout.
                # Put it on the poller to wait for data without consuming a thread.
                conn.sock.setblocking(False)
                # Use keepalive timeout for pending connections too
                conn.timeout = time.monotonic() + self.cfg.keepalive
                self.pending_conns.append(conn)
                self.poller.register(conn.sock, selectors.EVENT_READ,
                                     partial(self.on_pending_socket_readable,
        
        ...
    

Таким образом, поток воркера теперь блокируется при спекулятивном соединении не на всё время, а максимум на 5 сек. Однако это одноразовая мера: как только клиент пришлёт какие-либо данные, взводится флаг data_ready и воркер снова переходит к блокирующему чтению. Медленные клиенты, периодически подкидывающие данные, по-прежнему могут надолго занимать поток.

Теперь, когда мы изучили эволюцию воркера gthread и связанные с ним проблемы доступности, перейдём к запуску Gunicorn в Docker-контейнере.

Запуск Gunicorn в Docker-контейнере

При запуске приложения в Kubernetes клиентами могут быть как внешние пользователи, так и другие сервисы. Если пользовательский трафик ещё можно упорядочить на стороне внешнего Nginx или Ingress-контроллера, то с межсервисным взаимодействием ситуация сложнее: подразумевается, что внутри кластера приложения обращаются друг к другу напрямую. Такая прямая интеграция несёт все перечисленные ранее риски доступности: блокировки медленными клиентами, ошибки 502 в зависимости от версии Gunicorn и неготовность клиентов корректно обрабатывать разрывы соединений. Частично решить проблему можно с помощью Istio Sidecar, однако это уже инфраструктурная мера, а не часть приложения. Кроме того, не каждое развёртывание ограничивается Kubernetes: существуют и более простые схемы. Принимая во внимание эти обстоятельства, рассмотрим универсальный подход на уровне Docker-образа.

Идея проста: запустить Nginx и Gunicorn в одном контейнере.

FROM python:3.12.13

ARG USER=user-app
ARG USER_ID=999
ARG GROUP=group-app
ARG GROUP_ID=999

WORKDIR /opt/app/

# Set environment varibles
# python
ENV PYTHONFAULTHANDLER=1 \
    PYTHONUNBUFFERED=1 \
    PYTHONHASHSEED=random \
    # pip:
    PIP_NO_CACHE_DIR=off \
    PIP_DISABLE_PIP_VERSION_CHECK=on \
    PIP_DEFAULT_TIMEOUT=30

RUN apt-get update  \
    && apt-get install --no-install-recommends -y \
      supervisor \
      nginx \
    # Cleaning cache:
    && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* \
    # Installing `python` packages:
    && pip install --upgrade pip

COPY requirements.txt requirements.txt

RUN pip install --no-cache-dir -r requirements.txt

COPY gunicorn/config.py /etc/gunicorn/config.py
COPY gunicorn/launcher.sh /etc/gunicorn/launcher.sh

COPY nginx.conf /etc/nginx/nginx.conf

COPY server.cfg /etc/supervisor/conf.d/server.cfg
COPY server.sh /etc/server.sh

COPY wsgi.py wsgi.py

RUN addgroup --system --gid ${GROUP_ID} ${GROUP}
RUN adduser --system --uid ${USER_ID} --gid ${GROUP_ID} --no-create-home ${USER}

RUN chown -R ${USER}:${GROUP} /opt/app/

USER ${USER_ID}:${GROUP_ID}

CMD ["sh", "/etc/server.sh"]

В качестве команды запуска контейнера будет использован Shell-скрипт. Он подготовит временные директории и запустит Supervisor.

#!/bin/sh

set -o errexit
set -o nounset

if [ -d /tmp/nginx ]; then
  echo "/tmp/nginx directory exists."
else
  echo "/tmp/nginx does not exists. Create manually."
  mkdir -p /tmp/nginx
fi

if [ -d /tmp/gunicorn ]; then
  echo "/tmp/gunicorn directory exists."
else
  echo "/tmp/gunicorn does not exists. Create manually."
  mkdir -p /tmp/gunicorn
fi

echo "supervisor starting..."
exec supervisord -c /etc/supervisor/conf.d/server.cfg -n

Supervisor управляет процессами в контейнере. Его PID равен 1 (благодаря exec). При остановке контейнера он передаёт дочерним процессам сигнал завершения, ориентируясь на параметры stopsignal и stopasgroup.

[supervisord]
nodaemon=true
user=user-app

logfile=/dev/null
logfile_maxbytes=0

pidfile=/tmp/supervisord.pid

[program:nginx]
command=nginx -g 'daemon off;' -e /dev/stderr

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

stopsignal=QUIT
stopwaitsecs=20
stopasgroup=true
killasgroup=true

[program:app]
command=sh /etc/gunicorn/launcher.sh wsgi:app --config /etc/gunicorn/config.py

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

stopwaitsecs=20
stopasgroup=false

Сейчас мы не будем подробно останавливаться на конфигурациях Nginx и Gunicorn (полный пример со всеми файлами доступен по ссылке). Чуть позже отдельно разберём аспекты, связанные с требованиями кибербезопасности и Graceful Shutdown.

Приятные бонусы

Помимо защиты от медленных клиентов, контейнер приобретает ряд дополнительных функциональных возможностей.

Transfer-Encoding: chunked

Некоторые приложения отправляют запросы с заголовком Transfer-Encoding: chunked и телом, закодированным соответствующим образом (подробнее в Wiki).

HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Transfer-Encoding: chunked\r\n
Connection: keep-alive\r\n
\r\n
9\r\n
chunk 1, \r\n
7\r\n
chunk 2\r\n
0\r\n
\r\n

Gunicorn не поддерживает подобный формат и попросту отбрасывает тело запроса, продолжая обработку без него. Это неминуемо приводит к ошибкам валидации на стороне приложения. Nginx, напротив, буферизирует Chunked-запросы и передаёт приложению уже собранное тело, благодаря чему обработка завершается успешно.

Хостинг статики

Nginx идеально подходит для раздачи статических файлов — будь то статика админки Django или файлы Swagger-документации. За эффективность передачи отвечают три ключевые директивы: sendfiletcp_nopush и tcp_nodelay.

sendfile включает в себя системный вызов sendfile(), который передаёт данные напрямую из файлового дескриптора в сокет, минуя промежуточные копирования в пользовательском пространстве (Zero-Copy). Это существенно снижает нагрузку на процессор и ускоряет отдачу больших файлов. Директиву обычно включают в location, обслуживающий статику.

tcp_nopush активирует опции TCP_NOPUSH (FreeBSD) или TCP_CORK (Linux) и работает только совместно с sendfile. Когда опция включена, Nginx стремится отправлять данные полными пакетами: заголовок ответа и начало файла уходят в одном сегменте, а последующие блоки — максимально заполненными. Это сокращает количество мелких пакетов и повышает пропускную способность.

tcp_nodelay отключает алгоритм Нейгла, который задерживает небольшие пакеты ради их объединения. Когда опция включена, малые порции данных вроде финального куска ответа или заголовков Keep-Alive соединения отправляются немедленно. Это особенно важно для динамических ответов и HTTP Keep-Alive, где задержки нежелательны.

На практике все три директивы часто включают одновременно. Они прекрасно уживаются друг с другом:

sendfile        on;
tcp_nopush      on;   # полные пакеты для статики
tcp_nodelay     on;   # без задержек для keep-alive и динамики

Пока sendfile передаёт файл, tcp_nopush откладывает отправку данных, чтобы сформировать полные пакеты. После завершения передачи флаг снимается и tcp_nodelay позволяет немедленно отправить оставшиеся данные, например, завершающий пустой фрагмент при передаче тела частями. Это позволяет повысить эффективность при раздаче статики и обеспечивает незначительную задержку в динамических сценариях.

Сжатие ответа

Сжатие ответов с помощью gzip — один из самых действенных способов ускорить загрузку данных у клиента. Nginx поддерживает как динамическое сжатие «на лету», так и отдачу уже готовых сжатых файлов.

server {
    
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 4096;
    gzip_disable "msie6";

    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/javascript
        application/xml+rss
        application/json
        image/svg+xml;
}

gzip on включает динамическое сжатие, а gzip_comp_level определяет степень сжатия от 1 (быстро, но слабо) до 9 (максимально, но затратно). Значения 5—6 обычно обеспечивают хороший баланс между расходом процессора и итоговым размером.

Чтобы прокси-серверы и браузеры могли корректно кешировать ответы в зависимости от поддержки сжатия, включается gzip_vary on. Он добавляет заголовок Vary: Accept-Encoding. Для сжатия ответов, проходящих через Upstream, служит gzip_proxied any. Это разрешает сжимать все проксируемые запросы.

Минимальную длину ответа, при которой применяется сжатие, задаёт gzip_min_length. Мелкие ответы сжимать невыгодно, поэтому часто ставят порог 1—4 Кб. Длина определяется только из поля заголовка ответа «Content-Length». gzip_types перечисляет MIME-типы, которые должны сжиматься. По умолчанию сжимается только text/html, остальные типы нужно указывать явно.

gzip_disable отключает сжатие ответов для запросов, в заголовке которых «User-Agent» соответствует любому из указанных регулярных выражений. Специальная маска «msie6» отключает сжатие для Internet Explorer 6.

Если вы заранее подготовили .gz-версии статических файлов (например, style.css.gz рядом с style.css), то достаточно добавить gzip_static on;. Nginx сразу отдаст уже готовый сжатый файл, не тратя процессорное время на переупаковку. Это особенно полезно при интенсивной раздаче статики.

Управление таймаутами

Теперь Nginx может отклонять клиентов, которые слишком медленно передают заголовки или тело запроса. Это защищает приложение от подвисших соединений, возникающих по самым разным причинам: например, пользователь закрыл вкладку до завершения отправки, удалённый сервис аварийно завершился (OOMKiller, превышение Graceful-периода) или из-за сетевых сбоев.

Директивы client_header_timeout и client_body_timeout ограничивают время, в течение которого Nginx готов ждать от клиента при чтении заголовков и тела запроса соответственно. По умолчанию оба таймаута равны 60 сек. Чтобы сократить окно уязвимости, их обычно уменьшают:

server {
    client_body_timeout     15s;
    client_header_timeout   15s;
}

Требования кибербезопасности

Считается хорошей практикой запускать процессы в контейнере с минимально необходимыми привилегиями (принцип наименьших прав, PoLP). С точки зрения кибербезопасности процесс должен располагать теми же правами, которые нужны для выполнения его функций. Это ограничивает возможный ущерб при компрометации: злоумышленник не сможет установить вредоносное ПО через apt или pip, модифицировать системные файлы (/etc/usr/bin), сканировать соседние контейнеры или попытаться эскалировать привилегии на хост-систему.

Из этого принципа выводятся два ключевых требования: запрет запуска от root и запрет записи на диск (Read-Only файловая система). Иными словами, контейнер должен работать под непривилегированным пользователем, а его корневая файловая система быть смонтирована в режиме «только на чтение».

Запуск под Non-Root

Для выполнения первого требования необходимо создать системного пользователя и соответствующую системную группу, после чего в конце определить пользователя, под которым будет запущен этот контейнер.

ARG USER="user-app"
ARG USER_ID=999
ARG GROUP="group-app"
ARG GROUP_ID=999

...

RUN addgroup --system --gid ${GROUP_ID} ${GROUP}
RUN adduser --system --uid ${USER_ID} --gid ${GROUP_ID} --no-create-home ${USER}

...


USER ${USER_ID}:${GROUP_ID}

Для общей целостности можно прописать пользователя в конфигурациях Supervisor и Gunicorn, хотя это и не обязательно. При запуске текущий пользователь сверяется с указанным в конфигурации. Если они совпадают, то смена пользователя пропускается.

Supervisor (supervisord.conf):

[supervisord]
nodaemon=true
user=user-app

Gunicorn (config.py):

user = 'user-app'
group = 'group-app'

Запрет записи на диск

Прежде всего, для временных файлов необходимо смонтировать /tmp как tmpfs. В Kubernetes для этого подходит emptyDir со значением medium: Memory.

Поскольку Supervisor запущен с nodaemon=true, все его сообщения (о запуске и остановке процессов, а также об ошибках) уже попадают в stderr контейнера. Достаточно отключить запись логов в файл, а также задать путь к PID-файлу внутри /tmp.

[supervisord]
nodaemon=true
user=user-app

logfile=/dev/null
logfile_maxbytes=0

pidfile=/tmp/supervisord.pid

В конфигурации Gunicorn указывается временная директория воркера, а логи в Supervisor перенаправляются в stdout и stderr:

worker_tmp_dir = '/tmp/gunicorn'
[program:app]
command=sh /etc/gunicorn/launcher.sh wsgi:app --config /etc/gunicorn/config.py

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Для работы в режиме Read-Only требуется Nginx не ниже версии 1.19.5, поскольку именно в ней появилась опция командной строки -e, позволяющая направить лог ошибок в stderr.

[program:nginx]
command=nginx -g 'daemon off;' -e /dev/stderr

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

В самом nginx.conf необходимо задать пути к PID-файлу, Lock-файлу и временным каталогам внутри /tmp, а также отключить (или перенаправить) запись Access-лога и гарантировать, что буферизация ответов на диск не используется.

pid                         /tmp/nginx/nginx.pid;
lock_file                   /tmp/nginx/nginx.lock;
error_log                   /dev/stderr warn;

http {
    access_log off;

    proxy_temp_path           /tmp/nginx/proxy;
    fastcgi_temp_path         /tmp/nginx/fastcgi;
    uwsgi_temp_path           /tmp/nginx/uwsgi;
    scgi_temp_path            /tmp/nginx/scgi;

    client_body_temp_path     /tmp/nginx/body;

    server {

        location / {
            
            proxy_max_temp_file_size 0;
        }
    }
}

Обратите внимание, что при запуске Nginx пытается создать все временные каталоги, если их ещё нет. Поэтому важно, чтобы родительский путь до них уже существовал к моменту запуска. Именно для этого в server.sh выполняется следующая подготовка:

if [ -d /tmp/nginx ]; then
  echo "/tmp/nginx directory exists."
else
  echo "/tmp/nginx does not exists. Create manually."
  mkdir -p /tmp/nginx
fi

Для client_body_temp_path нет явного запрета на применение. Но чтобы исключить запись тела запроса на диск, достаточно выровнять размер буфера с максимальным допустимым размером тела.

http {
    # Максимальное тело запроса
    client_max_body_size 64k;
    
    # Размер буфера для тела запроса
    client_body_buffer_size 64k;
}

В результате контейнер полностью удовлетворяет изложенным требованиям безопасности: процессы не имеют прав Root, а файловая система остаётся Read-Only, за исключением временного раздела в памяти.

Graceful Shutdown

В рассматриваемом примере Gunicorn запускается не напрямую, а через launcher.sh. Его задача — отложить передачу сигнала TERM арбитру, дав Nginx небольшой запас времени на завершение буферизации текущего запроса. Если контейнер получает команду остановки одновременно с поступлением клиентского запроса, то Nginx сначала должен полностью прочитать тело, и затем, при необходимости, установить новое соединение с Upstream. Получив TERM немедленно, Gunicorn перестал бы принимать соединения, что привело бы к ошибке 503. Небольшая задержка, задаваемая переменной GUNICORN_DELAYED_SHUTDOWN, позволяет этого избежать.

#!/bin/sh

gunicorn "$@" &

GUNICORN_PID=$!
GUNICORN_DELAYED_SHUTDOWN=${GUNICORN_DELAYED_SHUTDOWN:-5}

delayed_shutdown() {
    sleep ${GUNICORN_DELAYED_SHUTDOWN}
    kill -TERM "$GUNICORN_PID"
    wait "$GUNICORN_PID"
    exit 0
}

trap delayed_shutdown TERM

wait "$GUNICORN_PID"

Скрипт запускает Gunicorn в фоновом режиме, запоминает его PID и переходит в вечное ожидание завершения этого процесса (арбитра). С помощью команды trap скрипт перехватывает TERM, спит GUNICORN_DELAYED_SHUTDOWN секунд, затем отправляет TERM арбитру и продолжает ожидать завершения процесса.

Получив TERM, арбитр пробрасывает его всем зарегистрированным воркерам, после чего каждый воркер выходит из основного цикла обработки соединений и переходит к логике Graceful Shutdown. Как вы уже знаете, её поведение сильно зависит от версии Gunicorn.

Для процесса Nginx безопасным сигналом остановки является QUIT, а не TERM, поэтому стоит указать его явно. Параметр stopasgroup=true предписывает отправить этот сигнал не только мастер-процессу, но и всем его воркерам, хотя для Nginx это необязательно.

[program:nginx]

...

stopsignal=QUIT
stopasgroup=true

stopwaitsecs=20
killasgroup=true

Для Gunicorn уже нельзя пробрасывать сигнал всей группе, иначе логика отложенной доставки TERM сломается. Поэтому stopasgroup явно отключаем.

[program:app]
...

stopasgroup=false

stopwaitsecs=20
killasgroup=true

Начиная с версии v24.0.0, после основного цикла воркер запускает завершающий цикл, в котором ожидает события по соединениям Keep-Alive. Поэтому параметр keepalive не должен превышать общее время, отведённое на остановку с учётом задержки GUNICORN_DELAYED_SHUTDOWN.

В этом же цикле задействован параметр graceful_timeout: события ожидаются до тех пор, пока есть активные соединения, но не дольше graceful_timeout. Следовательно, не имеет практического смысла устанавливать keepalive больше, чем graceful_timeout. Лучше выровнять эти значения.

keepalive = 10
graceful_timeout = 10

Тогда если по всем активным соединениям успеют пройти запросы, то цикл завершится естественным путём и воркер остановится досрочно. Если же время истечёт, то воркер выйдет из цикла и перейдёт к ожиданию пула потоков. Оставшийся до KILL запас времени даст шанс завершить уже выполняющиеся запросы. Понятно, что доводить до этой стадии нежелательно. Принудительное завершение обработки запроса может привести к непредсказуемым последствиям.

Graceful Shutdown в K8s

Когда Kubernetes отправляет поду сигнал TERM, он не сразу удаляет его из списка конечных точек сервиса. Эти два действия происходят параллельно и с некоторой задержкой, из-за чего под ещё какое-то время продолжает получать запросы, хотя процесс остановки уже запущен.

В этот период приложение ещё способно обслуживать запросы по установленным ранее соединениям, но попытка открыть новое будет отклонена. В идеале все клиентские компоненты должны повторять попытки, направляя запросы на другой работающий под. Однако при прямом межсервисном взаимодействии в отсутствие Istio Sidecar такую логику реализуют далеко не все клиенты.

Наиболее распространённым решением этой проблемы является приостановка передачи сигнала контейнеру с помощью хука preStop:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              command:
                - /bin/sh
                - '-c'
                - sleep 5

Хук выполняет sleep перед тем, как сигнал будет доставлен основному процессу (PID 1). За эти 5 сек. конечные точки пода успевают удалиться по всему кластеру, и новые запросы на него больше не направляются. Таким образом, контейнер получает TERM уже после того, как выведен из балансировки. Остаётся только завершить уже принятые соединения.

При использовании Istio Sidecar ситуация иная. Проблема повторного установления соединения решается централизованно на уровне VirtualService: достаточно добавить политику повторных попыток специально для ошибок подключения.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: my-service
spec:
  hosts:
  - my-service
  http:
  - route:
    - destination:
        host: my-service
    retries:
      attempts: 2
      retryOn: "connect-failure"

Благодаря этому нет необходимости приостанавливать TERM для самого Istio-Proxy. Повторные попытки будут прозрачно направлены на другие поды. Однако для основного контейнера приложения preStop с задержкой по-прежнему полезен. Если во время обработки запроса Sidecar потребуется установить соединение с приложением, то оно должно быть способным принять это соединение.

С внедрением Istio появляется другая проблема. По умолчанию Istio-Proxy будет находиться в состоянии Draining на протяжении 5 сек. Если приложение обрабатывает запросы дольше этого лимита (включая протяжённую передачу тела), то ответ не будет доставлен клиенту, а любые исходящие вызовы, необходимые в процессе обработки, окажутся недоступными. Решение простое: следует увеличить параметр terminationDrainDuration так, чтобы время жизни Sidecar совпадало с ожидаемым временем жизни приложения в период остановки.

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    metadata:
      annotations:
        proxy.istio.io/config: |
          terminationDrainDuration: 25s
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              command:
                - /bin/sh
                - '-c'
                - sleep 5

Очевидный недостаток такого подхода заключается в том, что под будет оставаться «живым» в указанном интервале, даже если приложение уже завершило обработку всех запросов. Однако простота делает его востребованным во многих реальных развёртываниях.

Health Checks в K8s

Readiness‑проба необходима для того, чтобы убедиться, что приложение инициализировано и готово принимать входящие запросы. При переходе пробы в статус Failed K8s исключает под из балансировки и возвращает его обратно только при восстановлении статуса Success.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-deployment
spec:
  template:
    spec:
      containers:
        - name: my-app-container
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8000
              scheme: HTTP
            initialDelaySeconds: 15
            timeoutSeconds: 5
            periodSeconds: 10
            successThreshold: 1
            failureThreshold: 2

Liveness-проба предназначена для того, чтобы убедиться, что сам сервис работает внутри пода. Эта проверка определяет, когда следует перезапустить под.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-deployment
spec:
  template:
    spec:
      containers:
        - name: my-app-container
          livenessProbe:
            httpGet:
              path: /health/live/
              port: 8000
              scheme: HTTP
            initialDelaySeconds: 60
            timeoutSeconds: 10
            periodSeconds: 30
            successThreshold: 1
            failureThreshold: 2

Readiness-проба должна срабатывать раньше и чаще, чем Liveness-проба, и выполнять более глубокую проверку готовности приложения и его связей с базами данных, брокерами сообщений и кешами. Она же позволяет сервису самостоятельно восстановиться без экстренного вмешательства. Liveness-проба, напротив, должна срабатывать позже и реже Readiness-пробы и выполнять минимальную проверку для снижения вероятности полного перезапуска сервиса. В большинстве случаев достаточно просто вернуть 200, чтобы сообщить K8s, что сервис жив.

Другими словами, если под испытывает трудности с обработкой трафика, то для начала его нужно убрать из балансировки, дать шанс на самовосстановление, и только потом прибегнуть к перезапуску пода. Это должно быть последним шагом при самовосстановлении сервиса, а злоупотребление механизмом аварийного перезапуска повышает вероятность ухода пода в CrashLoopBackOff и увеличения времени инцидента. Поэтому здесь важно подобрать оптимальные параметры проб. Так, в примере выше для пробы Readiness период periodSeconds равен 10 сек. С учётом порога failureThreshold под выведется из балансировки через 20 сек. После этого в запасе будет 25 сек., чтобы успешно пройти пробу Liveness, избежав при этом аварийного перезапуска.

Разумеется, все числа носят демонстрационный характер. Реальные значения нужно подбирать исходя из поведения конкретного приложения и допустимого риска недоступности.

Мониторинг

Для обеспечения стабильной работы приложения потребуется отслеживать его показатели. Предположим, что ваше wsgi-приложение собирает метрики prometheus_client: счётчики, гистограммы и т.д., например, количество входящих запросов или длительность их обработки; а также вы настроили приложение таким образом, чтобы /metrics возвращал все ваши метрики. Если режим работы prometheus_client оставить по умолчанию, то при запуске приложения с помощью Gunicorn более чем с одним воркером вы столкнётесь с проблемой согласованности метрик.

В такой конфигурации получается следующее:

  • Каждый воркер хранит собственные метрики изолированно

  • Запрос /metrics обрабатывается случайным воркером

На примере http_requests_total это приводит к тому, что Prometheus вместо общего счётчика запросов получает счётчик запросов воркера, причем каждый раз случайного. Как следствие, происходит сброс метрики и неверный расчёт RPS.

О способах решения этой проблемы написано в статье «Особенности сбора метрик. Запуск приложения Gunicorn‑ом в режиме мультипроцессинга».

Итог

Связка Nginx и Gunicorn — проверенное временем решение, но даже оно требует осознанной настройки. Буферизация, таймауты и выбор версии воркера напрямую влияют на стабильность под нагрузкой. Эволюция GThread показала, насколько сильно архитектурные изменения сказываются на доступности, а понимание этих механизмов позволяет предотвратить проблемы ещё на этапе проектирования.

Когда к этому добавляются платформенные нюансы и требования безопасности, картина становится полной. Если всё согласовано, то сервис работает предсказуемо. Время, потраченное на подробности, окупается спокойной эксплуатацией.

Спасибо за внимание!

? Документация и руководства

Gunicorn

Nginx

? Python и баги

? Gunicorn: issues и pull requests

?️ Issues

⬆️ Pull Requests

? Discussions

? Source code

? Стандарты

? Справочные материалы

☸️ Kubernetes

? Пример проекта

? Полезные статьи

Комментарии (0)