Skip to content

06 VM 模板与快速批量创建

本章目标:把第 5 章那台「手工调好」的 VM 固化成可复用资产,并用一条命令批量交付 N 台规格统一、地址不冲突的 VM。


6.1 三条批量路径怎么选

路径载体适合局限
VM Template + VersionVirtualMachineTemplate / VirtualMachineTemplateVersionUI 用户自助建 VM,规格受控每台 VM 的 IP/hostname 仍要人手填(除非配 DHCP 或脚本)
Cloud Config TemplateUI: Advanced → Cloud Config Templates统一 cloud-init(用户、SSH key、包、脚本)只管 cloud-init,不管 CPU/内存/磁盘/网络
脚本 + 黄金镜像(本书主推)gen-vms.sh + YAML 模板 + CSV几十上百台、IP 固定、要求可审计可回滚需要 Git 管理与评审流程

生产上三者组合用:黄金镜像(装好 agent、监控、基线加固)→ VM 模板(定 CPU/内存/磁盘/网络)→ Cloud Config Template(定用户与初始化)→ 脚本(定名字与 IP)。

6.1.1 本环境的批量约定(先定规矩再写脚本)

约定
VM 命名<业务>-<角色>-<序号>,如 web-front-01;全小写,只用 -
Namespace按业务/租户划分;默认 default
VLAN 静态 IP 段10.181.91.10110.181.91.200(100 个),一台一 IP,写进登记表
宿主机段10.181.91.11-.22(4.7),禁止分给 VM
LB VIP 段10.181.91.50-.99禁止分给 VM
DHCP 池(若启用)10.181.91.201-.250禁止与静态段重叠
镜像只用已登记的黄金镜像,名字带版本:ubuntu-22.04-golden-20260908
模板版本只增不改:v1v2……;旧版本保留以便回滚
标签每台 VM 必带 apptierenvowner 四个标签

6.2 VM 模板:VirtualMachineTemplate + VirtualMachineTemplateVersion

6.2.1 两个对象的关系

text
VirtualMachineTemplate(模板本体,只有名字/描述/默认版本指针)
   └── VirtualMachineTemplateVersion(版本 1)   spec.vm = 完整的 VM spec
   └── VirtualMachineTemplateVersion(版本 2)   ← 新规格
   └── ...
UI 建 VM 时选「模板 + 版本」,Harvester 把该版本的 spec.vm 渲染成新 VM

关键点:版本是不可变的历史记录。要改规格就新建版本,不要编辑已有版本(编辑会让「按 v1 建出来的 VM」与「v1 现在的定义」不一致,事故排查时无法追溯)。

6.2.2 UI 创建模板

  1. Advanced → VM Templates → Create
  2. 填 Name(如 tpl-web)、Namespace、Description。
  3. 在模板编辑界面配置:CPU/Memory、Volumes(选黄金镜像 + StorageClass + 容量)、Networks(加上 default/net-91,Type=bridge)、Cloud Config(可引用 Cloud Config Template)、Advanced(machine type q35、TPM、run strategy 等)。
  4. 保存后在列表里能看到 tpl-web 与其 Version 号。
  5. 建 VM 时:Virtual Machines → Create → VM Templatetpl-web 与版本 → 只需改 Name、IP 相关 cloud-init → Create。

6.2.3 YAML 创建(可直接 apply)

yaml
# appendix/vm/03-vmtpl-web.yaml
apiVersion: harvesterhci.io/v1beta1
kind: VirtualMachineTemplate
metadata:
  name: tpl-web
  namespace: default
  labels:
    harvesterhci.io/template-type: web
---
apiVersion: harvesterhci.io/v1beta1
kind: VirtualMachineTemplateVersion
metadata:
  name: tpl-web-v1
  namespace: default
