Die Annahme, die Ansible sonst machen darf

Fast jedes Ansible-Modul, das du benutzt hast, arbeitet so: Ansible verbindet sich zu dem Host, der im Inventar steht, kopiert ein kleines Python-Programm dorthin, führt es aus und liest das Ergebnis zurück. Der Host ist das Ding, das geändert wird, und das Ding, das die Arbeit macht.

Eine virtuelle Maschine anzulegen bricht das auf die grundlegendste Weise. Den Host, den du baust, gibt es nicht. Er hat keine IP, keinen SSH-Dienst, kein Python und kein Betriebssystem. Es ist nichts da, zu dem man verbinden könnte.

community.proxmox ist also kein Konfigurationsagent. Es ist ein API-Client, der zufällig als Ansible-Collection ausgeliefert wird. Alles in diesem Beitrag folgt aus dieser einen Tatsache — wo die Tasks laufen, wie du Zugangsdaten hineinbekommst, warum ein zweiter Lauf nicht tut, was du erwartest, und warum --check dir nicht die Wahrheit sagt.

Die Beispiele hier sind eingekürzt aus einem Playbook, das Windows- und Linux-VMs aus NetBox-Datensätzen baut: proxmox-create-vms.yml. Ich habe die Namen von Knoten, Speicher und Bridge der Lesbarkeit zuliebe verallgemeinert — das Echte liegt in diesem Repository.

Alles, was ich unten über das Verhalten der Module behaupte, wurde gegen community.proxmox 1.6.0 geprüft, die Fassung, die ich installiert habe:

$ ansible-galaxy collection list community.proxmox
# /home/damien/.ansible/collections/ansible_collections
Collection        Version
----------------- -------
community.proxmox 1.6.0

Zuerst: die Collection ist umgezogen

Liest du ein älteres Playbook oder eine ältere Antwort, hießen die Module community.general.proxmox_kvm. Sie wohnen jetzt in einer eigenen Collection, und dort findet die Entwicklung statt. Fassung 1.6.0 liefert 47 Module, die Ceph, SDN, Firewall, HA-Regeln und den Cluster-Beitritt abdecken, und nichts davon gab es in der Zeit von community.general.

ansible-galaxy collection install community.proxmox
pip install 'proxmoxer>=2.0' requests

Die Python-Abhängigkeit ist nicht wahlfrei und nicht mitgeliefert: die Collection erklärt requirements: ["proxmoxer >= 2.0", "requests"], und die müssen dort installiert sein, wo das Modul tatsächlich ausgeführt wird — und das ist, wie der nächste Abschnitt erklärt, nicht der Proxmox-Knoten.

requirements.yml, wenn du es lieber festnagelst:

---
collections:
  - name: community.proxmox
    version: ">=1.6.0"

Die Collection wird gegen ansible-core 2.17 bis 2.20 getestet. Deine community.general.proxmox_*-Tasks in community.proxmox.proxmox_* umzubenennen ist der größte Teil der Umstellung.

Jeder Proxmox-Task läuft auf localhost

Zwei Zeilen am Kopf des Plays tragen die Last, und beide sehen aus, als würden sie etwas Nützliches abschalten:

- name: Create Proxmox virtual machines
  hosts: "{{ target_hosts | default('cluster_pve:&status_planned') }}"
  gather_facts: false
  serial: 1

gather_facts: false ist keine Optimierung. Das Sammeln von Fakten verbindet sich zum Inventar-Host, und der Inventar-Host ist eine VM, die noch nicht gebaut ist. Lass es an, und das Play scheitert vor dem ersten Task.

Dann trägt jeder Proxmox-Task ein delegate_to: localhost:

- name: Create Proxmox VM
  delegate_to: localhost
  register: created_vm
  community.proxmox.proxmox_kvm:
    api_user: "{{ proxmox_user }}"
    api_password: "{{ proxmox_password }}"
    api_host: "{{ proxmox_api_ip }}"
    name: "{{ inventory_hostname }}"
    node: "{{ proxmox_api_host }}"
    ...

Der Inventar-Host ist jetzt bloß ein Name und ein Sack Variablen. inventory_hostname wird der Name der VM; seine Variablen beschreiben die Maschine, die du willst. Nichts verbindet sich dorthin. Der Task läuft auf dem Steuerknoten, der eine HTTPS-Sitzung zu api_host öffnet und eine VM-Definition abschickt.

Du wirst das auch als local_action: geschrieben sehen, die ältere Schreibweise für dasselbe. Das Abbau-Playbook in jenem Repository nutzt sie durchgehend. Sie sind gleichwertig. delegate_to ist die heutige Schreibweise.

Die Notluke zeigt auf den Knoten

Manches muss wirklich auf einem Proxmox-Host passieren, und diese Tasks delegieren woandershin:

- name: Fail if no ISO file exists for the OS
  delegate_to: "{{ proxmox_api_host }}"
  ansible.builtin.stat:
    path: "{{ iso | replace('isos:', '/mnt/pve/isos/template/') }}"
  register: iso_file
  failed_when: not iso_file.stat.exists

