devops
April 5, 2022

Использование Terraform на серверах с KVM. Часть 2

Навигация

Введение

После того, как мы научились создавать одну виртуальную машину в первой части, надо суметь сделать их столько, сколько нам хочется. В данном посте я собираюсь сделать несколько интересных вещей:

  • Создать несколько виртуальных машин на одном гипервизоре
  • Создать несколько виртуальных машин на разных гипервизорах
  • Настроить виртуальные машины с помощью Ansible.

Не будем терять времени и начнём развлекаться

Несколько ВМ на одном гипервизоре

Предисловие

Вообще, если удалось сделать одну ВМ на одном гипервизоре, то можно сделать и две, задублировав libvirt_domain, но мы так делать не будем, поскольку у Terraform есть гораздо более удобные подходы, позволяющие написать единый шаблон инфраструктуры и гонять по нему переменные.

variables.tf

Создадим переменную domains в variables.tf, в которой опишем параметры виртуальной машины. Однако переменная будет иметь сложный тип list(objects). В дальнейшем мы сможем описать с её помощью несколько ВМ:

variable "domains" {
  description = "List of VMs with specified parameters"
  type = list(object({
    name = string,
    cpu  = number,
    ram  = number,
    disk = number
  }))
}

terraform.tfvars

Сразу заполним параметрами созданную переменную в terraform.tfvars, описав две ВМ - controller и worker, каждый со своими параметрами:

domains = [
  {
    name = "controller"
    cpu  = 1
    ram  = 512
    disk = 10 * 1024 * 1024 * 1024
  },
  {
    name = "worker"
    cpu  = 2
    ram  = 1024
    disk = 20 * 1024 * 1024 * 1024
  }
]

main.tf

Теперь надо привести файл main.tf к корректному виду для работы с новой переменной.

Для начала создадим диски для каждой ВМ. В этом нам поможет count - он будет выступать в качестве итератора:

resource "libvirt_volume" "root" {
  count = length(var.domains)

  name           = "${var.prefix}${var.domains[count.index].name}-root"
  pool           = libvirt_pool.pool.name
  base_volume_id = libvirt_volume.image.id
  size           = var.domains[count.index].disk
}

Что тут происходит? В счётчик count мы передаём длину нашего списка domains с помощью функции length(var.domains). После этого мы ссылаемся на текущий порядковый номер элементов списка с помощью count.index. Магия terraform заключается в том, что он сам соображает что нужно пройтись циклом по всем элементам списка.

Продолжим. Теперь надо создать сами виртуальные машины. Принцип тот же самый:

resource "libvirt_domain" "vm" {
  count = length(var.domains)

  name   = "${var.prefix}${var.domains[count.index].name}"
  vcpu   = var.domains[count.index].cpu
  memory = var.domains[count.index].ram

  qemu_agent = true
  autostart  = true

  cloudinit = libvirt_cloudinit_disk.commoninit.id

  network_interface {
    bridge         = var.net
    wait_for_lease = true
  }
  disk {
    volume_id = libvirt_volume.root[count.index].id
  }

  console {
    type        = "pty"
    target_port = "0"
    target_type = "serial"
  }
  console {
    type        = "pty"
    target_type = "virtio"
    target_port = "1"
  }
  graphics {
    type        = "vnc"
    listen_type = "address"
    autoport    = true
  }
}

К каждой ВМ мы подключаем созданный ранее диск. Элемент привязки - всё тот же индекс из count.

output.tf

Теперь нам надо разобраться с output.tf, чтобы получить красивый выхлоп в консоль. Для этого, вместо созданных ранее переменных, создадим одну большую:

output "vms_info" {
  description = "General information about created VMs"
  value = [
    for vm in libvirt_domain.vm : {
      id = vm.name
      ip = vm.network_interface[0].addresses.0
    }
  ]
}

Здесь мы используем цикл for для итерации по созданным объектам чтобы выставить соответствие имени созданной ВМ и её IP

Запуск

Создаём описанную по-новой инфраструктуру командой terraform apply и смотрим на выхлоп плана работ.

Кстати, я тут подумал - наверное неплохо дать читателю пищу для размышлений при анализе ошибок после внесённых мной в изначальный код изменений. Вероятно я что-то упустил из виду, поэтому - удачной отладки. Ведь только дебаг делает нас сильнее.