spec:
  templateId: default/tpl-web        # <namespace>/<模板名>
  version: 1
  description: "web 基线:2C4G,双网卡(mgmt + net-91),黄金镜像 ubuntu-22.04-golden"
  keyPair:
    - default/ops-key                # 可选:绑定的 SSH KeyPair
  vm:
    metadata:
      labels:
        app: web
        tier: web
        env: prod
    spec:
      runStrategy: RerunOnFailure
      template:
        metadata:
          labels:
            app: web
            tier: web
            harvesterhci.io/creator: harvester
        spec:
          domain:
            machine:
              type: q35
            cpu:
              cores: 2
              sockets: 1
              threads: 1
            resources:
              requests:
                memory: 4Gi
            devices:
              disks:
                - name: disk-0
                  disk:
                    bus: virtio
                - name: cloudinitdisk
                  disk:
                    bus: virtio
              interfaces:
                - name: default
                  masquerade: {}
                  model: virtio
                - name: nic-1
                  bridge: {}
                  model: virtio
          networks:
            - name: default
              pod: {}
            - name: nic-1
              multus:
                networkName: default/net-91
          volumes:
            - name: disk-0
              dataVolume:
                name: tpl-web-disk-0
            - name: cloudinitdisk
              cloudInitNoCloud:
                secretRef:
                  name: tpl-web-cloudinit
                networkDataSecretRef:
                  name: tpl-web-cloudinit
      dataVolumeTemplates:
        - metadata:
            name: tpl-web-disk-0
            namespace: default
          spec:
            pvc:
              accessModes:
                - ReadWriteMany
              volumeMode: Block
              storageClassName: longhorn
              resources:
                requests:
                  storage: 40Gi
            source:
              pvc:
                name: image-79hdq          # ← 黄金镜像的 PVC 名(5.3.1 查)
                namespace: default
bash
kubectl apply -f appendix/vm/03-vmtpl-web.yaml
kubectl get virtualmachinetemplate -n default
kubectl get virtualmachinetemplateversion -n default
kubectl explain virtualmachinetemplateversion.spec      # 核对你的 v1.8 字段

⚠️ 模板里的 cloud-init 不要写死 IP。模板是公共资产,IP 必须在建 VM 时按机器注入(用脚本生成每台的 Secret,见 6.5)。若模板里写死 10.181.91.101,批量建出来的机器会全部抢同一个地址——这是最常见的批量事故。


6.3 从现有 VM 反向生成模板(最可靠的做法)

UI 上是否提供「从 VM 直接生成模板」的入口因版本而异。不依赖 UI 的可靠流程是:把调好的 VM 导出、清洗、塞进模板版本。

bash
NS=default; VM=vm-web-01; TPL=tpl-web

# ① 导出
kubectl get vm $VM -n $NS -o yaml > /tmp/$VM.yaml

# ② 清洗掉运行时/自动生成字段(需要 yq v4)
yq -i '
  del(.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp,
      .metadata.generation, .metadata.managedFields, .metadata.finalizers,
      .metadata.annotations."harvesterhci.io/imageId",
      .status) |
  del(.spec.template.metadata.annotations."harvesterhci.io/sshNames")
' /tmp/$VM.yaml

# ③ 只保留要模板化的部分:spec.template / spec.dataVolumeTemplates / spec.runStrategy
yq '{
  "runStrategy": .spec.runStrategy,
  "template": .spec.template,
  "dataVolumeTemplates": .spec.dataVolumeTemplates
}' /tmp/$VM.yaml > /tmp/$VM-spec.yaml

# ④ 把「机器专属」的名字改成模板名:磁盘 PVC、cloud-init Secret
sed -i "s/${VM}-disk-0/${TPL}-disk-0/g; s/${VM}-cloudinit/${TPL}-cloudinit/g" /tmp/$VM-spec.yaml

# ⑤ 组装成 VirtualMachineTemplateVersion
{
  cat <<EOF
apiVersion: harvesterhci.io/v1beta1
kind: VirtualMachineTemplateVersion
metadata:
  name: ${TPL}-v1
  namespace: ${NS}
spec:
  templateId: ${NS}/${TPL}
  version: 1
  description: "从 ${VM} 反向生成"
  vm:
    metadata: {}
    spec:
EOF
  sed 's/^/      /' /tmp/$VM-spec.yaml
} > /tmp/${TPL}-v1.yaml