Das ist eine echte SSH-Verbindung zu einem echten Knoten, die einen echten Pfad auf gemeinsamem Speicher prüft, denn die API nimmt bereitwillig eine ISO-Angabe an, die auf keine Datei zeigt, und das willst du lieber jetzt erfahren als beim Start. Beachte die Zeichenketten-Operation, die eine PVE-Speicherangabe (isos:iso/debian.iso) in einen Dateisystempfad überführt. Die Speicherabstraktion steht stat nicht zur Verfügung.

Ein einzelnes Play hat also Tasks, die an drei verschiedenen Stellen ausgeführt werden, und sie zu verwechseln ist die häufigste Weise, wie diese Playbooks scheitern:

Wo jeder Task eines Proxmox-Bau-Playbooks tatsächlich ausgeführt wirdAnsible-Steuerknotendelegate_to: localhostproxmox_kvmproxmox_vm_infoproxmox_diskproxmox_access_acljedes davon ist einHTTPS-Client, kein Agentproxmoxer ≥ 2.0 + requestshier installiert, nicht am Knotengather_facts: falseProxmox-Knoten — pve1pvedaemon, REST APIauf Port 8006qm, /etc/pve, Speicherdie VM-Definition landet hierdie VM, die du anlegstkeine IP · kein SSH · kein Pythonkein Betriebssystemim Inventar ist sie nurein Name und ein Sack VariablenAPISSHqm setstatnichts, wohin man verbindeterst in einem späteren Play
Drei Ausführungsorte in einem Play. Die Proxmox-Module berühren weder den Knoten noch den Gast — sie sind HTTPS-Clients, die neben dem Playbook laufen. Die qm-Notluke ist der einzige Teil, der SSH zu einem Hypervisor braucht.

Zugangsdaten, und eine Voreinstellung, die sich bald ändert

Die Auth-Optionen teilen alle Module der Collection über ein Dokumentationsfragment, sie sind also überall dieselben: api_host, api_user, und dann entweder api_password oder das Paar api_token_id / api_token_secret. Alle greifen auf Umgebungsvariablen zurück — PROXMOX_HOST, PROXMOX_USER, PROXMOX_PASSWORD, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET, PROXMOX_VALIDATE_CERTS —, und das ist der saubere Weg, Geheimnisse ganz aus dem Play zu halten.

Ein API-Token ist die bessere Voreinstellung. Es ist eingegrenzt, es ist widerrufbar, ohne das Passwort eines Menschen zu ändern, und es kann genau die Rechte bekommen, die das Playbook braucht, statt der Rechte, die eine Person zufällig hat:

- name: Create Proxmox VM
  delegate_to: localhost
  community.proxmox.proxmox_kvm:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    validate_certs: true
    ca_path: /etc/ssl/certs/pve-cluster-ca.pem

Setz validate_certs ausdrücklich, und zwar heute. Die eigene Dokumentation der Collection sagt es klar:

Currently defaults to false and changes default to true with community.proxmox 2.0.0.

Das heißt, ein Playbook, das es nie erwähnt, prüft TLS gerade nicht und wird anfangen zu prüfen — und damit anfangen, gegen das selbst ausgestellte Zertifikat zu scheitern, das jede frische Proxmox-Installation mitbringt —, sobald jemand --upgrade laufen lässt. Diese Entscheidung trifft man besser mit Absicht, als sie mitten in einem Aufbau zu erleben. Behältst du das selbst ausgestellte Zertifikat, schreib validate_certs: false und nimm den Befund hin; hast du eine richtige Kette, zeig mit ca_path darauf. So oder so steht es geschrieben.

Das kleinste taugliche Anlegen

Kürz den produktiven Task auf das, was eine Maschine tatsächlich beschreibt, und es ist lesbar:

- name: Create Proxmox VM
  delegate_to: localhost
  register: created_vm
  community.proxmox.proxmox_kvm:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    validate_certs: true

    node: pve1
    name: "{{ inventory_hostname }}"
    cores: "{{ vcpus | int }}"
    memory: "{{ memory }}"
    machine: q35
    bios: ovmf
    ostype: l26
    scsihw: virtio-scsi-single
    scsi:
      scsi0: "vmdata:32,format=qcow2,discard=on,ssd=1"
    sata:
      sata0: "isos:iso/debian-13-netinst.iso,media=cdrom"
    net:
      net0: "virtio,bridge=vmbr0"
    efidisk0:
      storage: vmdata
      format: raw
      efitype: 4m
      pre_enrolled_keys: true
    boot: "order=scsi0;sata0"
    agent: "enabled=1,fstrim_cloned_disks=1"
    onboot: true
    tags:
      - production

Ein paar Dinge an dieser Form sind es wert, sie zu kennen, bevor du deine eigene schreibst.

Die Geräteoptionen sind PVE-Syntax innerhalb von YAML. scsi, sata, net, virtio, ide sind alle als dict typisiert, mit Schlüsseln scsi0, net0 und so weiter, und die Werte sind die kommagetrennten Optionszeichenketten direkt aus man qm — <storage>:<size>,option=value für eine Platte, [model=]<enum>,option=value für eine NIC. Das Modul modelliert sie nicht; es leitet sie weiter. Wird etwas abgelehnt, steht die Antwort in der PVE-Optionsreferenz, nicht in der Ansible-Dokumentation.

