Как организовать отдачу статических файлов в контейнеризованном Django

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Этот вопрос часто возникает у студентов к одному из заданий в самом начале курса «Мидл Python-разработчик» в Яндекс Практикуме. Мы попросили наставника на курсе Евгения Морозова написать подробный ответ. Дублируем его здесь, потому что уверены, что он будет полезен не только нашим студентам.

Евгений Морозов

тимлид в Газпром-медиа и наставник на курсе «Мидл Python-разработчик» в Яндекс Практикуме

Более 20 лет я занимаюсь бэкенд-разработкой на Python. На одной из первых своих работ я был сисадмином, поэтому темы администрирования и DevОps мне также интересны и близки. В этой статье я отвечу на вопрос, как реализовать отдачу статических файлов — таких как CSS, js, картинки — в контейнеризованном Django.

Способов решения этой проблемы существует несколько. Например, всё чаще современные сайты используют CDN. Упрощенно говоря, CDN — это географически распределённый кэш для статического контента, чтобы отдавать его со специально оптимизированных серверов, находящихся на минимально возможном расстоянии до клиента. Но для учебного приложения и даже для небольшого промышленного приложения прибегать к CDN мне кажется избыточным. Кроме того, нам могут понадобиться статические файлы при локальной разработке или на тестовых серверах, и использование CDN во всех окружениях, а не только продовом, усложнит и замедлит разработку. Решение, которое опишу я, подходит не только для этой задачи — оно пригодится вам и в других ситуациях.

Но для начала расскажу о способе, к которому прибегают многие студенты. Они решают использовать общий том со статикой, к которому имеет доступ как контейнер с Django — для того, чтобы собрать в него статику командой `manage.py collectstatic`, — так и контейнер с веб-сервером, чтобы отдавать статику браузеру пользователя. В нашем случае это Nginx.

Мне этот способ кажется неоптимальным. При таком подходе контейнер с Django и контейнер с веб-сервером становятся тесно связанными — и должны находиться на одном сервере, если не использовать, например, NFS — сетевую файловую систему. А ещё он кажется мне некрасивым и порождающим лишнюю сущность — том, который нужен только для обеспечения доступа к файлам из разных контейнеров.

В статьях, посвящённых этому вопросу, я видел наименее оптимальное решение: устанавливать Nginx в контейнер с Django. Но тогда в итоговом контейнере с Nginx остаётся много мусора, который раздувает размер контейнера бесполезными файлами, не требующимися для его работы.

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

Приступим к разбору решения, которое я считаю одним из лучших.

Когда-то я увидел элегантный паттерн, который кажется очевидным после прочтения, но до которого достаточно сложно дойти самостоятельно. Я ни разу не видел статей, в которых рассказывалось бы о нём именно в таком контексте. Суть подхода заключается в использовании мультистадийной сборки контейнера. На первой стадии мы собираем Django и выполняем команду `collectstatic`, вторая стадия строится на основе образа `Nginx` — в неё копируются только статические файлы из первой стадии.

Для статьи я подготовил минимальный пример c Django приложением, упаковываемым в докер, где Nginx проксирует запросы Django приложению и также отдаёт статику. Рассмотрим подробнее Dockerfile:

```

FROM python:3.11-alpine3.18 AS django-static-builder

```

Здесь мы говорим, что наш образ первой стадии должен строиться на основе образа Python 3.11, и явно задаём имя `django-static-builder`, по которому сможем обратиться к образу в последующих стадиях сборки.

```

RUN pip install --no-cache-dir poetry==1.5.1

COPY . /app/src

WORKDIR /app/src

```

Добавляем в контейнер poetry для установки зависимостей и копируем исходный код приложения в образ. Замечу, что устанавливать poetry подобным образом неправильно, так как в этом случае зависимости самой poetry смешиваются с системными и с зависимостями проекта, что может привести к ошибкам как в работе самой poetry, так и нашего приложения. Подобные ошибки случаются регулярно, потому что у poetry довольно много собственных зависимостей. Но для простоты оставим пока так.

Опция `--no-cache-dir` необходима, так как после сборки образа временная файловая система, в которой он собирался, будет удалена. Поэтому нет смысла тратить время на запись кэша пакетов на диск — при следующем запуске сборки его уже не будет, и зависимости надо будет скачивать снова. Существует способ кэшировать зависимости при сборке образов, но здесь мы эту тему раскрывать не будем.

```

RUN poetry config virtualenvs.create false && \

    poetry install --no-interaction --no-ansi

```

Устанавливаем зависимости проекта, причём сразу в каталог site-packages системного интерпретатора. Обычно так делать не рекомендуется, но контейнер — это изолированная среда для выполнения ровно одного приложения, поэтому в нём это не вызовет проблем.

```

RUN ./manage.py collectstatic --noinput

```

Запускаем команду Django, которая находит все статические файлы приложений (в нашем случае только встроенной админки и зависимости django-extensions) и собирает их в один каталог `STATIC_ROOT`.

```

CMD ./manage.py runserver 0.0.0.0:8080

```

