В данной статье мы расскажем о том, как построить свой стек приложений для разработки, тестирования и запуска сайта или сайтов в продакшн для небольших фирм. Данный стек подразумевает под собой наличие одного 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 — вот скриншот настроек:

настройки drone

Чуть ниже находится интересный блок 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:

Список артефактов сборки

Описание артефакта сборки