Автоматическое развёртывание Django из GitLab

В этой статье я опишу настройку автоматического развёртывания веб-приложения на стеке Django + uWSGI + PostgreSQL + Nginx из репозитория на сервисе GitLab.com. Изложенное также применимо к кастомной инсталляции GitLab. Предполагается, что читатель располагает опытом в создании веб-приложений на Django, а так же опытом администрирования Linux-систем.


Развёртывание реализуем с помощью Fabric, Docker и docker-compose, а осуществлять его будет сервис непрерывной интеграции, встроенный в GitLab, под названием GitLab CI.


Механизм автоматического развёртывания

Развёртывание будет происходить следующим образом:


  1. При push’e новых коммитов в репозиторий будет автоматически запускаться GitLab CI.
  2. GitLab CI будет собирать Docker-образ с готовым к запуску Django-приложением.
  3. Затем GitLab CI отправит (push) собранный Docker-образ в GitLab container registry. Обратите внимание, настройки приватности в registry те же, что и у репозитория, т.е. для публичных репозиториев GitLab registry открыт для всех.
  4. Gitlab CI запустит юнит-тесты.
  5. В случае, если коммиты или merge request’ы производились в главную ветку (master), то после успешной сборки и тестирования Gitlab CI с помощью Fabric развернёт собранный Docker-образ на сервер с указанным нами IP-адресом.

Приватные данные, необходимые для развёртывания — закрытые ключи, SECRET_KEY для Django, токены сторонних сервисов и т.д. — хранить открытым текстом в репозитории определённо не стоит, поэтому для их хранения воспользуемся механизмом GitLab Secret Variables:


image


При таком подходе конфиденциальные данные доступны открытым текстом лишь в двух местах: в настройках проекта на GitLab.com и на сервере, на который осуществляется развёртывание. В свою очередь, на сервере конфиденциальные данные будут храниться в переменных окружения (читай: будут видны любому, кто может на него зайти по SSH).


Следующие переменные необходимы для работы механизма развёртывания:


  • DEPLOY_KEY — приватная часть SSH-ключа, который используется для входа на сервер;
  • DEPLOY_ADDR — его IP-адрес;
  • SECRET_KEY — соответствующая настройка Django.

Кроме того, в файле settings.py Django-проекта определим SECRET_KEY следующим образом:


SECRET_KEY = os.getenv('SECRET_KEY') or sys.exit('SECRET_KEY environment variable is not set.')

Шаг 1: Docker

В первую очередь, создадим Dockerfile для запуска Django и uWSGI на основе легковесного образа Alpine Linux:


web/Dockerfile
FROM python:3.5-alpine

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

RUN apk add --no-cache --virtual .build-deps gcc musl-dev linux-headers pkgconf \
    autoconf automake libtool make postgresql-dev postgresql-client openssl-dev && \
    apk add postgresql-libs postgresql-client && \
    # Предотвращаем неудачную компиляцию uWSGI внутри Docker, см. https://git.io/v1ve3
    (while true; do pip --no-cache-dir install uwsgi==2.0.14 && break; done)

COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt

COPY . /usr/src/app
RUN SECRET_KEY=build ./manage.py collectstatic --noinput && \
    ./manage.py makemessages && \
    apk del .build-deps

Предполагается, что зависимости нашего веб-приложения, как это принято в мире Python, хранятся в файле requirements.txt.


Шаг 2: docker-compose

Далее, для оркестрации Docker-контейнеров стека нам понадобится docker-compose.
Теоретически, можно было бы обойтись и без него, но тогда файл с инструкциями для CI стал бы раздутым и нечитаемым (см. для примера здесь).


Итак, в корневой директории репозитория создадим файл docker-compose.yml следующего содержания:


docker-compose.yml
version: '2'
services:
    web:
        # TODO: Смените username и project на подходящие вам значения.
        image: registry.gitlab.com/username/project:${CI_BUILD_REF_NAME}
        build: ./web
        ports:
            # открытые наружу порты
            - "8000:8000"
        environment:
            # переменные окружения, значения которых пробрасываются
            #  в контейнер из сервера
            - SECRET_KEY
        command: uwsgi /usr/src/app/uwsgi.ini
        volumes:
            - static:/srv/static
        restart: unless-stopped

    test:
        # TODO: Смените username и project на подходящие вам значения.
        image: registry.gitlab.com/username/project:${CI_BUILD_REF_NAME}
        command: python manage.py test
        restart: "no"

    postgres:
        image: postgres:9.6
        ports:
            # открытые наружу порты
            - "5432:5432"
        environment:
            # переменные окружения: пользователь и база данных
            - POSTGRES_USER=root
            - POSTGRES_DB=database
        volumes:
            # хранилище данных
            - data:/var/lib/postgresql/data
        restart: unless-stopped

    nginx:
        image: nginx:mainline
        ports:
            # открытые наружу порты
            - "80:80"
            - "443:443"
        volumes:
            # хранилища конфигов и статических файлов
            - ./nginx:/etc/nginx:ro
            - static:/srv/static:ro
        depends_on:
            - web
        restart: unless-stopped

