Использование Terraform на серверах с KVM. Часть 1
Введение
В статье я расскажу как использовать Terraform на сервере с KVM-виртуализацией. Эта статья не просто очередной мануал в стиле "основы работы с terraform" или "terraform за 10 минут", а пример решения конкретной задачи.
Почему не vagrant?
Vagrant - отличный инструмент, применяемый в разработке для разворачивания локальных тестовых стендов. Он лёгок в освоении, прост в настройке и дарует своим пользователям сверхспособности. Однако те, кто реально им пользовался, наверняка испытали всю прелесть работы на "альтернативных" гипервизорах, к которым относится kvm. Проблемы с поиском актуальных боксов, экспериментальные модули, слабая документация и нулевая поддержка - всё это приходит при попытке сделать конфигурацию чуть более сложную, чем hello world. В общем, в какой-то момент я захотел поэкспериментировать с альтернативами.
Почему terraform?
Использовать terraform в связке с kvm - довольно маргинальное решение. Если сравнить документацию провайдеров libvirt и aws, то сразу станет ясно почему. Тем не менее, имея в своей домашней лаборатории несколько серверов с kvm, мне не хочется лишать себя радости применения подхода IaC.
Я прекрасно понимаю, что не у каждого есть возможность содержать своё облако или покупать ресурсы у облачных провайдеров. Однако это не повод ограничивать себя в современных подходах и инструментах управления инфраструктурой.
Цель
Что хочу получить - поднять с помощью terraform кластер k0s из двух нод (controller и worker) на двух kvm-серверах
То есть выполнить следующие действия:
- Скачать облачный образ ОС и запустить из него виртуальные машины, по одной на каждом сервере KVM
- Настроить виртуальные машины с помощью Cloud-init
- Настроить кластер k0s
- Получить доступ к кластеру
- Использовать только одну команду -
terraform apply
Вероятно может выйти так, что что-то из этого списка не получится из-за ограничений провайдеров или вмешательства тёмной магии, но мы этого не узнаем, если не попробуем
Цель первой части - поднять только одну пустую виртуальную машину. Во-первых, это не так просто, как кажется. Во-вторых, объём материала достаточно большой. В-третьих, это облегчит навигацию в будущем.
Подготовка рабочего окружения
Установка terraform
Устанавливать terraform можно кучей способов, описанных на официальной странице загрузки дистрибутива. Единственное, там не указан способ с запуском из контейнера. С этим проблем нет - у hashicorp есть свой официальный docker-образ terraform. Так что можно выбирать любой понравившийся вариант. Я ставлю на Mac с помощью homebrew:
brew tap hashicorp/tap brew install hashicorp/tap/terraform
После установки проверяем работоспособность командой:
terraform help
И упрощаем себе жизнь, установив автодополнение командной строки:
terraform -install-autocomplete
Подключение зеркала репозитория с модулями
Может случиться, что при попытке скачать модули из официального репозитория hashicorp, получится отлуп Error 405
(причина).
Разрешить ситуацию можно по-разному:
- Использовать VPN. Это самый надёжный способ получить модули из официального репозитория
- Собрать модули из исходников и поднять своё зеркало
- Использовать стороннее зеркало
Я не буду указывать адреса зеркал по соображениям информационной безопасности (рекомендую использовать те, которым вы можете доверять), просто покажу как сконфигурировать terraform на использование зеркала глобально. Для этого необходимо создать в корне домашней директории файл с именем .terraformrc со следующим содержимым:
#~/.terraformrc provider_installation { network_mirror { url = "https://your_mirror_here/" } }
Вообще рекомендую ознакомиться с полной документацией по конфигурационному файлу terraform.
Базовый код инфраструктуры
Чтобы приступить к реализации задуманного, сначала надо получить минимальный рабочий вариант - мы подняимем виртуальную машину и получим к ней доступ по SSH.
Структура проекта
Код инфраструктуры можно описать в одном файле, но для наглядности лучше разбить его на несколько. Существуют Best Practices, применимые к инфраструктурам различного размера и типа, поэтому решение о том, как структурировать проект, в конечном итоге придётся принимать самостоятельно. В моём случае разбивка будет такой:
terraform-project/ ├── README.md ├── versions.tf ├── provider.tf ├── variables.tf ├── terraform.tfvars ├── main.tf ├── outputs.tf
Далее рассмотрим назначение каждого файла и что лежит внутри.
README.md
Я считаю, что это самый важный файл в проекте. Всегда нужно оставлять себе будущему послание из прошлого о том, что тут происходит, как работает и зачем.
versions.tf
Здесь хранится информация об используемых провайдерах. Для работы с kvm-сервером я буду использовать такой провайдер libvirt:
terraform { required_providers { libvirt = { source = "dmacvicar/libvirt" version = "~> 0.6.14" } } }
Когда я запущу команду terraform init
, terraform подгрузит в проект рабочие файлы провайдера и сложит их в директорию .terraform/providers/<provider_name>
providers.tf
Здесь описываются параметры провайдеров. В моём случае я опишу параметры подключения к своему kvm-серверу:
provider "libvirt" { uri = "qemu+ssh://<my-kvm-host>/system" }
variables.tf
В файле объявляются input-переменные, используемые в проекте. Переменные - это добро, потому что в коде может быть 100500 упоминаний одного и того же объекта и ни к чему писать каждый раз его значения. Особенно если нам потом не хочется их переписывать при внесении изменений. Я определю здесь те параметры, которые, предположительно, захочу менять в дальнейшем:
# Префикс для создаваемых объектов variable "prefix" { type = string default = "k0s-" } # Путь, где будет хранится пул проекта variable "pool_path" { type = string default = "/var/lib/libvirt/" } # Параметры облачного образа variable "image" { type = object({ name = string url = string }) } # Параметры виртуальной машины variable "vm" { type = object({ cpu = number ram = number disk = number bridge = string }) }
terraform.tfvars
После того, как переменные объявлены, можно присвоить им значения. Это можно сделать разными способами, я буду использовать файл с расширением tfvars
:
prefix = "k0s-" pool_path = "/var/lib/libvirt/" image = { name = "ubuntu-focal" url = "https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-amd64-disk-kvm.img" } vm = { bridge = "br0" cpu = 1 disk = 10 * 1024 * 1024 * 1024 ram = 512 }
Обратите внимание на значение для переменной disk
. Провайдеру необходимо передать значение 10 Гб в байтах, но 10737418240 считывается глазами гораздо хуже, чем описано выше. За такое terraform можно любить.
main.tf
Этот файл - основной, здесь происходит вся магия. Разберём код по кускам.
Сначала создадим storage pool, где будут хранится виртуальные диски и прочий "хлам" проекта:
resource "libvirt_pool" "pool" { name = "${var.prefix}pool" type = "dir" path = "${var.pool_path}${var.prefix}pool" }
В созданный пул мы загрузим облачный образ операционной системы:
resource "libvirt_volume" "image" { name = var.image.name format = "qcow2" pool = libvirt_pool.pool.name source = var.image.url }
Из этого образа мы создадим диск для виртуальной машины нужного нам размера:
resource "libvirt_volume" "root" { name = "${var.prefix}root" pool = libvirt_pool.pool.name base_volume_id = libvirt_volume.image.id size = var.vm.disk }
Теперь можно создать виртуальную машину:
resource "libvirt_domain" "vm" { name = "${var.prefix}master" memory = var.vm.ram vcpu = var.vm.cpu network_interface { bridge = var.vm.bridge wait_for_lease = true } disk { volume_id = libvirt_volume.root.id } }
Однако существует нюанс - создав виртуальную машину из облачного образа сейчас, мы не сможем никак на неё попасть из-за отсутсвия учётных записей. Вообще всё ещё хуже - мы проделали столько телодвижений, а на выходе получаем тыкву. Для выхода из этой ситуации потребуется совершить ещё один шаг - подготовить конфигурацию Cloud-Init.
Для этого создадим несколько блоков кода и пару файлов. Чтобы настроить сеть внутри виртуальной машины, нам понадобится файл network_config.cgf, а чтобы подготовить виртуальную машину к работе - cloud_init.cfg. Пока они пустые, продолжим дополнять файл main.tf.
Опишем расположение файлов конфигурации cloud-init. Они будут лежать в корне проекта:
data "template_file" "user_data" { template = file("${path.module}/cloud_init.cfg") } data "template_file" "network_config" { template = file("${path.module}/network_config.cfg") }
Теперь создадим ресурс, выступающий в качестве диска, с которого будет браться конфигурация cloud-init при запуске виртуальной машины:
resource "libvirt_cloudinit_disk" "commoninit" { name = "commoninit.iso" pool = libvirt_pool.pool.name user_data = data.template_file.user_data.rendered network_config = data.template_file.network_config.rendered }
И дополним конфигурацию виртуальной машины строчкой cloudinit = libvirt_cloudinit_disk.commoninit.id
, чтобы она понимала чего от неё хотят.
Теперь нам нужна конфигурация самого cloud-init.
network_config.cfg - это конфигурация netplan, в которой для интерфейса ens3 мы включаем только IPv4 и получаем конфигурацию по DHCP:
version: 2 ethernets: ens3: link-local: [ ipv4 ] dhcp4: true
cloud_init.cfg - здесь мы укажем пароль для пользователя root, создадим пользователя linux и разрешим ему доступ по ssh. Ещё мы установим пакет и запустим службу qemu-guest-agent для возможности получать ip-адрес виртуальной машины средствами libvirt с хоста. Так же мы сделаем грязный хак с конфигурацией сети чтобы интерфейс ens3 не получал дополнительно адрес из подсети 169.254.0.0/16 и не портил нам красоту:
#cloud-config ssh_pwauth: True chpasswd: list: | root:linux expire: False users: - name: linux sudo: ALL=(ALL) NOPASSWD:ALL plain_text_passwd: 'linux' shell: /bin/bash lock-passwd: false ssh_pwauth: True chpasswd: { expire: False } package_update: true packages: - qemu-guest-agent write_files: - path: /etc/cloud/cloud.cfg.d/99-custom-networking.cfg permissions: '0644' content: | network: {config: disabled} - path: /etc/netplan/my-new-config.yaml permissions: '0644' content: | network: version: 2 ethernets: ens3: dhcp4: true runcmd: - [ rm, /etc/netplan/50-cloud-init.yaml ] - [ netplan, generate ] - [ netplan, apply ] - [ systemctl, enable, --now, qemu-guest-agent ]
Ещё один штрих - добавить следующие строки в ресурс vm
, чтобы разрешить использование qemu-guest-agent и включить автостарт виртуальной машины после перезапуска хоста kvm
qemu_agent = true autostart = true
output.tf
Ну и главное - создав виртуальную машину, мы должны получить какие-то данные для подключения к ней. Хотя бы её имя и IP адрес
output "vm_name" { value = libvirt_domain.vm.name description = "VM name" } output "vm_ip" { value = libvirt_domain.vm.network_interface[0].addresses.0 description = "Interface IPs" }
Let's infrastructure as code!
Теперь, когда у нас всё описано, можно поднимать инфраструктуру. Для этого пройдём несколько этапов
Загрузка модулей
Чтобы terraform мог общаться с инфраструктурой, ему необходимо загрузить модули. Для этого выполним команду:
terraform init
Обратим внимание на выхлоп. Terraform скачал нужные нам плагины, а так же создал файл .terraform.lock.hcl
для фиксации произошедшего:
Initializing the backend... Initializing provider plugins... - Finding latest version of hashicorp/template... - Finding dmacvicar/libvirt versions matching "~> 0.6.14"... - Installing hashicorp/template v2.2.0... - Installed hashicorp/template v2.2.0 (verified checksum) - Installing dmacvicar/libvirt v0.6.14... - Installed dmacvicar/libvirt v0.6.14 (verified checksum) Terraform has created a lock file .terraform.lock.hcl to record the provider selections it made above. Include this file in your version control repository so that Terraform can guarantee to make the same selections by default when you run "terraform init" in the future. Terraform has been successfully initialized!
План работ
Шаг опциональный, но для параноиков не осудительный. Прежде чем вносить изменения (в нашем случае создавать новую инфраструктуру), ознакомимся с планом работы запустив команду:
terraform plan
terraform подключится к нашему kvm-хосту, пораскинет мозгами и выдаст список того, что будет делать (создавать, изменять, удалять). В этой огромной простыне мы легко увидим что план полностью повторяет то, что мы описывали в нашем коде, добавляя туда ещё охапку метаданных, которые мы оставили без внимания, положившись на волю дефолтных значений. Часть значений, конечно же, будет известна только после применения нашего плана.
Что мы видим в нашем случае? 5 ресурсов будет создано - пул, образ ОС, диск для ВМ, диск с cloud init и сама ВМ, IP которой мы узнаем только после её создания:
Plan: 5 to add, 0 to change, 0 to destroy. Changes to Outputs: + vm_ip = (known after apply) + vm_name = "k0s-master"
Создание инфраструктуры
Зависимости скачали, план проверили, осталось сделать главное, ради чего затевалась первая часть этого туториала - создать виртуальную машину. Заветная команда:
terraform apply
Видим ту же самую простыню в выхлопе, что и в предыдущей команде (а она была опциональной, я говорил) и ожидание ввода заветного слова yes, чтобы уже наконец-то запустить выполнение нашего плана. Соглашаемся!
Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes
Смотрим на выхлоп. Крутость terraform заключается в том, что он самостоятельно определяет когда и какие ресурсы подлежат созданию. То есть сначала он создаёт пул, потом складывает в них диски, распараллеливая, по возможности, процесс и только потом создаёт ВМ:
libvirt_pool.pool: Creating... libvirt_pool.pool: Creation complete after 5s [id=0fb833ad-e363-4850-ae3a-0883a5bd655d] ... libvirt_cloudinit_disk.commoninit: Creating... libvirt_volume.image: Creating... libvirt_volume.image: Creation complete after 1m55s [id=/var/lib/libvirt/k0s-pool/ubuntu-focal] libvirt_cloudinit_disk.commoninit: Creation complete after 1m55s [id=/var/lib/libvirt/k0s-pool/commoninit.iso;094c9e9e-619a-4be9-837a-124c9feb151b] ... libvirt_volume.root: Creating... libvirt_volume.root: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-root] ... libvirt_domain.vm: Creating... libvirt_domain.vm: Creation complete after 1m36s [id=41061348-675e-436b-92ee-d8974557db14] ... Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
После того, как все процессы отработали, terraform любезно предоставляет нам значения output-переменных, которые мы сами и определили:
Outputs: vm_ip = "192.168.99.126" vm_name = "k0s-master"
Проверка
Теперь мы знаем IP адрес созданной ВМ и можем к ней подключиться, используя логин и пароль, определённые в конфиге cloud-init (linux/linux
). Убедимся, что ВМ обладает заданными характеристиками, а именно - 1 vCPU, 512 Mb RAM, 10 Gb disk:
ssh linux@192.168.99.126 linux@ubuntu:~$ grep processor /proc/cpuinfo -c 1 linux@ubuntu:~$ grep MemTotal /proc/meminfo MemTotal: 496184 kB linux@ubuntu:~$ lsblk /dev/vda -o NAME,SIZE NAME SIZE vda 10G ├─vda1 9.9G ├─vda14 4M └─vda15 106M
Теперь у нас есть целая одна виртуальная машина и мы можем делать с ней всё что угодно!
Очистка
Чтобы всё за собой почистить, достаточно выполнить команду
terraform destroy
Terraform уничтожит всё, что создавал ранее:
Plan: 0 to add, 0 to change, 5 to destroy. Changes to Outputs: - vm_ip = "192.168.99.126" -> null - vm_name = "k0s-master" -> null
Смахиваем слезу и пишем yes
и наблюдаем как terraform уничтожает все ресурсы в определённом порядке:
libvirt_domain.vm: Destroying... [id=41061348-675e-436b-92ee-d8974557db14] libvirt_domain.vm: Destruction complete after 0s libvirt_volume.root: Destroying... [id=/var/lib/libvirt/k0s-pool/k0s-root] libvirt_cloudinit_disk.commoninit: Destroying... [id=/var/lib/libvirt/k0s-pool/commoninit.iso;094c9e9e-619a-4be9-837a-124c9feb151b] libvirt_cloudinit_disk.commoninit: Destruction complete after 0s libvirt_volume.root: Destruction complete after 0s libvirt_volume.image: Destroying... [id=/var/lib/libvirt/k0s-pool/ubuntu-focal] libvirt_volume.image: Destruction complete after 0s libvirt_pool.pool: Destroying... [id=0fb833ad-e363-4850-ae3a-0883a5bd655d] libvirt_pool.pool: Destruction complete after 5s Destroy complete! Resources: 5 destroyed.