Современный стек сборки и публикации сайтов на Django
В данной статье мы расскажем о том, как построить свой стек приложений для разработки, тестирования и запуска сайта или сайтов в продакшн для небольших фирм. Данный стек подразумевает под собой наличие одного VDS достаточной мощности.
Мы будет рассматривать процесс публикации сайта на платформе Django. Не разработки, так как статей подобного рода огромное количество в Интернете, а именно публикации.
Стек состоит из следующих платформ:
- Docker (docker-compose) как платформа развертывания
- платформа Django 2.2
- git сервер Gitea
- Сервер тестирования и публикации Drone
- Сервер хранения статики и медиа файлов Minio
- Сервер периодических задач Celery
- Сервер SQL Postgres
- Redis
Как видите достаточно большой набор разных сущностей объединенных вокруг вашего будущего сайта (или набора сайтов). Выбор своего сервера git и сервера тестирования обусловлен тем, что проекты, которые мы разрабатываем не являются open source и соответственно к ним не применимы бесплатные тарифы. Лично я вообще не люблю привязывать свой стек разработки какому-либо проприетарному решению. Как правило это чревато все возрастающим финансовым затратам.
Далее — почему не Gitlab например? В нем присутствует и git сервер и сервер публикации. Ответ кроется в его серьезных требованиях к процессору и оперативной памяти. Gitea и Droid очень легкие небольшие платформы — нагрузки на сервер минимальны.
Для начала еще одно небольшое уточнение. Мы для запуска проектов используем замечательный пакет cookiecutter-django - https://github.com/pydanny/cookiecutter-django
Этот пакет позволяет быстро развернуть первоначальный образ для написания сайта на django
В документации на cookiecutter-django подробно описан процесс создания нового проекта, но отдельно опишу те опции, которые мы обязательно выбираем при установке:
Самый первый вопрос о названии пакета — вводим название сайта без суффикса. То есть если будущий сайт будет хостится по адресу test.com, то вводим просто test.
- Select open_source_license:
- 5
- use_docker
- y
- Select cloud_provider:
- 3 (None)
- use_celery
- y
- use_sentry
- y
- use_whitenoise
- y
- debug
- y
Это ключевые настройки. На остальные можете отвечать как вам удобно. Немного поговорим о настройке use_whitenoise. Дело в том, что сервер на Django, помимо собственно логики описываемой в моделях, вьюхах и шаблонах оперирует еще статикой и медиа файлами. Статика это всевозможные файлы css, js, картинки которые никогда не будут меняться (например иконка сайта, логотип и прочее). Медиа файлы это файлы, которыми либо вы, либо пользователи оперируют в процессе работы сайта. И они могут и удаляться, и добавляться и как то модифицироваться. Например изображения в статьях, фото пользователей и т.д.
Для отдачи статики и служит пакет whitenoise. Но вот медиа файлы он не обслуживает. Для отдачи медиафайлов мы используем сервер Minio. Для него есть пакет django-minio-storage, который связывает Django с Minio.
Вообще, строго говоря, есть еще nginx который может и статику и медиа отдавать. Но в процессе запуска gitea и drone было достаточно много танцев с бубном при использовании nginx (учитывая то, что мы используем только https с сертификатами от letsencrypt). Поэтому мы перешли на traefik + minio.
Ну и чтобы совсем разжевать связку minio django — приведу пример работы этого пакета.
model.py:
from minio_storage.storage import MinioMediaStorage
def my_path(obj, name):
return str(obj.article_id) + '/' + (name)
class ImageArticle(models.Model):
ms = MinioMediaStorage()
ms.bucket_name = 'article'
foto = models.ImageField(storage=ms, upload_to=my_path, blank=True, null=True)
article = models.ForeignKey(Article, on_delete=models.CASCADE)
class Meta:
verbose_name = 'Изображение для статьи'
verbose_name_plural = 'Изображения для статей'
def __str__(self):
return "Изображения к " + self.article.title
admin.py:
class ImageArticleInline(admin.TabularInline):
model = ImageArticle
extra = 1
class ArticleAdmin(admin.ModelAdmin):
form = ArticleAdminForm
list_display = ('title', 'category', 'arhiv', 'created')
list_filter = ['arhiv', 'category']
inlines = [ImageArticleInline]
actions = [make_published]
Итак приступим к настройке нашего VDS. О установке docker писать не буду, ибо этих статей в интернете просто навалом. Надо только поставить docker-compose .
Далее создаем папочку в в вашей домашней папке (например www) и в ней создаем файл docker-compose.yml со следующим содержимым:
version: '2.0'
services:
traefik:
build:
context: .
dockerfile: ./compose/production/traefik/Dockerfile
image: production_traefik
command: --docker
container_name: traefik
volumes:
- traefik:/etc/traefik
networks:
- proxy
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:443:443"
- "0.0.0.0:8080:8080"
postgres:
build:
context: .
dockerfile: ./compose/production/postgres/Dockerfile
image: main_postgres
container_name: postgres
restart: always
volumes:
- postgres:/var/lib/postgresql/data
- postgres_backup:/backups
env_file:
- ./.envs/.production/.postgres
networks:
- db
networks:
proxy:
external: true
db:
external: true
volumes:
postgres:
postgres_backup:
traefik:
создаем папки .envs и compose в папке www. В папке compose создаем подпапку production, а в ней traefik и postgres. В обоих этих папках создаем Dockerfile для сборки со следующим содержимым:
traefik Dockerfile
FROM traefik:1.7-alpine
RUN mkdir -p /etc/traefik/acme
RUN touch /etc/traefik/acme/acme.json
RUN chmod 600 /etc/traefik/acme/acme.json
COPY ./compose/production/traefik/traefik.toml /etc/traefik
кроме того в папке traefik, помимо Dockerfile создаем файл traefik.toml с следующим содержимым:
logLevel = "INFO"
defaultEntryPoints = ["http", "https"]
# Entrypoints, http and https
[entryPoints]
# http should be redirected to https
[entryPoints.http]
address = ":80"
compress = true
[entryPoints.http.redirect]
entryPoint = "https"
permanent = true
# https is the default
[entryPoints.https]
address = ":443"
[entryPoints.https.redirect]
regex = "^https://www.(.*)"
replacement = "https://$1"
permanent = true
[entryPoints.https.tls]
[entryPoints.gar]
address=":8080"
[entryPoints.gar.auth]
[entryPoints.gar.auth.basic]
users = [
"test:***",
]
[api]
dashboard = true
entryPoint = "gar"
# Enable ACME (Let's Encrypt): automatic SSL
[acme]
# Email address used for registration
email = "test@test.com"
storage = "/etc/traefik/acme/acme.json"
entryPoint = "https"
onDemand = false
OnHostRule = true
# Use a HTTP-01 acme challenge rather than TLS-SNI-01 challenge
[acme.httpChallenge]
entryPoint = "http"
[file]
[backends]
[backends.django]
[backends.django.servers.server1]
url = "http://django:5000"
[backends.stage]
[backends.stage.servers.server1]
url = "http://stage:5100"
[backends.minio]
[backends.minio.servers.server1]
url = "http://minio:9000"
[backends.gitea]
[backends.gitea.servers.server1]
url = "http://gitea:3000"
[backends.drone]
[backends.drone.servers.server1]
url = "http://drone:80"
[frontends]
[frontends.django]
backend = "django"
passHostHeader = true
[frontends.django.headers]
HostsProxyHeaders = ['X-CSRFToken']
[frontends.django.routes.dr1]
rule = "Host:test.com"
[frontends.stage]
backend = "stage"
passHostHeader = true
[frontends.stage.headers]
HostsProxyHeaders = ['X-CSRFToken']
[frontends.stage.routes.dr1]
rule = "Host:dev.test.com"
[frontends.minio]
backend = "minio"
passHostHeader = true
[frontends.minio.headers]
HostsProxyHeaders = ['X-CSRFToken']
[frontends.minio.routes.dr1]
rule = "Host:files.test.com"
[frontends.gitea]
backend = "gitea"
passHostHeader = true
[frontends.gitea.headers]
HostsProxyHeaders = ['X-CSRFToken']
[frontends.gitea.routes.dr1]
rule = "Host:git.test.com"
[frontends.drone]
backend = "drone"
passHostHeader = true
[frontends.drone.headers]
HostsProxyHeaders = ['X-CSRFToken']
[frontends.drone.routes.dr1]
rule = "Host:ci.test.com"
Вместо test.com вставляйте ваш сайт. В строчке users = [ "test:***", ] выберите логин и пароль к доступу дашбоард traefik
Вообще, строго говоря при формировании проекта при помощи cookiecutter-django эти файлы и каталоги сформируются этим помощником. Нужно всего лишь скопировать эти настройки и внести их в результирующий docker-compose.yml .
И еще один момент — текущий cookiecutter-django формирует файл для traefik версии 2.0. Мы пока не перешли на эту версию, так как и текущая комбинация вполне себе нормально обслуживает сайты.
Итак на текущий момент поднимается web сервер traefik и база данных Postgres.
Приступим к gitea, drone и drone-agent. Файл docker-compose.yml для этих сервисов находится у нас в отдельной папке. Вот его содержимое:
version: "2"
volumes:
gitea:
driver: local
drone:
driver: local
networks:
db:
external: true
proxy:
external: true
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
networks:
- proxy
- db
volumes:
- gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
env_file:
- ./.envs/.gitea
expose:
- 3000
ports:
- "2222:22"
drone:
image: drone/drone:1
container_name: drone
restart: always
depends_on:
- gitea
networks:
- proxy
- db
volumes:
- drone:/data
env_file:
- ./.envs/.gitea
drone_agent:
container_name: drone_agent
image: drone/drone-runner-docker:1
depends_on:
- drone
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: always
env_file:
- ./.envs/.gitea
Внимание на форматирование! Yml файлы требуют аккуратной расстановки отступов. Как вы можете видеть конфиденциальные данные хранятся в отдельной папке и файле ./.envs/.gitea. Вот его пример. (звездочками обозначены пароли и пр.):
HTTP_PORT=3000
DB_TYPE=postgres
DB_HOST=postgres:5432
DB_NAME=*** (название базы gitea)
DB_USER=*** (логин базы gitea)
DB_PASSWD=*** (пароль базы gitea)
USER_UID=1000
USER_GID=1000
DISABLE_SSH=true
RUN_MODE=prod
##DRONE##
DRONE_AGENTS_ENABLED=true
DRONE_GITEA_SERVER=https://git.test.com (любой свой домен)
DRONE_GITEA_CLIENT_ID=***
DRONE_GITEA_CLIENT_SECRET=***
DRONE_RPC_SECRET=***
DRONE_SERVER_HOST=ci.test.com (любой свой домен)
DRONE_SERVER_PROTO=https
DRONE_RUNNER_NETWORKS=proxy
DRONE_USER_CREATE=username:***,admin:true (логин для входа в дашбоард drone)
# DRONE_TLS_AUTOCERT=true
DRONE_LOGS_DEBUG=true
DRONE_LOGS_TEXT=true
DRONE_LOGS_PRETTY=true
DRONE_LOGS_COLOR=true
DRONE_DATABASE_DRIVER=postgres
DRONE_DATABASE_DATASOURCE=postgres://***(логин базы drone):***(пароль базы drone)@postgres:5432/drone?sslmode=disable
##DRONE-RUNNER##
DRONE_RPC_HOST=ci.test.com
DRONE_RPC_SECRET=***
DRONE_RUNNER_CAPACITY=2
DRONE_RUNNER_NAME=test-drone-runner (наименование вашего агента drone)
DRONE_RPC_PROTO=https
Итак мы запустили git сервер, сервер интеграции и развертывания drone (состоит из двух частей - агента который следит за git сервером и при наличии коммитов запускает процесс сборки и самого сервера).
Для авторизации drone использует gitea. Поэтому его нужно добавить на закладку доверенных приложений gitea. Справа вверху пункт меню Настройки, закладка Приложения, раздел Управление приложениями OAuth2.
Поле ID клиента вставляется в DRONE_GITEA_CLIENT_ID файла .gitea, клиентский ключ в поле DRONE_GITEA_CLIENT_SECRET, имя приложения drone, URI переадресации https://ci.test.com/login этот URL тоже используется в файле .gitea
Собственно говоря стек gitea — drone готов к использованию. Теперь необходимо «объяснить» drone, как разворачивать сайт.
Для этого в админке drone создаем проект и переходим на закладку SETTINGS — вот скриншот настроек:
Чуть ниже находится интересный блок Secrets. В этом блоке вносятся переменные окружения django :
Они будут доступны при сборке контейнера docker. Давайте проанализируем файл с настройками Django - production.py или stage.py:
DATABASES["default"] = env.db("DATABASE_URL")
Как видите DATABASE_URL получает данные из переменных окружения конвеера linux. Как в эти переменные окружения попадает значение этой переменной? А очень просто — из файла docker-compose.yml из секции environment. Вот пример:
Drone собирает контейнер и встретив конструкцию с знаком доллар и фигурными скобками смотрит у себя в секретах и подставляет значение секретных данных в переменные окружения контейнера. А Django уже при запуске считывает их и использует в работе.
Очень удобное решение. У нас несколько файлов настроек local.py, stage.py, production.py
Что же давайте далее использовать полученные знания. Создаем в корне проекта файл с инструкциями для drone .drone.yml
---
################
# Build & Stage garantum #
################
kind: pipeline
name: run_stage
steps:
- name: stage
image: docker/compose:latest
environment:
DJANGO_SECRET_KEY:
from_secret: django_secret_key
DJANGO_ADMIN_URL:
from_secret: django_admin_url
DJANGO_EMAIL_PASSWORD:
from_secret: email_password
SENTRY_DSN:
from_secret: sentry_dsn
REDIS_URL:
from_secret: redis_url
CELERY_BROKER_URL:
from_secret: celery_broker_url
CELERY_FLOWER_USER:
from_secret: celery_flower_user
CELERY_FLOWER_PASSWORD:
from_secret: celery_flower_password
MINIO_ACCESS_KEY:
from_secret: minio_access_key
MINIO_SECRET_KEY:
from_secret: minio_secret_key
POSTGRES_HOST:
from_secret: postgres_host
POSTGRES_PORT:
from_secret: postgres_port
POSTGRES_DB:
from_secret: postgres_db_stage
POSTGRES_USER:
from_secret: postgres_user
POSTGRES_PASSWORD:
from_secret: postgres_password
DATABASE_URL:
from_secret: database_url_test
commands:
- docker-compose -f stage.yml build
- docker-compose -f stage.yml up -d
volumes:
- name: docker
path: /var/run/docker.sock
- name: notify
image: drillster/drone-email
subject: >
[{{ build.status }}]
{{ repo.owner }}/{{ repo.name }}
({{ build.branch }} - {{ truncate build.commit 8 }})
body: >
https://ci.test.com
settings:
from: info@test.com
host: smtp.yandex.ru
port: 465
username: ci@test.com
recipients: [ci@test.com]
recipients_only: true
password:
from_secret: email_password
when:
status: [success, changed, failure]
trigger:
branch:
- release/*
volumes:
- name: docker
host:
path: /var/run/docker.sock
services:
# This database stays running during the whole pipeline and can be accessed from any of the
# other steps.
- name: postgres
image: main_postgres
ports:
- 5432
environment:
POSTGRES_USER: ***
POSTGRES_PASSWORD: ***
POSTGRES_DB: ***
POSTGRES_PORT: 5432
POSTGRES_HOST: postgres
DATABASE_URL: postgres://***:***@postgres:5432/***
Внимание на символы --- вверху. Этими символами отделяются секции.
В данном наборе инструкций мы создаем блок запуска тестового окружения. Перед публикацией сайта у нас настроен домен dev.test.com на котором мы проверяем правки сайта. Если все ок, то запускаем публикацию боевого сайта.
Итак когда у нас готова новая версия сайта, мы создаем с помощью git-flow ветку release/номер ветки и отправляем ее в gitea.
Drone-agent видит создание этой ветки и по триггеру приведенному выше запускает сборку тестового сайта. После сборки высылает сообщение на емайл или в телеграмм о успешности или неуспешности сборки. Мы открываем dev.test.com и проверяем работу визуально. Кроме того до сборки на компе разработчика запускаются тесты.
Если все ок, то ветка release объединяется с master и опять публикуется в gitea. Для сборки боевого сайта используется блок run_prod:
---
################
# Build & Prod #
################
kind: pipeline
name: run_prod
steps:
- name: prod
image: docker/compose:latest
environment:
DJANGO_SECRET_KEY:
from_secret: django_secret_key
DJANGO_ADMIN_URL:
from_secret: django_admin_url
DJANGO_EMAIL_PASSWORD:
from_secret: email_password
SENTRY_DSN:
from_secret: sentry_dsn
REDIS_URL:
from_secret: redis_url
CELERY_BROKER_URL:
from_secret: celery_broker_url
CELERY_FLOWER_USER:
from_secret: celery_flower_user
CELERY_FLOWER_PASSWORD:
from_secret: celery_flower_password
MINIO_ACCESS_KEY:
from_secret: minio_access_key
MINIO_SECRET_KEY:
from_secret: minio_secret_key
POSTGRES_HOST:
from_secret: postgres_host
POSTGRES_PORT:
from_secret: postgres_port
POSTGRES_DB:
from_secret: postgres_db
POSTGRES_USER:
from_secret: postgres_user
POSTGRES_PASSWORD:
from_secret: postgres_password
DATABASE_URL:
from_secret: database_url
commands:
- docker-compose -f production.yml build
- docker stop garantum_next_celeryworker
- docker stop garantum_next_celerybeat
- docker rm garantum_next_celeryworker
- docker rm garantum_next_celerybeat
- docker stop stage
- docker rm stage
- docker stop garantum
- docker rm garantum
- docker-compose -f production.yml up -d
volumes:
- name: docker
path: /var/run/docker.sock
- name: notify
image: drillster/drone-email
subject: >
[{{ build.status }}]
{{ repo.owner }}/{{ repo.name }}
({{ build.branch }} - {{ truncate build.commit 8 }})
body: >
https://ci.test.com
settings:
from: info@test.com
host: smtp.yandex.ru
port: 465
username: ci@test.com
recipients: [ci@test.com]
recipients_only: true
password:
from_secret: email_password
when:
status: [success, changed, failure]
trigger:
branch:
- master
volumes:
- name: docker
host:
path: /var/run/docker.sock
если вы обратили внимание, то в командах сборки stage и prod используются разные файлы docker-compose - production.yml и stage.yml
Пример stage.yml:
version: "3"
services:
stage:
build:
context: .
dockerfile: ./compose/stage/django/Dockerfile
image: stage
container_name: stage
command: /start
environment:
- DEBUG=True
- DJANGO_SETTINGS_MODULE=config.settings.stage
- DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY}
- DJANGO_ADMIN_URL=${DJANGO_ADMIN_URL}
- DJANGO_SECURE_SSL_REDIRECT=True
- DJANGO_EMAIL_PASSWORD=${DJANGO_EMAIL_PASSWORD}
- WEB_CONCURRENCY=4
- SENTRY_DSN=${SENTRY_DSN}
- REDIS_URL=${REDIS_URL}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_FLOWER_USER=${CELERY_FLOWER_USER}
- CELERY_FLOWER_PASSWORD=${CELERY_FLOWER_PASSWORD}
- USE_DOCKER=Yes
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- POSTGRES_HOST=${POSTGRES_HOST}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- DATABASE_URL=${DATABASE_URL}
networks:
- db
- proxy
expose:
- 5100
networks:
proxy:
external: true
db:
external: true
Это позволяет использовать разные переменные окружения и настройки доступа к базе данных.
Данная схема работает уже около полугода и зарекомендовала себя с лучшей стороны.
Вот пример реальных сборок drone: