Цель статьи - показать один из возможных подходов для организации гибкого развёртывания dev/test стендов. Показать какие преимущества предоставляет нам IaC подход в сочетании с современными инструментами.
Предыстория
Имеется несколько стендов для разработчиков - devs, tests, production. Новые версии компонентов продукта появляются несколько раз в день.
В результате, имеющиеся стенды заняты, разработчики простаивают ожидая освобождения одного из стендов.
Создание дополнительных статичных стендов решит проблему, но приведёт к их переизбытку во время снижения активности разработчиков и, как следствие, повысит затраты компании на инфраструктуру.
Задача
Дать возможность разработчикам разворачивать и удалять стенды самостоятельно, исходя из текущих потребностей.
Стек
Gitlab CI, Terraform, Bash, любое приватное/публичное облако.
Технические сложности:
Terraform state file - “из коробки” у нас нет возможности использовать переменную в значении имени state файла. Нужно что-то придумывать или использовать другой продукт.
Subnets - каждое новое окружение должно быть создано в изолированной подсети. Нужно контролировать свободные/занятые подсети, некий аналог DHCP, но для подсетей.
Алгоритм
Gitlab CI выполняет pipeline. Связывает все остальные компоненты воедино.
Terraform создаёт и удаляет экземпляры виртуальных машин.
Configuration manager(CM) - разворачивает сервисы.
Bash сценарии подготавливают конфигурационные файлы специфичные для каждого стенда.
Структура репозитория
development-infrastructure/
deploy/
env1/
main.tf
backend.tf
ansible-vars.json
subnets.txt
env2/
...
cm/
...
modules/
azure/
main.tf
variables.tf
scripts/
env.sh
subnets.txt
.gitlab-ci.yml
deploy - содержит специфичные для каждого окружения файлы - файл с переменными для terraform и CM, файл содержащий адрес подсети стенда.
cm - в моём случае, тут хранятся Ansible плейбуки для настройки ОС и разворачивания сервисов.
modules - модули terraform которые будут получать в качестве параметров имя окружения и адрес подсети
scripts - bash сценарии для создания и удаления стендов и их конфигураций
.gitlab-ci.yml:
stages:
- create environment
- terraform apply
- cm
- destroy environment
.template:
variables:
ENV: $NAME_ENV
when: manual
tags: [cloudRunner01]
only:
refs:
- triggers
Create environment:
stage: create environment
extends: .template
script:
- ./scripts/create_env.sh -e $ENV -a create
artifacts:
paths:
- deploy/${ENV}/backend.tf
- deploy/${ENV}/main.tf
- deploy/${ENV}/vars.json
Create instances:
stage: terraform apply
extends: .template
script:
- cd ./deploy/$ENV
- terraform init -input=false
- terraform validate
- terraform plan -input=false -out=tf_plan_$ENV
- terraform apply -input=false tf_plan_$ENV
Deploy applications:
stage: cm
extends: .template
script:
- # мы можем передать имя окружения в качестве параметра нашей CM
- # в моём случае, на основе переменной $ENV генерируются сертификаты,
- # конфигурируется обратный прокси сервер и т.п.
- # также мы можем использовать данные из terraform
Destroy instances and environment:
stage: destroy environment
extends: .template
script:
- cd ./deploy/$ENV
- terraform init -input=false
- terraform destroy -auto-approve
- ./scripts/delete_env.sh -e $ENV -a delete
Остановимся подробнее на каждом шаге нашего пайплайна:
Create environment - на основе имени окружения, полученного из переменно NAME_ENV, создаём уникальные для окружения файлы, после чего помещаем их в наш git репозиторий.
Create instances - создаём инстансы(виртуальные машины) и подсети, которые будут использоваться окружением.
Deploy applications - разворачиваем наши сервисы с помощью любимого Configuration Manager.
Destroy instances and environment - с помощью bash сценария данный шаг удалит наши инстансы, после чего удалит из репозитория каталог с файлами окружения. Освободившаяся подсеть будет возвращена в файл scripts/subnets.txt.
Запуск пайплайна происходит с объявлением переменной NAME_ENV, содержащей имя нового стенда:

