Настройка автоматического разворачивания независимых development окружений на одной машине (Docker, Ansible, TeamCity)

в 14:34, , рубрики: Ansible, bash scripting, devops, docker, teamcity

В этом посте я расскажу как мы, в 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 — там ничего особенного, но надеюсь сути не упустил. На все вопросы готов ответить в комментариях.

Автор: ngalayko

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js