terraform apply

  # libvirt_cloudinit_disk.commoninit will be created
...
  # libvirt_domain.vm[0] will be created
...
  # libvirt_domain.vm[1] will be created
...
  # libvirt_pool.pool will be created
...
  # libvirt_volume.image will be created
...
  # libvirt_volume.root[0] will be created
...
  # libvirt_volume.root[1] will be created
...

Plan: 7 to add, 0 to change, 0 to destroy.

Итак, мы видим, что создаются уже не 5, а 7 объектов - пул, диск cloud-init, диск с ОС, 2 диска для ВМ и 2 ВМ. Всё как мы и хотели. Пишем yes и ждём создания наших ВМ:

libvirt_pool.pool: Creating...
libvirt_pool.pool: Creation complete after 5s [id=5bb9c002-cf1c-4fc5-bfe5-953778c68c79]
libvirt_volume.image: Creating...
libvirt_cloudinit_disk.commoninit: Creating...
libvirt_volume.image: Creation complete after 1m9s [id=/var/lib/libvirt/k0s-pool/ubuntu-focal]
libvirt_volume.root[0]: Creating...
libvirt_volume.root[1]: Creating...
libvirt_cloudinit_disk.commoninit: Creation complete after 1m9s [id=/var/lib/libvirt/k0s-pool/commoninit.iso;bbf959dc-cdf3-43b1-9ccb-10745c5b5b7a]
libvirt_volume.root[1]: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-worker-root]
libvirt_volume.root[0]: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-controller-root]
libvirt_domain.vm[0]: Creating...
libvirt_domain.vm[1]: Creating...
libvirt_domain.vm[1]: Creation complete after 1m26s [id=e90cab5b-89b8-4061-acab-c0b9638d44a0]
libvirt_domain.vm[0]: Creation complete after 1m36s [id=8369a939-3bc9-4ddd-ab91-9eb394b675ac]

Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Видим, что terraform, по возможности, запускает задачи параллельно и в нужной последовательности. А так же любуемся финальным выхлопом:

Outputs:

vms_info = [
  {
    "id" = "k0s-controller"
    "ip" = "192.168.99.111"
  },
  {
    "id" = "k0s-worker"
    "ip" = "192.168.99.110"
  },
]

Красота. Теперь мы можем создать некоторое количество виртуальных машин. Не факт, что это самый удобный способ масштабировать инфраструктуру, но мы работаем с kvm на коленке и в данном случае это приемлемо.

Несколько ВМ на разных гипервизорах

Про гипервизоры

Создавать инфраструктуру на одном гипервизоре интересно, но ресурсы быстро заканчиваются, поэтому попробуем масштабироваться горизонтально.

Будем считать, что наши гипервизоры настроены идентично, чтобы не омрачать стиль повествования лишними манёврами.

Дисклеймер

Прежде чем мы начнём писать код, хочу сделать одно очень важное замечание про Terraform. Если до этого момента он вам казался милым и забавным, то дальше случится суровая реальность архитектурных ограничений.

Мы работаем с разными гипервизорами, соответственно будем использовать и разные провайдеры (хотя они и будут одними и теми же библиотеками).

Особенность terraform заключается в том, что провайдер - это штука, прибитая гвоздями к фундаменту всей логики работы. То есть она статична на протяжении выполнения всего кода. Чтобы terraform мог построить план, ему нужно точно знать сколько ресурсов и где мы хотим получить. Именно поэтому мы не сможем указать провайдера с помощью переменных.

Теперь, обладая этим важным знанием, можно продолжить работу над кодом нашей инфраструктуры.

providers.tf

Если мы хотим использовать несколько гипервизоров, подключение к ним надо будет описать в файле providers.tf. И тут у нас есть неприятный момент - провайдеры у нас одинаковые - libvirt, поэтому нам надо будет придумать как к ним обращаться. Для этого в terraform есть свойство alias:

provider "libvirt" {
  uri = "qemu+ssh://kvm1/system"
}

provider "libvirt" {
  alias = "kvm2"
  uri   = "qemu+ssh://kvm2/system"
}