# ⑥ 校验后 apply
kubectl apply --dry-run=client -f /tmp/${TPL}-v1.yaml && kubectl apply -f /tmp/${TPL}-v1.yaml
kubectl get virtualmachinetemplateversion -n $NS

⚠️ 务必删掉 IP/hostname:反向生成时,cloudinitdisk 引用的 Secret 里往往写着这台机器的静态 IP。把它换成「不含 IP 的模板 Secret」(只提供用户、SSH key、包、脚本),IP 交给建 VM 时注入。

⚠️ dataVolumeTemplates 保留是对的:每次用模板建 VM 都会新克隆一份磁盘,天然做到「一 VM 一盘」。若改成引用同一个已存在 PVC(volumes[].persistentVolumeClaim),多台 VM 会共享同一块盘 —— 除非明确要只读共享,否则别这么写。


6.4 Cloud Config Template:统一 cloud-init 基线

UI 路径:Advanced → Cloud Config Templates → Create。用于把「每台 VM 都该有的初始化」固化下来,建 VM 时在 Cloud Config 面板直接选用。

该功能底层对象的 Kind 名随版本略有差异,先自查再写 YAML:

bash
kubectl api-resources --api-group=harvesterhci.io | grep -i -E 'cloud|template'

找不到对应 CRD 时,就用「模板 Secret + 脚本合并」的方式(6.5 的脚本已经这么做),效果等价。

6.4.1 标准 UserData 基线(可复制)

yaml
#cloud-config
timezone: Asia/Shanghai
manage_etc_hosts: true

ssh_pwauth: false                     # 生产禁用密码登录
disable_root: false

users:
  - name: ops
    groups: [sudo, docker]
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:xxx
    lock_passwd: xxx
    ssh_authorized_keys:
      - ssh-ed25519 AAAA...REPLACE_ME... ops@workstation

packages:
  - qemu-guest-agent                  # 必须:否则 Harvester 看不到副网卡 IP
  - curl
  - net-tools

runcmd:
  - systemctl enable --now qemu-guest-agent
  - sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
  - sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
  - systemctl restart ssh

final_message: "baseline done after $UPTIME seconds"

6.4.2 标准 NetworkData 基线(静态 IP 版,IP 用占位符)

yaml
version: 2
ethernets:
  eth0:
    dhcp4: true
    dhcp4-overrides:
      use-dns: false
      route-metric: 900
  eth1:
    dhcp4: false
    addresses:
      - __VM_IP__/24                 # ← 脚本替换
    nameservers:
      addresses: [10.181.0.2]
      search: [example.local]
    routes:
      - to: default
        via: 10.181.91.1
        metric: 100

DHCP 版基线(把 eth1 换成下面内容,注意 dhcp-identifier: mac):

yaml
  eth1:
    dhcp4: true
    dhcp-identifier: mac
    dhcp4-overrides:
      route-metric: 100

6.5 批量创建脚本(本章核心交付物)

思路:CSV 描述「要什么机器」,模板描述「机器长什么样」,脚本负责渲染 + 校验 + apply。所有产物落到 out/ 目录,可进 Git、可评审、可回滚。

6.5.1 目录结构

text
appendix/vm/
├── templates/
│   ├── vm.yaml.tpl              # VirtualMachine 模板(占位符)
│   └── cloudinit.yaml.tpl       # cloud-init Secret 模板(占位符)
├── vms.csv                      # 机器清单
└── gen-vms.sh                   # 渲染 + 校验 + apply

6.5.2 templates/vm.yaml.tpl

yaml
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: __VM_NAME__
  namespace: __NS__
  labels:
    app: __VM_NAME__
    tier: __TIER__
    env: __ENV__
    owner: platform
    harvesterhci.io/creator: harvester
  annotations:
    harvesterhci.io/volumeClaimClass: __STORAGE_CLASS__
