devops
October 24, 2021

Автоматизация сборки образов ОС с помощью Packer

Разворачивать операционную систему с ISO-образа удобно, но только руками и только один раз. Если встаёт задача делать это тысячу раз в день на каком-нибудь облаке, невольно задумаешься как автоматизировать процесс.

Я для этого выбрал инструментарий от Hashicorp - Packer, Vagrant и Terraform.

  • Packer - инструмент подготовки образов ОС.
  • Vagrant - инструмент запуска виртуальных машин при локальной разработке
  • Terraform - инструмент оркестрации облачной инфраструктуры

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

Вкратце

В чём суть?

В любом облаке самый быстрый способ развернуть ОС - использовать шаблон (template). Шаблон - это предварительно настроенная ОС, "золотой образ", которая будет копироваться на диск создаваемой виртуальной машины. Всё просто.

Шаблон можно подготовить кучей способов - установить руками, применить утилиты, предлагаемые облаком или вендором дистрибутива, либо использовать Packer как наиболее универсальное решение.

Почему я выбрал именно Packer?

Инструмент позволяет подготавливать шаблоны ОС под большинство облачных провайдеров и гипервизоров (Amazon, OpenStack, KVM, VmWare), а также делать образы для контейнеров (Docker). Причём делается это всё легко и непринуждённо он хорошо вписывается в пайплайн CI/CD.

Как работает Packer?

На самом деле никакой магии, он просто повторяет то, что пришлось бы делать руками - подключается к гипервизору или облаку, создаёт виртуальную машину, загружается с ISO-диска (или виртуального диска) и выполняет инструкции, указанные в конфигурации. После этих манипуляций появляется шаблон. Всё.

Пример настройки Ubuntu 20.04 для Vagrant под VirtualBox

Далее я постараюсь рассказать о том, как подготовить ОС из ISO-файла для запуска её с помощью Vagrant в VirtualBox.

Буду готовить образ Ubuntu Server с развёрнутым в нём k0s. Весь код вы сможете найти в моём репозитории. Он будет необходим, если вы захотите повторить всё написанное ниже.

Разберёмся с автоматической установкой Ubuntu 20.04

Первым делом нам нужно понять как вообще происходит установка дистрибутива. Если мы делаем это руками, то просто проходимся по визарду, нажимая кнопочки и заполняя формочки. Метод хороший, но нам нужен более контролируемый, подходящий для автоматической установки. В Ubuntu для этого есть две системы - Debian-installer и Subiquity.

  • Debian-Installer - старая система, использующаяся на Debian-based дистрибутивах уже многие годы. Подойдёт "олдам" и тем, кто привык работать с конфигурацией preseed. Для её использования нужен дистрибутив с Legacy-инсталлятором. Следует понимать, что для Ubuntu это тупиковый путь и использовать его в Ubuntu 20.04 не рекомендуется.
  • Subiquity - новая система, разработанная Canonical. Здесь используются файлы конфигурации autoinstall в формате yaml, что упрощает их чтение. Autoinstall включен в дистрибутив по умолчанию. Именно его я и буду использовать, потому что обожаю находить приключения на задницу исследовать новые технологии.

Разбираемся с Autoinstall

Ознакомившись с документацией, выясняю что нужно подготовить директорию в которой будут лежать два файла - meta-data и user-data.

  • meta-data - просто пустой файл, наличие которого триггерит автоматическую установку
  • user-data - файл, в котором описывается конфигурация установки в формате yaml. В нём вся магия.

Есть два способа подготовить user-data и оба требуют работы с напильником

  1. Установить ОС руками и забрать сгенерированный в процессе инсталляции файл конфигурации. Он будет находиться здесь - /var/log/installer/autoinstall-user-data. Туда попадёт некоторое количество мусора, но вариант, в целом, рабочий.
  2. Прочитать документацию и написать конфигурацию самому.

На практике оказалось что оба варианта - боль и страдание. Во-первых, автоматически сгенерированный конфиг стопроцентно не работает. Во-вторых, документация умалчивает некоторые нюансы, специфичные для среды запуска, что делает её не очень полезной. Через пару часов безуспешных попыток впихнуть в конфиг максимально возможное количество настроек, решено было вынести их на более поздний этап и оставить только минимально рабочий код в user-data:

#cloud-config
autoinstall:
  version: 1
  early-commands:
    - systemctl stop ssh # Отключить SSH-сервер для корректной работы Packer
  network:
    network: # Два раза, потому что это официально признанный баг
      version: 2
      ethernets:
        enp0s3:
          dhcp4: yes
          dhcp-identifier: mac
  storage:
    layout:
      name: lvm
  apt:
    preserve_sources_list: false
    primary:
      - arches: [amd64, i386]
        uri: "http://archive.ubuntu.com/ubuntu/"
      - arches: [default]
        uri: "http://ports.ubuntu.com/ubuntu-ports"
  ssh:
    install-server: yes
    authorized-keys: []
    allow-pw: yes
  identity:
    hostname: ubuntu-focal
    password: "$6$rounds=4096$2LajYFyw2u5q$47i.HErUFUrr3tGkRIFpsjCM0hUdBnLBh2I0uM5T5k0RUlzWx0UxBa3XpkrkrOl9Gu0MrgnpuvEmJ99egYklF0"
    username: vagrant
  late-commands:
    - echo 'vagrant ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/vagrant

