В этой статье я опишу настройку автоматического развёртывания веб-приложения на стеке 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
¦ L-- uwsgi_params
+-- web
¦ +-- project
¦ ¦ +-- __init__.py
¦ ¦ +-- settings.py
¦ ¦ +-- urls.py
¦ ¦ L-- wsgi.py
¦ +-- app
¦ ¦ +-- migrations
¦ ¦ ¦ L-- ...
¦ ¦ +-- __init__.py
¦ ¦ +-- admin.py
¦ ¦ +-- apps.py
¦ ¦ +-- models.py
¦ ¦ +-- tests.py
¦ ¦ L-- views.py
¦ +-- Dockerfile
¦ +-- manage.py
¦ +-- requirements.txt
¦ L-- uwsgi.ini
+-- docker-compose.yml
L-- 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
".
Оставим их пытливому читателю в качестве самостоятельного упражнения.
Ссылки
» GitLab CI: Учимся деплоить
» GitLab CI Quick Start
» GitLab Container Registry
» Django на production. uWSGI + nginx. Подробное руководство
» Fabric documentation
Комментарии (33)
EgorLyutov
25.11.2016 22:49+1А как правильно разделить, если пуш не в мастер, то собирать контейнер в конфигурации для разработки, ну и соответсвенно, если в мастер, то для продакта?
prefrontalCortex
26.11.2016 11:39Хороший вопрос. Я бы попробовал из
.gitlab-ci.yml
вызывать шелл-скрипт, который на основе переменнойCI_BUILD_REF_NAME
будет производить нужные действия, как-то в таком духе
build.shif [ "$CI_BUILD_REF_NAME" = "master" ]; then docker-compose build else docker-compose -f debug.yml build fi
Sovetnikov
26.11.2016 22:35В шагах .gitlab-ci.yml можно выбирать, при коммите в какой бранч выполнять шаг.
И соответственно разные шаги для разных бранчей с разными скриптами.
Вот тут подробно https://habrahabr.ru/company/softmart/blog/310502/
Вот тут официальная документация https://docs.gitlab.com/ce/ci/yaml/README.html#only-and-except
Sovetnikov
26.11.2016 22:30До zero downtime развертывания не планируете довести? :)
Было бы тоже интересно.renskiy
27.11.2016 11:17+1с контейнерами можно осуществить только near-zero downtime. Полноценный zero downtime гарантируется только при использовании кластерных решений (Swarm, Kubernetes, Mesos).
PS: можно конечно постараться осуществить нулевой простой и без помощи кластера контейнеров. Например, играясь с сетевой маршрутизацией между двумя контейнерами по аналогии с сине-зеленым деплоем. Но в этом случае вы скорее всего сделаете свой довольно сложный велосипед, тогда как перечисленные выше решения уже дают необходимое свойство.Sovetnikov
27.11.2016 11:57Тут варианты:
1. nginx+uwsgi в одном контейнере — да, тут кластер или маршрутизацией управлять.
2. nginx отдельно (отдельный контейнер или непосредственно на сервере) от uwgi — в этом случае можно ведь настроить nginx failover с proxy_next_upstream и нулевым timeout?
Спрашиваю, потому что на практике не делал, но думаю что работает.
3. Исходные коды храняться в data volume (а не в инстансе как в пп.1 и 2), который цепляется в инстанс uwsgi — тут можно использовать возможность uwsgi последовательно перезагружать воркеры, у них довольно подробная инструкция есть на эту тему.
Всё это конечно с допусками:
— Нет изменений в БД, которые не позволяют использовать предыдущую кодовую базу с новой версией БД.
— Время простоя на перезагрузку конфигурации nginx считаем незначительной и отдельный балансировщик делать не будем :)
Или что-то не так?renskiy
27.11.2016 13:531. В одном контейнере лучше не запускать более одного сервиса. А маршрутизацию можно осуществлять при помощи iptables (Docker так и делает).
2. Этот вариант в общем-то работает, но не дает zero downtime как такового, ибо nginx в данном случае пытается перенаправить запрос на следующий сервер, только если первый вернул ошибку либо отвалился по таймауту. В случае не идемпотентных запросов такое поведение опасно.
3. Этот вариант немного костыльный (зачем, например, нужны контейнеры, если исходный код сервиса все равно находится снаружи, а если так и нужна просто изоляция, то есть virtualenv и другие инструменты). И уж точно этот вариант не универсальный, то есть будет работать только при определенных условиях (как вы сами заметили) и только в случае с uwsgi.Sovetnikov
27.11.2016 14:261. Это понятно
2. nginx ведь не вернет ошибку, он внутри обработает запрос на доступный uwsgi. Клиент увидит только результат того uwsgi, который работает. Ошибки не увидит.
Попытка коннекта на закрытый порт это мне кажется всего несколько миллисекунд, а то и меньше.
А в случае не идемпотентных запросов, что может произойти?
Два инстанса сразу не должны быть доступны для запросов от клиента.
Но получается, что всёравно надо будет портами играться… надо ждать пока новый инстанс запустится, гасить порт старого, открывать порт нового.
В общем понятно.
3. Хранение исходных кодов в data volume (или в host директории) или в контейнере это разные стратегии применяемые в разных условиях (проект, команда, организация, куда идёт развертывание и т.п.).
virtualenv vs docker? Да ну его этот virtualenv…
uwsgi прекрасно работает, зачем гонять образы, если можно заменить только код и пользоваться его возможностями.renskiy
27.11.2016 17:06А в случае не идемпотентных запросов, что может произойти?
повторять не идемпотентные запросы нельзя, потому что если первый сервер ответил ошибкой на такой запрос, то вполне вероятно, что он мог успеть обновить данные в БД. Если nginx потом перенаправит этот запрос на резервный сервер, то данные могут обновиться дважды.Sovetnikov
27.11.2016 18:24Я и пишу, что «Два инстанса сразу не должны быть доступны для запросов от клиента.»
p0g0rel0v55
27.11.2016 17:08А зачем копировать исходники проекта в контейнер с окружением? почему б не сделать 2 контейнера и не пересобирать окружение каждый раз? можно его тогда вообще в открытом репозитарии хранить будет и использовать например для запуска контейнера с celery.
prefrontalCortex
27.11.2016 18:04почему б не сделать 2 контейнера
Проще уж тогда вообще один контейнер сделать и к нему подключать volume'ом директорию с кодом. В принципе, такой вариант тоже имеет право на существование.
Контейнеры с celery я, например, вообще запускал ровно из того же образа, в котором жили Django с uwsgi — это удобно, так как демону celery в любом случае понадобятся настройки Django, как минимум настройки доступа к БД.
p0g0rel0v55
27.11.2016 18:50+1согласен, у меня так в одном проекте и сделано в elastic beanstalk
1 контейнер с uwsgi
2 от него унаследован образ для celery, изза особеностей EB так удобней стартовый скрипт менять
3 контейнер nginx
4 контейнер logentries который пишет логи собирая их из docker api
скелет приложения в открытом репозитарие не совсем допилен но более менее понятна структураprefrontalCortex
27.11.2016 19:20контейнер logentries который пишет логи собирая их из docker api
Теоретически, можно было обойтись без отдельного контейнера, докер много куда изкоробки умеет логи писать, в том числе и в syslog, откуда практически любой сервис по работе с логами умеет их забирать (например, loggly).
p0g0rel0v55
27.11.2016 20:50да, я даже делал такие конфигурации, но с отдельным контейнером получилось универсальней, не надо ничего писать в конфиг хоста на котором докер запущен… те сейчас везде где докер работает можно запустить этот конфиг у разработчика без дополнительных конфигов
M_Muzafarov
28.11.2016 10:57+1Спасибо за статью, может наконец сподвигнусь еще раз попробовать контейнирозвать проект.
Есть вопросы по решению, хотелось бы обсудить:
- Забавное решение с uwsgi (while true). Правда ничего лучше так и нет?
- Зачем в docker-compose выставлять наружу каждый порт? Они ж друг-другу итак доступны за счет network. Это как биндить всё на 0.0.0.0, когда на самом деле все эти порты (кроме nginx конечно), нужны только внутри.
prefrontalCortex
28.11.2016 12:22- Можно взять за основу образ
alpine:edge
(ну, или 3.5, когда выйдет), тамuwsgi
есть пакетом. Ну или вообще Ubuntu какую-нибудь. - А ведь ваша правда. Я-то у себя на продакшене через unix domain socket'ы сделал. Спасибо, поправил статью.
- Можно взять за основу образ
darken99
А что и зачем вы копируете rsync'ом если у вас код в образе докера?
prefrontalCortex
rsync
'ом я копирую всё содержимое репозитория, чтобы можно было его в случае чего подредактировать на сервере, не заходя внутрь докера. Как мне кажется, это проще, чем вспоминать ключи командыdocker exec
, а потом ещё посылатьHUP
всем затронутым процессам.darken99
А что мешает сделать профиль для девелопмента в docker-compose и менять себе на здоровье локально перед коммитом?
Может для тестовой среды такое еще и можно делать, но если это production...
prefrontalCortex
Насчёт отладочного профиля в
docker-compose
вы правы, так и нужно делать.Но без
rsync
'а все равно не обойтись — конфиги для Nginx (и, потенциально, для других частей стека) хранятся в репозитории.darken99
Вопрос конфига для nginx решается через переменные, шаблоны и dockerize.
Ну и как бы конфигурация должна храниться отдельно.
А в целом спасибо за статью, у меня как раз в процессе перевод django приложения в Docker, возьму статью за основу :)
prefrontalCortex
Ух ты, не знал про такую штуку, спасибо, изучу :)
renskiy
Мы тоже решали эту задачу. Решили на наш взгляд довольно лаконично. Плюс: нулевой простой, применение и откат миграций и другие радости Docker.
prefrontalCortex, кстати, тоже на Fabric
prefrontalCortex
Вы, я так понимаю, автор того самого
fabricio
? Я в процессе построения описанного в статье решения на него натыкался, но решил, что быстрее напишу свой quick-and-dirty fabfile.У меня всё-таки больше упор на инфраструктуру Gitlab, в котором даже закрытый Docker registry дают на халяву и не нужно самому его поднимать.
renskiy
Fabricio поддерживает любые registry. Например, если вы хотите использовать gitlab, то можно использовать следующий конфиг:
Fabricio только по-умолчанию пытался работать с локальным registry (сейчас этот механизм deprecated, чтобы не сбивать больше пользователей с толку, теперь надо всегда задавать registry вручную, если нужно использовать репозитарий, отличный от hub.docker.com).
renskiy
Можно даже опустить отдельное указание registry и прописать его в самом названии образа:
prefrontalCortex
Спасибо, попробую.
Sovetnikov
«через переменные, шаблоны и dockerize» — немного непонятно.
Если мне надо деплоить вместе с кодом проекта конфиг nginx сайта проекта, то как оно должно быть устроено правильнее?
И где должна храниться конфигурация?
p0g0rel0v55
я через envsubst конфиг nginx собираю прямо в контейнере, те туда темплейт конфига а переменные уже при запуске подставляю но это не dockerize… а только переменные и шаблоны
Sovetnikov
Это логично и понятно, а про что darken99 писал я не понял…