spec:
  runStrategy: RerunOnFailure
  template:
    metadata:
      labels:
        app: __VM_NAME__
        tier: __TIER__
        env: __ENV__
        harvesterhci.io/creator: harvester
    spec:
      domain:
        machine:
          type: q35
        cpu:
          cores: __CPU__
          sockets: 1
          threads: 1
        resources:
          requests:
            memory: __MEMORY__
        devices:
          disks:
            - name: disk-0
              disk:
                bus: virtio
            - name: cloudinitdisk
              disk:
                bus: virtio
          interfaces:
            - name: default
              masquerade: {}
              model: virtio
            - name: nic-1
              bridge: {}
              model: virtio
      networks:
        - name: default
          pod: {}
        - name: nic-1
          multus:
            networkName: __NAD__
      volumes:
        - name: disk-0
          dataVolume:
            name: __VM_NAME__-disk-0
        - name: cloudinitdisk
          cloudInitNoCloud:
            secretRef:
              name: __VM_NAME__-cloudinit
            networkDataSecretRef:
              name: __VM_NAME__-cloudinit
  dataVolumeTemplates:
    - metadata:
        name: __VM_NAME__-disk-0
        namespace: __NS__
      spec:
        pvc:
          accessModes:
            - ReadWriteMany
          volumeMode: Block
          storageClassName: __STORAGE_CLASS__
          resources:
            requests:
              storage: __DISK__
        source:
          pvc:
            name: __IMAGE_PVC__
            namespace: __IMAGE_NS__

6.5.3 templates/cloudinit.yaml.tpl

yaml
apiVersion: v1
kind: Secret
metadata:
  name: __VM_NAME__-cloudinit
  namespace: __NS__
  labels:
    app: __VM_NAME__
    owner: platform
type: Opaque
stringData:
  userdata: |
    #cloud-config
    hostname: __VM_NAME__
    timezone: Asia/Shanghai
    manage_etc_hosts: true
    ssh_pwauth: false
    users:
      - name: ops
        groups: [sudo, docker]
        shell: /bin/bash
        sudo: ALL=(ALL) NOPASSWD:xxx
        lock_passwd: xxx
        ssh_authorized_keys:
          - __SSH_PUBKEY__
    packages:
      - qemu-guest-agent
      - curl
    runcmd:
      - systemctl enable --now qemu-guest-agent
    final_message: "__VM_NAME__ init done after $UPTIME seconds"
  networkdata: |
    version: 2
    ethernets:
      eth0:
        dhcp4: true
        dhcp4-overrides:
          use-dns: false
          route-metric: 900       # mgmt 默认路由降级,eth1 的 metric 100 做主默认路由
      eth1:
        dhcp4: false
        addresses:
          - __VM_IP__/__CIDR_LEN__
        nameservers:
          addresses: [__DNS__]
          search: [example.local]
        routes:
          - to: default
            via: __GW__
            metric: 100

6.5.4 vms.csv

csv
name,ip,cpu,memory,disk,tier,env
web-front-01,10.181.91.101,2,4Gi,40Gi,web,prod
web-front-02,10.181.91.102,2,4Gi,40Gi,web,prod
web-front-03,10.181.91.103,2,4Gi,40Gi,web,prod
api-svc-01,10.181.91.111,4,8Gi,60Gi,api,prod
api-svc-02,10.181.91.112,4,8Gi,60Gi,api,prod
db-master-01,10.181.91.121,8,32Gi,200Gi,db,prod

6.5.5 gen-vms.sh

bash
#!/usr/bin/env bash
# appendix/vm/gen-vms.sh —— 批量渲染 / 创建 Harvester VM(双网卡:mgmt + net-91)
#
# 用法:
#   IMAGE_PVC=image-79hdq ./gen-vms.sh vms.csv render   # 只生成 YAML 到 out/(评审用)
#   IMAGE_PVC=image-79hdq ./gen-vms.sh vms.csv apply    # 生成并 kubectl apply
#
# 依赖:bash 4+、sed、coreutils;apply 模式还需要可连集群的 kubectl。
set -euo pipefail

CSV="${1:-vms.csv}"
MODE="${2:-render}"

