В этой статье я опишу настройку автоматического развёртывания веб-приложения на стеке Django + uWSGI + PostgreSQL + Nginx из репозитория на сервисе GitLab.com. Изложенное также применимо к кастомной инсталляции GitLab. Предполагается, что читатель располагает опытом в создании веб-приложений на Django, а так же опытом администрирования Linux-систем.
Развёртывание реализуем с помощью Fabric, Docker и docker-compose, а осуществлять его будет сервис непрерывной интеграции, встроенный в GitLab, под названием GitLab CI.
Механизм автоматического развёртывания
Развёртывание будет происходить следующим образом:
- При push'e новых коммитов в репозиторий будет автоматически запускаться GitLab CI.
- GitLab CI будет собирать Docker-образ с готовым к запуску Django-приложением.
- Затем GitLab CI отправит (push) собранный Docker-образ в GitLab container registry. Обратите внимание, настройки приватности в registry те же, что и у репозитория, т.е. для публичных репозиториев GitLab registry открыт для всех.
- Gitlab CI запустит юнит-тесты.
- В случае, если коммиты или merge request'ы производились в главную ветку (
master), то после успешной сборки и тестирования Gitlab CI с помощью Fabric развернёт собранный Docker-образ на сервер с указанным нами IP-адресом.
Приватные данные, необходимые для развёртывания — закрытые ключи, SECRET_KEY для Django, токены сторонних сервисов и т.д. — хранить открытым текстом в репозитории определённо не стоит, поэтому для их хранения воспользуемся механизмом GitLab Secret Variables:

При таком подходе конфиденциальные данные доступны открытым текстом лишь в двух местах: в настройках проекта на 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:
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 следующего содержания:
version: '2'
services:
web:
# TODO: Смените username и project на подходящие вам значения.
image: registry.gitlab.com/username/project:${CI_BUILD_REF_NAME}
build: ./web
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
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, кроме того, все открытые порты контейнеров доступны друг другу, так как они находятся в одной внутренней сети Docker'а. Так, релевантная часть конфига Nginx будет выглядеть следующим образом:
upstream django {
server web:8000;
}… а настройки доступа Django к БД — следующим:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'database',
'HOST': 'postgres',
}
}Благодаря настройке restart: unless-stopped при перезагрузке сервера все контейнеры в нашем стеке автоматически перезапускаются с теми параметрами, с которыми они были запущены изначально, т.е. никаких дополнительных действий при перезапуске сервера совершать не требуется.
Шаг 3: GitLab CI
Создадим в корне репозитория файл .gitlab-ci.yml с инструкциями для GitLab CI:
# Сообщаем 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, как минимум содержащий следующее:
#!/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 минут.

(а бывает, что они до этого ещё в очереди торчат целую вечность)
Однако бывают случаи, когда каждая секунда на счету — для таких случаев мы и оставляем лазейку в виде полных исходников приложения. Для применения изменений, внесённых "наживую", достаточно перейти в директорию /srv/mywebapp и сказать в консоли
docker-compose build
docker-compose up -dЗаключение
Таким образом, мы реализовали непрерывную интеграцию веб-приложения с помощью сервиса GitLab.

Теперь все изменения будут прогоняться через батарею автоматических тестов (которые, разумеется, тоже нужно написать), а изменения в главной ветке будут автоматически разворачиваться на боевой сервер с околонулевым временем простоя.
За рамками статьи остались следующие вопросы:
- настройка ротации журналов Nginx;
- настройка бэкапов PostgreSQL;
- настройка docker-gc для предотвращения неловкой ситуации "
No space left on device".
Оставим их пытливому читателю в качестве самостоятельного упражнения.