В этом посте я расскажу как мы, в TheQuestion, осуществили нашу давнюю мечту — отдельные, автоматически разворачиваемые development среды для каждой отдельной задачи.


image// картинка


С самого начала наша разработка строилась таким образом:


  • Есть master ветка на GitHub, для которой настроена continuous integration — полная автоматизация деплоя на единственный тестовый сервер, архитектура которого максимально повторяет production.
  • Каждая новая задача ведется в fork-ветке разработчика, затем открывается пул реквест на master, который в итоге туда мержится.

Стоит сказать, что CI для master ветки устроен вполне обычным способом:


  1. Пуш на гитхаб
  2. TeamCity видит новый коммит и делает make
  3. Прогоняются автоматические тесты
  4. Собираются Docker контейнеры
  5. Ansible деплоит контейнеры

Эту последовательность и инструменты хотелось сохранить, чтобы не менять многое.


Очевидным недостатком одного dev'а является то, что на нем можно смотреть одновременно только одну ветку, незаконченные задачи мешают друг другу и приходится решать постоянные конфликты. Наша же цель заключалась в следующем: как только создается новая ветка на GitHub, создается отдельный dev для нее.


С первого взгляда задача не сложная — мы смотрим в API нашей облачной платформы и перед тем, как перый коммит в новой ветке начнет свой путь, создаваем для этой ветки отдельный сервер — все просто, развертка на отдельно взятой машине уже есть, спасибо Ansible!


Но тут есть одна существенная проблема: наша база данных. Полная ее развертка из сжатого dump (надо же еще скачать) на скромной машине занимает порядка двух часов. Можно конечно деплоить это все на более производительные машины или просто подождать, но мучится с API облака (при том, что при переезде на другое пришлось бы все переписывать) и платить лишнюю копеечку за каждую новую машину не хотелось. Так что для нашего решения используется одна средняковая машина.


TeamCity


Это замечательный инструмент, который почти не нужно настраивать. Единственное, что от него требуется — это рассказать скриптам, с какой веткой он работает.
Так что изменение единственного Build Step: command line из


cd clusters/dev
make

превратилось в


export branch_name=%teamcity.build.branch%
cd clusters/dev
make

Docker


При одном деве каждая часть инфраструктуры, будь то часть приложения приложения, Sphinx, Redis, Nginx или PostgreSQL запускались внутри отдельного контейнера. Которые запускались с указанием --network-mode=host, то есть каждый ip:port контейнера совпадал с localhost:port хост-машины.


Как вы понимаете, для нескольких девов это не прокатит, во-первых контейнеры должны общаться только с контейнерами одной ветки, во-вторых, nginx должен знать внутренние IP каждого нужного ему контейнера.


Тут на помощь приходит Docker network и запуск контейнеров превращается из


docker run /path/to/Dockerfile

в


docker network create ${branch_name} --opt com.docker.network.bridge.name=${branch_name}
docker run --network=${branch_name} -e branch_name=${branch_name}  /path/to/Dockerfile

Это дает нам:


  • название docker сети совпадает с названием ветки
  • название интерфейса сети совпадает с названием ветки
  • контейнеры каждой ветки находятся в одной docker сети, что позволяет им общаться по их именам (docker создает DNS-записи внутри каждой своей bridge сети)
  • внутри контейнеров создается переменная окружения с названием ветки, необходимая для генерации различных конфигов

PostgreSQL


Его мы запускаем в контейнере с --network=host, как раньше, чтобы СУБД была одна, но для каждый ветки — свой юзер и своя база.


Задача быстрого разворачивания новой базы отлично решается шаблонами:


CREATE DATABASE db_name TEMPLATE template_name

Плюс, каждый день хотелось бы иметь свежую копию базы с прода, чтобы при создании ветки, основываться на ней (тоже протекает в отдельном контейнере с --network=host)


Для этого создаем две базы. Каждую ночь тратим два часа на разворачивания свежего дампа в одну:


pg_restore -v -Fc -c -d template_new dump_today.dump

и если успешно:


DROP template_today;
CREATE DATABASE template_today TEMPLATE template_new;

По итогу имеем свежий шаблон каждое утро, который останется, даже если очередной дамп придет битым и развернется неуспешно.


При создании новой ветки создаем базу из шаблона


CREATE USER db_${branch_name};
CREATE DATABASE db_${branch_name} OWNER db_${branch_name} TEMPLATE template_today;