NS="${NS:-default}"
IMAGE_PVC="${IMAGE_PVC:?请设置 IMAGE_PVCkubectl get vmimages -A 查询,形如 image-79hdq)}"
IMAGE_NS="${IMAGE_NS:-$NS}"
STORAGE_CLASS="${STORAGE_CLASS:-longhorn}"
NAD="${NAD:-default/net-91}"
GW="${GW:-10.181.91.1}"
CIDR_LEN="${CIDR_LEN:-24}"
DNS="${DNS:-10.181.0.2}"
NET_PREFIX="${NET_PREFIX:-10.181.91.}"
SSH_PUBKEY="${SSH_PUBKEY:-$(cat "${SSH_KEY_FILE:-$HOME/.ssh/id_ed25519.pub}")}"
OUT="${OUT:-out}"
TPL_DIR="${TPL_DIR:-$(cd "$(dirname "$0")" && pwd)/templates}"

log() { printf '[%s] %s\n' "$(date +%T)" "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

[[ -f "$CSV" ]] || die "找不到 CSV: $CSV"
[[ -f "$TPL_DIR/vm.yaml.tpl" && -f "$TPL_DIR/cloudinit.yaml.tpl" ]] || die "缺少模板: $TPL_DIR"
[[ "$MODE" == render || "$MODE" == apply ]] || die "MODE 只能是 render 或 apply"
[[ "$SSH_PUBKEY" == *REPLACE_ME* || -z "$SSH_PUBKEY" ]] && die "SSH_PUBKEY 未设置或仍是占位符"

# ---------- 1. 校验 CSV ----------
head -1 "$CSV" | tr -d '\r' | grep -qx 'name,ip,cpu,memory,disk,tier,env' \
  || die "CSV 表头必须是:name,ip,cpu,memory,disk,tier,env"
ROWS="$(tail -n +2 "$CSV" | tr -d '\r' | grep -v '^[[:space:]]*$' || true)"
[[ -n "$ROWS" ]] || die "CSV 没有数据行"

NAMES="$(printf '%s\n' "$ROWS" | cut -d, -f1)"
IPS="$(printf   '%s\n' "$ROWS" | cut -d, -f2)"

DUP="$(printf '%s\n' "$NAMES" | sort | uniq -d || true)"; [[ -z "$DUP" ]] || die "VM 名字重复: $DUP"
DUP="$(printf '%s\n' "$IPS"   | sort | uniq -d || true)"; [[ -z "$DUP" ]] || die "IP 重复: $DUP"
BAD="$(printf '%s\n' "$NAMES" | grep -vEx '[a-z0-9]([-a-z0-9]*[a-z0-9])?' || true)"
[[ -z "$BAD" ]] || die "以下 VM 名字非法(只能小写字母/数字/'-',首尾为字母数字): $BAD"

# IP 必须落在允许段,且不能侵占基础设施保留区
check_ip() {
  local ip="$1" host o
  [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$ip 不是合法 IPv4"
  IFS=. read -r -a _oct <<< "$ip"
  for o in "${_oct[@]}"; do
    (( 10#$o <= 255 )) || die "$ip:八位组 $o 超出 0-255"
  done
  [[ "$ip" == "${NET_PREFIX}"* ]] || die "$ip 不在允许网段 ${NET_PREFIX}0/${CIDR_LEN}"
  host="${ip##*.}"
  case "$host" in
    0|1|2|3|4|5|6|7|8|9|10)        die "$ip:基础设施保留(网关/基础服务)" ;;
    1[1-9]|2[0-2])                 die "$ip:宿主机段 .11-.22(见第 4 章 HostNetworkConfig)" ;;
    5[0-9]|[6-9][0-9])             die "$ip:LoadBalancer 池 .50-.99" ;;
    20[1-9]|2[1-4][0-9]|250)       die "$ip:Managed DHCP 池 .201-.250" ;;
    255)                           die "$ip:广播地址" ;;
  esac
}
while read -r ip; do check_ip "$ip"; done <<< "$IPS"

