Оптимизация flatpages проекта на django под минимальные системные требования. Статья-шутка

в 13:02, , рубрики: django, memcached, nginx, uwsgi, Веб-разработка, метки: , , ,

Оптимизация flatpages проекта на django под минимальные системные требования. Статья шутка

Под катом много букв, но не беспокойтесь — вы всех их знаете.

Предыстория

Так интереснее

Когда-то давным-давно мы с моей ненаглядной пытались сделать свой первый маленький проект. Тогда я занимался только дизайном поэтому программиста пришлось нанять: я ему макеты, он нам верстку и само приложение. Помню хостинг нам обошелся где-то в 130-150 рублей — конечно это был LAMP.

Как это, обычно, бывает первый блин у нас не вышел. Прошло время и мы созрели для второго проекта. Теперь я мог все сделать сам. Накидав дизайн, я принялся за верстку, а затем и за само приложение. В этот раз ничего существенного: простой сайт с кучей статики — даже не интересно.

На хостинг я по привычке прикинул так: открываем страничку тарифов на VPS, выбираем первую строчку снизу — ну где-то рублей 800-1000. «Так много? Раньше мы платили рублей 200…» — супруга была озадачена. Действительно, почему больше-то? Ответ, конечно, очевиден: впс, как не крути, джанга там, туда-сюда, «это вам не джумля какаянить!» — а что джумла? Джумла, вон: 100 руб. живи месяц счастливо. Но это слишком простой ответ, так не интересно.

Задача

И так у нас есть задача: минимальная впс-ка и готовый проект на django — статика, парочка форм, админка и прочее. Синтетический минимум: 200 мб ОЗУ, 500 мгц проц, на винте — ну пусть будет два гига на все вместе с системой.

DB

Самое узкое место в задаче: ОЗУ. Возьмем sqlite. Whait. What?! Я серьезно, он не так уж плох. Его недостатки мы компенсируем кешем.

Кеш

Берем все самое мощное: nginx + memcached и научим работать с джангой.

Веб-сервер

Это будет uwsgi — быстрый и модный. Для для любителей сферически-вакуумных можно глянуть тесты.

Сессии

Сессии надо где-то хранить. Обычно я использую redis, но не здесь. Мемкешд тоже не подходит: учитывая архитектуру кеширования мы часто будем сбрасывать весь кеш, а вместе с ними и сессии удалятся. На помощь приходит новинка в джанге 1.4. Это cookie-based сессии. Принцип прост: все данные о сессии храниться в куках у пользователя. Сами данные не шифруются, но целостность обеспечивается за счет криптографической подписи. Подробнее можно почитать в доках.

Контент

Тут все просто берем MarkDown. «Почему маркдаун?». Он крут. Он офигенен. Даже эту статью я пишу на нем. Он поможет нам облегчить размер бд, он не содержит тегов. Текст в MD, порой, в половину легче треша из fckeditor alike редактора и раз в 100 приятнее глазу. К тому же, текст должен форматироваться стилями сайта, а не редактора: одни стили правят всем — прям как у саурона.

Софт

Возьмем Ubuntu server minimal, чтобы получить самый новый и самый быстрый софт: nginx, memcached, python 2.7. Вообще лучше что-нибудь полегче типа slitaz, puppy linux, slax — хотя бы потому-что минимум для убунты 128 мб ОЗУ, а настроенная «базовая система» + кое-какой софт у меня вышел больше гигабайта на винте. Серверный дистрибутив, сэр. Но мы решили получить удовольствие от статьи, в общем, будем знать меру.

Разработка

Django

Сначала нам нужно наладить наш конфиг джанги:

# Укажем новые бэкэнд к сессиям.
# Данные не шифруется, поэтому мы запретим их чтение через js.
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
SESSION_COOKIE_HTTPONLY = True

# https://docs.djangoproject.com/en/1.4/ref/settings/#std:setting-USE_I18N
# Выключим перевод в приложениях. Как написано в доках: This provides an easy way to turn it off, for performance. В данном случае речь скорее об админке, но это не кешируемая часть, а значит нам это пригодится
USE_I18N = False

# https://docs.djangoproject.com/en/1.4/ref/settings/#use-l10n
# Используется для локализации данных в местный формат. Форматирование дат с pytils все равно не сравнится.
USE_L10N = False

# https://docs.djangoproject.com/en/1.4/ref/settings/#use-tz
# Для работы с часовыми поясами
USE_TZ = False