Du wirst diese auch als JSON-Zeichenkette statt als YAML-Abbildung sehen:

    net: '{"net0":"virtio,bridge={{ vlan_bridge }}"}'

Beides funktioniert. Ansible wandelt die Zeichenkette für einen als dict typisierten Parameter um. Die JSON-Form existiert, weil es leichter ist, eine ganze Struktur in einem Jinja-Ausdruck zu erzeugen. Die Abbildungsform ist in sechs Monaten leichter zu lesen.

boot hat zwei Generationen Syntax. Das Modul nimmt die alten Buchstaben, wo boot: "cdn" heißt „versuch Platte, dann CD-ROM, dann Netz“. Das heutige PVE will eine ausdrücklich geordnete Liste — boot: "order=scsi0;sata0;net0" —, die eindeutig sagt, welche Platte. Die alte Form läuft weiter. Die ausdrückliche ist die, die du in neuer Arbeit willst. Ein echter Fallstrick, tief in der Moduldokumentation begraben: Netzstart braucht seit PVE 8.3.5, dass rng0 gesetzt ist.

numa und numa_enabled sind verschiedene Parameter. numa_enabled ist der Schalter, der NUMA einschaltet. numa ist ein Dict, das eine Topologie beschreibt (cpus, hostnodes, memory, policy). numa: true zu setzen ist ein Typfehler, und es ist eine leicht verlorene Stunde. Interessiert dich, warum das alles zählt, behandelt NUMA-Ausrichtung auf Proxmox das Problem darunter.

machine: q35 und bios: ovmf sind die richtigen Voreinstellungen, keine Zierde — das Argument in voller Länge.

vmid weglassen heißt, das Modul fragt die API nach der nächsten freien ID. Bequem, und der unmittelbare Grund für das Nächste.

Warum serial: 1

„Hol die nächste verfügbare ID, dann leg eine VM damit an“ sind zwei API-Aufrufe mit einer Lücke in der Mitte. Zwei Arbeiter, die das gleichzeitig tun, können dieselbe freie ID lesen, und der Verlierer bekommt einen Fehler oder, schlimmer, eine Überraschung.

serial: 1 macht die Anlege-Phase zu einem Host nach dem anderen. Es ist nicht schnell und muss es nicht sein. Der teure Teil des VM-Baus passiert, nachdem dieses Playbook übergeben hat. Das Gegenstück-Play, das die fertigen VMs startet, nutzt serial: 5, denn beim Starten gibt es keinen gemeinsamen Zähler, um den man sich streiten könnte.

Willst du lieber die Parallelität, teile die VMID selbst aus deiner verlässlichen Quelle zu und übergib sie ausdrücklich. Dann gibt es kein Lesen-Ändern-Schreiben und kein Rennen.

Idempotenz ist nicht, was du erwartest

Das ist der Abschnitt, den man zweimal liest, denn proxmox_kvm verhält sich nicht wie ansible.builtin.package.

name ist keine Identität. VM-Namen sind über einen Proxmox-Cluster hinweg nicht eindeutig, und das Modul sagt es. Mit state: present und ohne vmid steigt das Modul, wenn eine VM mit diesem Namen schon existiert, mit changed=false und msg: "VM with name <x> already exists" aus und tut nichts. Es vergleicht deine Parameter nicht mit der Wirklichkeit. Es konvergiert nicht. Es verweigert sich.

update steht voreingestellt auf false. memory: in deinem Playbook zu ändern und neu zu laufen ist also wirkungslos. Die VM behält den Speicher, mit dem sie gebaut wurde, der Task meldet Erfolg, und nirgends sagt dir etwas, dass die zwei auseinandergelaufen sind.

update: true verweigert die interessanten Parameter weiterhin. Aus der Moduldokumentation:

Because of the operations of the API and security reasons, I have disabled the update of the following parameters net, virtio, ide, sata, scsi. Per example updating net update the MAC address and virtio create always new disk…

update_unsafe: true hebt diese Einschränkung auf, und die Warnung ist keine Show:

Use this option with caution because an improper configuration might result in a permanent loss of data (for example disk recreated).

Die Platte, von der du dachtest, du würdest sie vergrößern, kann also durch eine neue leere ersetzt werden. Greif nicht danach — die verweigerten Parameter haben ihre eigenen Module, und das ist der nächste Abschnitt.

Und --check deckt nichts davon ab. Die Collection erklärt Check-Mode-Unterstützung je Modul, und sie ist genau in die falsche Richtung ungleichmäßig:

Modulcheck_modediff_mode
proxmox_kvmkeinerkeiner
proxmox_diskkeinerkeiner
proxmox_templatekeinerkeiner
proxmox_snapvollkeiner
proxmox_nicvollkeiner
proxmox_poolvollkeiner

