Предыстория: выбирали сертифицированное облако для всякой там сертифицированной жизни. Остановились на кое-каком B2B-колоссе, руководство заключило договор, и отделу SRE пришлось работать с облаком на основе VMware vCloud Director. И, как подобает секте свидетелей Infrastructure as Code, хотелось поменьше сидеть в веб-морде облака и больше -- в конфигурациях какого-нибудь Ansible и Terraform.
Эта статья - плод нескольких вечеров девопсера и бог знает скольких дней и ночей CTO. По горячим следам, поэтому, возможно, она несколько скомканная. Тем не менее, если вы столкнулись с облаком на основе vCloud - будет интересно.

Provisioning

Прежде чем перейдём к подготовке образа ВМ, поговорим немного про provisioning виртуалок.

Вот, скажем, есть фаза установки убунты из iso-образа, и есть фаза прогона ансиблового плейбука. Между ними - фаза заведения пользователей, установкой таймзоны и записей ssh-ключей. Как это сделать автоматизированно?
Или другой пример, более привычный для облаков: есть склонированная виртуалка, в которую надо прописать хостнейм, те же ключи, в конце-концов, задать IP-адрес, который вместо DHCP генерируется в интерфейсе облака. Тоже руками делать?

Или... составлять скрипты и вставлять их в раздел Customization script, в случае с vCloud?

"Нет, это всё бред" - подумали в Canonical, и сотворили cloud-init, который на сегодняшний день делает provisioning виртуалок во всех мейнстримных облаках, от Amazon EC2, GCP и Azure до Openstack и того же VMware.

Cloud-init работает с instance-data, документом, состоящим из трёх компонент:

  • Cloud metadata, содержит важные данные от облака. Например, instance id, адреса сетевых интерфейсов, хостнеймы.

  • Vendor data (опционально), несёт в себе дополнительные настройки от облака. Например, каких пользователей пользователь через веб-формы облака приказал создать, или какие пакеты надо доустановить.

  • User data (опционально). По формату идентично vendor data, но юзердата состоит из YAML-а, который пользователь скармливает cloud-init в свободной форме.

    Пример user-data:

#cloud-config
groups:
	# в группе ubuntu должны быть пользователи root и sys
  - ubuntu: [root,sys]
  # пустая группа
  - cloud-users
users:
  - default
  - name: foobar
    primary_group: foobar
    groups: users
    lock_passwd: false
    passwd: $6$j212wezy$7H/1LT4f9/N3wpgNunhsIqtMj62OKiS3nyNwuizouQc3u7MbYCarYeAHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/
  - name: barfoo
    sudo: ALL=(ALL) NOPASSWD:ALL
    groups: users, admin
    lock_passwd: true
    ssh_authorized_keys:
      - <ssh pub key 1>
      - <ssh pub key 2>
write_files:
- encoding: b64
  content: CiMgVGhpcyBmaWxlIGNvbnRyb2xzIHRoZSBzdGF0ZSBvZiBTRUxpbnV4...
  owner: root:root
  path: /etc/sysconfig/selinux
  permissions: '0644'
- content: |
    SMBDOPTIONS="-D"
  path: /etc/sysconfig/samba
ca-certs:
  trusted: 
  - |
   -----BEGIN CERTIFICATE-----
   YOUR-ORGS-TRUSTED-CA-CERT-HERE
   -----END CERTIFICATE-----
  - |
   -----BEGIN CERTIFICATE-----
   YOUR-ORGS-TRUSTED-CA-CERT-HERE
   -----END CERTIFICATE-----
manage_resolv_conf: true

resolv_conf:
  nameservers: ['8.8.4.4', '8.8.8.8']
  searchdomains:
    - foo.example.com
    - bar.example.com
  domain: example.com
  options:
    rotate: true
    timeout: 1

Полный список встроенных в cloud-init возможностей конфигурации (или модулей) доступен тут.

VMware и cloud-init

Подготавливаем шаблон

Возьмём за основу Ubuntu 20.04.3.

Загружаем ISO

Заходим в vCloud Director, там открываем меню сверху слева и переходим в Libraries:

Из Libraries попадаем в Media & Other, где нажимаем на ADD:

В качестве каталога выбираем любой, в котором в принципе можно хранить образы, выбираем ISO-файл на локальном диске, name подставится автоматически.

Как только медиа загрузится (задача загрузки будет висеть в нижней части экрана, в Recent Tasks), создаём пустой vApp, цепляем к нему какую-нибудь routed-сетку, дальше создаём VM со следующими параметрами:

В принципе, главное, чтобы Memory было не меньше гигабайта, и чтобы Operating System было Ubuntu Linux (64-bit)

Диск будет один, размер - минимальный, который вы собираетесь давать виртуалкам. И в качестве сети достаточно простой routed network, чтобы был выход в интернет для обновлений.

Установка и настройка дистрибутива

Дальше устанавливаем убунту как вам хочется, из скриншотов ограничусь одним-единственным:

cloud-init сам по себе не умеет проводить операции с LVM-разделами. Так что, если вы хотите в дальнейшем расширять корневой раздел через cloud-init -- оставьте эту галочку нетронутой.

После установки и ребута заходим в root shell, делаем там apt update-upgrade:

sudo -i
apt update
apt upgrade

Теперь, собственно, подготовка образа к cloud-init.

Удаляем сетевые конфиги netplan и cloud-init, созданные установщиком, чтобы не мешались по дороге:

rm /etc/netplan/00-installer-config.yaml
rm /etc/cloud/cloud.cfg.d/50-curtin-networking.cfg

А также говорим cloud-init не пытаться перетянуть одеяло с vmware tools. Идея в том, чтобы сначала open-vm-tools задавал сетевые параметры, а cloud-init делал всё остальное:

echo 'disable_vmware_customization: true' > /etc/cloud/cloud.cfg.d/91_vmware_cust.cfg

Настраиваем cloud-init через dpkg так, чтобы был только OVF datasource. То есть кнопкой пробел снимаем все галочки, кроме той, что рядом с OVF:

dpkg-reconfigure cloud-init

Наконец, удаляем данные cloud-init, чтобы при следующей загрузке системы он повторно инициализировался. Бонусом можно удалить логи open-vm-tools, чтобы впоследствии их было проще читать:

cloud-init clean --logs
rm -r /var/log/vmware*

Гасим виртуалку через poweroff и также делаем ей Power Off в vCloud. Теперь она готова к шаблонизации.

Создание шаблона в vCloud

Здесь всё просто: открываем Actions нашего vApp и жмём на Add to Catalog, в открывшейся форме выбираем нужный каталог, в имени пишем что-нибудь понятное, типа ubuntu-20-04, и в поле When using this template выбираем Customize VM settings.

Мой девопс делает вкуснейшие терраформ-модули

Рецепт усреднённый.

Берётся стабильный (1.0.4) терраформ, 0.14 не про него, на него ставится vcd-провайдер:

terraform {
  required_providers {
    vcd = {
      source = "vmware/vcd"
      version = "3.3.1"
    }
  }
  required_version = "= 1.0.4"
}

variable "vcd_user" {
  type = string
}
variable "vcd_pass" {
  type = string
  sensitive = true
}

provider "vcd" {
  user                 = var.vcd_user
  password             = var.vcd_pass
  auth_type            = "integrated"
  org                  = "org_name"
  vdc                  = "vdc_name"
  url                  = "https://vcd.someawesomecloud.ru/api"
}

Создаёт в той же папке какой-нибудь простой userdata.yaml:

#cloud-config
users:
  - default
  - name: igor
    ssh_authorized_keys:
      - "ecdsa-sha2-nistp256 <...> igor@laptop"
    groups: sudo
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']

И посыпает виртуалкой:

resource "vcd_vapp_vm" "TestVm" {

  vapp_name     = "vappName"
  name          = "TestVm"
  computer_name = "cloud-vm"
  memory        = 2048
  cpus          = 2
  cpu_cores     = 1

  os_type = "ubuntu64Guest"
  catalog_name = "catalogName"
  template_name = "ubuntu-20-04"

  customization {
    enabled = true
  }

  network {
    type               = "org"
    name               = "mainbridge"
    ip_allocation_mode = "DHCP"
    is_primary         = true
  }
}

И... Стоп, а вонища user data где?

Загадка о OVF-датасорсе cloud-init

Сейчас будет рассказ о том, почему, собственно, этот туториал и был написан.

Как нам передать user data в cloud-init?

В terraform-ресурсе vcd_vapp_vm есть такой аргумент, как guest_properties:

guest_properties - (Optional; v2.5+) Key value map of guest properties

Один из ключей к разгадке получен. Значит, в виртуалку можно передать набор ключ-значение, осталось понять, какие именно ключи.

Обратимся к документации OVF-датасорса:

For further information see a full working example in cloud-init’s source code tree in doc/sources/ovf

Отлично, живой пример! Открываем сорцы, и...

Примеры от 12 года, разумеется, без терраформа или чего-то такого.

Если вы вобьёте в любимый поисковик "vmware cloud-init", то найдёте несколько статей в блогах про то, как накурить cloud-init в vSphere через проперти guestinfo.metadata и guestinfo.userdata.

То есть, мы должны соорудить что-то типа такого?

  guest_properties = {
    "guestinfo.userdata"           = base64encode(file("${path.module}/userdata.yaml"))
    "guestinfo.userdata.encoding"  = "base64"
  }

Нет. К сожалению, если засунуть YAML как guestinfo.userdata, то cloud-init поведёт себя так, словно ничего в OVF не обнаружил.

Днями и ночами я копался над этой загадкой, пока не сдался и не залез в исходники cloud-init. Тут мне пригодилось знание Python и хорошая структурированность кодовой базы cloud-init.

Открываем сорцы. Ищем строку guestinfo. Находим такую функцию:

def transport_vmware_guestinfo():
    rpctool = "vmware-rpctool"
    not_found = None
    if not subp.which(rpctool):
        return not_found
    cmd = [rpctool, "info-get guestinfo.ovfEnv"]
    try:
        out, _err = subp.subp(cmd)
        if out:
            return out
        LOG.debug("cmd %s exited 0 with empty stdout: %s", cmd, out)
    except subp.ProcessExecutionError as e:
        if e.exit_code != 1:
            LOG.warning("%s exited with code %d", rpctool, e.exit_code)
            LOG.debug(e)
    return not_found

Мимо. Хотя давайте из любопытства выполним команду:

vmware-rpctool 'info-get guestinfo.ovfEnv'

Получим XML с набором данных, а также с пропертями типа таких:

<Property oe:key="guestinfo.userdata" oe:value="..."/>
<Property oe:key="guestinfo.userdata.encoding" oe:value="base64"/>

Ага! Значит, cloud-init получает наш набор пропертей, но они его по какой-то причине не устраивают?

Ищем места вызова функции, находим одно:

        else:
            np = [('com.vmware.guestInfo', transport_vmware_guestinfo),
                  ('iso', transport_iso9660)]
            name = None
            for name, transfunc in np:
                contents = transfunc()
                if contents:
                    break
            if contents:
                (md, ud, cfg) = read_ovf_environment(contents)
                self.environment = contents
                found.append(name)

В этом же куске кода есть вызов read_ovf_environment(). Посмотрим на него:

# This will return a dict with some content
#  meta-data, user-data, some config
def read_ovf_environment(contents):
    props = get_properties(contents)
    md = {}
    cfg = {}
    ud = None
    cfg_props = ['password']
    md_props = ['seedfrom', 'local-hostname', 'public-keys', 'instance-id']
    for (prop, val) in props.items():
        if prop == 'hostname':
            prop = "local-hostname"
        if prop in md_props:
            md[prop] = val
        elif prop in cfg_props:
            cfg[prop] = val
        elif prop == "user-data": # <- !!!
            try:
                ud = base64.b64decode(val.encode())
            except Exception:
                ud = val.encode()
    return (md, ud, cfg)

Ага! Значит, cloud-init вычитывает проперти password, seedfrom, local-hostname, public-keys, instance-id и наш драгоценный user-data!

Пробуем прописать такое в Terraform-ресурсе:

  guest_properties = {
    "local-hostname"      = "cloud-vm"
    "user-data"           = base64encode(file("${path.module}/userdata.yaml"))
  }

Зачем мы два раза задаём hostname, через computer_name и через local-hostname, спросите вы? computer_name предназначен для open-vm-tools, который его выставляет с проблемами (я пробовал исправить это согласно workaround-ам, и получил циклическую зависимость юнитов в systemd), а local-hostname - для cloud-init, который умеет всё делать по красоте.

Делаем экспорт логина-пароля в шелл, terraform apply, смотрим, какой адрес у полученной виртуалки в vCloud, пингуем его и пробуем подключиться:

export TF_VAR_vcd_user=username
export TF_VAR_vcd_pass=12345678
terraform apply # на запрос подтверждения отвечаем yes (перечитывая план, конечно
ping 10.10.0.155
<куча destination host unreachable>
64 bytes from 10.10.0.155: icmp_seq=40 ttl=64 time=2048 ms
64 bytes from 10.10.0.155: icmp_seq=41 ttl=64 time=1024 ms
64 bytes from 10.10.0.155: icmp_seq=42 ttl=64 time=0.375 ms
igor@gateway:~$ ssh igor@10.10.0.155
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)

Если удалось подключиться, то поздравляю - всё сработало как надо.

Всё! Мы получили работающий шаблон Ubuntu, который можно раскатать в облако на основе vCloud Director.

Если что-то не выходит, обратите внимание на следующие ресурсы:

Комментарии (5)


  1. berendiaev
    22.08.2021 01:07
    +1

    Шаблон ВМки тоже кодом можно делать, через Packer. Он вроде бы умеет vcloud.


    1. ultral
      16.09.2021 18:13

      Умеет. И в content library сливать умеет


  1. malykhin
    22.08.2021 04:41
    +1

    Еще стоит посмотреть на ubuntu cloud images
    Там готовые образы под разные платформы (я использовал для KVM)
    Они "вычищенные" и имеют минимальный размер.
    При первой загрузке корень автоматом расширяется на весь доступный размер выданного диска.
    Для передачи информации в cloud init я использовал подключенный к виртуалке iso-образ, который генерируется из конфигов с помощью cloud-localds (пакет cloud-image-utils)


  1. amarao
    22.08.2021 12:17

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


    1. cadmi
      22.08.2021 14:11
      +1

      Тут, строго говоря, вопросы не к terraform, а к рукожопам из vmware, которые писали свой vcd provider к terraform.

      Без них это выглядит куда проще.

      resource "digitalocean_droplet" "blabla" {
      # ...
      user_data = file("${path.module}/userdata.yaml")
      }


      Всё. Без этих приседаний.