# 与集群中已有对象比对(能连上集群才做)
if command -v kubectl >/dev/null 2>&1 && kubectl cluster-info >/dev/null 2>&1; then
  while read -r n; do
    kubectl get vm -n "$NS" "$n" >/dev/null 2>&1 && log "WARN: VM $n 已存在,apply 会覆盖"
  done <<< "$NAMES"
  USED="$(mktemp)"
  for s in $(kubectl get secret -n "$NS" -o name 2>/dev/null | sed 's#.*/##' | grep -- '-cloudinit$' || true); do
    kubectl get secret -n "$NS" "$s" -o jsonpath='{.data.networkdata}' 2>/dev/null \
      | base64 -d 2>/dev/null | grep -Eo "${NET_PREFIX}[0-9]+" >> "$USED" || true
  done
  sort -u -o "$USED" "$USED"
  CONFLICT="$(comm -12 <(printf '%s\n' "$IPS" | sort -u) "$USED" || true)"
  rm -f "$USED"
  [[ -z "$CONFLICT" ]] || die "以下 IP 已被集群中现有 VM 占用: $CONFLICT"
else
  log "WARN: 无法连接集群,跳过「与现有 VM 冲突」检查"
fi

# ---------- 2. 渲染 ----------
SSH_PUBKEY_ESC="${SSH_PUBKEY//&/\\&}"        # sed 替换值里的 & 需要转义
rm -rf "$OUT"; mkdir -p "$OUT"
COUNT=0
while IFS=, read -r name ip cpu mem disk tier env; do
  d="$OUT/$name"; mkdir -p "$d"
  for t in cloudinit vm; do                  # 先 cloudinit 后 vm,apply 顺序才对
    sed -e "s#__VM_NAME__#$name#g"       -e "s#__VM_IP__#$ip#g" \
        -e "s#__NS__#$NS#g"              -e "s#__CPU__#$cpu#g" \
        -e "s#__MEMORY__#$mem#g"         -e "s#__DISK__#$disk#g" \
        -e "s#__TIER__#$tier#g"          -e "s#__ENV__#$env#g" \
        -e "s#__NAD__#$NAD#g"            -e "s#__GW__#$GW#g" \
        -e "s#__DNS__#$DNS#g"            -e "s#__CIDR_LEN__#$CIDR_LEN#g" \
        -e "s#__STORAGE_CLASS__#$STORAGE_CLASS#g" \
        -e "s#__IMAGE_PVC__#$IMAGE_PVC#g" -e "s#__IMAGE_NS__#$IMAGE_NS#g" \
        -e "s#__SSH_PUBKEY__#$SSH_PUBKEY_ESC#g" \
        "$TPL_DIR/$t.yaml.tpl" > "$d/$t.yaml"
  done
  LEFT="$(grep -ohE '__[A-Z_]+__' "$d/vm.yaml" "$d/cloudinit.yaml" | sort -u || true)"
  [[ -z "$LEFT" ]] || die "$name 渲染后仍有未替换占位符: $LEFT"
  printf '  %-18s %-16s %sC/%s/%s %s/%s\n' "$name" "$ip" "$cpu" "$mem" "$disk" "$tier" "$env"
  COUNT=$((COUNT+1))
done <<< "$ROWS"
log "已渲染 $COUNT 台 VM 到 $OUT/"