Die Trennung ist nicht „lesende Module können es, schreibende nicht“ — proxmox_nic legt Schnittstellen an und löscht sie und achtet den Check-Mode einwandfrei. Sie ist, dass die drei Module, die mit Speicher und VM-Lebenszyklus zu tun haben, es nicht tun. Ein --check-Lauf eines Bau-Playbooks überspringt das Anlegen der VM still und berichtet dann über eine Welt, in der die VM nie gemacht wurde, sodass jeder Task danach über den falschen Zustand nachdenkt. Auf einem Bau-Playbook ist --check kein Auffangnetz, und es dafür zu nehmen ist schlimmer, als es nicht laufen zu lassen.

Was ein zweiter Lauf von proxmox_kvm tatsächlich tutzweiter Lauf — state: present, und eine VM dieses Namens existiertdas Modul vergleicht deine Parameter nie mit der laufenden VMupdate: falsedie Voreinstellungchanged = false“VM with name <x>already exists”ändere memory im Play,lauf neu, und nirgendssagt es dir etwasupdate: truekonvergiert das meistecores, memory, tags,agent, onboot greifennet, virtio, ide, sata,scsi, efidisk0, tpmstate0absichtlich verweigertnimm dafür proxmox_diskund proxmox_nicupdate_unsafe: truekonvergiert allesdie verweigerten Parametergreifen auchein Plattenparameter kanndie Platte neu anlegendauerhafter Datenverlustist das dokumentierte Risiko--checksagt dir nichtscheck_mode: noneder Task wird übersprungenjeder spätere Task denktdann über eine Welt nach,in der die VM nieangelegt wurdeZwei der vier konvergieren überhaupt etwas, und der,der Platten abdeckt, kann sie zerstören.Tor also selbst an der Existenz, und behandle dasAnlegen als einmaliges Ereignis.
Was ein zweiter Lauf tatsächlich tut. Zwei der vier Wege konvergieren überhaupt etwas, und der einzige, der Platten abdeckt, ist der, der sie zerstören kann.

Also mach die Existenz zum Tor

Angesichts all dessen ist das gangbare Muster, das Modul nicht länger um Idempotenz zu bitten und selbst zu entscheiden, ob gebaut wird. Das produktive Playbook macht es so:

- name: Check if VM is present or manually built
  delegate_to: localhost
  community.proxmox.proxmox_vm_info:
    api_host: "{{ proxmox_api_ip }}"
    api_user: "{{ proxmox_user }}"
    api_password: "{{ proxmox_password }}"
    name: "{{ inventory_hostname }}"
    config: current
  register: existing_vm
  ignore_errors: true
  failed_when: (existing_vm.proxmox_vms | length) == 0

- name: Configure Proxmox VM
  when: existing_vm is failed
  block:
    - name: Create Proxmox VM
      ...

proxmox_vm_info mit config: current gibt die VM und ihre laufende Konfiguration zurück, oder eine leere Liste. failed_when macht aus „leere Liste“ einen Fehlschlag, ignore_errors: true hindert diesen Fehlschlag daran, das Play zu beenden, und when: existing_vm is failed wird zu „die VM ist nicht da, bau sie“.

Einen absichtlich fehlgeschlagenen Task als Wahrheitswert zu nutzen liest sich schlecht, und ich will das nicht anders darstellen. Die Alternative ist when: (existing_vm.proxmox_vms | default([]) | length) == 0, was ehrlich dazu steht, eine Längenprüfung zu sein, und kein ignore_errors braucht. Beides läuft. Die Fassung oben ist die, die produktiv ist, und ihr einziger echter Vorteil ist, dass das registrierte Ergebnis die vorhandene Konfiguration für spätere Tasks mitbringt.

Der wichtige Teil ist die Form, nicht die Schreibweise: prüfen, dann verzweigen, und das Anlegen als einmaliges Ereignis behandeln. Die laufende Konfiguration einer VM ist ein anderes Problem als die Existenz einer VM, und dieses Modul ist nur im Zweiten gut.

Platten und NICs haben ihre eigenen Module

Hier ist das, was ich oben übergangen habe, und es verändert das ganze Bild: die Parameter, die proxmox_kvm nicht aktualisieren will, sind keine Lücke in der Collection. Sie sind abgegeben. community.proxmox.proxmox_disk und community.proxmox.proxmox_nic legen genau die Dinge an, ändern sie und entfernen sie, die das Anlege-Modul nicht anfassen will — anhand derselben Namen scsi0 und net0, die du beim Bauen der VM benutzt hast.

Beide sind besser erzogen als proxmox_kvm, und eines von ihnen ist das einzige Modul in diesem Ablauf, das man trocken laufen lassen kann.

proxmox_nic — eine Schnittstelle anlegen, umtaggen oder entfernen

- name: Move the VM's primary NIC to a new bridge and VLAN
  delegate_to: localhost
  community.proxmox.proxmox_nic:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    vmid: "{{ created_vm.vmid }}"
    interface: net0
    bridge: vmbr1
    tag: 120
    model: virtio
    mtu: 1
    queues: 4
    firewall: true
    state: present

