В этой статье я хочу рассмотреть задачу развертывания jenkins для django-приложения, использующего такой стек технологий, как redis (для кэша) и postgres (в качестве СУБД).

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

Я предполагаю, что на машине уже установлен docker (пользователь находится в группе docker) и python3.

Статью я решил разбить на четыре части:

  • создадим простое джанго приложение и настроим среду для его работы, изолируем postgres и redis в докер-контейнеры и научимся к ним подключаться;

  • добавим простой функционал в наше приложение;

  • напишем тесты к этому функционалу;

  • развернем jenkins и настроим его для работы с нашим приложением.

Если у вас есть уже свое приложение и вас интересует только подключение django к jenkins, вы можете пропустить первые три части.

Часть 1. Развертывание django приложения в тандеме с docker-compose

Начнем с простого - создадим виртуальную среду для python приложения. Я использую virtualenvwrapper.

mkdir django_jenkins_test
cd django_jenkins_test
mkvirtualenv django_jenkins_test -p `which python3`

Создадим файл requirements.txt со следующим содержимым:

Django==1.11
django-redis==4.8.0 # для использования redis в качестве кэша
psycopg2==2.7.3 # для работы с БД
coverage==4.4.1 # для генерации тестов покрытия

Установим зависимости:

pip install -r requirements.txt

Создадим приложение:

django-admin startproject django_jenkins_test .

Теперь, когда у нас есть заготовка приложения, надо подготовить базу данных и redis для кэширования. Окружение python прекрасно изолируется через virtualenv, а вот для изоляции БД и redis я воспользуюсь докер контейнерами. Для удобства работы я буду использовать docker-compose.

Docker compose - это обертка для Docker написанная на python, упрощающая работу с несколькими контейнерами. Устанавливается через pip. Можно установить как в виртуальную среду, так и глобально:

pip install docker-compose

Создадим файл docker-compose.yml со следующим содержимым:

version: '2'
services:
 db:
   image: "postgres:9.6-alpine"
   environment:
     POSTGRES_PASSWORD: admin
     POSTGRES_USER: admin
   ports:
     - 15432:5432
 redis:
   image: "redis:3.0-alpine"
   ports:
     - 16379:6379

Тут мы определили два сервиса:

  • Один для базы данных. Мы установили ему алиас db, в качестве образа указали: postgres:9.6-alpine, пробросили порт 5432 контейнера в 15432 порт хоста и указали пароль и юзера для суперюзера.

  • Второй сервис для redis. Мы указали ему алиас redis, в качестве образа используем redis:3.0-alpine и пробросили порт 6379 контейнера в 16379 порт хоста.

Теперь мы воспользуемся docker-compose, чтобы запустить эти сервисы. Для этого вызовем:

docker-compose up

Если ошибок не наблюдается, то тормозим контейнеры Ctrl+С и запускаем по новой:

docker compose start

Отличие start от up в том, что start просто запускает остановленные контейнеры, а up удаляет старые контейнеры, создает их по новой и запускает. Причем start, по умолчанию, запускает контейнеры в режиме демона, а up - в общем потоке, чтобы up запускался в detach состоянии, надо добавлять флаг -d.

Остановить контейнеры, запущенные через docker-compose, можно вызвав команду:

docker-compose stop

А удалить через:

docker-compose down

Теперь, когда redis и postgres у нас запущены, необходимо настроить django приложение, чтобы оно подключалось к сервисам, работающим в докер контейнерах. Для этого создадим файл django_jenkins_test/local_settings.py и пропишем в него наши настройки:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'default',
        'USER': 'admin',
        'PASSWORD': 'admin',
        'HOST': '127.0.0.1',
        'PORT': '15432', # тут используем порт, в который проборсили порт контейнера
    }
}

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:16379/1", # тут используем порт, в который проборсили порт контейнера
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    }
}

В конец файла django_jenkins_test/settings.py добавим:

from .local_settings import *

После всех действий, содержимое нашей папки должно выглядеть так:

Теперь попробуем запустить django миграции:

python manage.py migrate

и получим ошибку:

django.db.utils.OperationalError: FATAL: database "default" does not exist

Причина ошибки в том, что мы не создали БД в контейнере. Есть несколько способов решения проблемы. Самый простой, наверное, это изменить имя базы данных в local_settings на admin, и подключиться через:

python manage.py dbshell

И создать базу через CREATE DATABASE default и вернуть настройки обратно. Но мы пойдем другим путем и попробуем зайти в докер контейнер, подключиться изнутри к postgres и создать там базу данных.

