В этом посте я расскажу как мы, в TheQuestion, осуществили нашу давнюю мечту — отдельные, автоматически разворачиваемые development среды для каждой отдельной задачи.
С самого начала наша разработка строилась таким образом:
- Есть
master
ветка на GitHub, для которой настроена continuous integration — полная автоматизация деплоя на единственный тестовый сервер, архитектура которого максимально повторяет production. - Каждая новая задача ведется в fork-ветке разработчика, затем открывается пул реквест на
master
, который в итоге туда мержится.
Стоит сказать, что CI для master
ветки устроен вполне обычным способом:
- Пуш на гитхаб
- TeamCity видит новый коммит и делает
make
- Прогоняются автоматические тесты
- Собираются Docker контейнеры
- 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 — там ничего особенного, но надеюсь сути не упустил. На все вопросы готов ответить в комментариях.
Автор: ngalayko