interface ist die einzige nötige Option über die Auth hinaus — net[n], wobei n von 0 bis 31 geht —, und state: present oder absent gibt dir Anlegen und Entfernen. model steht voreingestellt auf virtio, was die richtige Antwort ist, solange ein Gast nicht damit überfordert ist.

Der Grund, warum dieses Modul existiert, ist die MAC-Adresse. Erinnere dich, warum proxmox_kvm sich weigert, net zu aktualisieren: „updating net update the MAC address“. proxmox_nic behebt das ausdrücklich:

When not specified this module will keep the MAC address the same when changing an existing interface.

Du kannst also ein VLAN umtaggen, eine Bridge wechseln, die MTU ändern oder die Firewall einschalten, ohne dass sich die NIC-Identität des Gastes darunter ändert. Das zählt mehr, als es klingt: eine neue MAC macht DHCP-Reservierungen ungültig, bricht alles, was auf eine NIC lizenziert ist, und bringt den NetBox-Schnittstellendatensatz aus dem Tritt, den das Anlege-Playbook so sorgfältig geschrieben hat. Das ist das Modul, das eine Netzänderung am zweiten Tag langweilig macht.

Ein paar Optionen, die es wert sind, bevor du sie brauchst:

  • rate ist in MB/s — Megabyte pro Sekunde, nicht Bit. Die Dokumentation ist ausdrücklich, und der Faktor-Acht-Fehler ist sehr leicht gemacht.
  • link_down: true trennt die Schnittstelle, in der Dokumentation beschrieben als „like pulling the plug“. Ein sauberer Weg, eine verdächtige VM zu isolieren, ohne sie zu stoppen oder den Gast anzufassen.
  • trunks nimmt eine Liste von VLAN-IDs zum Durchlassen, für einen Gast, der selbst taggt.
  • mtu: 1 ist kein Tippfehler und keine MTU von 1 Byte — es heißt „erbe die MTU der Bridge“, und es gilt nur für virtio.
  • queues setzt Multi-Queue, 0 bis 16. Bei allem, was echten Verkehr schiebt, lohnt es sich, das auf die vCPU-Zahl abzustimmen.

Und es unterstützt den Check-Mode voll. --check auf einem proxmox_nic-Task sagt dir die Wahrheit, und das macht ihn zum einzigen Teil dieses Ablaufs, den du sicher üben kannst. Seine Meldungen sind auch richtig idempotent. Eine unveränderte Schnittstelle meldet Nic net0 unchanged on VM with vmid 103, statt eine Änderung zu behaupten.

proxmox_disk — der ganze Lebenszyklus einer Platte

proxmox_disk ist das größte der drei Module, und sein state macht fünf verschiedene Aufgaben:

stateWas passiertUmkehrbar?
presentdie Platte anlegen, oder Optionen an einer vorhandenen ändernentfällt
resizedvergrößern — PVE kann nicht schrumpfen, und die Dokumentation sagt, das von Hand zu machennein
detachedwird unused[n]; das Volume und seine Daten bleibenja
movedden Speicher darunter wechseln, oder die Platte einer anderen VM gebenOriginal bleibt, außer bei delete_moved
absentaus dem Speicher darunter entferntnein

Der Abstand zwischen detached und absent ist das Auffangnetz, das proxmox_kvm dir nie gibt. Abtrennen ist eine Konfigurationsänderung; Löschen zerstört Daten. Zwei verschiedene Wörter, zwei verschiedene Folgen.

Eine zweite Platte an eine schon vorhandene VM hängen:

- name: Add a data disk
  delegate_to: localhost
  community.proxmox.proxmox_disk:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    vmid: "{{ created_vm.vmid }}"
    disk: scsi1
    storage: vmdata
    size: 200
    format: qcow2
    iothread: true
    aio: io_uring
    discard: "on"
    ssd: true
    backup: true
    state: present

create ist das Stellrad, das proxmox_kvm hätte haben sollen. Es steuert, was state: present tun darf:

  • regular (die Voreinstellung) — die Platte anlegen, wenn sie fehlt, sonst ihre Optionen ändern.
  • disabled — nur Optionen ändern und nie anlegen. Das ist das, wonach man greift, wenn man cache oder iothread an einer Platte ändert, die schon existieren muss. Es kann dich nicht überraschen, indem es wegen eines vertippten Schlüssels ein neues Volume herbeizaubert.
  • forced — immer anlegen. Eine vorhandene Platte wird abgetrennt und unbenutzt liegen gelassen, nicht gelöscht.

Das letzte Verhalten ist die wichtige Einzelheit. create: forced ist die zerstörerisch aussehende Möglichkeit, und sie zerstört dennoch nichts: das alte Volume überlebt als unusedN, und du kannst es wieder anhängen. Vergleich das mit proxmox_kvm plus update_unsafe, dessen dokumentierter Fehlerfall eine neu angelegte Platte ist. Ungefähr derselbe Vorgang, viel bessere Wirkungsweite.

Eine Platte vergrößern:

- name: Grow the data disk by 100 GiB
  delegate_to: localhost
  community.proxmox.proxmox_disk:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    vmid: "{{ created_vm.vmid }}"
    disk: scsi1
    size: "+100G"
    state: resized