Для начала узнаем имена контейнеров, которые создал docker-compose (команду, как и все прочие, надо вызывать из папки, в которой находится docker-compose.yml файл), вызовем:

docker-compose ps

увидим что-то подобное:

Name Command State Ports
------------------------------------------------------------------------------
djangotest_db_1 docker-entrypoint.sh postgres Up 0.0.0.0:15432->5432/tcp
djangotest_redis_1 docker-entrypoint.sh redis Up 0.0.0.0:16379->6379/tcp

То есть у нас два контейнера (как мы и ожидали). Имя контейнера состоит из названия папки, в котором лежит файл docker-compose.yml + алиас сервиса + порядковый номер запущенной копии контейнера.

Коннектимся к контейнеру базы данных, как это принято в обыкновенном docker:

docker exec -it djangotest_db_1 bash

Оказываемся внутри контейнера и создаем базу данных используя пользователя admin, указанного при создании сервиса. Любым способом, например, через psql:

echo “CREATE DATABASE "default";” | psql -Uadmin

или через утилиту postgres:

createdb -Uadmin “default”

Выходим из контейнера. Возвращаемся к миграциям, запускаем их:

python manage.py migrate

Теперь все должно быть зеленое.

Часть 2. Добавляем функционал

Для начала создадим приложение main:

python manage.py startapp main

Добавляем его в settings в INSTALLED_APPS:

INSTALLED_APPS = [
    ...
    'main',
]

Наш проект выглядит вот так:

Теперь создаем простое апи для добавления и просмотра существующих пользователей. Для этого в main/views.py пишем:

from django.contrib.auth.models import User
from django.core.cache import cache
from django.http.response import JsonResponse
from django.http.response import HttpResponseNotFound
from django.http.response import HttpResponse
from django.views.generic.base import View
from django.conf import settings

class UserApi(View):
   def post(self, request, *args, **kwargs):
       """
       Создает пользователя
       """
       user = User.objects.create_user(
           username=request.POST.get('username'),
           email=request.POST.get('email'),
           password=request.POST.get('password')
       )
       return {
           'id': user.id,
           'username': user.username,
           'email': user.email,
       }

   def get(self, request, *args, **kwargs):
       """
       Возвращает список пользователей
       """
       return {
           'users': [{
               'id': user.id,
               'username': user.username,
               'email': user.email,
           } for user in User.objects.all().order_by("id")]
       }

   def dispatch(self, request, *args, **kwargs):
       result = super(UserApi, self).dispatch(request, *args, **kwargs)
       return JsonResponse(result)

В этот же файл main/views.py добавляем простое апи для трекинга пользователей:

class TrackerView(View):
   def post(self, request, *args, **kwargs):
       """
       Фиксирует пользователя как активного на settings.TRACKER_CACHE_TIMEOUT секунд
       """
       if request.user:
           cache.set("tracker_{}".format(request.user.pk), "active", settings.TRACKER_CACHE_TIMEOUT)
       return HttpResponse()

   def get(self, request, *args, **kwargs):
       """
       Возвращает 200, если пользователь активный или 404 в обратном случае
       """
       user_id = request.GET['id']
       value = cache.get("tracker_{}".format(user_id))
       if not value:
           return HttpResponseNotFound()
       return HttpResponse()

При POST запросе в кеше будет создаваться ключ для пользователя, который будет очищаться через TRACKER_CACHE_TIMEOUT, в GET запросе можно будет узнать статус пользователя; если с момента вызова POST произошло больше TRACKER_CACHE_TIMEOUT секунд, в ответе будет приходить статус 404; если же пользователь все еще активен, будет выдавать статус 200.

Не забудем добавить в settings.py новую настройку:

TRACKER_CACHE_TIMEOUT = 10

Часть 3. Добавляем тесты

Один на проверку добавления пользователя, второй для тестирования вывода списка пользователей и третий для тестирования трекера. Для этого в main/tests.py пишем следующий код:

from time import sleep

from django.contrib.auth.models import User
from django.test import TestCase
from django.test.utils import override_settings
from django.urls.base import reverse