Срезаем ещё один угол за счёт использования встроенного отладочного сервера Django. В реальной жизни такого быть не должно, здесь должен запускаться настоящий WSGI сервер — такой, как gunicorn.

```

FROM nginx:1.25.1-alpine AS front

```

Вторая инструкция `FROM` в Dockerfile сигнализирует о том, что мы начинаем сборку нового образа. Она может ссылаться на предыдущий собранный образ, но в данном случае мы собираем образ с Nginx, в котором нам не нужен ни Python, ни Django, а нужны только статические файлы из первой стадии сборки.

```

COPY --from=django-static-builder /app/src/static /data/static

```

Самая важная инструкция второй стадии сборки. Здесь мы явно указываем, что необходимо скопировать каталог из предыдущей стадии сборки в текущую — то есть статику Django, собранную командой collectstatic. При этом ничего, кроме неё, не попадает в данную стадию из предыдущей.

Соберём и запустим данную конфигурацию из бэкенда Django c обратным прокси Nginx перед ним при помощи конфигурации docker-compose.yml. Рассмотрим эту конфигурацию:

```yaml

  staticfiles-api:

    build:

      target: django-static-builder

    image: staticfiles-api:develop

```

Самая важная инструкция здесь — это `target`, которая говорит, что надо собрать стадию с названием `django-static-builder` и тегировать её `staticfiles-api:develop`. Без инструкции `target` будет собрана самая последняя стадия в Dockerfile, но для полноценной конфигурации нам нужен образ с нашим Django-приложением.

```yaml

  staticfiles-front:

    build:

      target: front

    image: staticfiles-front:develop

```

Здесь определяется сервис с обратным прокси Nginx. Если используется самая последняя стадия из Dockerfile, то можно не указывать её явно, но здесь решил оставить название для ясности.

```yaml

    volumes:

      - ./conf/nginx.conf:/etc/nginx/nginx.conf:ro

```

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

```

    location / {

        proxy_pass http://staticfiles-api:8080;

    }

```

И где находятся статические файлы:

```

    location /static/ {

        root /data;

    }

```

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

Такой подход к построению контейнеров имеет как минимум ещё один вариант применения. Допустим, нас не интересует хранение статики в контейнере, потому что для хранения и доставки статики мы используем, например, S3 и/или CDN. Но нас беспокоит размер контейнера. Сейчас, с минимумом зависимостей и минимумом кода, он составляет 196 Мб. Скорее всего, по мере развития сервиса его размер достигнет нескольких гигабайт. С одной стороны кажется, что мелочь, но всё же, даже если проигнорировать стоимость хранения десятков образов по несколько гигабайт (т.к. CI/CD будет порождать как минимум один образ на каждую ветку, и нам необходимо хранить их хотя бы на срок в несколько недель), то работать с образами в несколько гигабайт просто неудобно. Например, иногда нужно на тестовом сервере или на ноутбуке разработчика запустить команду в каком-нибудь конкретном образе. Скачивание многогигабайтного образа, даже по быстрой сети, создаёт ощутимую задержку.

Внесём небольшие изменения в Dockerfile. Первое изменение — установка зависимостей poetry в виртуальное окружение внутри проекта:

```

RUN poetry config virtualenvs.in-project true && \

    poetry install --no-interaction --no-ansi

```

Второе изменение — дополнительная стадия на основе образа Python, в которую копируется каталог с проектом и виртуальным окружением:

```

FROM python:3.11-alpine3.18 AS django-api

COPY --from=django-static-builder /app/src /app/src/

WORKDIR /app/src

CMD /app/src/.venv/bin/python manage.py runserver 0.0.0.0:8080

```

За счёт того, что в стадии `django-api` нет poetry и её зависимостей, размер образа уменьшился на 90 Мб. В реальном проекте, скорее всего, понадобится более сложная конфигурация. Например, ещё один образ, в котором останется poetry — чтобы изменять зависимости проекта при разработке. Но можно добиться и более существенной экономии на размере образов.

Источник: https://habr.com/ru/companies/yandex_praktikum/articles/755470/


Интересные статьи

Интересные статьи

Про colab знают, наверное, все. Этот инструмент позволяет независимым исследователям использовать облачную инфраструктуру с GPU и TPU бесплатно или почти бесплатно. Как всегда, проблемы возникают на б...
Ищете новый способ организации своих файлов и выполнения над ними каких-либо операций? Тем, кто работает с компьютерами, часто надо что-то отсортировать. Например, список файлов. Сортировка файлов с п...
Итак, у нас есть идея потрясающей и всем необходимой батарейки для Django. После того, как мы написали весь код мы готовы релизнуть нашу батарейку в PyPI. Однако перед этим мы должны разобраться с нес...
Рады представить вам первый мажорный релиз PhpStorm в этом году! Под катом подробный разбор всех изменений и новых возможностей. Читать дальше →
Привет, Хабр! Меня зовут Денис Копырин, и сегодня я хочу рассказать о том, как мы решали проблему бэкапа по требованию на macOS. На самом деле интересная задача, с которой я столкнулся в институт...