Разработчики не имеют доступа к Git репозиторию и могут вносить в него изменения только через запуск pipeline.
modules/base/main.tf:
# лучшие практики и личный опыт настоятельно рекомендуют фиксировать версию провайдера
provider "azurerm" {
version = "=1.39.0"
}
…
# создаём новую группу ресурсов, это особенность Azure. Имя группы уникально, в этом случае будет удобно использовать имя окружения
resource "azurerm_resource_group" "product_group" {
name = "${var.env_name}"
location = "East Europe"
}
# создаем сеть
resource "azurerm_virtual_network" "vnet" {
name = "product-vnet"
resource_group_name = azurerm_resource_group.product_group.name
location = azurerm_resource_group.product_group.location
address_space = [var.vnet_address]
}
# используем подсеть полученную с помощью bash сценария
resource "azurerm_subnet" "subnet" {
name = "product-subnet"
resource_group_name = azurerm_resource_group.product_group.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefix = var.subnet_address
}
# теперь можем создать виртуальную машину
resource "azurerm_virtual_machine" "product_vm" {
name = "main-instance"
location = azurerm_resource_group.product_group.location
resource_group_name = azurerm_resource_group.product_group.name
network_interface_ids = [azurerm_network_interface.main_nic.id]
…
}
# прочие ресурсы и виртуальные машины...
Чтобы сократить листинг я убрал большую часть обязательных, но, в нашем случае, неважных ресурсов.
Для создания ресурсов, имена которых должны быть уникальными, в рамках нашего облачного аккаунта, мы используем имя окружения.
scripts/env.sh:
#!/bin/bash
set -eu
CIDR="24"
DEPLOY_DIR="./deploy"
SCRIPT_DIR=$(dirname "$0")
usage() {
echo "Usage: $0 -e [ENV_NAME] -a [create/delete]"
echo " -e: Environment name"
echo " -a: Create or delete"
echo " -h: Help message"
echo "Examples:"
echo " $0 -e dev-stand-1 -a create"
echo " $0 -e issue-1533 -a delete"
}
while getopts 'he:a:' opt; do
case "${opt}" in
e) ENV_NAME=$OPTARG ;;
a) ACTION=$OPTARG ;;
h) usage; exit 0 ;;
*) echo "Unknown parameter"; usage; exit 1;;
esac
done
if [ -z "${ENV_NAME:-}" ] && [ -z "${ACTION:-}" ]; then
usage
exit 1
fi
# приводим имя окружения к нижнему регистру
ENV_NAME="${ENV_NAME,,}"
git_push() {
git add ../"${ENV_NAME}"
case ${1:-} in
create)
git commit -am "${ENV_NAME} environment was created"
git push origin HEAD:"$CI_COMMIT_REF_NAME" -o ci.skip
echo "Environment ${ENV_NAME} was created.";;
delete)
git commit -am "${ENV_NAME} environment was deleted"
git push origin HEAD:"$CI_COMMIT_REF_NAME" -o ci.skip
echo "Environment ${ENV_NAME} was deleted.";;
esac
}
create_env() {
# создаём каталог для нового окружения
if [ -d "${DEPLOY_DIR}/${ENV_NAME}" ]; then
echo "Environment ${ENV_NAME} exists..."
exit 0
else
mkdir -p ${DEPLOY_DIR}/"${ENV_NAME}"
fi
# получаем адрес подсети
NET=$(sed -e 'a$!d' "${SCRIPT_DIR}"/subnets.txt)
sed -i /"$NET"/d "${SCRIPT_DIR}"/subnets.txt
echo "$NET" > ${DEPLOY_DIR}/"${ENV_NAME}"/subnets.txt
if [ -n "$NET" ] && [ "$NET" != "" ]; then
echo "Subnet: $NET"
SUBNET="${NET}/${CIDR}"
else
echo "There are no free subnets..."
rm -r "./${DEPLOY_DIR}/${ENV_NAME}"
exit 1
fi
pushd "${DEPLOY_DIR}/${ENV_NAME}" || exit 1
# Создаем main.tf terraform файл с нужными нам переменными нового окружения
cat > main.tf << END
module "base" {
source = "../../modules/azure"
env_name = "${ENV_NAME}"
vnet_address = "${SUBNET}"
subnet_address = "${SUBNET}"
}
END
# Cоздаём backend.tf terraform файл , в котором указываем имя нового state файла
cat > backend.tf << END
terraform {
backend "azurerm" {
storage_account_name = "terraform-user"
container_name = "environments"
key = "${ENV_NAME}.tfstate"
}
}
END
}
delete_env() {
# удаляем каталог окружения и высвобождаем подсеть
if [ -d "${DEPLOY_DIR}/${ENV_NAME}" ]; then
NET=$(sed -e '$!d' ./${DEPLOY_DIR}/"${ENV_NAME}"/subnets.txt)
echo "Release subnet: ${NET}"
echo "$NET" >> ./"${SCRIPT_DIR}"/subnets.txt
pushd ./${DEPLOY_DIR}/"${ENV_NAME}" || exit 1
popd || exit 1
rm -r ./${DEPLOY_DIR}/"${ENV_NAME}"
else
echo "Environment ${ENV_NAME} does not exist..."
exit 1
fi
}
case "${ACTION}" in
create)
create_env
git_push "${ACTION}"
;;
delete)
delete_env
git_push "${ACTION}"
;;
*)
usage; exit 1;;
esac
Сценарий
env.sh
принимает два параметра - имя окружения и действие(создание\удаление).При создании нового окружения:
В каталоге
DEPLOY_DIR
создаётся директория с именем окружения.Из файла scripts/subnets.txt мы извлекаем одну свободную подсеть.
На основе полученных данных генерируем конфигурационные файлы для Terraform.
Для фиксации результата отправляем каталог с файлами окружения в git репозиторий.
При удалении - сценарий удаляет каталог с файлами окружения и возвращает освободившуюся подсеть в файл
scripts/subnets.txt
scripts/subnets.txt:
172.28.50.0
172.28.51.0
172.28.52.0
...
В данном файле мы храним адреса наши подсетей. Размер подсети определяется переменной CIDR в файле scripts/create_env.sh
Результат
Мы получили фундамент который позволяет нам развернуть новый стенд путём запуска pipline’a в Gitlab CI.
Снизили затраты нашей компании на инфраструктуру.
Разработчики не простаивают и могут создавать и удалять стенды когда им это потребуется.
Также получили возможность создания виртуальных машин в любом облаке написав новый модуль Terraform и немного модифицировав сценарий созданиях\удаления окружений
Можем поиграться с триггерами Gitlab и разворачивать новые стенды из пайплайнов команд разработчиков передавая версии сервисов.