# Тут повыключаем, то что нам не нужно. По сути это экономия на спичках.
TEMPLATE_CONTEXT_PROCESSORS = (
    # default template context processors
    "django.contrib.auth.context_processors.auth",
    # "django.core.context_processors.debug",
    # "django.core.context_processors.i18n",
    # "django.core.context_processors.media",
    "django.core.context_processors.static",
    #"django.core.context_processors.tz",
    "django.contrib.messages.context_processors.messages",
    # required by django-admin-tools
    'django.core.context_processors.request',
    'orangetrans.utils.context_processors.common',
)

# Sentry мы использовать тут не можем, берем что есть в джанге.
# include_html вышлет на почту весь трейсбек, со всеми локальными переменными в случае эксепшена.
# Во флетпейджах как правило эксепшены очень редкие гости.
# https://docs.djangoproject.com/en/1.4/topics/logging/#django.utils.log.AdminEmailHandler
LOGGING = {
…
    'handlers': {
        'mail_admins': {
            'level': 'ERROR',
            'filters': ['require_debug_false'],
            'class': 'django.utils.log.AdminEmailHandler',
            'include_html': True,
        }
    },
…
}

# Следующее вышлет на почту уведомления о "сломанных" ссылках
SEND_BROKEN_LINK_EMAILS = True

Кеш

Это самая сильная часть проекта. Кеш будет нашим щитом перед медленным бекэндом. Многие, конечно, уже догадались о чем речь.

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

Правда здорово? Ну почти, этот тип кеша хорошо подходит для нашего случая, для сложного проекта он обладает множеством минусов. Например, мы не можем обновлять заполняемую юзером форму, потому-что всякий раз заходя по этому урлу юзер будет получать все ту же форму из кеша: без заполненных данных и полученных ошибок. Хвала моде нам это не понадобится :)

Django

Форма у нас будет отправляться на аяксе, нам достаточно показать ее разок, а данные и ошибки у нас будут ходить уже на js. Фоллбек вариант без аякса (вы же всегда его делаете, так? :) мы кешировать не будем. Есть еще момент, форма у нас работает через пост. Начиная с какой-то версии джанги (1.2?) для POST запросов стала обязательна защита от csrf-атак. В нашем случае это запрос обратного звонка: одно поле номера и никаких личных данных. Чтобы выключить защиту для определенной вьюхи нужно просто завернуть его в декоратор csrf_exempt.

Для осуществления нашего плана напишем middleware. В интернетах есть статья как осуществить работу такой связки. Она написана для предыдущих версий джанги и без просмотра сырцов я не разобрался. Я ее немого подправлю и обновлю. Итак в middleware:

import re
from django.core.cache import cache
from django.conf import settings

class NginxMemCacheMiddleWare:
    def process_response(self, request, response):
        url = request.get_full_path()
        
        cache_it = not settings.DEBUG 
            and request.method == 'GET' 
            and response.status_code == 200

        if cache_it:
            stoplist = [
                x for x 
                in settings.CACHE_IGNORE_REGEXPS
                if re.match(x, url)
            ]
            if not stoplist:
                cache.set(url, response.content)

        return response

Мы кешируем только GET, не кешируем во время разработки и 404. А почему? А потому-что боты в поисках админки или сам злоумышленник могут забить память всяким хламом.

Вернемся в конфиг:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': 'unix:/tmp/memcached.sock',
        'KEY_PREFIX': 'YOURSITE',
    }
}

Тут все просто: бэкэнд мемкешд, сокет и префикс. Кстати, pylibmc мне не удалось завести через сокет, чего-то там ему не нравится, да и все равно. Я взял python-memcached.

CACHE_IGNORE_REGEXPS = (
    re.compile(r'/admin.*'),
    re.compile(r'/some_url.*'),
)

Это список урлов запрещенных к кешированию, о котором говорилось чуть выше. Не забудьте добавить наш новый middleware в MIDDLEWARE_CLASSES в settings.

Кстати, для мониторинга состояния мемкешд можно воспользоваться программкой django-memcache-status. Она рисует симпатичную шкалу использованной отведенной памяти для мемкешд в админке и показывает еще кучу всякой инфы. Если честно, особого применения я ей не нашел :) просто разбавит и без того скучную для флетпейджев админку. К сожалению она не дружит с django-admin-tools.

Nginx

Конфиг для дев сервера, позже мы его обновим:

upstream dev {
    server 127.0.0.1:8000;
}

server {
    listen 80;
    server_name dev.local;
    charset utf-8;

    location ~^/(media|static) {
        root /home/user/project;
        access_log off;
        break;
    }

    location / {
        if ($request_method = POST) {
            proxy_pass http://dev;
            break;
        }
        default_type  "text/html; charset=utf-8";
        set $memcached_key "YOURSITE:1:$request_uri";
        memcached_pass unix:/tmp/memcached.sock;
        error_page 404 502 = @fallback;
    }

    location @fallback {
        proxy_pass http://dev;
    }
}