Теперь у нас есть два провайдера libvirt, один дефолтный, на нём будут создаваться ресурсы, если мы не укажем явно где мы хотим их создать. И вот тут у меня начинает подгорать, потому что мы не можем назначить дефолтному провайдеру алиас для единообразного оформления. То есть для terraform провайдер без алиаса - дефолтный. Есть два варианта решения:

  • Забить. Мне нравится, но не в мою смену
  • Использовать dummy-провайдер в качестве дефолтного. Звучит как подпорка костылями, но куда же без неё?

Итак, мой вариант:

provider "libvirt" {
  uri = "qemu:///system"
}

provider "libvirt" {
  alias = "kvm1"
  uri   = "qemu+ssh://kvm1/system"
}

provider "libvirt" {
  alias = "kvm2"
  uri   = "qemu+ssh://kvm2/system"
}

Чем он хорош? Во-первых, мы можем что-то тестировать локально с дефолтным провайдером. Во-вторых - мы явно должны указать провайдера при создании ресурсов, тем самым обезопасив свою инфраструктуру от любителей подхода "и так сойдёт".

variables.tf

Теперь нам надо придумать как лучше обыграть "архитектуру" нашего обращения к провайдерам. Это тема для долгих дискуссий, но суть её сводится к тому, что "дешевле" создать переменные для каждого провайдера, чем потом в коде изобретать способы обхода ограничений (хотя кому как). Поэтому вместо прежней переменной domains создадим две её копии - domains_kvm1 и domains_kvm2:

variable "domains_kvm1" {
  description = "List of KVM1 VMs with specified parameters"
  type = list(object({
    name = string,
    cpu  = number,
    ram  = number,
    disk = number,
  }))
}

variable "domains_kvm2" {
  description = "List of KVM2 VMs with specified parameters"
  type = list(object({
    name = string,
    cpu  = number,
    ram  = number,
    disk = number,
  }))
}

terraform.tfvars

Сразу же обновим значения наших переменных. Для наглядности на первом гипервизоре создадим две ВМ, на втором - одну:

domains_kvm1 = [
  {
    name = "controller"
    cpu  = 1
    ram  = 512
    disk = 10 * 1024 * 1024 * 1024
    location = "kvm1"
  },
  {
    name = "worker"
    cpu  = 2
    ram  = 1024
    disk = 20 * 1024 * 1024 * 1024
    location = "kvm2"
  }
]

domains_kvm2 = [
  {
    name = "worker"
    cpu  = 2
    ram  = 1024
    disk = 20 * 1024 * 1024 * 1024
    location = "kvm2"
  }
]

Переход к модулям

Сейчас мы снова оказались на распутье. С одной стороны мы можем просто скопипастить наш код в main.tf, описав каждый ресурс для каждого гипервизора (и это, к сожалению, заработает). С другой - мы можем вынести задачу создания виртуальных машин в модуль. Тогда нам не придётся делать двойную работу при внесении последующих изменений. Выбор, думаю, очевиден.

Создадим директорию modules/vm в корне проекта (ну или где душа пожелает, даже за пределами корня) со следующей структурой:

.
└── modules
    └── vm
        ├── cloud-init
        │   ├── cloud_init.cfg
        │   └── network_config.cfg
        ├── providers.tf
        ├── variables.tf
        ├── main.tf
        └── outputs.tf

Структура фактически та же самая, что и у корневого модуля. Это нормально, это стандартная структура. Теперь перенесём в эти файлы наши наработки из корневого:

vm/providers.tf

Чтобы terraform мог сделать init, надо дать ему информацию о провайдере:

terraform {
  required_providers {
    libvirt = {
      source  = "dmacvicar/libvirt"
      version = "~> 0.6.14"
    }
  }
}

vm/variables.tf

Переменные, которые нужны для создания инфраструктуры

variable "prefix" {
  type    = string
}

variable "pool_path" {
  type    = string
  default = "/var/lib/libvirt/"
}

variable "image" {
  type = object({
    name = string
    url  = string
  })
}

variable "net" {
  type    = string
}

variable "domains" {
  description = "List of VMs with specified parameters"
  type = list(object({
    name = string,
    cpu  = number,
    ram  = number,
    disk = number,
  }))
}

