Najpierw budujemy jedną VM z własną nazwą, siecią i pulą dysków laboratoryjnych. Dzięki temu można sprawdzić zmianę w systemie, zanim dotknie serwera produkcyjnego, oraz usunąć wyłącznie zasoby utworzone na potrzeby testu. Dalsza część pokazuje, jak ten sam wzorzec rośnie do większego labu.
W tym artykule najpierw odpowiadamy na pytania potrzebne przed wykonaniem pierwszego apply: co przygotować, gdzie uruchamiać polecenia, co one zmieniają i po czym poznać sukces. Przykład używa nazw laboratoryjnych, przykładowej prywatnej podsieci i publicznego klucza SSH; nie zawiera rzeczywistego hosta, adresu ani sekretu.
Spis treści
- Czy lokalny lab KVM ma sens w moim przypadku?
- Jak działają KVM, libvirt, provider, NAT, bridge i cloud-init?
- Co sprawdzić na hoście, zanim Terraform cokolwiek utworzy?
- Jak utworzyć jedną VM od katalogu projektu do SSH?
- Jak czytać plan i bezpiecznie wykonać apply?
- Jak potwierdzić, że VM działa, oraz gdzie szukać błędu?
- Jak usunąć tylko tę VM, jej dyski i sieć?
- Kiedy lokalny state wystarczy, a kiedy potrzebny jest backend?
- Czym różni się lokalny lab od produkcji?
- Czy wybrać Terraform czy OpenTofu?
- Kiedy podzielić większy lab na osobne stany?
- Dlaczego VM może nie uruchomić się po sklonowaniu dysku?
- Jak odtworzyć jedną VM bez naruszania pozostałych?
- Co sprawdzić przed rozpoczęciem testu?
Czy lokalny lab KVM ma sens w moim przypadku?
Tak, gdy chcesz sprawdzić playbook, aktualizację systemu, regułę zapory albo konfigurację usługi na świeżej maszynie, którą wolno odtworzyć. Jedna VM wystarczy do pierwszego testu i jest lepszym początkiem niż kopiowanie całego środowiska produkcyjnego.
Nie traktuj jej jako modelu wysokiej dostępności, wydajności produkcyjnej ani kopii bezpieczeństwa. Jeden host KVM jest pojedynczym punktem awarii. Lab odpowiada na pytanie „czy ta zmiana uruchomi się na czystym systemie?”, a nie „czy wytrzyma ruch produkcyjny?”.
| Jeżeli chcesz… | Wybierz… | Dlaczego |
|---|---|---|
| testować z własnego laptopa lub pojedynczego serwera | osobną sieć NAT i jedną VM | gość ma dostęp wychodzący, ale nie pojawia się bezpośrednio w LAN |
| udostępnić VM innym hostom w tej samej sieci | istniejący bridge, po osobnym przeglądzie sieci | gość dostaje adres z LAN i staje się pełnoprawnym uczestnikiem tej sieci |
| współdzielić infrastrukturę z zespołem | backend stanu, kontrolę dostępu i osobny etap testowy | lokalny plik stanu nie chroni przed równoległym apply |
W tym przewodniku używamy NAT. Nie podpinaj VM do bridge'a tylko po to, aby ominąć problem z SSH: najpierw sprawdź adres z sieci NAT i połącz się z hosta KVM.
Po co zapisywać lab jako kod?
Maszynę testową można utworzyć w graficznym menedżerze. Po kilku tygodniach zwykle nie wiadomo jednak, z jakiego obrazu powstała, ile zasobów dostała i które zmiany zrobiono ręcznie. Kod zapisany w repozytorium pozwala ten sam punkt wyjścia sprawdzić, zatwierdzić i odtworzyć.
Opisanie labu kodem rozwiązuje trzy konkretne rzeczy:
- Odtwarzalność. Maszynę można skasować i postawić od nowa w kilka minut, zawsze w tym samym stanie wyjściowym. Test, który zostawił po sobie bałagan, przestaje być problemem.
- Przegląd zmian. Zmiana pamięci z 2 na 4 GiB jest jedną linijką w pliku, którą widać w historii repozytorium. Nie trzeba jej pamiętać ani nikomu opowiadać.
- Świadomy dobór systemów. Lista systemów testowych staje się jawną decyzją, a nie efektem tego, co kto kiedyś postawił. Kiedy system wypada ze wsparcia, wypada z labu przez usunięcie jednego wiersza - a lab sam podpowiada, kiedy ten moment nadszedł, o czym niżej.
To nie jest argument za automatyzowaniem wszystkiego. Lab jest dobrym miejscem na taki kod właśnie dlatego, że jego zawartość zmienia się często, a cena błędu jest zerowa.
Co tworzy Terraform, a co trzeba zrobić na maszynach później
Warto od razu ustawić granicę, bo bez niej łatwo się rozczarować. Terraform dostarcza puste maszyny: procesor, pamięć, dysk, kartę sieciową, konto i klucz publiczny. Na tym jego rola się kończy. Maszyna wstaje, da się na nią zalogować - i nie ma na niej nic poza systemem.
Tym, co czyni ją użyteczną, zajmuje się druga warstwa: narzędzie do zarządzania konfiguracją, w naszym przypadku Ansible. To ono dokłada wersje interpreterów, serwery WWW, bazę i ustawienia bezpieczeństwa. Lab jest kompletny dopiero razem - i to jest zdrowy podział, bo obie warstwy mają inny cykl życia: maszyny kasuje się i stawia, a ich zawartość zbiega się do stanu opisanego w osobnym repozytorium.
Ten artykuł opisuje wyłącznie pierwszą warstwę. Druga to osobny temat, któremu poświęcamy inny materiał; tutaj wszystko, co dalej, dotyczy Terraform.
Rozszerzenie: ile zasobów potrzebuje lab z czterema VM?
Jedna VM z podstawowej ścieżki nie wymaga poniższego zestawu. To pomiar wariantu rozszerzonego, w którym cztery systemy testowe mają po 2 vCPU, 2 GiB pamięci i 10 GiB dysku. Służy jako punkt odniesienia do planowania własnego labu, a nie jako wymaganie minimalne.
| Zasób | Cztery maszyny naraz | Uwaga |
|---|---|---|
| Pamięć | 8 GiB | tylko gdy wszystkie cztery są włączone jednocześnie, a to rzadki przypadek |
| Rdzenie | 2 na maszynę | rdzenie się współdzieli, więc nie trzeba ich mieć osiem |
| Dysk na maszyny | 40 GiB | łączna pojemność logiczna czterech wolumenów po 10 GiB; zajęte bloki sprawdza się osobno |
| Dysk na obrazy bazowe | 6 GiB | zmierzone dla czterech systemów, pobierane raz |
W tym konkretnym labie całkowity rozmiar logiczny wyniósł około 46 GiB, a pełne uruchomienie czterech VM deklaruje 8 GiB pamięci. Rzeczywiste użycie pamięci, CPU i dysku zależy od obrazu, cache oraz pracy gości; przed rozbudową sprawdzamy je na własnym hoście przez free -h, df -h i metryki libvirt.
Nie trzeba uruchamiać wszystkich VM naraz. Do testu playbooka wystarcza jeden gość, a do porównania dwóch wydań systemu dwa. Wyłączone VM nie pobierają pamięci ani CPU, lecz ich obrazy nadal zajmują storage.
Lab zmniejsza zakres skutków błędu, ale go nie zeruje. Testuj reguły zapory, aktualizacje i konfigurację usług wyłącznie w sieci oraz puli oznaczonej dla labu. Przed zmianą sprawdź plan, a przed usunięciem zasobów wykonaj plan destroy i przeczytaj jego wynik.
Jak działają KVM, libvirt, provider, NAT, bridge i cloud-init?
- KVM (Kernel-based Virtual Machine) - mechanizm w jądrze Linuksa, który pozwala procesorowi uruchamiać system gościa z wirtualnym sprzętem. QEMU emuluje urządzenia maszyny, a KVM przyspiesza wykonywanie jej kodu.
- libvirt - usługa i zestaw narzędzi zarządzających maszynami QEMU/KVM, ich sieciami oraz storage. Polecenie
virshpyta libvirt o aktualny stan hosta. - Terraform - narzędzie, które czyta opis infrastruktury z plików tekstowych i doprowadza rzeczywistość do zgodności z tym opisem. Nie wykonuje poleceń krok po kroku, tylko porównuje stan opisany ze stanem faktycznym i wykonuje różnicę.
- OpenTofu - odgałęzienie Terraform powstałe po zmianie jego licencji, rozwijane pod skrzydłami Linux Foundation. Czyta te same pliki i ma te same polecenia; różnicom poświęcona jest osobna sekcja.
- HCL - z angielskiego HashiCorp Configuration Language, czyli język, w którym pisze się te pliki. To on odpowiada za bloki w rodzaju
resourceczyvariable. - Provider - wtyczka, która tłumaczy opis w HCL na wywołania konkretnego systemu. Provider
dmacvicar/libvirttworzy w tym przypadku sieć, storage i definicję VM przez libvirt. - NAT (Network Address Translation) - prywatna sieć tworzona na hoście. VM może wychodzić do sieci przez host, ale urządzenia w LAN nie łączą się z nią bez dodatkowego przekierowania portu lub połączenia przez hosta.
- Bridge - istniejący most sieciowy hosta, który wpina kartę VM bezpośrednio do LAN. Jest przydatny dla usług wymagających adresu z LAN, ale zmienia prawdziwą sieć i wymaga uzgodnienia adresacji, zapory oraz DHCP.
- Stan (
terraform.tfstate) - plik, w którym narzędzie zapisuje, co już utworzyło i pod jakim identyfikatorem. Bez niego kolejne uruchomienie nie wie, że VM o tej nazwie już istnieje. Stan może zawierać sekrety i nie trafia do Git. - Backend - miejsce przechowywania stanu. Lokalny plik wystarcza jednej osobie na jednym hoście; backend z blokowaniem potrzebny jest, gdy ten sam stan może zmieniać więcej niż jedna osoba lub system CI.
- Katalog główny - katalog z plikami, na którym uruchamiasz polecenia. Ma własny stan. W tym labie mamy dwa takie katalogi i to jest sedno całej konstrukcji.
- Moduł - zestaw plików napisany raz i używany z wielu miejsc, żeby nie kopiować tej samej definicji. Odpowiednik funkcji.
- Plik blokady - zapis, który dokładnie provider i w jakiej wersji został pobrany, razem z sumami kontrolnymi. Trafia do repozytorium; katalog z samą wtyczką już nie.
- Cloud image - gotowy, minimalny obraz dysku z zainstalowanym systemem, przeznaczony do uruchamiania w wirtualizacji. Nie przechodzi się przez instalator: obraz startuje od razu.
- cloud-init - mechanizm obecny w takim obrazie, który przy pierwszym starcie konfiguruje system z danych podanych z zewnątrz: nazwę hosta, konto, klucz publiczny, pakiety.
- Golden image - lokalna kopia obrazu systemu, z której klonuje się dyski maszyn. Pobierana raz i używana wiele razy, zamiast ściągać ten sam plik przy każdej maszynie.
Co sprawdzić na hoście, zanim Terraform cokolwiek utworzy?
Wszystkie polecenia w tej sekcji uruchamiasz na hoście KVM, najpierw jako zwykły użytkownik. Nie zmieniają one konfiguracji: pokazują tylko możliwości hosta i istniejące zasoby. Nie uruchamiaj apply, jeśli nie wiesz, które domeny, sieci i pule są już używane przez inne VM.
# Host KVM, zwykły użytkownik. Tylko odczyt aktualnego stanu.
grep -cE 'vmx|svm' /proc/cpuinfo
test -r /dev/kvm && echo '/dev/kvm jest dostępne'
virt-host-validate qemu
virsh -c qemu:///system list --all
virsh -c qemu:///system net-list --all
virsh -c qemu:///system pool-list --all
ip -brief address
Wynik pierwszego polecenia większy od zera oznacza, że procesor udostępnia rozszerzenia wirtualizacji. Błąd dotyczący KVM lub libvirt trzeba wyjaśnić przed dalszą pracą. Zapisz nazwy z trzech poleceń virsh: nie wolno użyć ich ponownie dla labu.
Na Debianie i Ubuntu zainstaluj QEMU, libvirt, genisoimage (nośnik cloud-init), ovmf (UEFI) oraz xsltproc używany przez szablon domeny, a potem odśwież sesję użytkownika:
# Host KVM, konto z prawem sudo. Instaluje pakiety i zmienia grupy konta.
sudo apt update
sudo apt install --no-install-recommends -y qemu-system-x86 qemu-system-modules-spice qemu-utils ovmf libvirt-daemon-system libvirt-clients genisoimage xsltproc curl make jq
sudo usermod -aG libvirt,kvm "$USER"
# Wyloguj się i zaloguj ponownie, a potem sprawdź dostęp bez sudo.
virsh -c qemu:///system list --all
Zainstaluj jedno narzędzie: Terraform albo OpenTofu. Aktualne polecenia są w dokumentacji Terraform i dokumentacji OpenTofu. Nie mieszaj ich w tym samym katalogu stanu.
Jak utworzyć jedną VM od katalogu projektu do SSH?
Poniższy przykład korzysta z referencyjnego kodu labu LinuxLab, ale lokalnymi plikami nadpisuje go tak, aby powstała tylko jedna VM Debian 13. Wszystkie zasoby mają prefiks llab-demo-20261003. Zastąp go własnym, nieużywanym identyfikatorem i zachowaj go w nazwie VM, sieci, puli i katalogów.
Granica bezpieczeństwa. Przykład tworzy własną pulę dysków i własną sieć NAT. Nie używa sieci default, puli images ani istniejącego bridge'a. Nie zastępuj tych nazw wartościami używanymi przez produkcję.
1. Utwórz katalog projektu, klucz i własną pulę dysków
Polecenia wykonaj na hoście KVM. Klucz prywatny zostaje w ~/.ssh; do VM trafi wyłącznie część publiczna. Trzy pierwsze polecenia virsh definiują, budują i uruchamiają nową, pustą pulę o jednoznacznej nazwie. Ostatnie tylko odczytuje jej stan.
# Host KVM, zwykły użytkownik; sudo jest potrzebne wyłącznie do puli.
LAB_ID=llab-demo-20261003
TOOL=terraform # albo: TOOL=tofu
VM_NAME="${LAB_ID}-deb13"
test -f "$HOME/.ssh/id_ed25519.pub" || ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519"
git clone --depth 1 https://github.com/sysgroup/linuxlab.git "$HOME/$LAB_ID"
git -C "$HOME/$LAB_ID" rev-parse HEAD
cd "$HOME/$LAB_ID/terraform"
sudo install -d -m 0750 -o root -g libvirt "/var/lib/libvirt/images/${LAB_ID}-vm"
sudo virsh -c qemu:///system pool-define-as "${LAB_ID}-vm" dir --target "/var/lib/libvirt/images/${LAB_ID}-vm"
sudo virsh -c qemu:///system pool-build "${LAB_ID}-vm"
sudo virsh -c qemu:///system pool-start "${LAB_ID}-vm"
virsh -c qemu:///system pool-info "${LAB_ID}-vm"
Sukces oznacza, że ostatnie polecenie pokazuje aktywną pulę ${LAB_ID}-vm. Jeżeli pool-define-as zgłosi istniejącą pulę, zatrzymaj się. Nie kasuj jej automatycznie: najpierw ustal, kto ją utworzył i czy nie zawiera dysków.
2. Przygotuj obraz oraz lokalne wartości Terraform
Obraz chmurowy ma już zainstalowany system i cloud-init. Dla tej wersji providera referencyjny kod używa obrazu raw; wyjaśnienie znajduje się w sekcji o formacie dysku. Ten krok nie zmienia istniejących VM.
# Host KVM, zwykły użytkownik, katalog terraform/ klonu.
mkdir -p "$HOME/${LAB_ID}-golden"
./scripts/cloud-image-to-raw.sh https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 "$HOME/${LAB_ID}-golden/debian-13.raw"
qemu-img info "$HOME/${LAB_ID}-golden/debian-13.raw"
Wynik musi zawierać file format: raw. Adres latest jest wygodny, ale nie daje identycznego buildu za miesiąc; dla odtwarzania błędu przypnij obraz i zapisz jego sumę kontrolną.
Utwórz w edytorze base/one-vm.auto.tfvars. Zmień ścieżkę /home/TWOJ_UZYTKOWNIK na własną i sprawdź, czy wybrana prywatna podsieć nie koliduje z trasami hosta.
golden_pool_name = "llab-demo-20261003-golden"
golden_pool_path = "/var/lib/libvirt/images/llab-demo-20261003-golden"
lab_network_name = "llab-demo-20261003-nat"
lab_network_cidr = "192.168.250.0/24"
lab_network_domain = "labdemo.test"
distros = {
"debian-13" = {
pretty = "Debian 13 (raw, lab)"
latest_url = "/home/TWOJ_UZYTKOWNIK/llab-demo-20261003-golden/debian-13.raw"
}
}
enabled_distros = ["debian-13"]
Następnie utwórz testboxes/one-vm.auto.tfvars. Ten plik nadpisuje całą mapę VM, więc stan katalogu testboxes obejmie tylko jedną maszynę.
vm_pool = "llab-demo-20261003-vm"
disk_strategy = "copy"
wait_for_lease = false
lab_network_domain = "labdemo.test"
vms = {
"llab-demo-20261003-deb13" = {
group = "demo"
profile = "base"
distro = "debian-13"
vcpus = 2
memory_mib = 2048
disk_gib = 12
network = "lab"
autostart = false
}
}
copy tworzy niezależny dysk VM. Zajmuje więcej storage niż copy-on-write, ale eliminuje zależność od pliku bazowego podczas pierwszego ćwiczenia. W pliku testboxes/terraform.tfvars ustaw własne konto, ścieżkę do publicznego klucza i unikatowe hasło do konsoli. Plik ma uprawnienia 0600, nie trafia do Git, ale jego wartość nadal jest obecna w stanie Terraform.
username = "admin"
ssh_public_key_path = "~/.ssh/id_ed25519.pub"
user_password = "WSTAW_TU_UNIKATOWE_HASLO_LABU"
# Host KVM, zwykły użytkownik.
chmod 600 testboxes/terraform.tfvars
Jak czytać plan i bezpiecznie wykonać apply?
Uruchamiaj najpierw base, a potem testboxes. Każdy katalog ma osobny stan. init pobiera provider, validate sprawdza składnię, plan pokazuje zmiany, a apply dopiero je wykonuje.
# Host KVM, zwykły użytkownik, katalog terraform/ klonu.
"$TOOL" -chdir=base init
"$TOOL" -chdir=base validate
"$TOOL" -chdir=base plan -out=base.tfplan
"$TOOL" -chdir=base show -no-color base.tfplan
"$TOOL" -chdir=base apply base.tfplan
"$TOOL" -chdir=testboxes init
"$TOOL" -chdir=testboxes validate
"$TOOL" -chdir=testboxes plan -out=testboxes.tfplan
"$TOOL" -chdir=testboxes show -no-color testboxes.tfplan
"$TOOL" -chdir=testboxes apply testboxes.tfplan
Przed każdym apply przeczytaj wynik show. Plan może zawierać tylko zasoby z własnym prefiksem: pulę golden, obraz, sieć NAT, dysk VM, seed cloud-init i domenę. Jeśli pokazuje destroy lub obcą nazwę, nie wykonuj polecenia. Sprawdź lokalne pliki *.tfvars oraz "$TOOL" -chdir=base state list lub "$TOOL" -chdir=testboxes state list.
Jak potwierdzić, że VM działa, oraz gdzie szukać błędu?
Przykład nie czeka na DHCP podczas apply. Po kilku chwilach odczytaj adres z DHCP lub od agenta QEMU. Łączysz się z hosta KVM, ponieważ NAT nie wystawia VM bezpośrednio do LAN.
# Host KVM, zwykły użytkownik. Te polecenia niczego nie zmieniają.
virsh -c qemu:///system dominfo "$VM_NAME"
virsh -c qemu:///system net-dhcp-leases "${LAB_ID}-nat"
virsh -c qemu:///system domifaddr "$VM_NAME" --source agent
# Wstaw adres pokazany przez poprzednie polecenie; artykuł nie zawiera prawdziwego IP.
SSH_IP=ADRES_VM_Z_POPRZEDNIEGO_POLECENIA
ssh -i "$HOME/.ssh/id_ed25519" -o StrictHostKeyChecking=accept-new admin@"$SSH_IP"
Po zalogowaniu wykonaj poniższe kontrole wewnątrz VM. Poprawny wynik to właściwa nazwa hosta, status cloud-init done, aktywny agent i działające sudo bez pytania o hasło.
# Wewnątrz VM, jako użytkownik admin.
hostnamectl --static
cloud-init status --wait
systemctl is-active qemu-guest-agent
sudo -n true
ip -brief address
Gdy VM nie dostaje adresu, sprawdź virsh net-info "${LAB_ID}-nat" na hoście. Gdy nie startuje, użyj virsh -c qemu:///system console "$VM_NAME" i ponownie sprawdź format dysku. Nie usuwaj puli ani dysków „na próbę”: popraw konfigurację, utwórz nowy plan i zastosuj wyłącznie jego przeczytany wynik.
Jak usunąć tylko tę VM, jej dyski i sieć?
W tym scenariuszu testboxes zawiera tylko jedną VM, a base tylko jej obraz i sieć. Mimo to najpierw zapisz i przeczytaj plan usunięcia. Nie używaj virsh destroy --all, virsh undefine --all, rm -rf /var/lib/libvirt/images ani czyszczenia całej puli: takie polecenia nie rozróżniają laboratorium od cudzych zasobów.
# Host KVM, zwykły użytkownik. Usuń najpierw jedyną VM opisaną w testboxes/.
"$TOOL" -chdir=testboxes plan -destroy -out=testboxes-destroy.tfplan
"$TOOL" -chdir=testboxes show -no-color testboxes-destroy.tfplan
"$TOOL" -chdir=testboxes apply testboxes-destroy.tfplan
# Dopiero po kontroli usuń własny obraz golden i własną sieć NAT.
"$TOOL" -chdir=base plan -destroy -out=base-destroy.tfplan
"$TOOL" -chdir=base show -no-color base-destroy.tfplan
"$TOOL" -chdir=base apply base-destroy.tfplan
# Konto z prawem sudo: tylko ręcznie utworzona, pusta pula o własnym prefiksie.
sudo virsh -c qemu:///system pool-destroy "${LAB_ID}-vm"
sudo virsh -c qemu:///system pool-undefine "${LAB_ID}-vm"
sudo rmdir "/var/lib/libvirt/images/${LAB_ID}-vm"
rmdir celowo odmówi usunięcia katalogu z nieoczekiwanymi plikami. To jest sygnał do sprawdzenia zawartości, a nie do użycia szerokiego rm.
Kiedy lokalny state wystarczy, a kiedy potrzebny jest backend?
Lokalny plik stanu jest dobry dla jednej osoby, jednego hosta KVM i jednego labu. Trzymaj go wraz z lokalnym katalogiem projektu, z kopią chronioną tak samo jak sekrety. Backend jest potrzebny, gdy stan zmienia zespół, pipeline CI/CD albo więcej niż jeden host administracyjny: powinien zapewniać kontrolę dostępu, szyfrowanie kopii i blokowanie zmian.
Czym różni się lokalny lab od produkcji?
W labie NAT, konto z sudo i łatwe odtworzenie VM są rozsądnymi ułatwieniami. Produkcja wymaga osobnej decyzji o sieci, adresacji, dostępie administracyjnym, kopiach zapasowych, monitoringu, aktualizacjach oraz odzyskaniu po awarii. Bridge nie jest „lepszym NAT-em”: wpina VM do prawdziwego LAN, dlatego wymaga właściciela sieci i reguł zapory.
Nie przenoś bez refleksji laboratoryjnego StrictHostKeyChecking=accept-new, hasła w pliku tfvars, konta z szerokim sudo ani lokalnego state do środowiska współdzielonego.
Dalsza część to rozszerzenie dla większego labu. Nie trzeba budować czterech ani sześciu VM, aby wykonać pierwszy test. Poniższe sekcje wyjaśniają podział na warstwy, obrazy i operacje dnia drugiego, gdy jedna VM przestaje wystarczać. Dla każdej zmiany nadal obowiązuje zasada: najpierw plan, potem ręczna kontrola i dopiero apply.
Czy wybrać Terraform czy OpenTofu?
W 2023 roku HashiCorp zmienił licencję Terraform z otwartej na Business Source License. W odpowiedzi powstało odgałęzienie kodu, dziś rozwijane jako OpenTofu pod skrzydłami Linux Foundation. Dla labu takiego jak ten wybór jest wygodniejszy, niż się wydaje, bo nie trzeba go podejmować raz na zawsze.
Sprawdziliśmy to wprost. Ta sama konfiguracja warstwy bazowej, bez żadnej zmiany w plikach, przeszła przygotowanie i walidację pod oboma narzędziami:
# Terraform 1.16.1
terraform init && terraform validate
# Success! The configuration is valid.
# OpenTofu 1.12.6 - te same pliki, nic nie zmieniane
tofu init && tofu validate
# Success! The configuration is valid.
Jedyna różnica, jaką widać po tej operacji, siedzi w pliku blokady - inny jest host rejestru, z którego pobrano wtyczkę:
| Narzędzie | Wpis w pliku blokady | Wersja providera |
|---|---|---|
| Terraform | registry.terraform.io/dmacvicar/libvirt | 0.8.3 |
| OpenTofu | registry.opentofu.org/dmacvicar/libvirt | 0.8.3 |
Praktyczny wniosek: pliki konfiguracji są wspólne, a narzędzie wybiera się jedną zmienną. W naszym pliku Makefile stoi po prostu TF ?= terraform, więc przełączenie całego labu na drugie narzędzie to make apply TF=tofu. Nie zalecamy natomiast mieszania obu na tym samym stanie - plik blokady jest wtedy przepisywany w tę i z powrotem, co zaśmieca historię repozytorium bez żadnego zysku. Wybierz jedno na dany katalog i trzymaj się go.
Czego ten test nie obejmuje: nie porównywaliśmy zachowania obu narzędzi przy realnym tworzeniu maszyn ani przy migracji istniejącego stanu między nimi. Sprawdzona jest zgodność samej konfiguracji, nie równoważność w każdym scenariuszu.
Kiedy podzielić większy lab na osobne stany?
Najważniejsza decyzja w całym labie nie dotyczy narzędzia, tylko podziału. Lab jest rozbity na dwa katalogi główne z osobnymi stanami:
lab/
├── modules/
│ └── vm-group/ wspólna definicja maszyny (dyski, cloud-init, domena)
├── base/ pula obrazów, obrazy bazowe, sieć lab → własny stan
├── testboxes/ maszyny testowe → własny stan
└── Makefile skróty na oba katalogi
Powód jest praktyczny. Obrazy bazowe waży się w gigabajtach i pobiera z Internetu. Maszyny testowe kasuje się i stawia po kilka razy dziennie. Gdyby jedno i drugie siedziało w tym samym stanie, każde destroy maszyn zabierałoby ze sobą obrazy, a każde odtworzenie labu zaczynałoby się od kilkunastu minut pobierania. Przy osobnych stanach kasowanie maszyn nie rusza obrazów.
Kod tego labu, rozbudowany od czasu tego artykułu o kolejne systemy testowe i trzecią warstwę z maszynami usługowymi, opublikowaliśmy w katalogu terraform/ repozytorium sysgroup/linuxlab (stan z 22 września 2026, commit eba6d62). Warstwy z tego artykułu to katalogi base/ i testboxes/, a opis uruchomienia jest w README.
Katalog z maszynami musi jednak wiedzieć, jakie obrazy powstały w warstwie bazowej i jak nazywa się sieć. Służy do tego odczyt cudzego stanu:
data "terraform_remote_state" "base" {
backend = "local"
config = {
path = var.base_state_path
}
}
Dzięki temu warstwa maszyn czyta identyfikatory obrazów i nazwę sieci z warstwy bazowej, zamiast mieć je przepisane. Zmiana w jednym miejscu wystarcza.
Sama definicja maszyny jest napisana raz, w module modules/vm-group, i używana przez katalog maszyn. To jest ta sama zasada, co wyciągnięcie powtarzającego się fragmentu do funkcji: opis dysku, konfiguracji startowej i domeny istnieje w jednym egzemplarzu, a katalogi podają mu tylko listę maszyn.
Kolejność ma znaczenie. Warstwę bazową buduje się pierwszą, bo maszyny czytają jej stan. Kasuje się ją natomiast ostatnią, z tego samego powodu. Odwrotna kolejność przy sprzątaniu kończy się maszynami, które odwołują się do nieistniejących już obrazów.
Jak rozbudować lab, gdy jedna VM przestaje wystarczać?
To opis architektury większego labu, nie kolejna obowiązkowa ścieżka po przykładzie jednej VM. Wybierz z niego potrzebne elementy dopiero wtedy, gdy świadomie rozszerzasz własny projekt o kolejne systemy. W każdym katalogu zachowaj procedurę z sekcji o planie: init, validate, zapisany plan, kontrola i dopiero apply.
Przy każdym kroku podajemy, co ma z niego wyjść, ponieważ sam komunikat o zakończeniu polecenia nie jest jeszcze potwierdzeniem gotowości VM.
Krok 1. Sprawdź, czy procesor hosta obsługuje wirtualizację sprzętową
grep -cE 'vmx|svm' /proc/cpuinfo
Wynik większy od zera oznacza, że procesor ma sprzętowe wsparcie wirtualizacji i jest ono włączone. Zero przy nowoczesnym procesorze prawie zawsze znaczy, że funkcja jest wyłączona w ustawieniach firmware komputera - bez niej maszyny albo nie ruszą, albo będą chodzić rozpaczliwie wolno.
Krok 2. Zainstaluj libvirt i QEMU, których Terraform nie zainstaluje
To jest pierwszy z warunków wstępnych opisanych dalej: warstwa wirtualizacji musi już istnieć. Nazwy pakietów są z Debiana i Ubuntu:
sudo apt update
sudo apt install -y \
libvirt-daemon-system qemu-system-x86 qemu-utils \
ovmf genisoimage
# Bez tego każde polecenie wymagałoby uprawnień administratora.
sudo usermod -aG libvirt "$USER"
newgrp libvirt # albo po prostu wyloguj się i zaloguj ponownie
Sprawdzenie: virsh list --all ma się wykonać bez sudo i zwrócić pustą listę zamiast błędu uprawnień. Pakiet ovmf dostarcza firmware UEFI, genisoimage jest potrzebny do budowania obrazu z konfiguracją startową, a qemu-utils do konwersji obrazów w kroku 4.
Krok 3. Zainstaluj Terraform albo OpenTofu
Wystarczy jedno z nich. Oba mają własne repozytoria pakietów - HashiCorp pod apt.releases.hashicorp.com, OpenTofu pod packages.opentofu.org. Aktualne polecenia dodania klucza i repozytorium weź z dokumentacji wybranego projektu - instrukcja instalacji Terraform albo instrukcja instalacji OpenTofu, bo adresy kluczy bywają zmieniane. Obie instrukcje instalują najnowsze wydanie, 24 września 2026 były to Terraform 1.16.4 i OpenTofu 1.12.6.
Sprawdzenie: terraform version albo tofu version zwraca numer. Na naszym hoście stoją oba, zainstalowane właśnie z tych repozytoriów - i to jest w pełni poprawna sytuacja, o ile nie używasz ich naprzemiennie na tym samym stanie.
Krok 4. Utwórz katalogi na kod i obrazy bazowe
mkdir -p ~/lab/{base,testboxes,modules/vm-group}
mkdir -p ~/lab-golden # tu wylądują obrazy bazowe
Katalog na obrazy musi być trwały. Nie /tmp - warstwa bazowa czyta te pliki przy każdym odtwarzaniu obrazu, więc po wyczyszczeniu katalogu tymczasowego odbudowa skończy się błędem o brakującym pliku źródłowym.
Krok 5. Pobierz obrazy i przekonwertuj je do formatu surowego
To jest ten krok, który wynika z pułapki opisanej wyżej - i jednocześnie drugi warunek wstępny, którego nie ma w kodzie. Tak przygotowujemy obrazy czterech systemów z tego artykułu:
cd ~/lab-golden
curl -fLO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
curl -fLO https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
curl -fLO https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
curl -fLO https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img
for f in *.qcow2 *.img; do
qemu-img convert -O raw "$f" "${f%.*}.raw"
done
# Kontrola: KAŻDY plik .raw musi zgłaszać format raw.
for f in *.raw; do echo -n "$f: "; qemu-img info "$f" | awk -F': ' '/file format/{print $2}'; done
Sprawdzenie jest tu ważniejsze niż zwykle: jeżeli któryś plik zgłosi qcow2, maszyna z niego zbudowana wstanie do pustego ekranu. Ten jeden warunek decyduje o powodzeniu całej reszty.
Krok 6. Zbuduj warstwę bazową: pulę, obrazy i sieć
W katalogu base/ umieść pliki pokazane w sekcji o warstwie bazowej: deklarację wersji, konfigurację połączenia, pulę, obrazy i sieć. W katalogu obrazów wskaż ścieżki do plików surowych z kroku 5, a nie adresy internetowe.
# Host KVM, katalog większego labu. Najpierw sprawdź zapisany plan.
cd ~/lab/base
terraform init
terraform validate
terraform plan -out=base.tfplan
terraform show -no-color base.tfplan
terraform apply base.tfplan
To jest moment, w którym deklaracja wersji z poprzedniej sekcji zaczyna działać. init czyta blok required_providers, pobiera dokładnie tę wersję providera - nie najnowszą dostępną - i zapisuje ją razem z sumami kontrolnymi w pliku .terraform.lock.hcl. Nagłówek tego pliku mówi to wprost: jest utrzymywany automatycznie przez init, więc nie edytuje się go ręcznie. Od tej chwili każde kolejne uruchomienie w tym katalogu, także u kogoś innego, użyje tego samego providera. Podniesienie wersji wymaga świadomej zmiany w pliku i ponownego init z flagą -upgrade.
Nie myl tego pliku ze stanem. .terraform.lock.hcl zapisuje, jakim narzędziem budujesz - wersje providerów i ich sumy kontrolne - i trafia do repozytorium, żeby wszyscy używali tego samego. Stan, czyli terraform.tfstate, powstaje dopiero przy apply i zapisuje, co zostało utworzone. Stanu do repozytorium się nie wrzuca: bywa duży, zmienia się przy każdym uruchomieniu i potrafi zawierać dane wrażliwe.
init wykonuje się osobno w każdym katalogu głównym. Katalog maszyn z kroku 7 ma własny plik blokady i własną kopię providera, mimo że wersja jest ta sama.
Sprawdzamy trzy rzeczy naraz:
virsh pool-list # ma być pula z obrazami
virsh vol-list golden # ma być po jednym obrazie na system
virsh net-list # ma być sieć laboratoryjna, aktywna
Na naszym hoście ten krok zajął 70 sekund dla sześciu systemów.
Krok 7. Zbuduj maszyny testowe
W katalogu testboxes/ umieść mapę maszyn z sekcji o czterech maszynach oraz odczyt stanu warstwy bazowej. Pamiętaj o dwóch ustawieniach, bez których wróci pułapka z kroku 5: disk_strategy = "copy" i firmware UEFI.
# Host KVM, katalog większego labu. Nie wykonuj apply bez kontroli planu.
cd ~/lab/testboxes
terraform init
terraform validate
terraform plan -out=testboxes.tfplan
terraform show -no-color testboxes.tfplan
terraform apply testboxes.tfplan
Sprawdzenie: virsh list --all pokazuje wszystkie maszyny w stanie uruchomionym. Na naszym hoście krok zajął 205 sekund dla sześciu maszyn - to najdłuższy etap, bo każdy dysk jest pełną kopią obrazu.
Krok 8. Poczekaj na konfigurację startową i zaloguj się
Maszyny są już uruchomione, ale przez pierwsze minuty instalują pakiety. Najpierw poczekaj, aż wszystkie dostaną adres:
virsh net-dhcp-leases lab
Potem wygeneruj wpisy SSH i zaloguj się po nazwie maszyny:
make ssh-config # albo ręcznie: virsh domifaddr <maszyna>
ssh deb13
U nas od uruchomienia do możliwości zalogowania mijały około dwóch minut. Jeżeli któraś maszyna zgłosi błąd konfiguracji startowej, zajrzyj do sekcji o Debianie 11 - to niekoniecznie usterka labu, a może być informacja o systemie, który wypadł ze wsparcia.
Łącznie: około sześciu minut na kroki od 6 do 8, przy założeniu, że kroki 1-5 zostały wykonane wcześniej. Te pierwsze pięć robi się raz. Późniejsze odtworzenie labu - także pełne, ze skasowaniem wszystkiego - to już tylko kroki 6, 7 i 8.
Jak działa wspólna baza obrazów i sieć NAT?
Warstwa bazowa tworzy trzy rzeczy: miejsce na obrazy, same obrazy i sieć, w której będą pracować maszyny. Zaczyna się od zadeklarowania wersji - narzędzia i wtyczki, bo to dwie różne rzeczy:
terraform {
required_version = ">= 1.3"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "0.8.3"
}
}
}
provider "libvirt" {
uri = "qemu:///system"
}
Wersja providera jest wpisana dokładnie, a nie jako zakres, i to jest świadoma decyzja. Zapis ~> 0.8 oznacza „wszystko poniżej 1.0”, więc wpuszcza także wydania z serii 0.9 - a te istnieją, od 0.9.0 do 0.9.9. Jedna z nich zepsuła nam walidację konfiguracji, i to bez żadnej zmiany po naszej stronie. Od tamtej pory każdy katalog ma wpisaną konkretną wersję.
Połączenie qemu:///system oznacza systemowy demon libvirt, ten sam, którego używa graficzny menedżer maszyn. Aby nie potrzebować uprawnień administratora do każdej operacji, konto powinno należeć do grupy libvirt.
Dalej powstaje pula, czyli katalog, w którym libvirt trzyma obrazy, oraz same obrazy bazowe:
resource "libvirt_pool" "golden" {
name = "golden"
type = "dir"
target {
path = "/var/lib/libvirt/images/golden"
}
}
resource "libvirt_volume" "base" {
for_each = toset(local.distros_to_build)
name = "${each.key}-base.qcow2"
pool = libvirt_pool.golden.name
source = local.distro_url[each.key]
format = "raw"
}
Konstrukcja for_each tworzy tyle obrazów, ile pozycji ma lista - jeden opis, wiele wyników. Nazwa local.distros_to_build odwołuje się do wartości wyliczonej wcześniej, w tym wypadku listy systemów do zbudowania.
Katalog systemów jest zwykłą mapą. Poniżej pokazujemy cztery pozycje, o które chodzi w tym artykule:
distros = {
"debian-12" = {
pretty = "Debian 12 (bookworm)"
latest_url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
}
"debian-13" = {
pretty = "Debian 13 (trixie)"
latest_url = "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
}
"ubuntu-2404" = {
pretty = "Ubuntu 24.04 LTS (noble)"
latest_url = "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
}
"ubuntu-2604" = {
pretty = "Ubuntu 26.04 LTS (resolute)"
latest_url = "https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img"
}
}
enabled_distros = ["debian-12", "debian-13", "ubuntu-2404", "ubuntu-2604"]
Zwróć uwagę na dwa szczegóły, które łatwo przeoczyć. Debian i Ubuntu mają różny układ adresów: Debian wstawia datę wydania i do nazwy katalogu, i do nazwy pliku, a Ubuntu tylko do nazwy katalogu. Dlatego trzymamy pełne adresy zamiast składać je z samej daty. Drugi szczegół: lista enabled_distros musi pokrywać wszystkie systemy używane przez maszyny, bo inaczej maszyna nie znajdzie swojego obrazu.
Na koniec definiujemy sieć laboratoryjną, osobną od domyślnej sieci hosta:
resource "libvirt_network" "lab" {
name = "lab"
mode = "nat"
addresses = ["192.168.123.0/24"]
domain = "lab"
dhcp { enabled = true }
dns { enabled = true }
autostart = true
}
Oddzielna sieć dla maszyn testowych ma prostą zaletę: eksperyment z regułami zapory albo z trasowaniem nie dotyka tego, co host ma w sieci domyślnej. Adresy przydziela usługa DHCP (od angielskiego Dynamic Host Configuration Protocol), więc nie przypisujemy ich w kodzie na sztywno - do czego jeszcze wrócimy przy logowaniu.
Jak opisać kilka maszyn bez kopiowania konfiguracji?
Cała lista maszyn to jedna mapa. Każdy wiersz opisuje jedną maszynę, a wszystko, czego nie podano, bierze wartość domyślną z modułu (w repozytorium: modules/vm-group):
vms = {
deb12 = { group = "testdeb", profile = "base", distro = "debian-12" }
deb13 = { group = "testdeb", profile = "base", distro = "debian-13" }
ubu2404 = { group = "testubu", profile = "base", distro = "ubuntu-2404" }
ubu2604 = { group = "testubu", profile = "base", distro = "ubuntu-2604" }
}
disk_strategy = "copy"
wait_for_lease = false
Wiersze są krótkie dzięki temu, że w typie modułu pola mają wartości domyślne. W praktyce wygląda to tak:
variable "vms" {
type = map(object({
group = string
profile = string
distro = string
vcpus = optional(number, 2)
memory_mib = optional(number, 2048)
disk_gib = optional(number, 10)
network = optional(string, "lab")
running = optional(bool, true)
autostart = optional(bool, false)
}))
}
Stąd te 2 rdzenie i 2 GiB z poprzedniej sekcji - nie trzeba ich powtarzać przy każdej maszynie. Jeżeli jedna z nich potrzebuje więcej, dopisujesz tylko to jedno pole: deb13 = { ..., memory_mib = 4096 }.
Warto zwrócić uwagę na dwa ustawienia spoza mapy. autostart = false oznacza, że maszyny nie wstają razem z hostem - to celowe, bo lab ma być włączany wtedy, kiedy jest potrzebny. wait_for_lease = false mówi, że tworzenie maszyn nie ma czekać, aż każda z nich dostanie adres z DHCP. Przy czterech maszynach na raz to oszczędza sporo czasu, ale ma konsekwencję: zaraz po utworzeniu narzędzie nie zna jeszcze ich adresów.
Pole group nie jest ozdobnikiem. Pozwala pracować na podzbiorze maszyn bez ruszania reszty:
# Host KVM, katalog testboxes/. Najpierw potwierdź, co filtr usunie lub utworzy.
terraform plan -var='only_groups=["testdeb"]' -out=testdeb.tfplan
terraform show -no-color testdeb.tfplan
terraform apply testdeb.tfplan
To jest wygodniejsze i bezpieczniejsze niż wskazywanie pojedynczych zasobów z linii poleceń, bo filtr działa na wejściu - narzędzie w ogóle nie widzi maszyn spoza grupy, zamiast widzieć je i pomijać.
Co cloud-init robi podczas pierwszego startu VM?
Obraz chmurowy startuje bez instalatora, ale też bez konta, na które można się zalogować. Uzupełnia to cloud-init, czytając przygotowany dla niego plik. W labie plik jest szablonem, w który przy tworzeniu maszyny wstawiane są nazwa hosta, konto i klucz publiczny. Wszystkie profile tych szablonów są w katalogu modules/vm-group/cloud-init:
#cloud-config
hostname: ${hostname}
fqdn: ${hostname}.${domain}
users:
- name: ${username}
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ${ssh_public_key}
package_update: true
package_upgrade: false
packages:
- qemu-guest-agent
- python3
- sudo
- curl
final_message: "cloud-init OK - ${hostname}.${domain} gotowy"
NOPASSWD:ALL jest tu wyłącznie uproszczeniem odtwarzalnego labu. Na hoście z danymi lub dostępem szerszym niż testowy zawęź uprawnienia sudo do potrzebnych poleceń albo wymagaj hasła i udokumentuj wyjątki.
Cztery pakiety z tej listy nie są przypadkowe. python3 jest potrzebny, bo Ansible wykonuje na maszynie docelowej moduły napisane w Pythonie - bez niego playbook nie ruszy. qemu-guest-agent to mały program, przez który host może zapytać system w środku o jego adres; bez niego zostaje odczyt z tablicy DHCP, który bywa nieaktualny. package_upgrade: false jest świadome: maszyna ma wstawać szybko i w stanie zgodnym z obrazem, a aktualizacja pakietów to często właśnie to, co chcemy przetestować, a nie coś, co ma się wydarzyć samo w tle.
Ostatnia linia bywa niedoceniana. final_message pojawia się na konsoli szeregowej maszyny, kiedy cloud-init skończy pracę. Kiedy maszyna „nie wstaje”, jej brak jest pierwszą informacją diagnostyczną: albo konfiguracja startowa nie została w ogóle wczytana, albo przerwała się w połowie.
Dlaczego VM może nie uruchomić się po sklonowaniu dysku?
To jest fragment, dla którego warto było napisać ten artykuł. Objaw był taki: maszyny powstawały poprawnie, narzędzie kończyło pracę bez błędu, a system się nie uruchamiał. Starszy firmware pokazywał komunikat o dysku, z którego nie da się uruchomić systemu, a UEFI kończyło w swojej powłoce, nie widząc żadnego systemu plików. Przy tym qemu-img i narzędzia do partycji czytały obraz bez zarzutu, a maszyna utworzona ręcznie przez virt-install z tego samego obrazu wstawała normalnie.
Przyczyna okazała się jedna. Provider w wersji 0.8.3, przy podpięciu dysku przez disk { file = ... }, wpisuje do opisu maszyny format surowy, ignorując rzeczywisty format pliku. Widać to w opisie działającej maszyny:
$ virsh dumpxml deb13 | grep -A2 "<disk type"
<disk type='file' device='disk'>
<driver name='qemu' type='raw'/>
<source file='/var/lib/libvirt/images/deb13.img'/>
Obrazy chmurowe są natomiast publikowane w formacie qcow2, czyli takim, który ma własny nagłówek na początku pliku. Jeżeli dysk jest w rzeczywistości qcow2, a maszyna dostaje informację, że jest surowy, to qemu podaje firmware'owi początek pliku tak, jak leży. Firmware czyta nagłówek qcow2 w miejscu, w którym spodziewa się tablicy partycji - i słusznie stwierdza, że żadnej tam nie ma.
Dlaczego inne narzędzia działały? Bo qemu-img i fdisk same rozpoznają format po nagłówku, a virt-install wpisuje do opisu maszyny format prawidłowy.
Rozwiązanie: skoro provider i tak zadeklaruje format surowy, dysk faktycznie ma być surowy. Provider dziedziczy format ze źródła, więc wystarczy, żeby obraz bazowy był surowy - dyski maszyn odziedziczą to po nim. Konwersję robi jedno polecenie i nie wymaga uprawnień administratora.
mkdir -p ~/virt_infra/golden-raw
qemu-img convert -O raw \
debian-13-genericcloud-amd64.qcow2 \
~/virt_infra/golden-raw/debian-13.raw
Następnie w warstwie bazowej wskazujesz te pliki zamiast adresów w Internecie, a maszyny tworzysz z ustawieniem disk_strategy = "copy". Sprawdzone: przy takim układzie UEFI czyta tablicę partycji i partycję rozruchową oryginalnego obrazu chmurowego, maszyna wstaje, cloud-init się wykonuje i logowanie działa.
Dwie rzeczy, które warto zapamiętać przy okazji:
- Trzymaj obrazy surowe w katalogu trwałym, nie w
/tmp. Warstwa bazowa czyta te pliki przy każdym odtwarzaniu obrazu. Po wyczyszczeniu katalogu tymczasowego odtworzenie maszyny kończy się błędem o brakującym pliku źródłowym. - Nie myl pojemności logicznej z zajętym miejscem. Cztery wolumeny raw po 10 GiB mają łącznie 40 GiB pojemności logicznej, ale sposób alokacji zależy od tworzenia i prealokacji. Porównaj
ls -lhzdu -horazqemu-img infoprzed planowaniem miejsca.
Przy okazji rozwiązały się dwa mniejsze kłopoty. Dysk budowany jako warstwa na obrazie bazowym, zamiast pełnej kopii, potykał się o mechanizm kontroli dostępu na hoście, który nie dopuszczał pliku bazowego - pełna kopia nie ma tego problemu. A konfiguracja startowa podpinana jako napęd optyczny nie działała na nowszym typie maszyny wirtualnej, który nie ma odpowiedniego kontrolera; podpięcie jej jako zwykłego dysku rozwiązało sprawę.
Zastrzeżenie: to wszystko dotyczy providera w wersji 0.8.3. W nowszych wydaniach zachowanie mogło się zmienić - dlatego właśnie wersja jest zadeklarowana wprost, a nie zakresem.
Czy pobierać zawsze najnowszy obraz chmurowy?
Dystrybucje publikują obrazy chmurowe okresowo, z wbudowanymi poprawkami bezpieczeństwa, w katalogach oznaczonych datą. Obok nich jest katalog wskazujący najnowszy. To stawia przed wyborem, który dobrze pokazuje, na czym polega powtarzalność:
| Wybór | Zaleta | Koszt |
|---|---|---|
| Zawsze najnowszy | świeże poprawki bez żadnej pracy | ten sam kod uruchomiony dziś i za miesiąc zbuduje lab z innego obrazu; różnicy nie widać w repozytorium |
| Konkretny dzień | ten sam kod daje zawsze ten sam lab | obraz się starzeje i trzeba go świadomie podnosić |
To jest dokładnie ta sama decyzja, co przy wersji providera, tylko przeniesiona na obrazy systemów. W labie trzymamy oba adresy i przełącznik między nimi, bo jedno i drugie ma swoje zastosowanie: przy zwykłej pracy chcemy świeżych poprawek, a przy odtwarzaniu błędu sprzed miesiąca - dokładnie tamtego obrazu.
Jest tu jedna pułapka praktyczna, o której łatwo się przekonać w najgorszym momencie: serwery Debiana usuwają stare zrzuty po kilku tygodniach. Adres wskazujący konkretny dzień sprzed pół roku zwróci błąd. Jeżeli budujesz z konkretnej daty i dostajesz odpowiedź o braku pliku, trzeba wziąć aktualne oznaczenie z listy katalogów. Ubuntu trzyma swoje wydania dłużej, ale zasada jest ta sama - nie zakładaj, że adres sprzed roku nadal odpowiada.
Jak odtworzyć jedną VM bez naruszania pozostałych?
Postawienie labu to jedno, a codzienna praca z nim to drugie. Trzy operacje wykonujemy najczęściej.
Odtworzenie jednej maszyny poleceniem make restore
Najczęstsza sytuacja: test popsuł maszynę i chcemy ją mieć z powrotem w stanie wyjściowym, nie ruszając pozostałych. Wymaga to wymuszenia wymiany trzech rzeczy naraz - dysku, konfiguracji startowej i samej maszyny:
terraform plan \
-replace='module.vms.libvirt_volume.vm_disk["deb13"]' \
-replace='module.vms.libvirt_cloudinit_disk.vm_init["deb13"]' \
-replace='module.vms.libvirt_domain.vm["deb13"]' \
-out=restore-deb13.tfplan
terraform show -no-color restore-deb13.tfplan
terraform apply restore-deb13.tfplan
Adresy wyglądają rozwlekle, bo zasoby siedzą w module: najpierw jego nazwa, potem typ zasobu, a w nawiasie klucz z mapy maszyn. Właśnie dlatego w labie mamy na to skrót w pliku Makefile - make restore DIR=testboxes VM=deb13 - który składa te trzy adresy sam. Odtworzenie całej grupy działa tak samo, tylko pętla po nazwach maszyn z grupy.
Ważne, żeby wymienić wszystkie trzy rzeczy. Sam dysk bez konfiguracji startowej da maszynę z czystym systemem, ale ze starym kluczem i nazwą hosta z poprzedniego wcielenia.
Powiększenie dysku maszyny bez utraty danych
Dysk maszyny da się powiększyć w miejscu, bez odtwarzania. W labie robi to make resize VM=deb13 GB=30, a pod spodem jest zwykła operacja libvirt na woluminie. Tu działa zasada „tylko w górę”: warstwa bazowa i maszyny mają wymuszanie rozmiaru zapisane tak, żeby powiększać, a nigdy nie zmniejszać. Zmniejszenie dysku z danymi to prosta droga do uszkodzenia systemu plików, więc kod po prostu tego nie robi.
Logowanie przez SSH po nazwie maszyny zamiast adresu IP
Tu wracamy do adresów przydzielanych przez DHCP. Sieć laboratoryjna ma włączony DNS, ale rozwiązywanie nazw gości bywa zawodne - zwłaszcza tuż po starcie maszyny albo gdy dostała inny adres niż poprzednio. Zamiast walczyć z tym po stronie sieci, generujemy zwykły plik konfiguracyjny SSH:
make ssh-config # odpytuje maszyny o adresy i zapisuje ~/.ssh/config-lab
Powstaje plik z wpisem na każdą maszynę, który wystarczy raz podłączyć w ~/.ssh/config linią Include ~/.ssh/config-lab. Od tego momentu ssh deb13 po prostu działa. Adresy odczytywane są najpierw przez agenta w systemie gościa, a dopiero w drugiej kolejności z tablicy DHCP - dlatego ten agent jest na liście pakietów w konfiguracji startowej.
Plik jest nadpisywany w całości przy każdym uruchomieniu, więc własnych wpisów w nim nie trzymamy - od tego jest osobny plik, którego generowanie nie rusza. Maszyny lab są odtwarzane często i przy każdym odtworzeniu zmieniają klucz hosta, dlatego wpisy celowo nie sprawdzają kluczy znanych. To jest ustępstwo uzasadnione w labie i nie należy go przenosić na maszyny produkcyjne.
Jak usunąć większy lab w kontrolowanej kolejności?
Kasowanie odbywa się w kolejności odwrotnej do budowania: najpierw maszyny, na końcu warstwa bazowa. W każdym katalogu zapisz plan usunięcia i upewnij się, że zawiera wyłącznie zasoby należące do tego labu:
terraform -chdir=testboxes plan -destroy -out=testboxes-destroy.tfplan
terraform -chdir=testboxes show -no-color testboxes-destroy.tfplan
terraform -chdir=testboxes apply testboxes-destroy.tfplan
terraform -chdir=base plan -destroy -out=base-destroy.tfplan
terraform -chdir=base show -no-color base-destroy.tfplan
terraform -chdir=base apply base-destroy.tfplan
Podział na dwa stany pokazuje tu swoją wartość. Skasowanie maszyn nie zabiera obrazów bazowych, więc postawienie labu z powrotem trwa tyle, co skopiowanie plików - bez ponownego pobierania kilku gigabajtów. W praktyce drugiego polecenia używa się rzadko: warstwa bazowa stoi miesiącami, a maszyny znikają i wracają.
Domyślna sieć hosta nie jest zarządzana przez ten kod, więc sprzątanie jej nie dotyczy. To celowe - nie chcemy, żeby operacja na labie zabrała sieć, z której korzystają inne maszyny.
Ile trwa pełne odtworzenie labu od zera
Żeby nie zostawiać tego w sferze deklaracji, skasowaliśmy cały lab i zbudowaliśmy go od nowa. Najpierw maszyny, potem warstwa bazowa - czyli w kolejności odwrotnej do budowania - a następnie odbudowaliśmy go w kolejności właściwej. Zmierzone czasy pokazuje tabela:
| Krok | Co się stało | Czas |
|---|---|---|
| Kasowanie maszyn | 19 zasobów: dyski, konfiguracje startowe, maszyny | natychmiast |
| Kasowanie warstwy bazowej | 14 zasobów: pula, obrazy bazowe, sieć laboratoryjna | natychmiast |
| Odbudowa warstwy bazowej | pula, obrazy, sieć | 70 s |
| Odbudowa maszyn | dyski, konfiguracje startowe, maszyny wraz z powiększeniem dysków | 205 s |
| Start i konfiguracja startowa | adres z DHCP, konto, klucz, pakiety | około 2 min |
Od pustego miejsca do maszyn, na które można się zalogować: około sześciu minut. Kasowanie jest natychmiastowe, bo sprowadza się do usunięcia plików i definicji. Najdłuższy krok to odbudowa maszyn - i tu widać koszt formatu surowego, bo każdy dysk jest pełną kopią obrazu, a nie warstwą na nim.
Po tej operacji warto od razu odświeżyć plik konfiguracji SSH, bo adresy przydzielone przez DHCP prawie na pewno będą inne niż poprzednio. Sprawdziliśmy całą ścieżkę: po wygenerowaniu pliku logowanie po nazwie maszyny działa bez podawania adresu.
Do tych liczb mamy jedno zastrzeżenie. Był to host, na którym libvirt jest zainstalowany, obrazy w formacie surowym leżą na dysku, a pliki ze zmiennymi są na miejscu - czyli wszystko, o czym mowa w następnej sekcji. Zmierzony jest cykl skasowania i odbudowy, a nie postawienie labu na maszynie, która nigdy go nie widziała.
Lab sam pokazuje, kiedy system wypada ze wsparcia
Przy tej odbudowie wydarzyła się rzecz, której nie planowaliśmy, a która dobrze pokazuje, po co lab w ogóle istnieje. Pięć z sześciu maszyn zakończyło konfigurację startową czysto. Szósta - Debian 11, którego w tym artykule celowo nie opisujemy jako systemu testowego - zgłosiła błąd:
$ cloud-init status
status: error
$ sudo apt-get update
E: Release file for .../bullseye-security/InRelease is expired
(invalid since 2d 16h 44min 11s)
Plik z podpisem repozytorium bezpieczeństwa wygasł dwa dni wcześniej. Debian celowo nadaje tym plikom datę ważności: kiedy wydanie przestaje być wspierane, nikt ich już nie odświeża, a menedżer pakietów odmawia korzystania z przeterminowanego indeksu, zamiast po cichu instalować ze źródła, którego nikt nie pilnuje.
Maszyna wstała i wpuściła po SSH - konto i klucz powstają wcześniej niż instalacja pakietów - ale krok z aktualizacją pakietów zakończył się niepowodzeniem i cała konfiguracja startowa dostała status błędu. To jest dokładnie ten sygnał, dla którego warto mieć lab: system wypadł ze wsparcia i pierwszą osobą, która się o tym dowiedziała, była maszyna testowa, a nie serwer klienta.
Wniosek praktyczny jest prosty i wart zapamiętania: jeżeli konfiguracja startowa ma włączone odświeżanie listy pakietów, to odtworzenie maszyny jest zarazem testem, czy jej system nadal żyje. Nic nie trzeba w tym celu dopisywać.
Czego kod Terraform nie przygotuje za mnie?
„Infrastruktura opisana kodem” brzmi jak obietnica, że jedno polecenie odtworzy całość na dowolnej maszynie. W praktyce prawie każdy taki projekt ma warunki wstępne, których w kodzie nie ma - i lepiej je znać, niż odkryć w trakcie. Poniżej opisujemy cztery, które dotyczą tego labu.
Po pierwsze, przygotowanie hosta. Terraform zakłada, że zainstalowane są libvirt oraz QEMU - czyli, kolejno, warstwa zarządzania maszynami wirtualnymi i sam emulator, który je uruchamia, że w systemie leży firmware UEFI dla maszyn wirtualnych i że Twoje konto należy do grupy pozwalającej zarządzać maszynami bez uprawnień administratora. Sam się o to nie zatroszczy.
Po drugie, nie każda pula jest zarządzana. Warstwa bazowa tworzy pulę na obrazy systemów, ale dyski maszyn lądują w puli, która musi już istnieć - jej nazwa jest zwykłą zmienną wejściową, opisaną wprost jako „istniejąca”. To celowe: jest to standardowa pula hosta, z której korzystają też maszyny spoza labu, więc nie chcemy, żeby operacja na labie mogła ją usunąć.
Po trzecie, obrazy w formacie surowym powstają poza Terraformem. Konwersję z sekcji o formacie dysku wykonuje osobny skrypt, uruchamiany ręcznie (w repozytorium: scripts/cloud-image-to-raw.sh). Nie ma na to ani zasobu w kodzie, ani skrótu w pliku Makefile - warstwa bazowa tylko czyta gotowe pliki z dysku.
Po czwarte - i to jest najbardziej podstępne - pliki, które wybierają działającą ścieżkę, są wyłączone z repozytorium. Reguły ignorowania obejmują wszystkie pliki ze zmiennymi oraz katalog z obrazami surowymi. Sprawdziliśmy, co dostaje ktoś, kto sklonuje repozytorium: nie ma tam ani pliku wskazującego obrazy surowe, ani pliku ustawiającego pełną kopię dysku, ani samych obrazów.
Skutek jest gorszy niż błąd. Lista maszyn ma wartość domyślną, więc po sklonowaniu repozytorium polecenie wykona się bez żadnego ostrzeżenia - tyle że sięgnie po wartości domyślne: obrazy qcow2 prosto z Internetu i dysk budowany jako warstwa. To jest dokładnie ta kombinacja, która daje maszyny niebootujące się z powodów opisanych wyżej. Dostajesz więc lab, który powstał „poprawnie” i nie działa, a przyczyna siedzi w dwóch plikach, których w repozytorium nie ma.
Wnioski są dwa i oba wykraczają poza ten lab. Pierwszy: reguły ignorowania warto przeglądać pod kątem tego, czy nie wypada przez nie konfiguracja, a nie tylko sekrety i stan. Wykluczenie wszystkich plików ze zmiennymi jest wygodne, dopóki nie okaże się, że to właśnie w nich siedzi jedyna działająca kombinacja ustawień. Rozwiązaniem jest tu wersja przykładowa trzymana w repozytorium - u nas jest taka dla zmiennych przypinających obraz do konkretnego dnia, ale dla pozostałych plików jej brakuje.
Drugi: test „czy da się odtworzyć” trzeba wykonać na czystej maszynie, a nie na tej, na której się pracuje. Na hoście, który ma już pule, obrazy i pliki lokalne, wszystko będzie działać niezależnie od tego, co faktycznie znalazło się w repozytorium. Dopiero klon w pustym katalogu pokazuje prawdę - i to zajmuje kwadrans, a nie dzień.
Warto przy tym oddzielić dwie rzeczy, bo łatwo je pomylić. Braki opisane wyżej dotyczą pierwszego uruchomienia - postawienia labu tam, gdzie go nie było. Codzienne odtwarzanie, opisane w sekcji o operacjach dnia drugiego, działa bez zastrzeżeń: skasowanie maszyny i postawienie jej z powrotem w stanie wyjściowym to jedno polecenie i kilka minut.
Jakie ograniczenia ma lab na jednym hoście?
- To lab, nie miniatura produkcji. Jedna maszyna fizyczna, jeden host wirtualizacji, brak nadmiarowości. Nadaje się do sprawdzenia, czy playbook działa na czterech systemach; nie nadaje się do wnioskowania o wydajności ani o zachowaniu przy awarii sprzętu.
- Stan leży w pliku lokalnym. Dla jednej osoby to zaleta - zero infrastruktury dodatkowej. Dla zespołu byłby to problem, bo dwie osoby pracujące równocześnie nadpiszą sobie stan nawzajem. Wtedy trzeba przenieść stan w miejsce wspólne, z blokowaniem.
- Opis dotyczy jednej wersji providera. Zachowanie z formatem dysku, kluczowe dla całej konstrukcji, sprawdziliśmy na
0.8.3. Przy podnoszeniu wersji trzeba to sprawdzić ponownie, zaczynając od jednej maszyny. - Format raw nie mówi sam, ile miejsca zajęto. Cztery wolumeny po 10 GiB to 40 GiB pojemności logicznej; faktyczne bloki zależą od metody utworzenia i prealokacji. Monitoruj
duoraz pulę libvirt. - Konfiguracja startowa działa raz. Wszystko, co ma się wydarzyć przy pierwszym uruchomieniu, dzieje się tylko wtedy. Zmiana w tym pliku nie wpłynie na istniejącą maszynę, dopóki jej nie odtworzysz - i to jest częsta przyczyna zdziwienia.
- Lab nie zastępuje etapu przejściowego. Sprawdzenie zmiany na czystym Debianie 13 nie dowodzi, że zadziała na serwerze z pięcioletnią historią konfiguracji. To dwa różne testy i oba są potrzebne.
Co sprawdzić przed rozpoczęciem testu?
- Host ma włączoną wirtualizację, zainstalowane libvirt z QEMU, a konto należy do grupy
libvirt. - Wersja narzędzia i wersja providera są zadeklarowane wprost, a plik blokady jest w repozytorium.
- Warstwa bazowa jest budowana przed maszynami, a kasowana po nich.
- Obrazy bazowe są w formacie surowym i leżą w katalogu trwałym, nie tymczasowym.
- Lista systemów w warstwie bazowej pokrywa wszystkie systemy używane przez maszyny.
- Konfiguracja startowa wstawia Twój klucz publiczny, a nie przykładowy z artykułu.
- Maszyny nie startują razem z hostem, chyba że świadomie tego chcesz.
- Wiesz, jak odtworzyć jedną maszynę bez ruszania pozostałych, i sprawdziłeś to raz na spokojnie.
- Wygenerowany plik konfiguracji SSH jest podłączony w
~/.ssh/config, a własne wpisy trzymasz osobno. - Sprawdziłeś na czystym katalogu, co naprawdę zawiera repozytorium - czy reguły ignorowania nie wycięły plików potrzebnych do pierwszego uruchomienia.
- Ustępstwa poczynione dla wygody labu - brak sprawdzania kluczy hostów, konto bez hasła do
sudo- zostają w labie.
Lab zwraca zainwestowany czas w momencie, w którym przestajesz się zastanawiać, czy możesz coś zepsuć. Cała ta konstrukcja - dwie warstwy, wspólny moduł, zadeklarowane wersje - służy jednej rzeczy: żeby odpowiedź na pytanie „a co się stanie, jak…” była na wyciągnięcie jednego polecenia, a jej koszt wynosił kilka minut.
Chcesz testować zmiany, zanim trafią na produkcję?
Budujemy środowiska testowe odwzorowujące Twoją infrastrukturę, opisane kodem i odtwarzalne w kilka minut - żeby aktualizacja systemu przestała być wieczorem pełnym napięcia.
Zobacz też