class TestMainViews(TestCase):
   def test_create_user(self):
       # создаем пользователя
       data = {
           'username': 'username',
           'email': 'email@email.ru',
           'password': 'password',
       }
       self.client.post(reverse("users"), data)
       self.assertEqual(1, User.objects.count())

       # проверяем, что он создался с корректными данными
       user = User.objects.get()
       self.assertEqual(user.username, 'username')
       self.assertEqual(user.email, 'email@email.ru')

       # проверяем, что можем залогиниться
       logged = self.client.login(username='username', password='password')
       self.assertTrue(logged)
       self.client.logout()

   def test_list_users(self):
       # создаем двух пользователей
       user1 = User.objects.create_user(
           username='username1',
           email='email1@email.ru',
           password='password',
       )

       user2 = User.objects.create_user(
           username='username2',
           email='email2@email.ru',
           password='password',
       )

       # проверяем, что они возвращаются в корректном порядке и количестве
       r = self.client.get(reverse("users"))
       data = r.json()
       self.assertEqual(2, len(data['users']))
       self.assertEqual(data['users'][0]['id'], user1.id)
       self.assertEqual(data['users'][1]['id'], user2.id)

   @override_settings(TRACKER_CACHE_TIMEOUT=1)
   def test_tracker_works(self):
       # создаем пользователя
       user = User.objects.create_user(
           username='username',
           email='email@email.ru',
           password='password',
       )

       # логинимся под ним
       self.client.login(username='username', password='password')

       # дергаем трекер
       self.client.post(reverse("tracker"))

       # проверяем, что при запросе статуса пользователя в трекере,
       # пользователь значится как активный
       r = self.client.get(reverse("tracker"), {
           'id': user.pk
       })
       self.assertEqual(200, r.status_code)

       # еще раз дергаем трекер
       self.client.post(reverse("tracker"))

       # проверяем, что через 2 секунды пользователь уже не значится залогиненым
       sleep(2)
       r = self.client.get(reverse("tracker"), {
           'id': user.pk
       })
       self.assertEqual(404, r.status_code)

Часть 4. Разворачиваем Jenkins

Итак, у нас есть готовое приложение. Подключим git (или любую другую vcs, если вдруг еще не сделали) к приложению и отправим его во внешний репозиторий. Это нам потребуется в будущем, чтобы jenkins мог подтягивать приложение независимо от того, где мы его развернем.

Также убедитесь, что файл django_jenkins_test/local_settings.py исключен из репозитория.

Разворачивать мы будем по следующей схеме. Дженкинс мы поместим в отдельный докер контейнер + выделим два дополнительных контейнера под postgres и redis.

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

version: '2'
services:
 postgres:
   image: "postgres:9.6-alpine"
   environment:
     POSTGRES_PASSWORD: admin
     POSTGRES_USER: admin
 redis:
   image: "redis:3.0-alpine"
 jenkins:
   build:
     context: .
     dockerfile: Dockerfile_jenkins
   ports:
     - "9090:8080"
     - "50000:50000"
   depends_on:
     - redis
     - postgres

Как мы видим, первые два сервиса postgres и redis имеют практически идентичные настройки тем, что мы прописывали для приложения на основном приложении.

Главное отличие в том, что мы не пробрасываем порты, так как не планируем обращаться к этим контейнерам напрямую.

А вот с jenkins все сложнее, возможностей стандартного образа нам не хватит, поэтому придется его расширить. Для этого создадим файл jenkins/Dockerfile_jenkins и поместим в него следующее содержимое:

FROM jenkins/jenkins:2.75-alpine

USER root
RUN apk add --update\
   python3 \
   python3-dev \
   build-base \
   py3-pip  \
   postgresql-dev
USER jenkins

Тут мы дополнительно установим python3, а также dev-пакеты для разработки для python и postgresql.

Мы укажем имя этого файла для нашего сервиса в docker-compose.yml в настройке dockerfile: Dockerfile_jenkins

  • ports - мы пробросим 8080 из контейнера в 9090 порт хоста.

  • depends_on - означает, что мы не запустим этот контейнер пока не будут запущены указанные в списке сервисы.

Наш проект должен выглядеть примерно так:

А все действия мы будем теперь выполнять из папки jenkins, для этого перейдем в нее:

cd jenkins

Запускаем docker-compose из папки jenkins:

docker-compose up -d

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

docker-compose logs

Причем, наверное, лучше даже так (чтоб листать удобнее было):

docker-compose logs | less

Открываем http://localhost:9090 там нас встречает:

Пароль можно подсмотреть в логах:

либо вызвать команду:

docker exec jenkins_jenkins_1 cat /var/jenkins_home/secrets/initialAdminPassword