Таким образом, на создание отдельной базы под ветку уходит 20 минут, а не 2 часа, а подключение к ней изнутри docker-контейнеров осуществляется по eth0 инетрфейсу, который всегда указывает на IP хост-машины.


nginx


Его мы так же установим на хост-машине, а конфигурацию будем собирать с помощью docker inspect — эта команда дает полную информацию о контейнерах, из которой нам нужно одно: IP адрес, который подставим в шаблон конфигурации.


А благодаря тому, что имя интерфейса сети совпадает с названием ветки, может генерировать одним скриптом конфиги для всех девов сразу:


for network in $(ip -o -4 a s | awk '{ print $2 }' | cut -d/ -f1); do
    if [ "${network}" == "eth0" ] || [ "${network}" == "lo" ]  || [ "${network}" == "docker0" ]; then
        continue
    fi
    IP=$(docker inspect -f "{{.NetworkSettings.Networks.${network}.IPAddress}}" ${container_name})
    sed -i "s/{{ ip }}/${IP}/g" ${nginx_conf_path}
    sed -i "s/{{ branch_name }}/${network}.site.url/g" ${nginx_conf_path}
done

Удаление веток


Из-за недолгой жизни каждой ветки, кроме master, возникает необходимость переодически удалять все, что относится к ветке — конфиг nginx, контейнеры, базу.


К сожалению, я не смог найти, как заставить TeamCity рассказать, что ветка удалена, поэтому пришлось исхитряться.


При деплое очередной ветки, на машине вызывается создается файл с ее именем:


touch /branches/${branch_name}

Это позволяет запоминать не только все ветки, которые у нас есть, но и их время последнего изменения (оно совпадает со временем изменения файла). Очень полезно, чтобы удалять ветку не сразу, а через неделю после того как она перестает использоваться. Выглядит он примерно следующим образом:


#!/usr/bin/env bash

MAX_BRANCH_AGE=7

branches_to_delete=()
for branch in $(find /branches -maxdepth 1  -mtime +${MAX_BRANCH_AGE}); do
    branch=$(basename ${branch})

    if [ ${branch} == "master" ]; then
        continue
    fi

    branches_to_delete+=(${branch})
done

dbs=()
for db in $(docker exec -it postgresql gosu postgres psql -c "select datname from pg_database" |         grep db_ |         cut -d'_' -f 2); do

        dbs+=(${db})
done

for branch in ${branches_to_delete[@]}; do
        for db in ${dbs[@]}; do
                if [ ${branch} != ${db} ]; then
                        continue
                fi

                # branch file
                rm /branches/${branch}
                # nginx
                rm /etc/nginx/sites-enabled/${branch}
                # containers
                docker rm -f $(docker ps -a | grep ${branch}- | awk '{ print $1 }')
                # db
                docker exec -i postgresql gosu postgres psql <<-EOSQL
                    DROP USER db_${branch};
                    DROP DATABASE db_${branch};
EOSQL
        done
done

service nginx reload

Несколько подводных камней


Как только все заработало и помержено в master — он не собрался. Оказывается, слово master ключевое для утилиты iproute2, так что вместе нее для определения IP контейнеров, стали использовать ifconfig


было:


ip -o -4 a s ${branch_name} | awk '{ print $2 }' | cut -d/ -f1

стало:


ifconfig ${branch_name} | grep 'inet addr:' | cut -d: -f2 | awk '{ print $1}'

Как только была создана ветка thq-1308 (по номеру задачи из Jira) — она не собралась. А все из-за тире. Оно мешается в нескольких местах: PostgreSQL и шаблон вывода Docker Inspect
В итоге, узнаем IP хоста:


docker inspect -f "{{.NetworkSettings.IPAddress}}" ${network}-theq

Изменяем владельцев всех таблиц новой базы:


tables=`gosu postgres psql -h ${DB_HOST} -qAt -c "SELECT tablename FROM pg_tables WHERE schemaname = 'public';" "${DB_NAME}"`
    for tbl in $tables ; do
        gosu postgres psql -h ${DB_HOST} -d "${DB_NAME}" <<-EOSQL
        ALTER TABLE $tbl OWNER TO "${DB_USER}";
EOSQL
done

В общем-то, это все. Не приводил полных команд, скриптов (разве что кроме последнего), ролей ansible — там ничего особенного, но надеюсь сути не упустил. На все вопросы готов ответить в комментариях.

