Если вы запускаете Django проект через gunicorn с опцией --max-requests или через другой wsgi сервер с похожей опцией, полезно было бы знать, что, при каждом форке воркер процесса, по-умолчанию много ресурсов сервера уходит в никуда и клиентские запросы в это время подвисают на несколько секунд.


Недавно перенесли наш Django проект в Kubernetes кластер. Там есть readiness/liveness probes, которые могут дёргать каждый запущенный экземпляр wsgi сервера (в нашем случае это gunicorn) за указанную http ручку. У нас это /api/v1/status:


class StatusView(views.APIView):
    @staticmethod
    def get(request):
        overall_ok = True

        try:
            with django.db.connection.cursor() as cursor:
                cursor.execute('SELECT version()')
                cursor.fetchone()
        except Exception:
            log.exception('Database failure')
            db = 'fail'
            overall_ok = False
        else:
            db = 'ok'

        try:
            cache.set('status', 1)
        except Exception:
            log.exception('Redis failure')
            redis = 'fail'
            overall_ok = False
        else:
            redis = 'ok'

        if overall_ok:
            s = status.HTTP_200_OK
        else:
            s = status.HTTP_500_INTERNAL_SERVER_ERROR

        return Response({
            'web': 'ok',
            'db': db,
            'redis': redis,
        }, status=s)

Так вот, до переезда в Kubernetes у нас стоял Zabbix, который каждую минуту делал запрос на /api/v1/status через loadbalancer. И этот health check особо никогда не фэйлился. Но после переезда, когда проверки стали выполняться для каждого отдельного gunicorn инстанса и с большей частотой, вдруг оказалось, что иногда мы не укладываемся в таймаут 5 секунд.


Тем не менее все работало нормально, проблем у пользователей не было. Поэтому особо внимание этому не уделял, но поставил себе фоновую задачу все таки разобраться в чем же дело. И вот что удалось выяснить:


По-умолчанию gunicorn запускает master процесс, который форкает количество процессов указанное аргументом --workers. Причем модуль wsgi, переданный gunicorn в качестве основного аргумента, загружается каждым воркером уже после форка. Но есть опция --preload. Отсюда правило:


На проде всегда запускай gunicorn с опцией --preload, что позволит сократить время инициализации каждого воркера. В результате инициализация по большей части будет происходить только в мастер процессе, далее будут форкаться уже инициализированные worker процессы.

Стоит отметить, что большая часть этих оптимизаций имеет смысл, если у вас gunicorn запущен с опцией --max-requests. Это обычно делается для избежания чрезмерного поглощения памяти в случае утечек. И я рекомендую использовать это на проде.


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


В инициализацию wsgi довавьте фейковый запрос к health/status эндпоинту, чтобы сразу проинициализировать максимум подсистем.

Например, я добавил в wsgi.py следующее:


# make request to /api/v1/status to prepare everything for first user request
def make_init_request():
    from django.conf import settings
    from django.test import RequestFactory

    f = RequestFactory()
    request = f.request(**{
        'wsgi.url_scheme': 'http',
        'HTTP_HOST': settings.SITE_DOMAIN,
        'QUERY_STRING': '',
        'REQUEST_METHOD': 'GET',
        'PATH_INFO': '/api/v1/status',
        'SERVER_PORT': '80',
    })

    def start_response(*args):
        pass

    application(request.environ, start_response)

if os.environ.get('FORCE_FULL_INIT'):
    make_init_request()

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


Полностью… Да не очень. Ведь еще же нужно подключиться к postgres, redis и к чему бы там еще ни было. Ведь подключиться нужно в каждом форкнутом процессе. Хотелось бы это сделать до первого клиентского запроса. Поэтому вот еще одно правило:


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

Пример для gunicorn:


# Some wsgi servers have option to preload wsgi module before forking.
# This will save much time during worker initialization. But this won't work for connections to DB and cache.
# This routine installs hooks to run make_init_request one more time after forking.
def worker_full_init():
    try:
        import gunicorn
        assert gunicorn
    except ImportError:
        pass
    else:
        monkeypatch_gunicorn_worker()

def monkeypatch_gunicorn_worker():
    from gunicorn.workers.base import Worker
    original_load_wsgi = Worker.load_wsgi

    def load_wsgi_and_make_init_request(self):
        ret = original_load_wsgi(self)
        make_init_request()
        return ret

    Worker.load_wsgi = load_wsgi_and_make_init_request

if os.environ.get('FORCE_FULL_INIT'):
    make_init_request()
    worker_full_init()

По трейсам проблемы с инициализацией прекратились… почти. К своему стыду я не знал об этой особенности. Оказывается, по-умолчанию, Django при каждом запросе переподключается к БД. За это отвечает настройка CONN_MAX_AGE, которая только лишь(?) по историческим причинам заставляет ваше Django приложение работать как php скрипт из нулевых. Так что правило:


В настройки Django БД адаптера добавить CONN_MAX_AGE=None, чтобы подключения были постоянными.

Я бы даже и не заметил этого. Но, по какой-то причине, вызов psycopg2.connect иногда подвисает ровно на 5 секунд. Вот в этом я до конца не разобрался. Параллельно запущенный скрипт, который вызывает эту функцию раз в 10 секунд работал стабильно и подключался к БД быстрее чем за секунду за все время пока был запущен.


И последнее. Пока я все это отлаживал, у меня стоял --max-requests=10 и --workers=2. В то же время делались две, практически одновременные, проверки каждые 20 секунд. Поэтому воркеры всегда перезапускались одновременно. До того как я сделал все оптимизации, они просто не успевали инициализироваться до следующего запроса. Узнал, что есть еще одна полезная штука:


Запускать gunicorn c max-requests-jitter, чтобы воркеры не перезапускались одновременно, даже если они это делают достаточно быстро.

На этом все. Напишите пожалуйста, если вы сталкивались с проблемой подвисания psycopg2.connect и решили её. Если нужно, могу выложить трейсы и логи почти для любого описанного случая.

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