主题
06 VM 模板与快速批量创建
本章目标:把第 5 章那台「手工调好」的 VM 固化成可复用资产,并用一条命令批量交付 N 台规格统一、地址不冲突的 VM。
6.1 三条批量路径怎么选
| 路径 | 载体 | 适合 | 局限 |
|---|---|---|---|
| VM Template + Version | VirtualMachineTemplate / VirtualMachineTemplateVersion | UI 用户自助建 VM,规格受控 | 每台 VM 的 IP/hostname 仍要人手填(除非配 DHCP 或脚本) |
| Cloud Config Template | UI: 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.101 – 10.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 |
| 模板版本 | 只增不改:v1、v2……;旧版本保留以便回滚 |
| 标签 | 每台 VM 必带 app、tier、env、owner 四个标签 |
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 创建模板
- Advanced → VM Templates → Create。
- 填 Name(如
tpl-web)、Namespace、Description。 - 在模板编辑界面配置:CPU/Memory、Volumes(选黄金镜像 + StorageClass + 容量)、Networks(加上
default/net-91,Type=bridge)、Cloud Config(可引用 Cloud Config Template)、Advanced(machine type q35、TPM、run strategy 等)。 - 保存后在列表里能看到
tpl-web与其 Version 号。 - 建 VM 时:Virtual Machines → Create → VM Template 选
tpl-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: defaultbash
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:
bashkubectl 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: 100DHCP 版基线(把 eth1 换成下面内容,注意 dhcp-identifier: mac):
yaml
eth1:
dhcp4: true
dhcp-identifier: mac
dhcp4-overrides:
route-metric: 1006.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 # 渲染 + 校验 + apply6.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: 1006.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,prod6.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_PVC(kubectl 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"
fi6.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).txt6.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 黄金镜像怎么做
- 用官方 cloud image(Ubuntu/openSUSE/Rocky 的
cloudimg/nocloudqcow2)建一台「基线 VM」。 - 在 VM 内装齐:
qemu-guest-agent、监控 agent、日志 agent、公司基线加固脚本、常用工具。 - 清理机器身份(关键,否则克隆出来的机器会「继承身份」):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 - 关机后导出磁盘(或用 Harvester 的镜像上传功能重新登记为新版本镜像)。
- 镜像命名带日期与用途:
ubuntu-22.04-golden-20260908。
⚠️ 第 3 步里的
--machine-id与第 5 章讲的dhcp-identifier: mac是同一类问题的两个面:克隆体的身份必须重新生成,否则 DHCP 会发出相同 IP、SSH 会出现相同 host key 告警、监控会出现重复主机。
6.6.2 模板版本的演进规则
| 场景 | 做法 |
|---|---|
| 换黄金镜像 | 新建 tpl-web-v2(version: 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=canary6.7 本章验证清单
| # | 检查项 | 命令 | 期望 |
|---|---|---|---|
| 1 | 模板已创建 | kubectl get virtualmachinetemplate -A | tpl-web 存在 |
| 2 | 模板版本可用 | kubectl get virtualmachinetemplateversion -A | tpl-web-v1,version=1 |
| 3 | UI 可选模板 | Virtual Machines → Create → VM Template | 下拉框有 tpl-web |
| 4 | 模板不含 IP | kubectl get virtualmachinetemplateversion tpl-web-v1 -o yaml | grep -c '10.181.91.1[0-9][0-9]' | 0 |
| 5 | Cloud Config Template | Advanced → 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 |
| 11 | IP 登记表已归档 | ip-registry-*.txt | 已提交到运维库 |
| 12 | 克隆体身份唯一 | 两台 VM 内 cat /etc/machine-id | 不相同 |
下一章:把这些资产纳入日常运维——巡检、备份、升级、节点维护、迁移。