用 Terraform 管理本地 KVM 虚拟机:从模板镜像到批量交付

测试环境、预发环境总要反复开虚拟机:手工 virt-clone 一台台克隆、逐台改主机名和 IP,十台机器能耗掉半天,还经常出现配置漂移——谁也说不清某台机器当初是怎么建的。用 Terraform 管理本地 KVM,可以把虚拟机的规格、数量、网络全部写进代码,一次 apply 批量交付,销毁与重建也可复现。本文完成从模板镜像到批量交付的完整流程,并对比手工方式的取舍。

适用环境:一台装有 KVM/libvirt 的 Linux 主机(宿主机示例 IP 192.0.2.30),Terraform 在运维机上运行、通过本机或 SSH 连接 libvirt。下文以 Ubuntu 云镜像做模板、桥接网络为例。

前置条件

  • 宿主机已安装 qemu-kvm、libvirt-daemon 等组件,virsh list 可正常执行,且 CPU 已开启虚拟化(egrep -c '(vmx|svm)' /proc/cpuinfo 大于 0)。
  • 运维机已安装 Terraform 1.5+。
  • 宿主机已准备好桥接网卡 br0(桥接到物理网卡),虚拟机将直接获取局域网地址;没有桥接时可用默认的 NAT 网络 default 先跑通流程。
  • 运行 Terraform 的账号对 libvirt 有操作权限(加入 libvirt 组,或使用系统级连接 qemu:///system)。

第一步:声明 libvirt provider

Terraform 通过社区维护的 dmacvicar/libvirt provider 操作 KVM。在工程目录建 main.tf,先写 provider 声明并初始化:

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

provider "libvirt" {
  # 本机执行用 qemu:///system,远程执行用 qemu+ssh://root@192.0.2.30/system
  uri = "qemu:///system"
}
terraform init

需要注意,这个 provider 与宿主机上的 libvirt 版本存在兼容关系,升级宿主机 libvirt 大版本前先查 provider 的发布说明;另外 provider 本质是调用 libvirt API,Terraform 能做的操作受 libvirt 能力边界限制,快照、热迁移这类高级动作并不在它的舒适区内。

第二步:准备 cloud-init 模板镜像

批量交付的关键是模板:用发行版官方云镜像(自带 cloud-init),首次启动时自动完成建用户、注入 SSH 公钥、设主机名。先把基础镜像放进存储池,并做一个放大后的基础卷作为所有虚拟机的底盘:

wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
sudo cp jammy-server-cloudimg-amd64.img /var/lib/libvirt/images/base-ubuntu22.qcow2

在 Terraform 里用基础卷 + 每台机独立的差分卷(backing store)方式定义磁盘,克隆近乎瞬时且省空间。cloud-init 配置同样写成代码,用 templatefile 渲染,把主机名与公钥参数化:

# cloud-init/user-data.tpl
#cloud-config
hostname: ${hostname}
ssh_pwauth: false
users:
  - name: ops
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ${ssh_key}
packages:
  - qemu-guest-agent
runcmd:
  - systemctl enable --now qemu-guest-agent

第三步:用 count 与 for_each 批量定义虚拟机

先用变量把「要什么规格、要几台」抽出来。规格相同的测试机用 count 最简洁:

variable "vm_count" { default = 3 }

resource "libvirt_volume" "base" {
  name   = "base-ubuntu22.qcow2"
  pool   = "default"
  source = "/var/lib/libvirt/images/base-ubuntu22.qcow2"
  format = "qcow2"
}

resource "libvirt_volume" "disk" {
  count          = var.vm_count
  name           = "test-${count.index}.qcow2"
  pool           = "default"
  base_volume_id = libvirt_volume.base.id
  size           = 20 * 1024 * 1024 * 1024
}

resource "libvirt_cloudinit_disk" "init" {
  count     = var.vm_count
  name      = "init-${count.index}.iso"
  pool      = "default"
  user_data = templatefile("${path.module}/cloud-init/user-data.tpl", {
    hostname = "test-${count.index}"
    ssh_key  = file("~/.ssh/id_ed25519.pub")
  })
}

resource "libvirt_domain" "vm" {
  count  = var.vm_count
  name   = "test-${count.index}"
  memory = 4096
  vcpu   = 2

  cloudinit = libvirt_cloudinit_disk.init[count.index].id

  network_interface {
    bridge = "br0"
  }

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

  console {
    type        = "pty"
    target_port = "0"
    target_type = "serial"
  }
  graphics {
    type        = "spice"
    listen_type = "address"
  }
}

count 适合「同规格、同质化」的机器,缺点是删中间一台会引起后续实例整体重建(索引位移)。规格各异、生命周期独立的生产型虚拟机改用 for_each,以名字为键,增删互不影响:

variable "vms" {
  default = {
    db01  = { cpu = 4, mem = 8192, disk = 100 }
    app01 = { cpu = 2, mem = 4096, disk = 40 }
  }
}

resource "libvirt_domain" "prod" {
  for_each = var.vms
  name     = each.key
  vcpu     = each.value.cpu
  memory   = each.value.mem
  # disk、cloudinit 等资源同样用 for_each 定义并以 each.key 索引引用
}

经验法则:临时测试集群用 count,长期服役、规格不一的机器用 for_each。两者不要在同一个资源里混改,count 与 for_each 之间迁移会触发重建,要提前评估。

第四步:plan/apply 日常工作流

terraform plan     # 先看将要增删改什么,确认无误
terraform apply    # 执行变更
terraform output   # 查看输出(如需在 domain 中定义 IP 输出)
virsh list --all   # 在宿主机上核对

铁律是 apply 前必看 plan:重点核对有没有意外的 destroy。改规格(加内存、扩盘)通常是原地更新或停机更新,改磁盘的 backing、改网络连接方式多半触发重建。团队协作时代码进 Git,每次变更走评审,plan 输出贴到变更单里,这套流程与管理云资源没有区别。销毁用 terraform destroy -target 精确指定,慎用全量 destroy。

第五步:state 的管理与风险

terraform.tfstate 记录了代码与真实虚拟机的映射关系,是整套管理的命门:丢了 state,Terraform 就「不认识」已经建好的机器,只能逐个 import 回来,非常痛苦;state 里还可能含明文敏感信息(如初始化密码),不能随便分发。本地单机自用至少做到两点:state 文件随工程一起做定时备份;不要手工编辑 state,也不要在 apply 过程中强杀进程——中断可能留下写一半的 state。多人协作必须换远程后端(如 S3、Consul 或 Terraform Cloud)并启用状态锁,否则两人同时 apply 必然互相覆盖。本地 KVM 场景也可以把后端放在一台小的文件服务器上用 S3 兼容存储实现,关键是「集中存放 + 加锁 + 有备份」。

与手工 virt-clone 的取舍

virt-clone 适合一次性的单台克隆:命令简单、无需学 HCL、改一台不影响任何「状态文件」。Terraform 的优势在规模与一致性:规格即代码、可评审、可重建、批量操作一条命令。台数在个位数、建完就不动的环境,手工方式反而更划算;超过十台或需要反复重建(如 CI 测试环境),Terraform 的投入很快回本。两者的共同前提都是把模板镜像做好——模板里的 cloud-init、guest agent、基础软件规范化了,两种方式都受益。

验证

terraform state list
virsh list --all
ssh ops@192.0.2.41 hostname

apply 后在宿主机确认虚拟机数量与状态为 running,等 cloud-init 跑完(约 1 分钟)后能用注入的公钥免密登录、主机名与规划一致,即算交付完成。可再执行一次 terraform plan,应显示 No changes,这是验证代码与现实一致的最好证据。

常见问题

1. apply 报 permission denied 或连不上 libvirt。先确认连接串:本机用 qemu:///system 需要账号在 libvirt 组里(加入后要重新登录会话才生效);远程 qemu+ssh 先用 virsh -c qemu+ssh://root@192.0.2.30/system list 单独测通,再回头查 Terraform,别把 SSH 问题当 provider 问题查。

2. 报 storage pool 'default' not found 或找不到基础镜像。存储池未定义或未激活。用 virsh pool-list --all 查看,缺失时用 pool-define-as 建池并 pool-start、pool-autostart;基础镜像路径必须是 libvirt 进程可读的位置,放用户家目录常因权限被拒,统一放 /var/lib/libvirt/images。

3. 虚拟机起来了但拿不到桥接网络地址。先分层:宿主机 br0 是否包含物理网卡(bridge link)、交换机端口是否开了 MAC 数量限制或 802.1X(桥接虚拟机的 MAC 对交换机可见,企业网络常因此拦截)、DHCP 服务是否正常。用 default NAT 网络建一台对照测试,NAT 通而桥接不通,问题基本锁定在桥与物理网络侧。

4. plan 里出现意料之外的重建。多为改动了触发重建的属性(如磁盘、网络块的连接方式)或在 count 资源里删了中间元素。对照第三步的选型结论处理;确需调整时可用 terraform state mv 等手段保住既有实例,但先备份 state 再动手。

小结

用 Terraform 管 KVM,真正的工作量在模板与规范:cloud-init 模板决定交付质量,count/for_each 的选型决定后续变更的平滑程度,state 的存放与备份决定这套体系敢用多久。把这三件事做对,本地虚拟化环境就能获得和云上一致的管理体验:一切可复现,一切有记录。

发表回复

后才能评论