# ---------- 3. apply ----------
if [[ "$MODE" == apply ]]; then
  command -v kubectl >/dev/null || die "apply 模式需要 kubectl"
  for d in "$OUT"/*/; do
    kubectl apply -f "$d/cloudinit.yaml" -f "$d/vm.yaml"
  done
  log "已提交,等待 Running(最多 5 分钟)..."
  kubectl wait --for=jsonpath='{.status.printableStatus}'=Running \
    vm -n "$NS" -l owner=platform --timeout=300s \
    || log "WARN: 部分 VM 未在 5 分钟内 Running,请用 kubectl describe vm 查看事件"
  kubectl get vm -n "$NS" -l owner=platform \
    -o custom-columns='NAME:.metadata.name,STATUS:.status.printableStatus'
else
  log "render 模式:请人工评审 $OUT/ 后执行 apply"
fi

6.5.6 使用流程

bash
chmod +x appendix/vm/gen-vms.sh
cd appendix/vm

# 第一步:只渲染,人工评审(不会碰集群)
IMAGE_PVC=image-79hdq ./gen-vms.sh vms.csv render
ls out/; cat out/web-front-01/cloudinit.yaml; cat out/web-front-01/vm.yaml

# 第二步:确认无误后创建
IMAGE_PVC=image-79hdq ./gen-vms.sh vms.csv apply

# 需要改参数时用环境变量覆盖,无需改脚本
IMAGE_PVC=image-79hdq NS=team-a ./gen-vms.sh vms.csv render
IMAGE_PVC=image-79hdq SSH_KEY_FILE=~/.ssh/id_rsa.pub ./gen-vms.sh vms.csv render

# 交付到另一个 VLAN 网段(例如 VLAN 92 / 10.181.92.0/24)
NAD=team-b/net-92 GW=10.181.92.1 NET_PREFIX=10.181.92. NS=team-b \
  IMAGE_PVC=image-79hdq ./gen-vms.sh vms-vlan92.csv render

脚本会在 apply 前替你挡掉这些错误(都是真实踩过的坑):

校验拦截的错误
CSV 表头列顺序写错导致 CPU/IP 串位;空 CSV
名字唯一性 + 合法性K8s 对象名非法(大写、下划线、以 - 开头);同名 VM 互相覆盖
IP 唯一性两台 VM 抢同一个地址
IP 段位把 VM 塞进宿主机段 .11-.22、LB 池 .50-.99、DHCP 池 .201-.250、网关/广播地址
IPv4 格式手滑打成 10.181.91.1O1,或八位组 > 255(如 10.181.91.999
SSH 公钥忘了替换 REPLACE_ME 占位符,交付出去一批登不进去的机器
与集群现有 VM 比对新批次撞了已存在的 IP / 覆盖已有 VM
占位符残留模板改了字段名但脚本没同步

6.5.7 批量交付后的验证

bash
NS=default
# 1) 全部 Running
kubectl get vm -n $NS -l owner=platform

# 2) 每台的两块网卡与 MAC(用于交换机侧核对、DHCP 保留登记)
for v in $(kubectl get vm -n $NS -l owner=platform -o name | cut -d/ -f2); do
  printf '%-18s ' "$v"
  kubectl get vmi -n $NS "$v" -o jsonpath='{range .status.interfaces[*]}{.name}={.mac} {end}' 2>/dev/null
  echo
done

# 3) 逐台 ping + SSH 冒烟测试(在能路由到 10.181.91.0/24 的机器上执行)
tail -n +2 vms.csv | cut -d, -f1,2 | while IFS=, read -r n ip; do
  ping -c1 -W2 "$ip" >/dev/null && r=PING_OK || r=PING_FAIL
  ssh -o BatchMode=yes -o ConnectTimeout=3 "ops@$ip" true 2>/dev/null && s=SSH_OK || s=SSH_FAIL
  printf '%-18s %-16s %-9s %s\n' "$n" "$ip" "$r" "$s"
done

# 4) 生成 IP 登记表(交付物,务必归档到运维知识库)
tail -n +2 vms.csv | awk -F, '{printf "%-18s %-16s %-4s %-6s %-7s %-6s %s\n",$1,$2,$3,$4,$5,$6,$7}' \
  | tee ip-registry-$(date +%Y%m%d).txt

6.5.8 回滚 / 删除一批

bash
# 删除本批次的全部 VM(注意:默认会连带删除 dataVolume 生成的 PVC,即数据盘)
kubectl delete vm -n default -l owner=platform

# 删除对应的 cloud-init Secret
for n in $(tail -n +2 vms.csv | cut -d, -f1); do
  kubectl delete secret -n default "${n}-cloudinit" --ignore-not-found
done

# 只删一台
kubectl delete vm web-front-03 -n default
kubectl delete secret web-front-03-cloudinit -n default

# 保盘删除:先确认 PVC,再删 VM
kubectl get pvc -n default | grep web-front-03

⚠️ 想保留数据盘,就不要依赖 dataVolumeTemplates 的自动 PVC:改为先手工创建 PVC,再在 VM 里用 volumes[].persistentVolumeClaim.claimName 显式引用。这样删 VM 时 PVC 不会被级联回收。


6.6 黄金镜像与模板版本管理

6.6.1 黄金镜像怎么做

  1. 用官方 cloud image(Ubuntu/openSUSE/Rocky 的 cloudimg/nocloud qcow2)建一台「基线 VM」。
  2. 在 VM 内装齐:qemu-guest-agent、监控 agent、日志 agent、公司基线加固脚本、常用工具。
  3. 清理机器身份(关键,否则克隆出来的机器会「继承身份」):
    bash
    sudo cloud-init clean --logs --machine-id     # 清除 cloud-init 状态与 machine-id
    sudo truncate -s 0 /etc/machine-id
    sudo rm -f /var/lib/dbus/machine-id
    sudo systemctl disable --now NetworkManager-wait-online 2>/dev/null || true
    sudo rm -f /etc/ssh/ssh_host_*                # 让新机器首次启动重新生成主机密钥
    sudo history -c; sudo shutdown -h now
  4. 关机后导出磁盘(或用 Harvester 的镜像上传功能重新登记为新版本镜像)。
  5. 镜像命名带日期与用途:ubuntu-22.04-golden-20260908

⚠️ 第 3 步里的 --machine-id 与第 5 章讲的 dhcp-identifier: mac 是同一类问题的两个面:克隆体的身份必须重新生成,否则 DHCP 会发出相同 IP、SSH 会出现相同 host key 告警、监控会出现重复主机。

6.6.2 模板版本的演进规则

场景做法
换黄金镜像新建 tpl-web-v2version: 2),不要改 v1
加内存/CPU新建版本
改网络(加第三块网卡)新建版本
修 cloud-init 基线更新 Cloud Config Template 或模板 Secret,再新建版本
紧急回滚建 VM 时选回旧版本即可(版本一直在)
bash
kubectl get virtualmachinetemplateversion -n default -l harvesterhci.io/templateId 2>/dev/null || \
kubectl get virtualmachinetemplateversion -n default
# 看某个版本到底定义了什么
kubectl get virtualmachinetemplateversion tpl-web-v2 -n default -o yaml | sed -n '/  vm:/,$p'

6.6.3 灰度发布新模板版本

bash
# 1) 用新版本建 1 台金丝雀
IMAGE_PVC=image-newgolden NS=default OUT=out-canary ./gen-vms.sh canary.csv apply
# 2) 跑业务冒烟测试(连通性、性能、监控上报)
# 3) 通过后再批量铺开;不通过就删金丝雀,旧版本继续用
kubectl delete vm -n default -l env=canary

6.7 本章验证清单

#检查项命令期望
1模板已创建kubectl get virtualmachinetemplate -Atpl-web 存在
2模板版本可用kubectl get virtualmachinetemplateversion -Atpl-web-v1,version=1
3UI 可选模板Virtual Machines → Create → VM Template下拉框有 tpl-web
4模板不含 IPkubectl get virtualmachinetemplateversion tpl-web-v1 -o yaml | grep -c '10.181.91.1[0-9][0-9]'0
5Cloud Config TemplateAdvanced → Cloud Config Templates基线模板存在
6脚本 render 通过IMAGE_PVC=... ./gen-vms.sh vms.csv render退出码 0,生成 N×2 个 YAML
7非法输入被拦用重复 IP / 保留段 IP 测试退出码非 0,报错清晰
8批量 apply 成功./gen-vms.sh vms.csv apply全部 Running
9每台双网卡6.5.7 第 2 步每台都有 default + nic-1
10每台 IP 不冲突6.5.7 第 3 步全部 PING_OK + SSH_OK
11IP 登记表已归档ip-registry-*.txt已提交到运维库
12克隆体身份唯一两台 VM 内 cat /etc/machine-id不相同

下一章:把这些资产纳入日常运维——巡检、备份、升级、节点维护、迁移。