Achte auf die Einheiten, denn size ändert mit state seine Bedeutung. Mit state: present ist es GiB als nackte Zahl (size: 200). Mit state: resized nimmt es ein Suffix — +100G, um zur aktuellen Größe zu addieren, oder 500G als absolutes Ziel. Ein Parameter, zwei Konventionen, und der Fehlschlag ist still, wenn du falsch rätst.

Eine Platte auf anderen Speicher verschieben, der Fall der Live-Migration eines einzelnen Volumes:

- name: Move the disk to NVMe storage
  delegate_to: localhost
  community.proxmox.proxmox_disk:
    api_host: "{{ proxmox_api_ip }}"
    api_user: ansible@pve
    api_token_id: automation
    api_token_secret: "{{ proxmox_token_secret }}"
    vmid: "{{ created_vm.vmid }}"
    disk: scsi1
    target_storage: nvme-pool
    bwlimit: 200000
    delete_moved: true
    timeout: 3600
    state: moved

target_storage verschiebt innerhalb einer VM; target_vmid gibt die Platte einer anderen VM und verlangt denselben Speicher auf beiden. Sie schließen sich gegenseitig aus. delete_moved steht voreingestellt auf false, du endest also standardmäßig mit zwei Kopien und dem Original, das unbenutzt herumliegt — sicher, und ein guter Weg, einen Speicherpool zu füllen, wenn du nie zurückkommst.

timeout steht hier voreingestellt auf 600, gegen 30 in proxmox_kvm. Gleich aussehender Parameter, zwanzigfacher Unterschied, denn diese Vorgänge kopieren Daten. Setz ihn für große Abbilder oder langsamen Speicher höher — die Dokumentation sagt das für moved und für import_from.

Was zu der Option führt, die dieses Modul zum Weg für V2V und Cloud-Abbilder macht:

    import_from: "vmdata:9000/base-debian13.qcow2"

import_from baut die Platte aus einem vorhandenen Volume, statt ein leeres zuzuteilen — <STORAGE>:<VMID>/<NAME>, oder <STORAGE>:import/<NAME> über das Import-Verzeichnis des Speichers ab PVE 9.x. Es schließt sich mit size gegenseitig aus, und nur root darf absolute Dateisystempfade nutzen.

Der Rest der Parameterliste ist der Grund, Platten mit diesem Modul anzuhängen statt inline im Anlege-Aufruf: cache, aio, iothread, discard, ssd, backup, detect_zeroes, und die ganze Drosselfamilie — iops, iops_rd, iops_wr, ihre _max- und _max_length-Varianten, und die bps_*_max_length-Steuerung für Stöße. Nichts davon ist über proxmox_kvm nach dem Anlegen erreichbar.

Zwei Vorbehalte, beide aus der eigenen Dokumentation des Moduls:

  • Manche Optionsänderungen brauchen einen Neustart. „Some updates on options (like cache) are not being applied instantly and require VM restart.“ Ein grüner Task heißt, dass die Konfiguration geschrieben wurde, nicht dass die laufende VM sich anders verhält.
  • Es unterstützt den Check-Mode nicht. check_mode: none, wie proxmox_kvm. Die Collection teilt sich also mitten durch: NIC-Änderungen kann man mit --check üben, Plattenänderungen nicht.

Die Arbeitsteilung

Um das zu tunNimm
Die VM anlegenproxmox_kvm, einmal, an der Existenz getort
Kerne, Speicher, Tags, Agent, onboot ändernproxmox_kvm mit update: true
Eine NIC anlegen, umtaggen, trennen oder entfernenproxmox_nic
Eine Platte anlegen, vergrößern, verschieben, abtrennen oder entfernenproxmox_disk
Momentaufnahmeproxmox_snap (auch voller Check-Mode)
Alles, was keines davon offenlegtqm set über SSH
Eine Platte oder NIC über proxmox_kvm ändernnichts — dafür ist update_unsafe da, und deshalb solltest du es nicht nutzen

Bau die VM mit einem knappen proxmox_kvm-Aufruf, dann häng die Platten und Schnittstellen mit ihren eigenen Modulen an. Es sind mehr Tasks, und es ist die Fassung, in der Änderungen am zweiten Tag einen Weg haben, der nicht über eine Option führt, deren dokumentiertes Risiko der Verlust einer Platte ist.

Wo das Modul aufhört

proxmox_kvm hat eine riesige Parameterliste und deckt dennoch nicht alles ab, was qm kann. Statt zu warten, fällt das produktive Playbook auf die Kommandozeile des Knotens zurück:

- name: Set RNG source and better SPICE quality
  delegate_to: "{{ proxmox_api_host }}"
  become: true
  ansible.builtin.command:
    cmd: >-
      /usr/sbin/qm set {{ created_vm.vmid }}
      --rng0 source=/dev/urandom
      --spice_enhancements videostreaming=all

