Skip to content

05 创建带双网卡的 VM:管理网 + 10.181.91.x 直通

本章目标:创建一台带两块网卡的 VM——eth0 走 Harvester 管理网(集群内访问/控制台),eth1net-91 直接获得 10.181.91.x,并能从外部 SSH 进去。这也是后续模板化批量创建(第 6 章)的原型。


5.1 先接受五个事实(官方文档明确写出,与直觉相反)

#事实影响
1Management Network(mgmt)上的 VM IP 只能在集群节点内部访问外部机器不能直接 SSH 到 VM 的 mgmt IP,必须走 VLAN 网络或 LoadBalancer
2VM 重启后 mgmt IP 会变(非典型行为,官方明确提示)不要把 mgmt IP 写进任何配置/监控/DNS;要稳定入口就用 K8s Service 或 VLAN 网络
3连到 mgmt 的 VM 网卡 MTU = 1450(Calico/Flannel 每包 50 字节开销)VM 内 eth0 是 1450 而不是 1500;跨网卡传大包时要注意
4VLAN 网络下IPv4 地址靠 DHCPv4 下发,VM 应配置为 DHCP 获取没有外部 DHCP 时必须用静态 cloud-init 或 Managed DHCP(4.8)
5一块网卡在 mgmt、另一块在 VLAN 时,VLAN 网关会覆盖 VM 的默认路由,导致节点访问不到 VM 的 mgmt IP官方 note 原话;必须按 5.4 处理路由,否则「VM 起来了但控制台连不上」

这五条解释了为什么"直连外部网络"必须用 VLAN 网络,也解释了双网卡最常见的坑。

5.1.1 三种取地址方式怎么选

方式前提优点缺点本环境
外部 DHCPVLAN 91 上有 DHCP 服务器VM 侧零配置;迁移/重启自动续租地址不可预测,需 DHCP 保留才能固定若有 DHCP 则首选
静态 IP(cloud-init networkData)无 DHCP,或要求地址固定地址完全可控,便于登记与防火墙每台 VM 一份 networkData;改 IP 要重建 cloud-init本书默认
Managed DHCP装了 4.8 的 addon集中管理租约,UI 可见实验特性;不支持 RELEASE;改 IPPool 要重启 agent规模化后可选

⚠️ 无论哪种方式,VM 的 MAC 地址是 Harvester 自动生成的(除非显式指定),因此外部 DHCP 的「MAC 保留」策略在 VM 重建后会失效。需要固定 IP 就用静态方式或 DHCP 池 + 记录映射。

5.1.2 接口类型(Type)只有两个选项

Type含义何时用
bridge通过 Linux bridge 接入(VLAN 网络必须用它)eth1(net-91)
masquerade通过 iptables NAT 出去(Pod 网络)eth0(Management Network)

5.1.3 两个「反直觉」的地址现象,先知道就不慌

eth0(masquerade)的地址为什么长这样? VM 内 ip -br addr 你会看到:

text
eth0    UP    10.52.7.31/32 ...              ← /32!掩码不是网段
ip route:  default via 169.254.1.1 dev eth0  ← 网关不在任何网段里

这是 KubeVirt masquerade 的设计:VM 拿到的是 Pod 网段(10.52.0.0/16)里的一个地址,但掩码是 /32,网关是链路本地地址 169.254.1.1(由 virt-launcher Pod 应答)。所有出向包都发给这个「假网关」,由宿主机 NAT 改写源地址后从 mgmt 网出去。所以「网关不在网段里」不是配错了,是正常现象(原理图见 1.3.0 ④)。

Guest 里的网卡名不一定是 eth0/eth1。 本书示例统一用 eth0/eth1,但部分镜像启用了 systemd 可预测命名,你会看到 ens3/ens4enp1s0 等。以 VM 内 ip -br link 的实际输出为准:网卡顺序由 PCI 枚举顺序决定,第一块网卡(Management Network)总是排前面,第二块(net-91)排后面;不确定时用 MAC 地址与 kubectl get vmi <vm> -o jsonpath='{.status.interfaces[*].mac}' 对照确认。


5.2 UI 创建双网卡 VM(推荐第一次这样做,便于理解字段)