vm/main.tf

Опишем сам процесс создания виртуальной машины:

# Create storage pool to store created VM's data
resource "libvirt_pool" "pool" {
  name = "${var.prefix}pool"
  type = "dir"
  path = "${var.pool_path}${var.prefix}pool"
}

# Setup Cloud-Init
data "template_file" "user_data" {
  template = file("${path.module}/cloud-init/cloud_init.cfg")
}
data "template_file" "network_config" {
  template = file("${path.module}/cloud-init/network_config.cfg")
}
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
}

# Get Cloud OS image
resource "libvirt_volume" "image" {
  name   = var.image.name
  format = "qcow2"
  pool   = libvirt_pool.pool.name
  source = var.image.url
}

# Create VM root Volume
resource "libvirt_volume" "root" {
  count = length(var.domains)

  name           = "${var.prefix}${var.domains[count.index].name}-root"
  pool           = libvirt_pool.pool.name
  base_volume_id = libvirt_volume.image.id
  size           = var.domains[count.index].disk
}

# Create VM
resource "libvirt_domain" "vm" {
  count = length(var.domains)

  name   = "${var.prefix}${var.domains[count.index].name}"
  vcpu   = var.domains[count.index].cpu
  memory = var.domains[count.index].ram

  qemu_agent = true
  autostart  = true

  cloudinit = libvirt_cloudinit_disk.commoninit.id

  network_interface {
    bridge         = var.net
    wait_for_lease = true
  }

  disk {
    volume_id = libvirt_volume.root[count.index].id
  }

  console {
    type        = "pty"
    target_port = "0"
    target_type = "serial"
  }

  console {
    type        = "pty"
    target_type = "virtio"
    target_port = "1"
  }

  graphics {
    type        = "vnc"
    listen_type = "address"
    autoport    = true
  }
}

vm/outputs.tf

Не забудем оформить выхлоп информации о наших ВМ

output "vms_info" {
  description = "General information about created VMs"
  value = [
    for vm in libvirt_domain.vm : {
      id = vm.name
      ip = vm.network_interface[0].addresses.0
    }
  ]
}

