В этой статье я опишу настройку автоматического развёртывания веб-приложения на стеке 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
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 будет выглядеть следующим образом:
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 *ntStrictHostKeyChecking nonn" > ~/.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.
Теперь все изменения будут прогоняться через батарею автоматических тестов (которые, разумеется, тоже нужно написать), а изменения в главной ветке будут автоматически разворачиваться на боевой сервер с околонулевым временем простоя.
За рамками статьи остались следующие вопросы:
- настройка файрвола на целевом сервере для запрета доступа к PostgreSQL и uWSGI извне;
- настройка ротации журналов Nginx;
- настройка бэкапов PostgreSQL.
Оставим их пытливому читателю в качестве самостоятельного упражнения.
Ссылки
» GitLab CI: Учимся деплоить
» GitLab CI Quick Start
» GitLab Container Registry
» Django на production. uWSGI + nginx. Подробное руководство
» Fabric documentation
Автор: prefrontalCortex