В следующем окне можно выбрать плагины, но мы это сделаем чуть позже. Поэтому вместо выбора варианта, просто закрываем окно:

Попадаем на dashboard дженкинса:

Заходим в Manage Jenkins / Manage Plugins, выбираем вкладку Available и выбираем следующие плагины:

Config File Provider Plugin -- потребуется нам для создания/редактирования файла local_settings.py.

ShiningPanda Plugin -- потребуется чтобы работать в python окружении.

Git plugin -- потребуется для работы с git репозиторием.

Ждем пока дженкинс закончит установку, перегружаем его. Для перезагрузки можно поставить галочку внизу:

Но у меня, почему-то, не всегда корректно происходит перезагрузка. Так что я предпочитаю дождаться установки плагинов, и только после этого ставить галочку.

Убедитесь, что все плагины установлены, прежде чем перейти к следующему шагу. Их названия должны появиться в Manage Jenkins / Manage Plugins, во вкладке Installed.

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

Сначала настроим ShiningPanda и укажем путь к python, для этого переходим в Manage Jenkins / Global Tool Configuration и в разделе Python (должен быть в самом низу) укажем следующие настройки:

Адрес python в контейнере можно узнать, вызвав команду:

docker exec jenkins_jenkins_1 which python3

Теперь создадим конфигурационный файл для local_settings.py. Для этого перейдем в раздел Manage Jenkins / Managed files:

Жмем Add a new config:

Выбираем custom file и жмем submit:

Заполняем данные, в поле Name указываем что-то вроде “DjangoJenkinsTestLocalSettings”(это псевдоним нашего файла), а в поле content вбиваем:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'default',
        'USER': 'admin',
        'PASSWORD': 'admin',
        'HOST': 'postgres',
        'PORT': '5432',
    }
}

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://redis:6379/1",
        "OPTIONS": {
                "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    }
}

Как видите, в качестве хостов для БД и redis, я указываю не адрес и даже не localhost, а имена сервисов, как они были записаны в docker-compose.yml. Docker compose сам заботится о создании внутренней сети между контейнерами и позволяет контейнерам общаться друг с другом, используя алиасы сервисов. В качестве портов я указываю стандартные порты для postgres и redis.

Подготовительные работы завершены. Создаем новый Freestyle project:

В настройках проекта в Source Code Management подразделе указываем путь к репозиторию с проектом и в случае необходимости добавляем юзеры/пароли для доступа. В итоге получаем вот такое:

Раздел Build Environment настраиваем следующим образом:

В File выбираем созданный ранее Configuration File, а в поле Target указываем куда он будет скопирован, то есть, в нашем случае, файл надо положить в папку django_jenkins_test и дать ему имя local_settings.py.

В разделе Build нажимаем Add build step и выбираем Virtualenv Builder:

А теперь просто прописываем все команды, которые мы будем вызывать:

Разберем по командам:

pip install -r requirements.txt # устанавливаем зависимости

coverage run --source='.' manage.py test # прогоняем тесты с отчетом по
покрытию

coverage html # генерируем html отчет покрытия

В качестве последнего шага, в раздел Post-build Actions добавляем “Publish coverage.py HTML reports”:

В качестве Report Directory, указывается путь к папке, куда генеритcя отчет по покрытию.

Отлично, все готово и можно запускать:

Наслаждаемся успешным результатом:

Послесловие

В данной статье я попытался описать процесс разворачивания дженкинса для джанго проекта с использованием докер-контейнеров. Как видите, конфиг дженкинса/докера, получился достаточно простой. В нашей компании мы используем похожую конфигурацию, и, хотя наш проект в сотни раз больше приведенного тестового проекта, сам конфиг, по существу, отличается только немного более усложненным шагом Virtualenv Builder. Плюс в post-build actions мы генерируем дополнительные отчеты, помимо отчета по покрытию.

Также вы, наверное, заметили, что наш конфиг не создает никакой информации по результатам тестирования: какие тесты упали, а какие прошли успешно. К сожалению, это один из минусов стандартного джанговского testrunner`а который ничего не умеет, кроме прогонки тестов. Для решения этой проблемы существует как минимум два решения: подключить к проекту пакет django-jenkins или unittest-xml-reporting или сменить систему тестирования, например, на куда более мощный py.test.

Подробнее данный вопрос поднимается в статье. Только не забудьте поставить плагин JUnit Plugin для работы с junits отчетами.

Дополнительно, для более глубокого ознакомления с docker`ом рекомендую статью.