Создание docker-окружения для Django проекта

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

О преимуществах и недостатках такого подхода к разработке и развертыванию уже написано много статей, не вижу смысла повторяться. Сосредоточимся сразу на основной теме статьи: создание docker-окружения для разработки и запуска web-проекта, написанного на python. В качестве примера буду использовать мой последний проект на Django, но все написанное ниже на 95% подойдет к любому другому web-фреймворку на python, будь то Twisted, Flask или что-либо еще.

В итоге мы получим Docker-файл с настройками для создания образа с web-приложением и файл с конфигом для docker-compose, который позволит запустить полноценный сервис с базой данных postgres и redis-ом в качестве хранилища для celery. Итоговый набор контейнеров следующий:

  • web (django + gunicorn)
  • postgres
  • redis

Можно добавить nginx и получить полностью готовую к развертыванию сборку, но я предпочитаю запускать полноценный web-сервер (apache или nginx) на сервере, а уже за ним - docker-контейнер. Такой подход позволяет запустить несколько сервисов на базе docker на сервере и обслуживать их все при помощи одного nginx.

Собираем Docker-файл

Для начала выберем образ. Тут встречаются два подхода: в качестве основы выбрать базовую ubuntu и самостоятельно установить все необходимые python-пакеты, или взять за основу уже собранный и готовый образ с нужной версией python на борту. Я обычно выбираю второй вариант, так как это избавляет от необходимости самостоятельно устанавливать всякие python-dev, pip и прочие mast have пакеты для разработки проекта на этом языке программирования.

В качестве версии языка я выбираю текущую стабильную, то есть 3.5. Таким образом первая строка в Docker-файле, определяющая образ, на основе которого мы будум запускать наш сервис, будет:

FROM python:3.5

В python-репозитории на Docker Hub можно увидеть несколько вариантов образов. Рассмотрим основные, заслуживающие на мой взгляд внимания:

  • python:<номер_версии> - самый общий и универсальный образ на все случаи жизни. Собран на основе стабильного дистрибутива debian, содержит основные пакеты для старта ОС и запуска python-приложения.
  • onbuild - образ рассчитан для быстрого запуска проекта, проверки основных функций во время разработки. При запуске автоматически ищет requirements.txt в текущей диретории и устанавливает пакеты оттуда. Таким образом, в Dockerfile на базе такого образа достаточно указать параметр CMD и получим простейшую рабочую систему для проверки основных возможностей.
  • slim - в этом образе отсутствуют много стандартных для ОС пакетов, собрано минимальное окружение, необходимое для запуска python.

Помимо указанных выше есть еще alpine и windowsservercore образы, но они используются реже - не будем углубляться.

Следующим шагом, после выбора основного образа, будем устанавливать дополнительное ПО. В моем случае это less и vim - чтобы иметь возможность в любой момент подключиться к запущенному контейнеру и посмотреть/отредактировать те или иные файлы, и postgresql-client - чтобы иметь возможность запускать manage.py-команду dbshell. В зависимости от сложности и требований проекта, можно добавить rsyslog и cron. Добавим в Dockerfile следующую строку:

RUN apt-get update && apt-get install -y cron less vim rsyslog postgresql-client

Изначально в настройках временной зоны запускаемого образа стоит UTC. Чтобы иметь временные отметки в логах в соответствии с моим локальным временем, я изменяю конфигурацию временных зон:

RUN echo "Asia/Yekaterinburg" > /etc/timezone && \
      dpkg-reconfigure -f noninteractive tzdata

Поскольку в качестве источника мы использовали не onbuild-образ, то устанавливать зависимости будем вручную. Сначала создадим внутри контейнера домашнюю папку для проекта, скопируем в неё requirements.txt и запустим pip install:

RUN mkdir -p /srv/app/public_static/ WORKDIR /srv/app
COPY requirements.txt /srv/app/ RUN pip install -r requirements.txt

Следующим этапом идет копирование конфигурационных файлов для дополнительных сервисов, необходимых для работы проекта, (к примеру, celery). Предположим, что имеем следующую структуру проекта:

.
├── conf
├── src
├── Dockerfile
└── requirements.txt

и в папке conf лежат все необходимые для запуска проекта дополнительные конфигурационные файлы. Добавим в Dockerfile директивы для копирования этих файлов:

COPY conf/celeryconf /etc/default/celeryd
COPY conf/celeryd /etc/init.d/celeryd
RUN chmod 640 '/etc/default/celeryd'