Видите эту строчку: 'set $memcached_key «YOURSITE:1:$request_uri»;'? Тут и происходит вся магия. Каждая страница хранится в кеше по ключу префикс + версия кеша + полный урл. Достаточно разок открыть страницу, джанга положит ее в кеш за ключом, а нжинкс в следующий раз по этому ключу ее достанет. Даже если выключить джангу сайт будет работоспособен. Пост-запросы мы сразу передаем бэкэнду, не дергая мемкешд. Еще момент: я использую гет-ключи для фаллбек режима если у юзера отключен js (у меня noscript :P): вкладки там всякие, так вот поэтому request_uri вместо uri — последнее урл без гет-ключей.

Начиная с джанги 1.3 нам не надо формировать весь ключ самостоятельно. Достаточно передать уникальную часть в методы set(), get(), delete(), а остальное джанга сделает самостоятельно. Уникальная часть в нашем случае это абсолютный урл страницы.

С привычным нам gzip'ом есть небольшая проблема: нжинкс не сжимает, то что берет из мемкешда. Я нашел в сети патч 4-х летней давности для версии 0.6 нжинкса, а на дворе уже 1.*. Если вы не обслуживаете ие6 вы можете включить компрессию в самой джанге и уже отдавать сжатый контент. В нашем случае старпер-браузер важен. Для всего остального gzip работать будет.

Memecached

Скажем memcached работать через сокет, /etc/memcached.conf:

#-s unix socket path to listen on (disables network support)
-s /tmp/memcached.sock
#-a access mask for unix socket, in octal (default 0700)
-a 0777

Статика

Я использую jquery и html5shim для IE. Хвала гуглу, он облегчит нам участь:

<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>

Остальную статику сожмем с помощью django-compressor. Я использую less — django-compressor может еще и скомпилировать его в css. Если вы тоже используете less, не забудьте поставить node.js на сервер и сам компилятор. Для всей статики включим компрессию и склеим вместе: js с js, css c css. Красота.

Приложение также дает файлам уникальные имена, так что при изменении исходников будут скомпилированы новые с новыми именами, следовательно юзер их скачает по новой и не будет задаваться вопросом «почему все выглядит так странно?». Кстати, джанга 1.4 тоже так может.

Локальный тест

Окей, мы почти закончили.
Возьмем любимую виртуальную машину, поставим систему и развернем наш проект. Заодно все подготовим к продакшену: обновим requirements для pip, которые, почему-то, всегда неактуальны и настроим uwsgi-сервер. С процессором есть один момент: виртуалбокс и вмваре плеер не умеют отдавать определенное количество герцов, ограничимся одним ядром.

По настройке uwsgi-сервера, я рекомендую почитать доки и еще статью на welinux. Объяснять это здесь не имеет смысла: взять пример конфига из доков, да запустить. Единственно я рекомендую указывать virtualenv, потому как большая часть проблем связана с pythonpath и окружением — ох, сколько времени я убил пока не догадался глянуть в доки. Судя по гуглу: я не один такой, народ извращается как может: добавляет пути в окружение из скриптов и т.д. А всего-то старое доброе rtfm.

И так, все готово. Откроем приватный режим в браузере, чтобы все по честноку, замерим за сколько мы получим страницу:

18 requests  ❘  284.93KB transferred  ❘  1.11s (onload: 1.12s, DOMContentLoaded: 979ms)

Включим кеш, сгенерим кеш, откроем новый приватный браузер:

18 requests  ❘  298.09KB transferred  ❘  291ms (onload: 293ms, DOMContentLoaded: 150ms)

Выполним POST запрос (потом объясню зачем). В нем выполняется пару раз get_or_create:

21 ms. Просто запомним.

Как видим есть прирост, как и немного больший вес без гзипа.
Круто? Круто.

Можно круче

Вот что мы сделаем:

  • начиная с версии 1.3 джанга собирает всю статику всех приложений в папку со статикой (manage.py collectstatic) — лучше не придумаешь. Положим все в память;
  • бд у нас это файл и обращаться к нему приложение будет как к файлу, положим и ее в память;
  • настроим копирование бд раз в час на винт, чтобы спать спокойно.

Статика это 2 мегабайта и бд больше пары мегабайт не вырастит — берем 5 Мб. Создадим директорию в памяти:

# Укажем в конце /etc/fstab:
tmpfs /mnt/project/ tmpfs size=5M,mode=0777 0 0