Поделиться с друзьями
-->

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


  1. servarius
    16.03.2017 18:33
    +1

    А не пробовали смотреть в сторону кластеризации/оркестрации типа kubernetes и openshift? Не нужно было бы заморачиваться с вводом новых ВМ, сетью на уровне docker. Для каждого дева свой namespace, поддомен и т.д. да и деплой из Гита есть из коробки.


    1. ngalayko
      16.03.2017 18:44

      Пробовал, как раз
      Первой мыслью были кубернейтес или докер сворм. Но почитав, понял, что они именно для многих хостов, плюс переписывать пришлось пришлось бы много всего, настраивать с нуля…
      Хотелось максимально сохранить налаженную схему и сохранить окружение как на проде

      Кластеризация это круто, конечно, и в планах перейти на них есть, но не в приоритете пока что


      1. servarius
        16.03.2017 19:37

        Еще дополню, раз в планах есть — fabric8. Деплоится на OpenShift, kubernetes и miniShift. Разворачиваются нэймспейсы с набором утилит cd-pipeline, сразу три окружения типа test-staging-prod.


  1. tipugin
    16.03.2017 22:45

    А связку registrator/consul/consul-template не рассматривали для конфигов nginx?


    1. farcaller
      16.03.2017 23:24

      У меня так. Consul начинает прилично подтупливать и, иногда, вообще не обновляет сервисы. Вариант кранить в консульном KV настройки vhost'ов для consul-template вообще нифига не масштабируется. Смотрю в сторону etcd, или уже сразу k8s.


    1. ngalayko
      17.03.2017 00:45

      да их и jinja2 которая в ansible встроена можно было бы верстать, но так как никакой сложной логики нет, подставляй да и все, то просто sed ограничился


  1. amberovsky
    17.03.2017 00:12

    • Раз уж используете докер — то делайте сборку проекта в докере.
    • Почему не подошёл docker-compose? Точно такое же изолированное окружение со своей сетью и dns.
    • Прописываете * в днс на сервере и делаете поддомен на каждый новый бранч. Вот это поможет не плясать с бубном и не теребить docker inspect


    Если ваше использование teamcity ограничивается вышенаписанным, то в конце заворачиваете всё в ansible и, в общем-то, teamcity становится не особо нужным.


    1. ngalayko
      17.03.2017 00:28

      Возможно из приведенных кусков кода не ясно, но все задачи makefile'ов выглядят примерно так, все в своих контейнерах


      docker build ...
      docker run ...
      ansible-playbook ...

      Docker-compose делает тоже самое, что и настроенные ansible роли для контейнеров ( https://docs.ansible.com/ansible/docker_container_module.html ), а дергать ансиблом docker-compose по сути тоже самое, но будет один большой файл, а не полностью разделенные (в том числе по файлам, для удобной навигации) файлики ролей


      За репозиторий спасибо, гляну! Выглядит отличным решением, плюс тот же докер в итоге станет


      А тимсити нужен, по большому счету, чтобы при изменениях в git, запускать ансибл (разве сам ансибл можно настроить так?), ну и всяческие задачи по расписанию


      1. amberovsky
        17.03.2017 01:37

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

        2.

        Docker-compose делает тоже самое, что и настроенные ansible роли для контейнеров (

        Таки нет. Этот модуль — обёртка над docker api. docker-compose в общем-то, простенькая утилита для удобного развёртывания/прибивания докер-окружения, которую, кстати, можно использовать в связке со swarm. Из приятных бонусов, как я уже упомянул — dns и изолированная сеть, а ещё не надо думать про имена хостов и контейнеров.

        3. Если вынести весь management в ansible (чего я советую), то teamcity, как дорогой enterprise-продукт, становится избыточным. Я, честно говоря, не имею опыта использования teamcity как scheduler, возможно это как-то оправдано, но я бы порекомендовал использовать что-то другое, в зависимости от проекта.


  1. M_Muzafarov
    17.03.2017 09:26

    Вместо touch /branch_name можно обращаться напрямую к Git, например, через Github API, или через git branch (в зависимости от глубины репозитория при клонировании). Не даст времени последнего изменения, но, по крайней мере, избавит от зависимости от файлов и одной машины.


    А для совместимости со всякими базами и прочим я обычно использую что-нибудь вроде ${BRANCH_NAME//-/_}


    1. grossws
      17.03.2017 13:35
      -1

      можно обращаться напрямую к Git, например, через Github API

      И мой локальный, и удаленный git-репозитории отказались отвечать через GitHub API. ЧЯДНТ?


      1. M_Muzafarov
        20.03.2017 09:52

        Сарказм? Я же сказал "например", дальше описание через git branch.