vm/cloud-init/*

Сюда мы складываем файлы настройки Cloud-init. Они с прошлого раза не менялись

Корневой модуль

После того, как перенесли все наши труды в модуль, можем заняться описанием инфраструктуры, приведя её к желаемому состоянию

main.tf

Отсюда будем вызывать наш модуль vm, передавая ему в качестве аргументов созданные ранее переменные (корневой variables.tf) и провайдеров (корневой providers.tf). Один и тот же модуль мы вызываем дважды под разными именами:

module "kvm1" {
  source = "./modules/vm"
  providers = {
    libvirt = libvirt.lab-kvm1
  }

  prefix  = var.prefix
  image   = var.image
  net     = var.net
  domains = var.domains_kvm1
}

module "kvm2" {
  source = "./modules/vm"
  providers = {
    libvirt = libvirt.lab-kvm2
  }

  prefix  = var.prefix
  image   = var.image
  net     = var.net
  domains = var.domains_kvm2
}

output.tf

В данном файле мы опишем финальный выхлоп, значения которого будут браться из outputs.tf модуля vm, вызываемого в корневом main.tf:

output "vms_kvm1" {
  description = "KVM1 VMs info"
  value       = module.kvm1.vms_info
}

output "vms_kvm2" {
  description = "KVM2 VMs info"
  value       = module.kvm2.vms_info
}

Запуск

Проделав кучу работы, можем наконец-то делегировать дальнейшие действия terraform. Первым делом выполним реинициализацию модулей и провайдеров:

terraform init

Initializing modules...
Initializing the backend...
Initializing provider plugins...

Terraform has been successfully initialized!

Теперь можем запустить создание инфраструктуры:

terraform apply

Terraform will perform the following actions:
  # module.kvm1.libvirt_cloudinit_disk.commoninit will be created
  # module.kvm1.libvirt_domain.vm[0] will be created
  # module.kvm1.libvirt_domain.vm[1] will be created
  # module.kvm1.libvirt_pool.pool will be created
  # module.kvm1.libvirt_volume.image will be created
  # module.kvm1.libvirt_volume.root[0] will be created
  # module.kvm1.libvirt_volume.root[1] will be created
  # module.kvm2.libvirt_cloudinit_disk.commoninit will be created
  # module.kvm2.libvirt_domain.vm[0] will be created
  # module.kvm2.libvirt_pool.pool will be created
  # module.kvm2.libvirt_volume.image will be created
  # module.kvm2.libvirt_volume.root[0] will be created

Plan: 12 to add, 0 to change, 0 to destroy.

Видим, что будет создаваться 12 ресурсов: 7 на гипервизоре kvm1 и 5 на гипервизоре kvm2. Похоже на правду? Если да, то говорим терраформу yes:

module.kvm2.libvirt_pool.pool: Creating...
module.kvm1.libvirt_pool.pool: Creating...
module.kvm2.libvirt_pool.pool: Creation complete after 5s [id=b777e042-f7c9-45d4-852c-c30883a61a59]
module.kvm2.libvirt_cloudinit_disk.commoninit: Creating...
module.kvm2.libvirt_volume.image: Creating...
module.kvm1.libvirt_pool.pool: Creation complete after 6s [id=0c17f680-48c0-49f8-ac3c-aa81d67db2df]
module.kvm1.libvirt_cloudinit_disk.commoninit: Creating...
module.kvm1.libvirt_volume.image: Creating...
module.kvm1.libvirt_volume.image: Creation complete after 1m52s [id=/var/lib/libvirt/k0s-pool/ubuntu-focal]
module.kvm1.libvirt_volume.root[1]: Creating...
module.kvm1.libvirt_volume.root[0]: Creating...
module.kvm1.libvirt_cloudinit_disk.commoninit: Creation complete after 1m52s [id=/var/lib/libvirt/k0s-pool/commoninit.iso;e48ff56b-7716-4b23-883e-4adc98f6e5ea]
module.kvm1.libvirt_volume.root[1]: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-worker-root]
module.kvm1.libvirt_volume.root[0]: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-controller-root]
module.kvm1.libvirt_domain.vm[1]: Creating...
module.kvm1.libvirt_domain.vm[0]: Creating...
module.kvm2.libvirt_volume.image: Creation complete after 2m1s [id=/var/lib/libvirt/k0s-pool/ubuntu-focal]
module.kvm2.libvirt_volume.root[0]: Creating...
module.kvm2.libvirt_cloudinit_disk.commoninit: Creation complete after 2m1s [id=/var/lib/libvirt/k0s-pool/commoninit.iso;c9773ce2-9574-41cf-85d8-1b2e805a1485]
module.kvm2.libvirt_volume.root[0]: Creation complete after 0s [id=/var/lib/libvirt/k0s-pool/k0s-worker-root]
module.kvm2.libvirt_domain.vm[0]: Creating...
module.kvm2.libvirt_domain.vm[0]: Creation complete after 1m16s [id=3bdec3bb-8c0f-4df9-b876-c1e816472ac0]
module.kvm1.libvirt_domain.vm[1]: Creation complete after 1m27s [id=4c8b761b-1bd8-4465-a124-bb632b6029ad]
module.kvm1.libvirt_domain.vm[0]: Creation complete after 1m37s [id=8e458071-f08d-48c4-bf44-26678e8b48dd]

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:

vms_kvm1 = [
  {
    "id" = "k0s-controller"
    "ip" = "192.168.99.118"
  },
  {
    "id" = "k0s-worker"
    "ip" = "192.168.99.117"
  },
]
vms_kvm2 = [
  {
    "id" = "k0s-worker"
    "ip" = "192.168.99.119"
  },
]

Как видим, создались три виртуальных машины, две на первом гипервизоре и одна на втором - как мы и планировали. Ещё можно заметить как работает магия terraform - он запускает создание ресурсов параллельно не только в пределах провайдера, но и по всем гипервизорам в целом. Круто? Круто.

Выводы

  1. Мы научились масштабировать нашу инфраструктуру в пределах одного провайдера
  2. Мы научились масштабировать нашу инфраструктуру по нескольким провайдерам (почти как в облаке, хе-хе)
  3. Мы научились в модули, благодаря чему можем переиспользовать свой код многократно