devops
March 30, 2022

Использование 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-серверах

Схема инфраструктуры

То есть выполнить следующие действия:

  1. Скачать облачный образ ОС и запустить из него виртуальные машины, по одной на каждом сервере KVM
  2. Настроить виртуальные машины с помощью Cloud-init
  3. Настроить кластер k0s
  4. Получить доступ к кластеру
  5. Использовать только одну команду - 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.

Выводы

  1. Terraform работает с libvirt и kvm
  2. Terraform может управлять инфраструктурой не хуже Vagrant
  3. Мы теперь немного умеет писать инфраструктуру как код