Этот вопрос часто возникает у студентов к одному из заданий в самом начале курса «Мидл 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. Рассмотрим эту конфигурацию:

  staticfiles-api:

    build:

      target: django-static-builder

    image: staticfiles-api:develop

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

  staticfiles-front:

    build:

      target: front

    image: staticfiles-front:develop

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

    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 — чтобы изменять зависимости проекта при разработке. Но можно добиться и более существенной экономии на размере образов.

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


  1. ivankudryavtsev
    21.08.2023 10:51
    -2

    Курс «middle разработчик». А вы талантливые мошенники, как я посмотрю. Этот курс должен включать 1 предложение «иди, паши». Ну тыщ 10 можно брать…

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


    1. MarieSuperhero1 Автор
      21.08.2023 10:51
      -1

      Иван, добрый день! Очень грустно, что у Вас сложилось такое впечатление о нашей работе.

      В Яндекс Практикуме много практики: можно образно сказать, что студенты "пашут", чтобы расширить стек и освоить новые инструменты, вовремя сдавать учебные проекты. Студентов поддерживают наставники, ревьюеры, кураторы. Мы создаем среду, в которой каждый может добиться результатов, помогаем прийти к заветной цели быстрее. Верим, что учиться новому с поддержкой легче, чем без неё. И искренне гордимся нашими выпускниками: в группе Программирование и тестирование вы можете почитать реальные истории тех, кто добился успеха.
      Давайте не будем клеймить тех, кто хочет учиться! Расти и развиваться в профессии не должно быть чем-то постыдным.


  1. baldr
    21.08.2023 10:51

    В целом, все довольно грамотно описано. Однако, зачем отдельный контейнер для статики? nginx - это бинарник и довольно легко ставится на хост, тем более что на него, обычно, приходит существенная нагрузка от клиентов и дополнительные задержки на сеть докера ему ни к чему. Вдобавок, при изменении конфига nginx не придется перестартовывать весь контейнер - достаточно дать команду reload для мягкой перезагрузки.


    1. brotchen
      21.08.2023 10:51
      +1

      Перезагрузить nginx можно и внутри контейнера.


      1. baldr
        21.08.2023 10:51

        Ну можно, конечно. Через docker exec? Но все равно это неудобно - надо монтировать папки с конфигами, с сертификатами. Обновить бинарник "наживую" тоже не получится, но это уже и не стоит делать в наше время.


    1. MarieSuperhero1 Автор
      21.08.2023 10:51

      Привет! Автор статьи сейчас в отпуске и без стабильной связи, но он просил передать вот что:

      Я бы сказал, что редко требуется менять конфигурацию nginx, тем более, она у нас монтируется в контейнер. И перезапустить в контейнере тоже не сильно сложно.

      Накладные расходы в проде будут минимальные, если использовать сеть host.


  1. AgaFonOff
    21.08.2023 10:51
    +4

    Папка /media/ для загружаемых файлов приложения входит в чат и все становится еще веселее :)