Лето - это не только сезон отпусков, но и период, когда высокая температура становится дополнительным фактором риска для инфраструктуры. В моём случае жара, вероятно, внесла свой вклад в отказ одной из нод домашнего Proxmox-кластера: купленный несколько лет назад Minisforum UM890 Pro перестал подавать признаки жизни. Вместо замены вышедшей из строя ноды я решил воспользоваться ситуацией и реализовать давно откладываемую задачу - перенести домашнюю лабораторию в Облако.
Для новой инфраструктуры была выбрана площадка Hostkey и выделенный сервер с предустановленным Proxmox VE 9.*. После развертывания хоста я восстановил из резервных копий основные сервисы и получил рабочую среду. Поскольку инфраструктуру фактически приходилось собирать заново, это оказался подходящий момент для следующего шага - перехода к Infrastructure as Code (IaC).
В предыдущем посте я разбирал автоматическое формирование Ansible Inventory на основе данных из Proxmox API. По расписанию скрипт получал актуальное состояние виртуальной инфраструктуры и генерировал inventory, благодаря чему Ansible всегда работал с актуальным списком серверов. Однако это решение закрывало только одну конкретную проблему: inventory соответствовал тому, что уже существует в Proxmox.
При этом оставался более фундаментальный вопрос: где описано то, что вообще должно существовать?
Количество контейнеров, их параметры, выделенные CPU и RAM, диски, сетевые настройки и другие характеристики инфраструктуры по-прежнему существовали преимущественно в моей голове, отдельных командах и истории bash.
Скрипт синхронизации мог ответить на вопрос:
«Что сейчас запущено в Proxmox?»
Но не мог ответить на другой, гораздо более важный:
«Как должна выглядеть инфраструктура и как воспроизвести её с нуля?»
Именно эту задачу я решил закрыть с помощью OpenTofu - open-source форка Terraform.
В этом посте разберу, как я перевёл домашнюю инфраструктуру под управление OpenTofu, связал её с self-hosted GitLab CI/CD и Proxmox, организовал хранение и применение конфигурации через pipeline, а также расскажу о проблемах, с которыми столкнулся в процессе миграции.
А проблем оказалось достаточно. Некоторые из них были вполне ожидаемыми, а некоторые заставили довольно серьёзно пересмотреть первоначальную архитектуру решения.
Зачем вообще IaC в домашней инфраструктуре?
Когда говорят про Infrastructure as Code, первым аргументом обычно называют воспроизводимость: инфраструктуру можно описать кодом и при необходимости развернуть заново. Для моей домашней лабы это оказалось полезно, но не это стало основной причиной перехода на IaC. На практике гораздо важнее оказались две вещи:
Первая — возможность увидеть изменения до их применения.
tofu plan заранее показывает, какие ресурсы будут созданы, изменены или удалены. После нескольких ситуаций из серии «я всего лишь хотел поменять один параметр» начинаешь относиться к этой возможности совсем иначе.
Вторая — нормальный процесс внесения изменений. Инфраструктура теперь меняется примерно так же, как код приложения: создаётся MR, pipeline выполняет fmt → validate → plan, я смотрю результат и только после этого вручную запускаю apply.
Для домашней инфраструктуры это может выглядеть избыточно. Но когда внутри уже живут базы данных, мониторинг, DNS, различные домашние сервисы и прочие вещи, которые не хочется восстанавливать субботним вечером, дополнительная проверка перед изменением начинает выглядеть вполне разумно.
Как всё организовано в репозитории
Структура репозитория получилась достаточно простой:
modules/
lxc-container/ # переиспользуемый модуль LXC-контейнера
sdn-zone-simple/ # Proxmox SDN (zone/vnet/subnet)
backup-job/ # Proxmox backup job (vzdump)
firewall-security-group/
envs/
prod/
providers.tf # provider "proxmox" { ... }
variables.tf # общие переменные (SSH-ключи, пароли)
containers.tf # по одному module-блоку на контейнер
network.tf # SDN — отдельный apply от containers.tf!
backup.tf # legacy backup job
backup-auto.tf # авто-группировка бэкапов (см. ниже)
.gitlab-ci.ymlВ качестве провайдера использую bpg/proxmox. На момент написания статьи это один из наиболее функциональных community-провайдеров для Proxmox VE.
State хранится в GitLab-managed Terraform state. Для моей задачи этого более чем достаточно, поэтому поднимать бакет в развернутом недавно RustFS (S3) только ради хранения state-файла смысла и желания особо не было.
403...
Отдельная проблема появилась на CI-раннере. Из-за блокировок он не мог напрямую получить провайдер из registry.opentofu.org, но пара кликов и сделал локальный filesystem mirror. Сам провайдер публикуется как Generic Package внутри GitLab-проекта, а pipeline генерирует .terraformrc примерно следующего вида:
provider_installation {
filesystem_mirror {
path = "/root/.terraform.d/plugin-cache"
include = ["registry.opentofu.org/bpg/proxmox"]
}
direct {
exclude = ["registry.opentofu.org/bpg/proxmox"]
}
}Работает стабильно, но появилась дополнительная операция при обновлении провайдера: скачать бинарник нужной версии и архитектуры, загрузить его в Generic Package Registry и обновить используемую версию пакета.
Не самое красивое решение, зато CI больше не зависает во время каждого запуска.
Модуль контейнера: от простого к сложному
Я решил что буду постепенно описывать параметры создаваемых контейрнеров постепенно и начал с примитива: VMID, hostname, CPU, память, диск и сеть.
Постепенно он оброс параметрами, которые наиболее часто вариативны и важны на этапе ввода в эксплуатацию:
module "ct_app_backend" {
source = "../../modules/lxc-container"
vmid = 120
hostname = "app-backend"
cores = 2
memory = 2048
disk_size = 20
ip_address = "10.20.30.15/24"
gateway = "10.20.30.1"
ssh_public_keys = var.admin_ssh_public_keys
root_password = var.admin_root_password
generate_root_password = var.admin_root_password == null
backup = {
enabled = true
schedule = "0 3 * * *"
keep_last = 5
}
}Некоторые решения появились не сразу. Но именно они в итоге сделали модуль удобным для повседневного использования.
SSH-ключи и пароли
SSH-ключи и root-пароль передаются через переменные. Пароль приходит из protected + masked переменной GitLab:
TF_VAR_admin_root_password
Если пароль не передан, модуль может сгенерировать его через random_password.
Логика выглядит примерно так:
locals {
effective_root_password = var.root_password != null ? var.root_password : (
var.generate_root_password ? random_password.root[0].result : null
)
}Здесь, кстати, была небольшая HCL-грабля. Изначально хотелось использовать coalesce(), но в варианте, когда все переданные ему значения оказываются null, функция завершается ошибкой. В итоге обычный тернарный оператор оказался и проще, и понятнее.
Отдельно стоит помнить: sensitive защищает значение от обычного отображения в CLI/output, но не означает, что секрет отсутствует в state. Поэтому сам state тоже необходимо считать чувствительными данными и соответствующим образом ограничивать доступ к нему.
Требование создавать резервную копию контейнера или VM должно быть свойством в описании контейнера
Первоначально backup job существовал отдельно и содержал список VMID:
120, 121, 124...При создании нового контейнера нужно было не забыть открыть конфигурацию backup job и добавить туда ещё один VMID.
Можно догадаться, чем заканчивается такой процесс. Контейнер создаётся за минуту, работает полгода, а потом внезапно выясняется, что всё это время никто его не бэкапил.
Поэтому я перенёс описание политики резервного копирования непосредственно в конфигурацию контейнера:
backup = {
enabled = true
schedule = "0 2 * * *" # cron, ограниченное подмножество
keep_last = 3
storage = "backup-nas"
}Теперь при создании мы сразу определяем необходимо ли создавать резервные копии или нет, и с какими параметрами если ДА.
Оставалось решить вторую проблему: не создавать отдельный vzdump job для каждого LXC. Для этого контейнеры с одинаковыми schedule, retention и storage автоматически объединяются в группы:
auto_backup_groups_raw = {
for e in local.auto_backup_entries :
join("|", [e.schedule, tostring(e.keep_last), e.storage]) => e...
}Обратите внимание на e... в конце выражения. Без троеточия одинаковые ключи привели бы к ошибке, а с ним OpenTofu объединяет такие элементы в список. Именно за счёт этого контейнеры с одинаковыми параметрами бэкапа собираются в одну группу.
В результате пять контейнеров с одинаковой политикой резервного копирования превращаются в одну задачу vzdump на пять VMID, а не в пять отдельных jobs.
ID задачи при этом формируется детерминированно из параметров самой backup-политики. Поэтому добавление нового контейнера в существующую группу меняет состав VMID, но не приводит к пересозданию самой задачи.
SDN и reloadnetworkall: самый неприятный момент миграции
Сетевую часть Proxmox — zones, VNets и subnets — я тоже перенёс под управление OpenTofu. И именно здесь поймал самый неприятный инцидент за всё время миграции.
При применении изменений SDN Proxmox вызывает reloadnetworkall, чтобы активировать новую сетевую конфигурацию. Сам по себе этот механизм штатный, но проблемы могут начаться, если в рамках того же apply одновременно меняется SDN и конфигурация контейнеров, которые используют эту сеть... Да, бывают в жизни огорчения.. После применения изменений сеть восстановилась не полностью: возникли проблемы с bridge-интерфейсом, следом не поднялся завязанный на него dnsmasq. В результате начали падать pre-start hooks у LXC-контейнеров, подключённых к этой сети, и одно изменение затронуло сразу несколько сервисов.
После этого я ввёл простое правило, которое теперь отдельно зафиксировано в документации:
Изменения network.tf и containers.tf никогда не применяются в одном apply.
Изменения SDN идут отдельным MR и отдельным apply. После применения сетевой конфигурации я сначала проверяю состояние хоста:
ip addr
systemctl status dnsmasq
pct listЕсли сеть и контейнеры находятся в ожидаемом состоянии, можно переходить к следующему изменению.
В итоге разделение сетевых и ресурсных изменений стало для меня не рекомендацией «на всякий случай», а обязательной частью процесса. Один неудачный apply оказался достаточным аргументом.
GitLab CI/CD
Сам pipeline здесь довольно небольшой:
stages:
- validate
- plan
- apply
validate:
script:
- tofu fmt -check -recursive
- tofu validate
plan:
script:
- tofu plan -out=tfplan
artifacts:
paths: [tfplan]
apply:
script:
- tofu apply tfplan
when: manual # осознанно — живая инфра с данными
only: [main]apply оставлен ручным намеренно.
Технически ничего не мешает автоматически применять изменения после merge в main. Для тестовой инфраструктуры я, возможно, так бы и сделал. Но здесь находятся сервисы с реальными данными, поэтому экономить один клик мне кажется сомнительной оптимизацией.
Я хочу сначала увидеть
plan, убедиться, что там действительно находится ожидаемое изменение, и только потом разрешить его применение.
Например:
$ tofu plan
Terraform will perform the following actions:
# module.ct_app_backend.proxmox_virtual_environment_container.this will be updated in-place
~ resource "proxmox_virtual_environment_container" "this" {
id = "120"
~ memory {
~ dedicated = 1024 -> 2048
}
}
# module.auto_backup["0 3 * * *|5|backup-nas"].proxmox_backup_job.this will be created
+ resource "proxmox_backup_job" "this" {
+ schedule = "03:00"
+ vmid = ["120", "121", "124"]
}
Plan: 1 to add, 1 to change, 0 to destroy.$ tofu planВот ради этой последней строки и возможности спокойно посмотреть изменения до их появления в Proxmox мне в первую очередь и понадобился IaC.
Что сейчас сделал бы иначе
Если бы пришлось повторить миграцию ещё раз, несколько вещей я бы изменил с самого начала.
Сразу импортировал бы существующую инфраструктуру. Не стоит надолго оставлять ситуацию, когда половина ресурсов управляется OpenTofu, а половина существует только в Proxmox. Получаются два источника правды, которые рано или поздно начинают расходиться.
tofu importтолько поначалу кажется дополнительной работой, но потом сэкономит гораздо больше времени.Сразу разделил бы сетевые и ресурсные изменения. Это правило появилось у меня только после инцидента с SDN. Лучше было прийти к нему теоретически, а не экспериментально.
Сложные HCL-выражения проверял бы через
tofu console. Особенно это касаетсяforexpressions, группировок и преобразования cron-выражений. Гонять полныйplanреальной инфраструктуры только для проверки выражения - медленно и неудобно.Секреты вынес бы в CI/CD variables с первого коммита. Фраза «потом вынесу» хорошо работает до момента, когда секрет уже оказался в Git history. После этого простого удаления строки недостаточно — приходится заниматься ротацией и чисткой истории.
Что получилось в итоге
В результате OpenTofu оказался полезен даже для небольшой Proxmox-инфраструктуры из десятка виртуальных машин и контейнеров.
Причём размер инфраструктуры здесь вообще не главное.
Для меня основная ценность оказалась в другом: теперь перед изменением я вижу diff, а сама инфраструктура проходит понятный и повторяемый процесс:
Изменение HCL
↓
Merge Request
↓
fmt / validate
↓
tofu plan
↓
проверка изменений
↓
manual apply
↓
ProxmoxИ здесь логично возвращаемся к проблеме из предыдущей статьи про динамический Ansible inventory. Тогда я научил inventory автоматически получать из Proxmox информацию о том, что уже существует. Это решало проблему устаревшего списка серверов, но Proxmox всё равно оставался первичным источником правды: сначала я руками создавал инфраструктуру, а потом автоматизация её обнаруживала.
Теперь схема поменялась.
Контейнер существует не потому, что когда-то был создан через Web UI или pct create, а потому что он описан в репозитории:
Git / OpenTofu → Proxmox → Proxmox API → Ansible inventoryСтарый механизм генерации Ansible inventory при этом никуда не делся. Он по-прежнему получает актуальное состояние через Proxmox API. Просто теперь за этим состоянием стоит декларативное описание инфраструктуры.
Получилась довольно логичная цепочка: OpenTofu отвечает за то, что должно существовать, Proxmox — за фактическое состояние виртуализации, а Ansible — за конфигурацию уже созданных систем.
Если у вас уже есть Proxmox, несколько LXC/VM и GitLab, но большая часть инфраструктуры всё ещё создаётся вручную, необязательно сразу пытаться описать кодом всю лабу.
Я бы начал с одного контейнера: описал его в HCL, импортировал существующий ресурс через tofu import и посмотрел на первый чистый tofu plan.
После этого довольно быстро становится понятно, нужен ли вам IaC для остальных ресурсов.
Если вы тоже используете Proxmox и уже пробовали управлять им через OpenTofu или Terraform - поделитесь своим опытом в комментариях. Особенно интересно, как у вас организованы state, бэкапы, работа с SDN и CI/CD: вполне возможно, какие-то решения у вас реализованы проще или надёжнее.
Если материал оказался полезным - буду рад обратной связи. А если тема зайдёт, в следующих постах можно подробнее разобрать структуру модулей, GitLab pipeline и отдельные грабли bpg/proxmox, которые в одну статью уже не поместились.


Станьте первым, кто прокомментирует эту запись
Комментировать