Этот вопрос часто возникает у студентов к одному из заданий в самом начале курса «Мидл 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)
baldr
21.08.2023 10:51В целом, все довольно грамотно описано. Однако, зачем отдельный контейнер для статики? nginx - это бинарник и довольно легко ставится на хост, тем более что на него, обычно, приходит существенная нагрузка от клиентов и дополнительные задержки на сеть докера ему ни к чему. Вдобавок, при изменении конфига nginx не придется перестартовывать весь контейнер - достаточно дать команду reload для мягкой перезагрузки.
brotchen
21.08.2023 10:51+1Перезагрузить nginx можно и внутри контейнера.
baldr
21.08.2023 10:51Ну можно, конечно. Через docker exec? Но все равно это неудобно - надо монтировать папки с конфигами, с сертификатами. Обновить бинарник "наживую" тоже не получится, но это уже и не стоит делать в наше время.
MarieSuperhero1 Автор
21.08.2023 10:51Привет! Автор статьи сейчас в отпуске и без стабильной связи, но он просил передать вот что:
Я бы сказал, что редко требуется менять конфигурацию nginx, тем более, она у нас монтируется в контейнер. И перезапустить в контейнере тоже не сильно сложно.
Накладные расходы в проде будут минимальные, если использовать сеть host.
AgaFonOff
21.08.2023 10:51+4Папка /media/ для загружаемых файлов приложения входит в чат и все становится еще веселее :)
ivankudryavtsev
Курс «middle разработчик». А вы талантливые мошенники, как я посмотрю. Этот курс должен включать 1 предложение «иди, паши». Ну тыщ 10 можно брать…
Прям можно любому пришедшему на курс выдавать диплом о профнепригодности, как мне кажется.
MarieSuperhero1 Автор
Иван, добрый день! Очень грустно, что у Вас сложилось такое впечатление о нашей работе.
В Яндекс Практикуме много практики: можно образно сказать, что студенты "пашут", чтобы расширить стек и освоить новые инструменты, вовремя сдавать учебные проекты. Студентов поддерживают наставники, ревьюеры, кураторы. Мы создаем среду, в которой каждый может добиться результатов, помогаем прийти к заветной цели быстрее. Верим, что учиться новому с поддержкой легче, чем без неё. И искренне гордимся нашими выпускниками: в группе Программирование и тестирование вы можете почитать реальные истории тех, кто добился успеха.
Давайте не будем клеймить тех, кто хочет учиться! Расти и развиваться в профессии не должно быть чем-то постыдным.