Django, Postgres, Nginx с помощью Docker-Compose

В настоящем руководстве описан механизм автоматического деплоя трех контейнеров на локальный сервер. Контейнер с django проектом будет раздавать логику с помощью Gunicorn, контейнер с nginx будет раздавать статику и осуществлять так называемую reverse-proxy на Gunicorn для логики, контейнер с базой данных Postgres будет работать с настройками по умолчанию.

Важные аспекты деплоя

  1. Контейнеры зависимы друг от друга, сначала запускаем базу данных, затем приложение, последним — nginx
  2. При создании контейнеров с помощью docker-compose они автоматически объединяются в локальную сеть.
  3. Выход из этой сети будет один и только один — это открытый порт контейнера nginx,
  4. Остальные контейнеры не будут иметь связей с портами хостовой машины, что, в целом, не мешает им открывать порты внутри той самой локальной сети, созданной docker-compose. Делают они это неявно, и конкретные порты, как правило, можно найти в документации к используемому образу
  5. Стандартный порт, где доступна база данных — 5432
  6. Стандартный порт, где доступен Gunicorn — 8000

Volumes — Тома

Нам понадобится 4 тома для хранения следующей информации: база данных, статика, медиа файлы, конфигурация nginx. Объявим их сразу за версией docker-compose:

version: '3.8'

volumes:
  postgres_data:
  static:
  media:
  nginx_conf:

Контейнер 1: База данных

Если внимательно прочитать документацию образа Postgres на Docker Hub, то можно выяснить, что контейнер этот легко запустить без каких либо дополнительных инструкций, главное, чтобы в наших переменных окружения был закрытый список необходимых для деплоя значений. Здесь мы используем одну маленькую, но очень удобную хитрость: мы используем одинаковые переменные окружения для контейнера с базой данных и контейнера с приложением django, где описано подключение к базе данных. Вот список:

DB_ENGINE=django.db.backends.postgresql # для Django
DB_NAME=postgres # для Django и Postgres
POSTGRES_USER=postgres # для Django и Postgres
POSTGRES_PASSWORD=postgrespostgres # для Django и Postgres
DB_HOST=db # для Django
DB_PORT=5432 # для Django

Таким образом, код для docker-compose получается следующим:

services:
  db:
    image: postgres:12.4
    restart: always
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - ./.env

Указан образ, стоит ли перезапускать, если произойдет ошибка или сервер перезагрузится, путь к файлу базы данных внутри контейнера (это значение тоже из документации), а также откуда брать переменные окружения (в одной директории с docker-compose.yaml должен лежать файл .env).

Наш контейнер теперь доступен только внутри сети из трех контейнеров по адресу db:5432

Контейнер 2: Django и Gunicorn

Так как Django раздает сам себя только в режиме debug=True, нам понадобится что-то более профессиональное, пусть этим займется Gunicorn. По умолчанию он раздает логику приложения на порту 8000, и нас это вполне устраивает, никаких дополнительных настроек здесь не требуется. Наш контейнер должен собраться только после того, как будет готова база данных. Как правило, скачивание образа, на котором будет построен ваш контейнер, занимает время, и за это время база уже построится и будет готова принимать соединения. Но при повторном деплое может произойти так, что база поднимется чуть позже, чем мы заходим применить стандартные наши команды: применить миграции и собрать статику. Поэтому в целях предотвращения этого я добавил в файл entrypoint.sh (который выполняется каждый раз при старте контейнера) инструкцию «подождать 10 секунд». Вот как выглядит код:

  django:
    image: matakov/yamdb:latest
    depends_on:
      - db
    restart: always
    env_file:
      - ./.env
    volumes:
      - static:/code/static
      - media:/code/media
      - nginx_conf:/code/nginx/
    entrypoint: /code/entrypoint.sh

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

  1. Статику в директорию со статикой
  2. Медиа файлы в директорию с медиафайлами
  3. Конфигурация Nginx берется их папки nginx. Тут есть важный момент. Наш контейнер с приложением должен первым забрать конфигурацию из этой папки и положить в свежесозданный образ. Если первым это сделает контейнер с nginx, то он подключит в образ свой конфиг по умолчанию, и он перезатрет (на самом деле не так, но для целей понимания будем говорить так) конфигурацию в нашем контейнере с приложением.

Последняя инструкция запускает специальный файл каждый раз, когда контейнер стартует. Подробнее о проблемах, которые могут возникнуть с entrypoint.sh.

entrypoint.sh

#!/bin/sh

sleep 10

python manage.py migrate
python manage.py createcachetable
python manage.py collectstatic  --noinput
gunicorn api_yamdb.wsgi:application --bind 0.0.0.0:8000

exec "$@"

Файл, который отслеживает подключение к базе данных, как только оно появляется, дает 10 секунд на то, чтобы все необходимые технические операции были завершены, затем мигрирует, создает кэш, собирает статику и, наконец, запускает Gunicorn на 8000 порту контейнера.

Наш контейнер теперь доступен только внутри сети из трех контейнеров по адресу django:8000

nginx.conf

Чтобы наш сервер знал, как и с чем ему работать — нужно задать для него конфигурацию. Общая схема такая: на наш сервер на порт 80 приходит запрос, мы его проксируем на 80 порт контейнера с ngnix, он смотрит, если запрос на статику и медиа — отдает сам, а если запрос на логику — переадресовывает на контейнер с приложением на порт 8000. Приступим:

upstream djangodocker {
    server django:8000;
}

server {

    listen 80;

    location / {
        proxy_pass http://djangodocker;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }
    location /static/ {
        alias /code/static/;
    }

    location /media/ {
        alias /code/media/;
    }

}

Директива upstream позволяет нам дать название адресу с портом (что довольно удобно), а также дает возможность в будущем настроить здесь балансировщик нагрузки, просто добавив еще переменных server внутри upstream.

Дальше стандартный раздел server:

Слушаем 80 порт, если запрос пришел на главную страницу и все остальные, кроме статики и медиа, отправляем на наш контейнер django:8000, используя красивый upsteam-ярлык для этого djangodocker. Остальные строки просто передают также дальше и заголовки запросов.

Обратите внимание на команды root- Это означает, что /static/ приклеится к пути /code/ и станет /code/static

Команда alias в свою очередь указывает сразу на конечное расположение. Маленькая хитрость, лучше знать.

Контейнер 3: Nginx

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

  nginx:
    image: nginx:1.19.0-alpine
    ports:
      - "80:80"
    volumes:
      - static:/code/static
      - media:/code/media
      - nginx_conf:/etc/nginx/conf.d/
    depends_on:
      - django
    restart: always

Обратите внимание, контейнер зависит от django, поэтому конда он получит свой том nginx_conf — в нем уже будет лежать правильных конфиг, который мы написали выше.

Если у вас на сервере уже есть сайт?

Вы не можете в таком случае поднять свою сеть контейнеров на 80 порту, ведь он уже занят. В таком случае можно указать связку 8080:80, тогда ваше приложение будет доступно по ip:8080 в интернете. Если же вы хотите, чтобы оба сайта работали одновременно и на разных доменах, прочитайте статью про настройку веб-сервера Caddy и обязательно про безопасность открываемых портов.

Далее — Continues Integration

Разворачиваем приложение с помощью GitHub Actions