Daran ist nichts falsch. Es ist in keinem sinnvollen Sinn idempotent — qm set ist ein Schreiben und wird jeden Lauf changed melden —, aber es ist ausdrücklich, es ist lesbar, und es gibt nicht vor, etwas anderes zu sein. Bekommt ein Modul den Parameter später, löschst du den Task.

Anderes braucht einen zweiten Durchgang durch das Modul mit update: true, weil es nicht im selben Aufruf gesetzt werden kann, der die VM anlegt:

- name: Add SPICE-compatible USB device
  delegate_to: localhost
  community.proxmox.proxmox_kvm:
    api_host: "{{ proxmox_api_ip }}"
    api_user: "{{ proxmox_user }}"
    api_password: "{{ proxmox_password }}"
    node: "{{ proxmox_api_host }}"
    vmid: "{{ created_vm.vmid }}"
    usb:
      usb0: "spice,usb3=1"
    update: true
  when: spice_usb | default(false)

Beachte, dass es vmid übergibt, nicht name. Sobald du die ID hast, nutz sie. Sie ist der einzige Bezeichner, den die API als eindeutig behandelt.

Und für die Dinge, die QEMU kann und für die PVE keine Option hat, gibt es args, das wortwörtlich an die QEMU-Kommandozeile weitergegeben wird:

    args: >-
      -global scsi-hd.physical_block_size=4k
      -global scsi-hd.logical_block_size=4096

Das zeigt die virtuelle Platte als 4Kn statt 512e, und das zählt mehr, als es klingt — Blockgrößen, 4Kn und 512e. Das Modul beschriftet args mit „for experts only“, und der Grund ist, dass PVE es nicht prüft und ein falsches Flag die VM nicht starten lässt, mit einem Fehler, der von QEMU kommt und nicht von Proxmox.

Der Cluster ist nicht sofort einig

- name: Let registration complete on cluster
  ansible.builtin.pause:
    seconds: 5
  when: created_vm.changed

Ein pause in einem Playbook riecht meist, und dieses trägt Last. Der Anlege-Aufruf kehrt zurück, wenn die API die Definition angenommen hat, und das ist nicht dasselbe, wie dass jeder Knoten der Existenz der VM zustimmt — und der unmittelbar nächste Task will eine ACL auf /vms/<vmid> setzen. Fünf Sekunden Geduld sind billiger als eine Wiederholschleife um einen Fehler, der nur unter Last auftritt.

Das Abbau-Playbook hat aus demselben Grund dieselbe Form: stoppen, warten, dann löschen.

Zurücklesen, was du gebaut hast

proxmox_kvm dokumentiert drei Rückgabewerte: vmid, status und msg. In der Praxis willst du einen vierten, und der steht nicht in der Dokumentation.

- name: Get MAC address of VM
  ansible.builtin.set_fact:
    primary_mac_addr: "{{ created_vm.mac.net0 }}"

created_vm.mac ist echt — das Modul baut es in get_vminfo() und klatscht es ins Ergebnis —, aber es fehlt im dokumentierten RETURN-Block, was heißt, dass nichts verspricht, dass es weiter funktioniert. Es ist wert, genau zu wissen, wie es sich verhält, denn es hat zwei Fallstricke:

  1. Es erscheint nur, wenn das Modul die VM tatsächlich angelegt hat. mac wird nur auf dem Weg des Anlegens und Ausrollens zusammengesetzt. Nimm den Zweig „existiert schon“, und das Ergebnis hat vmid und msg und nichts weiter.
  2. Es enthält nur die Schnittstellen, die du übergeben hast. Der Code geht die Parameter durch, die du geliefert hast, sucht die heraus, die auf net[0-9] passen, und liest die gespeicherte Konfiguration jeder einzelnen aus der API zurück. Kein net-Parameter, kein mac-Schlüssel.

Deshalb braucht das produktive Playbook beide Hälften, und die zweite ist hässlich:

- name: Get MAC address of VM
  ansible.builtin.set_fact:
    primary_mac_addr: >-
      {{ created_vm.mac.net0 if created_vm is defined and created_vm.changed
         else ((existing_vm.proxmox_vms[0].config.net0 | split(','))[0] | split('='))[1] }}

Existierte die VM schon, gibt es kein mac, die MAC muss also aus der rohen Konfigurationszeichenkette gegraben werden. net0 kommt von der API als virtio=AE:AE:5C:A8:89:85,bridge=vmbr0 zurück, also: an Kommas trennen, das erste Feld nehmen, an = trennen, die zweite Hälfte nehmen. Es ist Zeichenketten-Chirurgie an einer API-Antwort, und es ist der ehrliche Preis eines Moduls, dessen Rückgabeform davon abhängt, welchen Zweig es genommen hat.

Brauchst du die MAC in beiden Fällen verlässlich, hol sie bedingungslos von proxmox_vm_info und wert eine Form aus statt zweier.

Es aus einer verlässlichen Quelle antreiben

Sieh die Zeile am Kopf des Plays noch einmal an:

hosts: "{{ target_hosts | default('cluster_pve:&status_planned') }}"

Das ist die eigentliche Architektur, und es ist klar zu sagen wert: die zu bauenden VMs sind keine Liste in einer Vars-Datei. Sie sind die Hosts in deinem Inventar, deren erfasster Status sagt, dass sie existieren sollen und noch nicht existieren.