前置:已上传镜像(Advanced → Images → Create,填 URL 或直接上传),记下镜像名。

  1. Virtual Machines → Create
  2. 基本信息
    • Name:vm-web-01
    • Namespace:default
    • (可选)VM Template:先不选,第 6 章再模板化
  3. Basics 标签页
    • CPU:2,Memory:4 Gi
    • SSHKey:选择或上传公钥(对应 KeyPair 对象,会注入 cloud-init)
  4. Volumes 标签页
    • 根盘:Image Volume → 选镜像 → StorageClass longhorn(默认)→ 大小 ≥ 镜像要求
    • cloud-init 盘会自动出现(cloudinitdisk
  5. Networks 标签页(本章重点):
    • 默认已有一行 Management Network,Type = masquerade,保留它作为 eth0
    • Add Network
      • Network Name:选 default/net-91(第 4 章创建的 VM 网络)
      • Type:bridge
      • Model:virtio
    • 顺序决定 Guest 内网卡顺序:Management Network 在上 → eth0net-91 在下 → eth1
  6. Node Scheduling / VM Scheduling:初期保持 Any available node,便于验证自动平衡与迁移。
  7. Advanced Options → Cloud Config:填入 userdatanetworkData(可复制内容见 5.3.2)。UI 会把它们存成一个 Secret(名为 <vm>-cloudinit)。
  8. 提交,观察 Virtual Machines 列表状态从 StartingRunning
bash
# UI 之外随时可用命令核对
kubectl get vm,vmi -n default
kubectl get vmi vm-web-01 -n default -o jsonpath='{.status.interfaces[*]}' | python3 -m json.tool 2>/dev/null || \
kubectl get vmi vm-web-01 -n default -o yaml | sed -n '/interfaces:/,/^  [a-z]/p'

vmi.status.interfaces 里能看到每块网卡的 namemacipAddress(mgmt 那块会有 IP,VLAN 那块通常为空,因为地址由 Guest 内部配置)。


5.3 用 YAML 创建(可复制、可进 Git、可批量)

强烈建议:先用 5.2 的 UI 建一台,然后 kubectl get vm vm-web-01 -n default -o yaml 导出,把 dataVolumeTemplates(镜像 PVC 名、StorageClass、容量)等环境相关字段抄进你的模板。网络与 cloud-init 部分则完全照抄下面的内容——这两部分才是本章的重点,也是版本间最稳定的部分。

5.3.1 查镜像与 PVC 名

bash
kubectl get vmimages -A
# NAMESPACE   NAME           DISPLAY-NAME                              SIZE         AGE
# default     image-79hdq    focal-server-cloudimg-amd64.img           566886400    5h

kubectl get vmimage image-79hdq -n default -o jsonpath='{.status.pvcName}{"\n"}'
kubectl get pvc -n default | grep image-

5.3.2 cloud-init Secret(静态 IP 版)

yaml
# appendix/vm/01-secret-vm-web-01-cloudinit.yaml
apiVersion: v1
kind: Secret
metadata:
  name: vm-web-01-cloudinit
  namespace: default
type: Opaque
stringData:
  userdata: |
    #cloud-config
    hostname: vm-web-01
    timezone: Asia/Shanghai
    manage_etc_hosts: true
    users:
      - name: ops
        groups: [sudo, docker]
        shell: /bin/bash
        sudo: ALL=(ALL) NOPASSWD:xxx
        ssh_authorized_keys:
          - ssh-ed25519 AAAA...REPLACE_ME... ops@workstation
    packages:
      - qemu-guest-agent
      - curl
    runcmd:
      - systemctl enable --now qemu-guest-agent
    final_message: "cloud-init done after $UPTIME seconds"
  networkdata: |
    version: 2
    ethernets:
      eth0:                       # Management Network(masquerade / Pod 网络)
        dhcp4: true
        dhcp4-overrides:
          use-dns: false          # DNS 统一由 eth1 侧提供,避免两份 resolv.conf 打架
          route-metric: 900       # 关键:mgmt 默认路由降级,确保 eth1 的 metric 100 永远生效
      eth1:                       # net-91(VLAN 91,bridge)
        dhcp4: false
        addresses:
          - 10.181.91.101/24
        nameservers:
          addresses:
            - 10.181.0.2
          search:
            - example.local
        routes:
          - to: default
            via: 10.181.91.1
            metric: 100           # 关键:显式低 metric,确保默认路由走 eth1

⚠️ Secret 的 key 名是 userdata / networkdata(小写、无连字符)。不同版本/来源的示例可能写成 userData,以你环境导出的为准: kubectl get secret vm-web-01-cloudinit -o jsonpath='{.data}' | python3 -c 'import sys,json;print(list(json.load(sys.stdin)))'

5.3.3 VirtualMachine(双网卡)

yaml
# appendix/vm/02-vm-web-01.yaml
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-web-01
  namespace: default
  labels:
    app: vm-web
    tier: web
  annotations:
    harvesterhci.io/volumeClaimClass: longhorn
spec:
  running: true
  template:
    metadata:
      labels:
        app: vm-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                 # → Guest 内 eth0
              masquerade: {}
              model: virtio
            - name: nic-1                   # → Guest 内 eth1
              bridge: {}
              model: virtio
      networks:
        - name: default
          pod: {}                           # Management Network
        - name: nic-1
          multus:
            networkName: default/net-91     # 第 4 章创建的 NAD
      volumes:
        - name: disk-0
          dataVolume:
            name: vm-web-01-disk-0
        - name: cloudinitdisk
          cloudInitNoCloud:
            secretRef:
              name: vm-web-01-cloudinit
            networkDataSecretRef:
              name: vm-web-01-cloudinit
  dataVolumeTemplates:
    - metadata:
        name: vm-web-01-disk-0
        namespace: default
      spec:
        pvc:
          accessModes:
            - ReadWriteMany
          volumeMode: Block
          storageClassName: longhorn
          resources:
            requests:
              storage: 40Gi
        source:
          pvc:
            name: image-79hdq               # ← 换成 5.3.1 查到的镜像 PVC 名
            namespace: default
bash
kubectl apply -f appendix/vm/01-secret-vm-web-01-cloudinit.yaml
kubectl apply -f appendix/vm/02-vm-web-01.yaml

kubectl get vm vm-web-01 -n default -w          # Starting → Running
kubectl get vmi vm-web-01 -n default -o wide
kubectl get pvc -n default | grep vm-web-01

5.3.4 三个变体

变体 A:VLAN 侧用 DHCP(外部有 DHCP 服务器)

yaml
  networkdata: |
    version: 2
    ethernets:
      eth0:
        dhcp4: true
      eth1:
        dhcp4: true
        dhcp-identifier: mac        # ← 必写,见下方警告
        dhcp4-overrides:
          route-metric: 100         # 让 eth1 的默认路由优先

⚠️ dhcp-identifier: mac 不是可选项。官方明确说明:Ubuntu 16.04 之后的镜像默认由 netplan 管理网络,而 netplan 默认用 machine-id 作为 DHCP client identifier。若省略该字段,从快照/备份恢复或克隆出来的 VM 会拿到与原机相同的 IP,造成地址冲突。批量创建(第 6 章)时尤其致命。

变体 B:使用 Managed DHCP(4.8)——networkdata 与变体 A 相同(同样要写 dhcp-identifier: mac),地址由 Harvester 的 DHCP agent 下发;可另用 field.cattle.io/ports 注解在 UI 上展示端口(见 5.6.2)。

变体 C:只要 VLAN 网卡,彻底去掉 Management Network(官方明确:配了 VLAN 网络时 mgmt 是可选的)

yaml
      devices:
        interfaces:
          - name: nic-1
            bridge: {}
            model: virtio
      networks:
        - name: nic-1
          multus:
            networkName: default/net-91

此时 Guest 内只有一块网卡(通常仍是 eth0),没有默认路由冲突,最简单;代价是失去集群内的 mgmt IP(UI 上不再显示 mgmt 地址)。对外提供服务、且完全由 10.181.91.x 访问的 VM,推荐这个变体。

⚠️ 接口名(default / nic-1)在 devices.interfacesnetworks 两处必须一一对应,写错会导致 VM 卡在 Starting 且事件里报网络找不到。Guest 内的网卡名(eth0/eth1)由内核按 PCI 顺序分配,与这里的 name 无关。


5.4 双网卡最容易踩的坑:默认路由冲突

5.4.1 现象与原理

现象:VM 已 Running,从外部 ping 10.181.91.101 通,但在 Harvester 节点上 ping VM 的 mgmt IP 不通,或 UI 里 VM 网络状态异常、控制台打不开。

原理(官方 note 描述的行为):

text
eth0(mgmt/masquerade):10.52.7.31/32,默认路由由 Pod 网络 DHCP 下发
eth1(net-91/bridge)  :10.181.91.101/24,cloud-init 又下发一条默认路由 via 10.181.91.1

后配置的默认路由覆盖了前一条 →
  节点访问 10.52.7.31 时,VM 的回包从 eth1 发给 10.181.91.1
  而 10.181.91.1(业务网关)不知道 10.52.0.0/16 怎么走 → 回包被丢
  → 非对称路由,连接失败

一句话:入向走 eth0,出向走 eth1,回不来。

5.4.2 诊断(在 VM 内执行)

bash
ip -br addr                       # 两块网卡的地址
ip route show default             # 有几条默认路由?metric 各是多少?
ip route get 10.52.0.1            # 去 Pod 网段走哪个接口?← 关键
ip route get 8.8.8.8              # 去外网走哪个接口?
ip rule show                      # 是否有策略路由

判断标准:

检查期望
ip route get 8.8.8.8dev eth1(外部流量走业务网)
ip route get 10.52.0.1dev eth0(集群内流量走 mgmt,靠直连路由即可)
ip route show default只有 一条默认路由,或 eth1 的 metric 明显更小

5.4.3 三种修法(按推荐度排序)

修法一:让 eth1 的默认路由 metric 更小(本书默认)

只要 eth0 保留直连路由(10.52.x.x/32 dev eth0 或对应网段),回程就不依赖默认路由,冲突自然消失。cloud-init 里显式给 eth1 的默认路由加 metric: 100(5.3.2 已这么写):

bash
# VM 内立即验证/临时修复
sudo ip route del default dev eth0 2>/dev/null || true
sudo ip route add default via 10.181.91.1 dev eth1 metric 100

修法二:不要 mgmt 的默认路由,只保留直连

yaml
  networkdata: |
    version: 2
    ethernets:
      eth0:
        dhcp4: true
        dhcp4-overrides:
          use-routes: false        # 不接受 mgmt 下发的默认路由
      eth1:
        addresses: [10.181.91.101/24]
        routes:
          - to: default
            via: 10.181.91.1

⚠️ 用 use-routes: false 后,若你的 Guest 镜像把 mgmt 侧的直连路由也一并丢弃,节点将无法访问 VM 的 mgmt IP。改完必须用 5.4.2 的 ip route get 10.52.0.1 复核。

修法三:干脆去掉 Management Network(变体 C)

只留 VLAN 网卡,没有第二条默认路由,问题从根上消失。对外服务的 VM 首选。

5.4.4 需要 VM 同时访问管理网段和业务网段时(策略路由)

如果 VM 既要访问 10.181.0.0/24(管理网)又要走 10.181.91.0/24 出网,且两块网卡在同一物理网络的不同 VLAN,最稳的是加静态路由而非策略路由:

yaml
      eth1:
        addresses: [10.181.91.101/24]
        routes:
          - to: default
            via: 10.181.91.1
            metric: 100
          - to: 10.181.0.0/24          # 明确管理网段的走法(若需经业务网关绕行)
            via: 10.181.91.1

策略路由(ip rule + 多张路由表)留给真正需要源地址选路的场景,cloud-init 里可用 routing-policy 字段声明,但会让排障复杂度翻倍——能用静态路由解决就不要上策略路由


5.5 VM 内部验证清单

进入 VM 的方式(按可用性排序):

bash
# ① UI 控制台:Virtual Machines → vm-web-01 → Console(VNC,不依赖网络,永远可用)
# ② 集群内 SSH(走 mgmt IP,只能在节点上执行)
ssh rancher@10.181.0.11
kubectl get vmi vm-web-01 -n default -o jsonpath='{.status.interfaces[?(@.name=="default")].ipAddress}{"\n"}'
ssh ops@<上面得到的 IP>
# ③ 外部 SSH(走 VLAN 地址,本章最终目标)
ssh ops@10.181.91.101

VM 内逐项检查:

bash
# 网卡与地址
ip -br link                       # 期望 eth0、eth1 均 UP,driver=virtio_net
ethtool -i eth1 | head -3         # driver: virtio_net
ip -br addr show eth1             # 10.181.91.101/24

# 路由(见 5.4.2)
ip route show
ip route get 8.8.8.8

# L2/L3 连通
ping -c3 10.181.91.1              # 网关
arping -c2 -I eth1 10.181.91.1    # 二层可达(能拿到网关 MAC 说明 VLAN 通了)
ping -c3 10.181.91.11             # 同网段其他机器
ping -c3 10.181.0.2               # 跨网段(DNS 服务器)

# DNS
resolvectl status 2>/dev/null | head -20 || cat /etc/resolv.conf
getent hosts harvester.example.local
dig +short @10.181.0.2 harvester.example.local

# MTU(mgmt 侧应为 1450,VLAN 侧 1500)
ip link show eth0 | grep -o 'mtu [0-9]*'
ip link show eth1 | grep -o 'mtu [0-9]*'
ping -M do -s 1472 -c2 10.181.91.1     # 1500 MTU 不分片测试

# cloud-init 是否真的跑完
cloud-init status --long
sudo tail -30 /var/log/cloud-init-output.log

# qemu-guest-agent(UI 里能否看到 Guest 信息、能否优雅关机都靠它)
systemctl status qemu-guest-agent --no-pager | head -5

⚠️ qemu-guest-agent 决定 Harvester 能否看到「第二块网卡」的信息。官方说明:guest agent 负责把 VM、用户、文件系统以及 secondary networks 的信息回传给宿主机。没装/没启动 qga 时,UI 上 VLAN 网卡的 IP 一栏会一直是空的——这不是网络不通,只是没人上报。新建 VM 时 UI 的 "Install guest agent" 默认勾选;用 YAML 建 VM 时必须自己在 cloud-init 里装(5.3.2 已包含)。

openSUSE < 15.3 的服务名是 qemu-ga.service 而不是 qemu-guest-agent.service

检查项期望
eth1 地址10.181.91.101/24
ping 10.181.91.1
arping 网关能收到应答(证明 VLAN 标签正确)
默认路由eth1
到 Pod 网段路由eth0(若保留 mgmt 网卡)
cloud-init statusdone
qemu-guest-agentactive (running)

5.6 从外部访问 VM

5.6.1 直连(本环境主路径)

VM 在 10.181.91.0/24 里有地址后,同网段/可路由到该网段的机器可直接访问:

bash
ping 10.181.91.101
ssh ops@10.181.91.101
curl -I http://10.181.91.101:8080
nmap -p22,80,443,8080 10.181.91.101

5.6.2 让 UI 显示 VM 的端口(field.cattle.io/ports

如果用了 Managed DHCP(4.8),可在 VM 上声明端口,UI 的 Virtual Machines 页面会显示对应地址与端口:

bash
kubectl annotate vm vm-web-01 -n default --overwrite \
  'field.cattle.io/ports=[[{"port":22,"protocol":"TCP"},{"port":8080,"protocol":"TCP"}]]'

⚠️ 这个注解只影响 UI 展示,不会真的去交换机或防火墙上开端口,也不做端口转发。

5.6.3 不想给 VM 配 VLAN 地址时:LoadBalancer Service

若 VM 只有 mgmt 网卡(或想让外部通过一个稳定 VIP 访问),可以创建 LoadBalancer 类型的 Service,从 LB 地址池(本环境规划 10.181.91.50-.99)分配 VIP。

先建 LB 的 IPPool(注意是 networking.harvesterhci.io 这个 group,与给 VM 发 DHCP 地址的 network.harvesterhci.io 完全不同):

yaml
# appendix/network/07-ippool-lb-net-91.yaml
apiVersion: networking.harvesterhci.io/v1beta1
kind: IPPool
metadata:
  name: pool-lb-net-91
spec:
  subnet: 10.181.91.0/24
  gateway: 10.181.91.1
  ranges:
    - rangeStart: 10.181.91.50
      rangeEnd: 10.181.91.99
bash
# 字段名以集群实际 CRD 为准
kubectl api-resources --api-group=networking.harvesterhci.io
kubectl explain ippool.networking.harvesterhci.io.spec --recursive
kubectl apply -f appendix/network/07-ippool-lb-net-91.yaml
kubectl get ippools.networking.harvesterhci.io -A      # 必须带全限定名,否则会列到另一个 IPPool

再建 Service

yaml
# appendix/network/08-svc-lb-vm-web-01.yaml
apiVersion: v1
kind: Service
metadata:
  name: vm-web-01-lb
  namespace: default
  annotations:
    cloudprovider.harvesterhci.io/vip: "10.181.91.60"      # 或留空由 IPPool 自动分配
    cloudprovider.harvesterhci.io/vip-type: "fixed"
spec:
  type: LoadBalancer
  selector:
    kubevirt.io/vmiName: vm-web-01     # 注意:selector 指向 VMI 标签
  ports:
    - name: ssh
      port: 22
      targetPort: 22
    - name: http
      port: 8080
      targetPort: 8080

LB 侧的 IPPool(networking.harvesterhci.io group)与完整用法见 6.5 与附录 appendix/network/

5.6.4 外部访问不通时的排查顺序(严格自下而上)

text
① 交换机:端口 trunk 是否放行 VLAN 91、是否 LACP 协商成功
② 宿主机:bridge vlan show dev cn-vm-bo | grep 91
③ 宿主机:bridge fdb show | grep <VM 的 MAC>     ← MAC 有没有学到?在哪个口?
④ 宿主机:tcpdump -eni cn-vm-br -e 'vlan 91'     ← 有没有带 tag 的包?
⑤ VM 内:ip addr / ip route / arping 网关
⑥ VM 内:防火墙(ufw/firewalld)是否放行
⑦ 客户端:是否与 10.181.91.0/24 路由可达、有无 ACL 拦截

拿 VM 的 MAC:

bash
kubectl get vmi vm-web-01 -n default \
  -o jsonpath='{range .status.interfaces[*]}{.name}{"\t"}{.mac}{"\n"}{end}'

第 ③ 步是性价比最高的判断点:MAC 学到了但 ping 不通 → 问题在 VM 内部或 L3;MAC 学不到 → 问题在 VLAN/bridge/交换机。


5.7 最佳实践:内部互通 + 固定 IP 的标准形态

把需求拆开:「内部网络通」= VM 能被集群节点访问(mgmt 通道,用于集群内互访、控制台兜底、LB 后端探测);「固定 IP」= 外部系统能用不变的地址直连 VM。两个都要,答案只有一个:双网卡 + mgmt 侧 DHCP(高 metric)+ VLAN 侧静态 IP(低 metric)

5.7.1 为什么是这套组合(与其他方案对比)

方案集群内可达固定 IP外部直连结论
只接 mgmt(masquerade)❌ 重启会变只能用于纯内部工作负载
只接 net-91 静态(变体 C)最简单,但失去集群内通道
双网卡 + 两侧都 DHCP地址不可控,靠 DHCP 保留才能固定
双网卡 + mgmt DHCP + VLAN 静态本书标准形态
双网卡 + LB VIP(5.6.3)✅(VIP 固定)✅(经 VIP)适合「只暴露服务端口」的场景

5.7.2 标准形态的完整定义

所有决策一次写死,第 6 章的批量模板就按这张表生成:

标准值理由
eth0 网络Management Network,masquerade,DHCP集群内部通道
eth0 默认路由route-metric: 900永远输给 eth1,杜绝双默认路由撞车(8.4 案例 3)
eth0 DNSuse-dns: falseDNS 只由 eth1 下发,resolv.conf 不打架
eth1 网络default/net-91bridge静态 IP固定地址,外部直连
eth1 默认路由via 10.181.91.1 metric 100唯一的「主」默认路由
eth1 DNS10.181.0.2 + search domain内网域名解析
IP 分配vms.csv 台账 + gen-vms.sh 校验一台一 IP,避开保留段,可审计

networkData 最终形态(与 5.3.2 及 6.5.3 模板完全一致):

yaml
networkdata: |
  version: 2
  ethernets:
    eth0:                            # 内部通道:Management Network
      dhcp4: true
      dhcp4-overrides:
        use-dns: false
        route-metric: 900            # mgmt 默认路由降级为兜底
    eth1:                            # 固定 IP:net-91
      dhcp4: false
      addresses: [10.181.91.101/24]
      nameservers:
        addresses: [10.181.0.2]
        search: [example.local]
      routes:
        - to: default
          via: 10.181.91.1
          metric: 100

⚠️ dhcp-identifier: mac 只需要写在走外部 DHCP / Managed DHCP 的网卡上(变体 A/B);eth0 的 DHCP 由 KubeVirt 按 Pod 应答,不存在撞车问题,无需加。

5.7.3 实施步骤

  1. 确认网络底座就绪(第 4 章清单):kubectl get clusternetwork cn-vmvlanstatus 12 条全 Ready、NAD default/net-91 Ready。
  2. 从台账取号:在 vms.csv / ip-registry-*.txt 里取一个 .101–.200 未占用地址(gen-vms.sh 会自动拦截保留段与冲突)。
  3. 创建 VM:按 5.3 的 Secret + VM YAML,或直接用第 6 章 gen-vms.sh 批量。
  4. 等待就绪kubectl get vm <name> -w 到 Running,VM 内 cloud-init statusdone
  5. 跑验收脚本(5.7.4):全绿才算交付。
  6. 登记台账:把 IP↔VM 对应关系写回 ip-registry

5.7.4 一键验收脚本

bash
#!/usr/bin/env bash
# appendix/ops/vm-net-verify.sh
# 双网卡 VM(内部互通 + 固定 IP)一键验收
# 用法: ./vm-net-verify.sh <vm-name> [namespace] [ssh-user]
# 前提: 本机 kubectl 可连集群;本机到固定 IP 网段路由可达;VM 内已注入本机公钥
set -uo pipefail
VM="${1:-}"; NS="${2:-default}"; SSH_USER="${3:-ops}"
[ -n "$VM" ] || { echo "用法: $0 <vm-name> [namespace] [ssh-user]"; exit 2; }
NET_PREFIX="${NET_PREFIX:-10.181.91.}"   # 固定 IP 网段前缀
GW="${GW:-10.181.91.1}"                  # VLAN 网关
VIP="${VIP:-10.181.0.100}"               # 集群 VIP(内部连通性验证目标)
OK=$'[ OK ]'; BAD=$'[FAIL]'; WRN=$'[WARN]'; FAIL=0
ok(){ printf '%s %s\n' "$OK" "$*"; }
no(){ printf '%s %s\n' "$BAD" "$*"; FAIL=$((FAIL+1)); }
wn(){ printf '%s %s\n' "$WRN" "$*"; }

echo "== ① 平台层:VM 与双网卡 =="
st=$(kubectl get vm -n "$NS" "$VM" -o jsonpath='{.status.printableStatus}' 2>/dev/null)
[ "$st" = "Running" ] && ok "VM $NS/$VM Running" || { no "VM 状态=${st:-不存在},终止"; exit 1; }
nad=$(kubectl get vmi -n "$NS" "$VM" -o jsonpath='{.spec.networks[*].multus.networkName}' 2>/dev/null)
echo "$nad" | grep -q 'net-91' && ok "第二块网卡挂在 net-91($nad)" || no "未找到 net-91 网卡($nad)"
podnet=$(kubectl get vmi -n "$NS" "$VM" -o jsonpath='{.spec.networks[?(@.pod)].name}' 2>/dev/null)
[ -n "$podnet" ] && ok "保留 Management Network(内部通道)" || wn "没有 mgmt 网卡(变体 C,放弃集群内可达)"

echo "== ② 固定 IP:从 cloud-init Secret 解析 =="
VMIP=$(kubectl get secret -n "$NS" "${VM}-cloudinit" -o jsonpath='{.data.networkdata}' 2>/dev/null \
  | base64 -d 2>/dev/null | awk '/eth1:/{f=1} f && /^ *- +[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/{print $2; exit}')
VMIP="${VMIP%/*}"
case "$VMIP" in
  ${NET_PREFIX}*) ok "cloud-init 固定 IP = $VMIP" ;;
  "") no "未能从 Secret ${VM}-cloudinit 解析到 eth1 静态 IP"; exit 1 ;;
  *)  no "解析到 $VMIP,不在 ${NET_PREFIX}0/24 网段" ;;