Приведённый файл отвечает следующей структуре проекта:


repository
├── nginx
│   ├── mime.types
│   ├── nginx.conf
│   ├── ssl_params
│   └── uwsgi_params
├── web
│   ├── project
│   │   ├── __init__.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── app
│   │   ├── migrations
│   │   │   └── ...
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── apps.py
│   │   ├── models.py
│   │   ├── tests.py
│   │   └── views.py
│   ├── Dockerfile
│   ├── manage.py
│   ├── requirements.txt
│   └── uwsgi.ini
├── docker-compose.yml
└── fabfile.py

Теперь весь стек запускается одной командой docker-compose up, а внутри Docker-контейнеров стека доступ к другим запущенным контейнерам происходит по DNS-именам, соответствующим записям в файле docker-compose.yml. Так, релевантная часть конфига Nginx будет выглядеть следующим образом:


nginx.conf
upstream django {
    server web:8000;
}

…, а настройки доступа Django к БД — следующим:


settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'database',
        'HOST': 'postgres',
    }
}

Благодаря настройке restart: unless-stopped при перезагрузке сервера все контейнеры в нашем стеке автоматически перезапускаются с теми параметрами, с которыми они были запущены изначально, т.е. никаких дополнительных действий при перезапуске сервера совершать не требуется.


Шаг 3: GitLab CI

Создадим в корне репозитория файл .gitlab-ci.yml с инструкциями для GitLab CI:


.gitlab-ci.yml
# Сообщаем Gitlab CI, что мы будем использовать Docker при сборке.
image: docker:latest
services:
    - docker:dind

# Описываем, из каких ступеней будет состоять наша непрерывная интеграция:
#  - сборка Docker-образа,
#  - прогон тестов Django,
#  - выкат на боевой сервер.
stages:
    - build
    - test
    - deploy

# Описываем инициализационные команды, которые необходимо запускать
#  перед запуском каждой ступени.
# Изменения, внесённые на каждой ступени, не переносятся на другие, так как запуск
#  ступеней осуществляется в чистом Docker-контейнере, который пересоздаётся каждый раз.
before_script:
    # установка pip
    - apk add --no-cache py-pip
    # установка docker-compose
    - pip install docker-compose==1.9.0
    # логин в Gitlab Docker registry
    - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY

# Сборка Docker-образа
build:
    stage: build
    script:
        # собственно сборка
        - docker-compose build
        # отправка собранного в registry
        - docker-compose push

# Прогон тестов
test:
    stage: test
    script:
        # вместо повторной сборки, забираем собранный на предыдущей ступени
                #  готовый образ из registry
        - docker-compose pull test
        # запускаем тесты
        - docker-compose run test

# Выкат на сервер
deploy:
    stage: deploy
    # выкатываем только ветку master
    only:
        - master
    # для этой ступени другие команды инициализации
    before_script:
        # устанавливаем зависимости Fabric, bash и rsync
        - apk add --no-cache openssh-client py-pip py-crypto bash rsync
        # устанавливаем Fabric
        - pip install fabric==1.12.0
        # добавляем приватный ключ для выката
        - eval $(ssh-agent -s)
        - bash -c 'ssh-add <(echo "$DEPLOY_KEY")'
        - mkdir -p ~/.ssh
        - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
    script:
        - fab -H $DEPLOY_ADDR deploy

Стоит отметить, что Docker-runner’ы GitLab CI, которые мы используем, в качестве основы используют всё тот же образ Alpine Linux, что создаёт ряд трудностей — из коробки нет bash, непривычный пакетный менеджер apk, непривычная стандартная библиотека musl-libc и др. Трудности компенсируются тем, что образы на основе Apline Linux получаются действительно легковесными; так, официальный образ python:3.5.2-alpine весит всего 27.6 MB.


Шаг 4: Fabric