Отлично, с подготовкой окружения закончили, теперь настроим запуск контейнера. Мало скопировать конфиги для celery, нужно еще настроить автозапуск служб при старте контейнера. Для этого в Dockerfile используется комбинация директив ENTRYPOINT и CMD. Разница между ними в том, что ENTRYPOINT - точка входа - содержит команды, которые выполняются при каждом запуске контейнера. А в CMD - команды, которые будут выполнены после ENTRYPOINT, если через командную строку не указано других.

Предположим, мы имеем образ, у которого в качестве ENTRYPOINT указана команда echo, а в качестве CMD - "Hello World!". При запуске такого образа через командную строку docker run print, мы увидим в консоли сообщение 'Hello World!'. Но если мы запустим этот образ через docker run print 'Goodbye!', то в консоли вместо приветствия миру будет 'Goodbye!': параметр командной строки имеет более высокий приоритет относительно директивы CMD.

В качестве ENTRYPOINT можно указать непосредственно набор команд, или исполняемый файл, в котором эти команды указаны. Второй вариант намного гибче, поэтому в корне проекта создадим docker-entrypoint.sh следующего содержания:

#!/bin/bash
set -e
CELERYPID=/var/run/celery/worker1.pid [[ -f "$CELERYPID" ]] && rm -f "$CELERYPID"
/etc/init.d/celeryd start
exec "$@"

Тут мы удаляем старый pid-файл от celery, если по каким-либо причинам он остался после предыдущего запуска контейнера, запускаем celery при помощи скопированного ранее init.d-скрипта и выполняем остальные команды, которые поступили либо из CMD, либо из командной строки.

В качестве CMD для запуска django-проекта укажем:

CMD [ "./manage.py", "runserver", "0.0.0.0:8000" ]

Собрав все воедино, на текущий момент мы имеем следующий Docker-файл:

FROM python:3.5

RUN apt-get update && apt-get install -y cron less vim rsyslog postgresql-client
RUN echo "Asia/Yekaterinburg" > /etc/timezone && \
      dpkg-reconfigure -f noninteractive tzdata

RUN mkdir -p /srv/app/public_static/ WORKDIR /srv/app
COPY requirements.txt /srv/app/ RUN pip install -r requirements.txt

COPY conf/celeryconf /etc/default/celeryd
COPY conf/celeryd /etc/init.d/celeryd
RUN chmod 640 '/etc/default/celeryd'

COPY docker-entrypoint.sh /root
ENTRYPOINT ["/root/docker-entrypoint.sh"]

CMD [ "./manage.py", "runserver", "0.0.0.0:8000" ]

И вот такую структуру проекта:

.
├── conf
├── src
├── Dockerfile
├── docker-entrypoint.sh
└── requirements.txt

Можно попробовать собрать его:

docker build -t my-project .

И запустить, подключив папку с исходными файлами проекта к /srv/app

docker run -it --rm -v $(pwd)/src:/srv/app -p 8000:8000 my-project

Связываем все при помощи docker-compose

Однако для полноценного django-сервиса нам не хватает базы данных, ну и redis-хранилища для работы celery. И вот тут на сцену выходит docker-compose: инструмент, с помощью которого можно управлять запуском нескольких образов, связывать их между собой, указывать зависимости, перезапускать при падении и много чего еще.

Про docker-compose я уже немного писал, поэтому повторяться не буду, а сразу перейду к делу. Для начала, создадим в корне проекта файл docker-compose.yml, в котором и будем хранить настройки для запуска всех наших служб. Добавим уже имеющиеся данные для старта django-контейнера, назовем его web:

web:
  restart: always
  build: .
  ports: - "8000:8000"
  volumes:
    - ./srс:/srv/app

Теперь выполнив в консоли docker-compose up мы можем наблюдать как сначала из нашего Dockerfile будет собран а затем запущен контейнер с кодом проекта.

Если по порядку озвучить содержимое файла, то

  • web - название образа
  • restart: always говорит о том, что контейнер необходимо перезапускать в случае сбоев или при перезапуске сервера
  • build: . указывает, что для создания образа используется Dockerfile в текущей директории
  • ports: - "8000:8000" Разрешаем доступ к 8000-му порту запущенного контейнера снаружи
  • volumes: - ./srс:/srv/app В качестве /srv/app (рабочей папки проекта) используем содержимое папки ./src - наш исходный код.

По сути, на текущем этапе, содержимое файла позволяет выполнить те же операции, что и команда

docker run -it --rm -v $(pwd)/src:/srv/app -p 8000:8000 my-project

Мы просто запускаем проект и подключаем папку с исходным кодом, пока ничего нового. Более подробно о возможностях настройки образов при помощи docker-compose.yml можно почитать на официальном сайте.