Для проверки конфигурации я запускал http-сервер из директории с подготовленными файлами следующей командой:

python3 -m http.server 8080

После чего запускал установку ОС вручную. При запуске указывал следующие параметры загрузки ядра:

initrd=/casper/initrd quiet autoinstall ds=nocloud-net;s=http://_gateway:8080/

Если система не показывает Welcome-экран, а сразу начинает установку, значит autoinstall подготовлен корректно. В противном случае предстоит увлекательная отладка user-data вслепую.

Готовим Packer

Теперь, когда мы знаем как подготовить ОС вручную, можно браться за Packer. Нам необходимо подготовить файл конфигурации, который будет описывать параметры виртуальной машины, в которой будет происходить установка, сервисные команды ОС, provision (запуск скриптов после успешной установки ОС) и некоторые другие задачи.

Документация всё это хорошо описывает, мне остаётся только поделиться своим опытом применения инструмента.

Для начала упростим себе жизнь и определимся со структурой проекта. В дальнейшем это поможет более эффективно применять конфигурацию для подготовки других дистрибутивов. Моя структура выглядит так:

ansible/               - provision-скрипты (в моём случае ansible)
http/                  - файлы конфигурации autoinstall
iso/                   - исходные iso-дистрибутивы ОС
vagrant/               - конфигурация Vagrant
vars/                  - переменные Packer
|-ubuntu.pkrvars.hcl   - файл с переменными Packer
template.pkr.hcl       - основной файл конфигурации Packer

Теперь о содержимом файлов:

  • template.pkr.hcl
# Переменные
variable "headless" {
  type = bool
  default = false
}
variable "iso_url" {
  type = string
}
variable "iso_checksum" {
  type = string
}
variable "ssh_username" {
  type = string
  default = "vagrant"
  sensitive = true
}
variable "ssh_password" {
  type = string
  default = "vagrant"
  sensitive = true
}
variable "vm_name" {
  type = string
  default = "ubuntu"
}
variable "output_directory" {
  type = string
  default = "./builds"
}
variable "output_filename" {
  type = string
}
variable "http_dir" {
  type = string
  default = "http"
}
variable "boot_command" {
  type = list(string)
}

# Конфигурация бэкэнда сборки
source "virtualbox-iso" "ubuntu" {
  # Запускать виртуальную машину с GUI или в фоновом режиме
  headless = "${var.headless}"
  
  # Путь и контрольная сумма исходного ISO-образа
  iso_url = "${var.iso_url}"
  iso_checksum = "${var.iso_checksum}"
  
  # Учётная запись, под которой будет выполняться настройка образа
  ssh_username = "${var.ssh_username}"
  ssh_password = "${var.ssh_password}"
  ssh_timeout = "15m"
  
  # Параметры сохранения образа
  vm_name = "${var.vm_name}"
  output_directory = "${var.output_directory}"
  output_filename = "${var.output_filename}"
  
  # Конфигурация виртуальной машины
  vboxmanage = [
    ["modifyvm", "{{.Name}}", "--memory", "1024"],
    ["modifyvm", "{{.Name}}", "--cpus", "1"],
  ]
  disk_size = "5120"
  guest_os_type = "Ubuntu_64"
  
  # Параметры http-сервера и подключения к нему
  http_directory = "${var.http_dir}"
  boot_command = "${var.boot_command}"
  boot_wait = "5s"
  
  # Команда выключения виртуальной машины по завершению всех операций
  shutdown_command = "echo 'vagrant' | sudo -S /usr/sbin/shutdown -P now"
}

# Конфигурация сборки
build {
  # Используемые бэкэнды сборки
  sources = ["sources.virtualbox-iso.ubuntu"]
  
  # Параметры запуска скриптов подготовки
  provisioner "ansible" {
    playbook_file = "./ansible/playbook.yml"
  }
  
  # Создание box-файла для Vagrant
  post-processor "vagrant" {
    output = "vagrant/releases/${var.output_filename}_{{.Provider}}.box"
  }
  
  # Запуск виртуальной машины из подготовленного образа
  post-processor "shell-local" {
    inline = ["cd ./vagrant", "vagrant up"]
  }
}
  • ubuntu.pkrvars.hcl. Вынести в отдельный файл переменные - хорошая практика. Таким образом мы можем переиспользовать шаблон для других дистрибутивов.
headless = "false"

iso_url = "./iso/ubuntu-20.04.3-live-server-amd64.iso"
iso_checksum = "sha256:f8e3086f3cea0fb3fefb29937ab5ed9d19e767079633960ccb50e76153effc98"

ssh_username = "vagrant"
ssh_password = "vagrant"

vm_name = "ubuntu-focal"
output_directory = "./builds/ubuntu-20.04"
output_filename = "ubuntu-20.04-server-amd64"