Для выката приложения на сервер нужно в корневой же директории репозитория создать файл fabfile.py, как минимум содержащий следующее:


fabfile.py
#!/usr/bin/env python2

from fabric.api import hide, env, settings, abort, run, cd, shell_env
from fabric.colors import magenta, red
from fabric.contrib.files import append
from fabric.contrib.project import rsync_project
import os

env.user = 'root'
env.abort_on_prompts = True
# TODO: Смените на путь на сервере, по которому будут скопированы файлы приложения
PATH = '/srv/mywebapp'
ENV_FILE = '/etc/profile.d/variables.sh'
VARIABLES = ('SECRET_KEY', )

def deploy():
    def rsync():
        exclusions = ('.git*', '.env', '*.sock*', '*.lock', '*.pyc', '*cache*',
                      '*.log',  'log/', 'id_rsa*', 'maintenance')
        rsync_project(PATH, './', exclude=exclusions, delete=True)

    def docker_compose(command):
        with cd(PATH):
            with shell_env(CI_BUILD_REF_NAME=os.getenv(
                    'CI_BUILD_REF_NAME', 'master')):
                # прячем прогресс-бар, см. https://git.io/vXH8a
                run('set -o pipefail; docker-compose %s | tee' % command)

    # Сохраняем переменные на сервере
    variables_set = True
    for var in VARIABLES + ('CI_BUILD_TOKEN', ):
        if os.getenv(var) is None:
            variables_set = False
            print(red('ERROR: environment variable ' + var + ' is not set.'))
    if not variables_set:
        abort('Missing required parameters')
    with hide('commands'):
        run('rm -f "%s"' % ENV_FILE)
        append(ENV_FILE,
               ['export %s="%s"' % (var, val) for var, val in zip(
                   VARIABLES, map(os.getenv, VARIABLES))])
    # Fabric перечитывает переменные из профиля при каждом вызове run(),
    #  поэтому нет смысла делать это явно. см. http://stackoverflow.com/q/38024726/1336774

    # Логинимся в registry
    run('docker login -u %s -p %s %s' % (os.getenv('REGISTRY_USER',
                                                   'gitlab-ci-token'),
                                         os.getenv('CI_BUILD_TOKEN'),
                                         os.getenv('CI_REGISTRY',
                                                   'registry.gitlab.com')))

    # Выполняем начальную установку, если нужно
    with settings(warn_only=True):
        with hide('warnings'):
            need_bootstrap = run('docker ps | grep -q web').return_code != 0
    if need_bootstrap:
        print(magenta('No previous installation found, bootstrapping'))
        rsync()
        docker_compose('up -d')

    # Включаем заглушку "технические работы", см. https://habr.ru/post/139968
    run('touch %s/nginx/maintenance && docker kill -s HUP nginx_1' % PATH)
    rsync()
    docker_compose('pull')
    docker_compose('up -d')
    # Убираем заглушку
    run('rm -f %s/nginx/maintenance && docker kill -s HUP nginx_1' % PATH)

Вообще говоря, копировать rsync'ом весь репозиторий необязательно, для запуска было бы достаточно файла docker-compose.yml и содержимого директории nginx.
Код приложения хранится на сервере на случай, если вдруг понадобится внести срочные изменения «наживую». На бесплатных аккаунтах gitlab.com для запуска CI используется сравнительно слабое виртуализированное железо, поэтому сборка, тесты и выкат, как правило, происходят за 5–10 минут.


image
(правда, бывает, что они до этого ещё в очереди торчат целую вечность)


Однако бывают случаи, когда каждая секунда на счету — для таких случаев мы и оставляем лазейку в виде полных исходников приложения. Для применения изменений, внесённых «наживую», достаточно перейти в директорию /srv/mywebapp и сказать в консоли


docker-compose build
docker-compose up -d

Заключение

Таким образом, мы реализовали непрерывную интеграцию веб-приложения с помощью сервиса GitLab.


image


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


За рамками статьи остались следующие вопросы:


  • настройка файрвола на целевом сервере для запрета доступа к PostgreSQL и uWSGI извне;
  • настройка ротации журналов Nginx;
  • настройка бэкапов PostgreSQL.

Оставим их пытливому читателю в качестве самостоятельного упражнения.


Ссылки


» GitLab CI: Учимся деплоить
» GitLab CI Quick Start
» GitLab Container Registry
» Django на production. uWSGI + nginx. Подробное руководство
» Fabric documentation

Комментарии (0)

© Habrahabr.ru