Das Inventar hier ist NetBox. Eine VM wird angefordert, indem ein NetBox-Datensatz mit dem Status planned angelegt wird, der CPU, Speicher, Platte, VLAN, Eigentümer und Plattform trägt. Das Playbook wählt die planned-Maschinen aus, baut sie, teilt eine IP zu, schreibt DNS und setzt den Datensatz dann auf staged — und ab dann passt dieser Host nicht mehr auf das Host-Muster des Plays, und ein Handler aktualisiert das Inventar, damit das nächste Play den neuen Zustand sieht:

handlers:
  - name: Refresh inventory
    ansible.builtin.meta: refresh_inventory

Das Statusfeld ist ein Zustandsautomat, das Playbook ist ein Übergang darin, und das Ganze ist wiederholbar, weil ein Host, der schon weitergegangen ist, nicht mehr ausgewählt wird. Das ist eine viel bessere Eigenschaft als jede Menge Idempotenz auf Modulebene, und es ist der Grund, warum der Anlege-Task damit durchkommt, ein Einmalschuss zu sein.

community.proxmox liefert auch ein eigenes Inventar-Plugin mit, das ein Inventar aus dem Cluster baut — die richtige Wahl, wenn Proxmox die verlässliche Quelle ist. Hier ist es umgekehrt: NetBox ist maßgeblich, und Proxmox ist, wo seine Absicht Wirklichkeit wird. Das ist ein eigener Beitrag, und ich schreibe ihn gesondert.

Es wieder wegnehmen

Anlegen ohne Abbau ist ein halber Lebenszyklus, und der Weg zum Entfernen hat seinen eigenen Fallstrick — eine laufende VM kann man nicht löschen:

- name: Force stop the VM if it is running
  delegate_to: localhost
  community.proxmox.proxmox_kvm:
    api_host: "{{ proxmox_api_ip }}"
    api_user: "{{ proxmox_user }}"
    api_password: "{{ proxmox_password }}"
    name: "{{ inventory_hostname }}"
    state: stopped
    force: true
    timeout: 10

- name: Allow the cluster to stop the VM before removing it
  ansible.builtin.pause:
    seconds: 10

- name: Remove the VM from the cluster
  delegate_to: localhost
  community.proxmox.proxmox_kvm:
    api_host: "{{ proxmox_api_ip }}"
    api_user: "{{ proxmox_user }}"
    api_password: "{{ proxmox_password }}"
    name: "{{ inventory_hostname }}"
    state: absent
    force: true
    timeout: 10

state: stopped ist ein sanftes Herunterfahren, und das Zusammenspiel mit timeout ist dokumentiert und wert, es sich zu merken: wird die Zeit mit force: true erreicht, wird die VM hart abgeschaltet; mit force: false scheitert der Task stattdessen. Zehn Sekunden sanftes Fenster, gefolgt vom Ziehen des Steckers, ist eine vernünftige Regel für eine Maschine, die abgebaut wird, und eine schlechte für alles andere.

Der volle Abbau (proxmox-remove-vms.yml) wickelt dann den Rest des Datensatzes ab: DNS, die zugeteilte IP, die NetBox-Schnittstellen, die NetBox-VM und die alten Einträge in known_hosts. Es hüllt den Block in ignore_errors: true, was in einem Abbau vertretbar ist. Du entfernst Dinge, die schon weg sein können, und eine halb gelöschte Maschine ist schlimmer als ein lautes Protokoll.

Was ich in einem frischen Aufbau ändern würde

Nachdem ich den Modul-Quellcode gelesen habe und nicht bloß seine Dokumentation, vier Dinge:

  • Nimm ein API-Token, nicht api_user plus api_password. Eingegrenzt, widerrufbar, und es gehört nie einer Person.
  • Setz validate_certs ausdrücklich, bevor 2.0.0 es dir unter den Füßen wegzieht.
  • Teil die VMID selbst zu aus der verlässlichen Quelle. Es beseitigt das Rennen um Lesen-Ändern-Schreiben, lässt dich serial: 1 weglassen und gibt jedem späteren Task einen festen Bezeichner statt eines Namens, der nicht eindeutig ist.
  • Leg die VM nackt an, dann häng ihre Platten und NICs mit proxmox_disk und proxmox_nic an. Mehr Tasks, aber jede Platte und jede Schnittstelle hat dann ein Modul, das sie später ändern kann — samt create: disabled für Änderungen nur an Optionen und state: detached statt Löschen — und keine Konfiguration, die nur über update_unsafe zu ändern ist.
  • Hol die MAC von proxmox_vm_info an einer Stelle, damit eine Form auszuwerten ist statt einer Bedingung über einen dokumentierten und einen undokumentierten Rückgabewert.

Und greif auf einem Bau-Playbook nicht nach --check. Das Modul, auf das es ankommt, kann es nicht achten.

Ein Trockenlauf, der immer ja sagt, ist schlimmer als kein Trockenlauf, denn du wirst ihm glauben.

Quellen