http_dir = "./http"
boot_command = [
  "<wait><enter><esc><wait5><f6><esc><wait>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs><bs><bs><bs><bs><bs><bs><bs>",
  "<bs><bs><bs>",
  "<wait>",
  "/casper/vmlinuz ",
  "initrd=/casper/initrd quiet ",
  "<wait>",
  "autoinstall ",
  "<wait>",
  "ds=nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ --- ",
  "<enter><wait5>"
]

Запуск сборки выполняется следующей командой:

packer build -var-file=./vars/ubuntu.pkrvars.hcl template.pkr.hcl

Во время работы откроется окно Virtualbox, в котором Packer автоматически введёт символы из переменной boot_command, после чего будет ожидать возможности подключиться к виртуальной машине по SSH. Когда это произойдёт, будет запущен плейбук Ansible, затем виртуальная машина будет выключена, а её файлы экспортированы в box для Vagrant

Особенность Packer заключается в том, что при ошибках в сборке нельзя будет продолжить с того места, где всё поломалось - придётся запускать сборку по-новой. То есть, если вы допустили ошибку в таске Ansible, нельзя будет начать с шага provision. По этой причине отладка может затянуться на многие часы.

Ещё одна неожиданная особенность сборки на VirtualBox - Packer может рандомно пропускать некоторые символы при наборе boot_command. Как же у меня от этого пригорело! Заметил я это случайно через полчаса отладки, посмотрев внимательно в /proc/cmdline, когда в очередной раз установщик неожиданно загрузился в интерактивный режим. Помогло добавление дополнительных символов <wait> между словами. Вроде как это проблема переполнения буфера в VNC-сессии.

Проверяем работу в Vagrant

Packer создаёт vmdk и ovf файлы в директории builds. Можно импортировать их в VirtualBox и проверить работоспособность вручную. А можно экспортировать эти файлы в box и запустить с помощью Vagrant. За это отвечает секция post-processor в файле template.hcl. По завершении сборки образа, происходит экспорт в директорию vagrant/releases/.

Сама же конфигурация Vagrant хранится в файле vagrant/Vagrantfile:

Vagrant.configure("2") do |config|
  # Использование локального файла box
  config.vm.box = "ubuntu-k0s"
  config.vm.box_url = "releases/ubuntu-20.04-server-amd64_virtualbox.box"
  
  # Параметры виртуальной машины
  config.vm.hostname = "k0s.local"
  config.disksize.size = "10GB"
  config.vm.provider "virtualbox" do |v|
    v.cpus = 1
    v.memory = "1024"
  end
  
  # Запуск подготовительных скриптов после старта виртальной машины
  config.vm.provision "ansible" do |ansible|
    ansible.playbook = "ansible/playbook.yml"
  end
end
  

Здесь следует уточнить момент, почему мне пришлось использовать Ansible при запуске виртуальной машины. Диск, используемый при подготовке образа, составляет 5 Гб. Этого недостаточно для полноценного запуска k0s. Сам Vagrant из коробки не поддерживает возможность изменять размер диска виртуальной машины и эту проблему можно обойти парой способов:

  • Указать больший размер диска при сборке. Поскольку диск динамического размера, места он будет занимать только фактическими данными. В нашем случае этого бы хватило, но на моей практике случалось, что не было возможности использовать динамические диски, поэтому я предпочитаю собирать образ на маленьком диске. Стандартная же практика подготовки box для Vagrant - делать диск размером 20 Гб. Для тестов этого обычно хватает. Но я такой подход не люблю.
  • Использовать плагин disk-size. Он экспериментальный, но позволяет управлять размером диска на стороне Vagrant. Мне этот способ нравится гораздо больше, потому что я могу создать диск любого размера и изменить его уже на запущенной машине. Установить плагин можно командой vagrant plugin install vagrant-disksize.

С использованием плагина есть нюанс - он не управляет разметкой диска. То есть мы увидим что физический диск стал нужного нам размера, но файловая система по прежнему маленькая, поэтому её придётся расширить руками. В моём случае диск размечен с использованием LVM, поэтому увеличение раздела можно произвести прямо из ОС. Для этого я и использую Ansible.

После того, как Vagrant запустил виртуальную машину, я могу зайти на неё и начать работать с k0s:

# Подключиться к виртуальной машине
vagrant ssh

# Посмотреть статус службы k0s
sudo k0s status
# Посмотреть статус ноды k0s
sudo k0s get nodes
# Запустить деплоймент с nginx
sudo k0s kubectl apply -f nginx-deployment.yaml

Когда мы наиграемся, виртуальную машину можно будет удалить:

vagrant destroy -f

Итог

Мы узнали про Packer и собрали свой "золотой образ". И пусть это только VirtualBox на локальной машине, полученных основ уже хватит для подготовки образа под другие платформы (Amazon, Azure, OpenStack и т.п.)

Использование Packer даёт возможность хранить конфигурацию образа как код, что позволяет применять к нему передовые практики DevOps. Например, настроить CI/CD или подключить этап тестирование внесённых изменений.