День: 28.02.2021

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

    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

  • GitHub Actions: Как решить проблему entrypoint.sh: permission denied

    GitHub Actions: Как решить проблему entrypoint.sh: permission denied

    Некоторые проблемы возникают на ровном месте. И обычные способы решения не подходят. Вчера я увидел следующее сообщение при выполнении workflow на GitHub Actions:

    Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"/entrypoint.sh\": permission denied": unknown

    Мы запускаем docker-compose, контейнеры начинают собираться и в момент, когда контейнер web решает запустить свой entrypoint (действие, совершаемое при каждом старте контейнера), мы получаем эту ошибку.

    Быстрый поиск нам дает понимание, что это проблема с разрешениями, и нужно просто выполнить классический набор команд, чтобы сделать файл исполняемым:

    chmod +x entrypoint.sh
    git add entrypoint.sh
    git commit

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

    ls -la

    Вы видите, что исполнительный бит «х» добавился, и ну не может такого быть, чтобы он терялся по пути. А оказывается может. И теряется.

    В тот момент, когда мы делаем на Windows Git Bash команду git add, мы теряем исполнительный бит. Как до этого догадаться? Я поднял контейнеры без запуска entrypoint.sh и проверил его права, executive bit был потерян. Я начал искать его на виртуальной машине, которую создает GitHub Actions для деплоя и тоже не нашел его там. Тогда я догадался переформулировать поисковый запрос на…

    Git add lost executive bit

    Только такая формулировка дала мне правильный набор решений. Оказывается, именно под операционной системой Windows система контроля версий git может (но не всегда) терять исполнительный бит при добавлении в репозиторий. Решается это следующим набором команд:

    git add --chmod=+x -- entrypoint.sh
    git commit

    То есть мы добавляем не файлу в операционной системе исполнительный бит, а файлу в момент присоединения его к коммиту.

    Решение не помогло? Замените CRLF на LF

    Есть еще одна особенность, которая может повредить исполняемые bash-файлы — это окончания строк. В среде Windows принято в конце строки ставить перевод каретки на следующую строку и в самое начало, и называется это дело CRLF.

    Естественно, в среде Linux все иначе, там перевод на начало строки не делают, только разрыв оставляют: LF. Проблема в том, что текстовые файлы по умолчанию в Windows используют несовместимые управляющие символы, и если вы хотите сделать ваш entrypoint.sh исполняемым — зайдите в настройки вашего любимого текстового редактора или IDE и установите параметр «Окончание строк» равным LF (Unix). Я лично рекомендую Notepad++ для этого:

    Надеюсь это маленькое расследование однажды сэкономит вам несколько часов времени.