Теперь подключим и запустим контейнер с базой данных. Для этого добавим в файл с настройками строки

postgres:
  restart: always
  image: postgres:9.5
  env_file: env
  expose:
    - "5432"
  volumes:
    - ./data:/var/lib/postgresql/data/

Рассмотрим новые параметры:

  • image: postgres:9.5 - указываем образ, который использовать для создания контейнера с базой данных
  • env_file: env - файл с переменными окружения, которые мы хотим использовать внутри контейнера. По-умолчанию в контейнере для работы используется учетная запись postgres и соответствующая база данных. Образы с postgres базой данных собраны таким образом, что позволяют изменить это поведение, добавив в окружение переменные POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD с нужными нам значениями. Тогда при инициализации контейнера будет создана отдельная учетная запись и база данных.
  • expose: - "5432" - этим параметром мы открываем порт 5432 для других контейнеров (в нашем случае - для web), но (в отличии от параметра ports) оставляем его недоступным снаружи, из хостовой операционной системы или любых других контейнеров, не определенных в docker-compose.yml.
  • volumes: - ./data:/var/lib/postgresql/data/ - по-умолчанию все наши данные хранятся внутри файловой системы контейнера и, в случае удаления контейнера, теряются навсегда. Чтобы избежать этого, подключаем отдельную папку на сервере (./data), в директории нашего проекта и храним в ней все файлы, создаваемые базой данных. В дальнейшем перенос БД на другой сервер сводится к клонированию репозитория, копированию папки data и запуску docker-compose.

Осталось только указать web-контейнеру, что он может общаться с базой данных, добавив в соответствущий раздел docker-compose.yml строки

web:
  restart: always
  ...
  links:
    - postgres:postgres

Указав postgres в разделе links мы не просто разрешили сетевое подключение к этому контейнеру из web, но и значительно упростили этот процесс. Теперь, из окружения внутри web контейнер с базой данных доступен по указанному имени, в нашем случае - postgres. То есть, если запустить из консоли ping postgres, то будет запущен пинг до контейнера с БД, а в настройках проекта в качестве парамера host для подключения к базе данных можно просто прописать postgres.

Отдельно стоит рассмотреть настройки django для подключения к базе данных. В postgres-контейнер мы передаем имя базы данных и данные учетной записи при помощи env-файла. Этот же файл можно использовать и для заполнения переменных окружения в web, а потом - в файле settings.py для настроек базы данных. В итоге пункт DATABASES будет выглядеть следующим образом:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ['POSTGRES_DB'],
        'USER': os.environ['POSTGRES_USER'],
        'PASSWORD': os.environ['POSTGRES_PASSWORD'],
        'HOST': os.environ['POSTGRES_HOST'],
        'PORT': os.environ['POSTGRES_PORT'],
    },
}

Исходя из данных в docker-compose.yml, строку 'HOST': os.environ['POSTGRES_HOST'] можно заменить на 'HOST': 'postgres'. Но в данном случае я решил этого делать, чтобы все настройки хранились вместе - в файле env.

Достигнутого нами результата уже достаточно для разработки обычного сайта на django. Однако мы изначально решили, что наш проект будет включать в себя celery-задачи, и пришла пора подключить redis в качестве брокера для хранения очередей.

По аналогии с postgres, добавим в docker-compose.yml строки для запуска redis-контейнера и подключения его к web. Думаю, в данном случае комментарии излишни:

web:
  restart: always
  ...
  links:
    - postgres:postgres
    - redis:redis

    ...

redis:
  restart: always
  image: redis
  expose:
    - "6379"

Внутри нашего основного контейнера (web) в качестве адреса для подключения к redis-хранилищу можно будет указывать строку redis:6379.

Выводы

Используя Docker и docker-compose мы можем достаточно быстро создать окружение для запуска python-проекта как на компьютере разработчика, так и на боевом сервере. Причем в обоих случаях мы получим одинаковую операционную систему и идентичный набор программ, что значительно упростит написание кода и его дальнейшую отладку.

Однако, стоит помнить, что не смотря на широкое распространение и активную поддержку со стороны разработчиков, docker - молодой сервис, который продолжает активно развиваться и очень часто новые версии содержат изменения, несовместимые с предыдущими. Хотя я достаточно часто использую его не только в процессе разработки но и для запуска сервисов на production-серверах, ключевые элементы инфраструктуры (например, базу данных) лучше запускать на уровне операционной системы, а не в docker-контейнере. Ну или всегда иметь на готове сценарий экстренного восстановления.