# Создадим директорию
$ sudo mkdir /mnt/project/

# Смонтируем в первый раз ручками, при ребуте директория смонтируется автоматически:
$ sudo mount /mnt/project/

# mode=0777 позволит нам делать все, что нам захочется
$ mkdir /mnt/project/static
$ mkdir /mnt/project/db

Если вы правильно собрали свой проект, то вся статика у вас окажется в директориях приложений. Для этого статику нужно класть в static каждого приложения, подробнее и больше в доках. Так джанга сможет их найти и собрать в нужном месте. Делаем:

# В локальных настройках джанги укажем путь до директории сбора статики.
# Наверняка забудете про компрессор, ему тоже надо обновиться:
STATIC_ROOT = COMPRESS_ROOT = '/mnt/project/static/'

# В директории проекта выполним (--noinput не будет задавать вопросы, а -с потрет все лишнее в директории назначения):
$ ./manage.py collectstatic --noinput -c

# Тоже самое для бд. Укажим в настроках полный путь:
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': '/mnt/project/db/db',
    }
}

# Скопируем бд
$ cp -r project/db /mnt/project/db/db

# Ребутнем проект
$ killall -HUP uwsgi

Автоматизацию всего этого при старте и бекап по крону оставим в качестве домашнего задания.

Обновим конфиг нжинкса:

upstream project {
    server unix:/tmp/project.sock;
}

server {
    listen 80;
    server_name project.ru www.project.ru;
    if ($host = www.project.ru){
        rewrite ^(.*)$ http://project.ru$1 permanent;
    }

    location /static {
        root /mnt/project/;
        access_log off;
        expires max;
    }

    location /media {
        root /project/;
        access_log off;
        expires 5d;
    }
    
    location / {
        include /etc/nginx/uwsgi_params;
        if ($request_method = POST) {
            uwsgi_pass project;
        }
        default_type  "text/html; charset=utf-8";
        set $memcached_key "project:1:$request_uri";
        memcached_pass unix:/tmp/memcached.sock;
        error_page 404 502 = @fallback;
    }

    location @fallback {
        include /etc/nginx/uwsgi_params;
        uwsgi_pass project;
    }
}

Media у нас физически остался там же где и был. Статика переехала, остальное мы уже видели.
Стоит отметить строчку 'expires max' для статики: это ведь не круто. А что если завтра мы картиночку подправим, м? Если вы используете django-compressor, вы будите приятно удивлены увидев такое в css: /static/i/button-small.png?e59177bafc62.

Без кеша:

14 requests  ❘  284.93KB transferred  ❘  743ms (onload: 775ms, DOMContentLoaded: 708ms)

С кешем:

14 requests  ❘  298.09KB transferred  ❘  174ms (onload: 175ms, DOMContentLoaded: 140ms)

POST:

17ms

Есть прирост. Небольшой, но есть. А при большей посещаемости это станет заметно.

Бекапы

Обычно я настолько расточителен, что использую Dropbox, но здесь больше подойдет WebDav. Простую и достаточно подробную инструкцию вы найдете на MyDrive. Благо сейчас сервисов полно — выбирайте. Это позволит нам сэкономить место на винте и вынесет бекапы в облако одновременно.

Итак, что мы сделали?

  • мы сэкономили ресурсов заменив mysql на sqlite (и немного их потратили положив бд в память :) однако до поры добились большой скорости;
  • использовали cookie-based сессии — они у пользователя, а значит нам не стоит заботится об их хранении, чистке и прочей ерунде;
  • мы оптимизировали работу джанги, в основном админки, за счет отключения перевода, но это некешируемая часть, а значит это важно;
  • нжинкс, джанга и мемкешд общаются через сокеты — незнаю насколько это точно прибавит, но должно быть быстрее чем через порты;
  • мы использовали, наверное, самый быстрый кеш из существующих и при правильном использовании можем дать фору в скорости сайтам покруче;
  • часть статики размещена во вне, а значит сайт будет грузиться еще быстрее: из разных источников или из локального кеша (штука-то популярная);
  • свою статику мы сжали, положили в память и отдаем через нжинкс;
  • бекапы? Checked.

Так в чем же шутка?

Мне было интересно решить задачу, просто так, из любопытства и непривычки работать в тесных условиях. В основном, решения безвредные и даже полезные, но некоторые несут исключительно спортивный характер: выбранный кеш непрактичен, а sqlite не позволит сильно расширить проект (бэк-офис для обработки звонков, например). Да и в здравом уме экономить на сервере я не буду, так что все это не более чем фан.

Вроде, все. Спасибо за внимание.

Автор: magic4x

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js