esac

echo "== ③ 外部可达性(本机 → VM)=="
ping -c2 -W2 "$VMIP" >/dev/null 2>&1 && ok "ping $VMIP 通" || no "ping $VMIP 不通(回第 8 章 L0–L2 查)"
ssh -o BatchMode=yes -o ConnectTimeout=5 "$SSH_USER@$VMIP" true 2>/dev/null \
  && ok "SSH $SSH_USER@$VMIP 可登录" || { no "SSH 不通,VM 内检查终止"; exit 1; }

echo "== ④ VM 内部(经固定 IP SSH 进入)=="
vmin(){ ssh -o BatchMode=yes -o ConnectTimeout=5 "$SSH_USER@$VMIP" "$@" 2>/dev/null; }
nic=$(vmin "ip -br addr" | awk -v ip="$VMIP" 'index($0,ip){print $1; exit}')
[ -n "$nic" ] && ok "Guest 内 $nic 持有 $VMIP" || no "Guest 内未见 $VMIP(cloud-init 未生效?查 8.2.6)"
dev=$(vmin "ip route get 1.1.1.1" | grep -oE 'dev [^ ]+' | head -1 | awk '{print $2}')
[ "$dev" = "$nic" ] && ok "默认路由走 $nic(固定 IP 网卡)" || no "默认路由走 ${dev:-?},固定 IP 在 $nic(metric 冲突,查 8.2.7)"
vmin "ip route show default" | grep -q 'metric 900' && ok "mgmt 默认路由已降级(metric 900)" || wn "未见 mgmt 高 metric 默认路由"
vmin "ping -c2 -W2 $GW" >/dev/null && ok "VM → 网关 $GW 通" || no "VM → 网关不通"
vmin "ping -c2 -W2 $VIP" >/dev/null && ok "VM → 集群 VIP $VIP 通(内部通道正常)" || no "VM → VIP 不通(mgmt/NAT 通道异常)"
vmin "getent hosts harvester.example.local" >/dev/null && ok "DNS 解析正常" || wn "DNS 解析失败(检查 eth1 nameservers)"
vmin "systemctl is-active qemu-guest-agent" | grep -q active && ok "qemu-guest-agent running" || wn "qga 未运行(UI 看不到第二块网卡 IP,见 8.4 案例 5)"

echo
[ "$FAIL" -eq 0 ] && echo "✅ $VM 验收通过" || echo "❌ $VM$FAIL 项未通过"
exit "$FAIL"
bash
chmod +x appendix/ops/vm-net-verify.sh
./appendix/ops/vm-net-verify.sh vm-web-01 default ops
# 批量验收(配合第 6 章台账)
tail -n +2 appendix/vm/vms.csv | cut -d, -f1 | while read -r n; do
  ./appendix/ops/vm-net-verify.sh "$n" || echo "$$ 注意: $n 未通过"
done

脚本按「平台层 → 固定 IP → 外部可达 → VM 内部」四层递进,任何一层 FAIL 都给出下一步该去的章节;网卡名不假设 eth1,而是先定位「持有固定 IP 的那块卡」再验证默认路由,因此对 ens3/ens4 命名的镜像同样适用。


5.8 本章验证清单

#检查项命令期望
1VM 运行kubectl get vm,vmi -n defaultvm-web-01 Running/Ready
2两块网卡都在kubectl get vmi vm-web-01 -o yaml | grep -A4 interfaces:default(masquerade) + nic-1(bridge)
3网卡挂到了正确 NADkubectl get vmi vm-web-01 -o yaml | grep -A3 networks:multus.networkName: default/net-91
4Guest 内 eth1 有地址VM 内 ip -br addr show eth110.181.91.101/24
5默认路由正确VM 内 ip route get 8.8.8.8dev eth1
6集群内可达 mgmt节点上 ping <mgmt IP>
7外部可 SSH外部 ssh ops@10.181.91.101成功
8外部到网关VM 内 ping 10.181.91.1
9DNSVM 内 getent hosts harvester.example.local解析成功
10MAC 已学习宿主机 bridge fdb show | grep <MAC>出现在 cn-vm-br 相关端口
11cloud-init 完成VM 内 cloud-init statusdone
12重启后地址不变virtctl restart vm-web-01(或 Guest 内 reboot / UI ⋮ → Restart)eth1 仍是 10.181.91.101

第 12 项务必做:mgmt IP 重启会变,VLAN IP 不该变。如果 VLAN IP 也变了,说明 cloud-init networkData 没生效(多半是 Secret key 名或引用写错)。

下一章:把这台 VM 变成模板,用脚本一次交付几十台规格统一的机器。