KVM 虚拟机初始化:cloud-init 自动配置主机

用 cloud-init 自动创建用户、写入 SSH 公钥和安装基础软件,减少 KVM 虚拟机手工配置。

技术文章约 14 分钟177 次阅读

#开篇:解决的问题与最底层的机制

给每一台 KVM 虚拟机"装机之后再说"的做法——手动改主机名、建用户、粘 SSH 公钥、装软件——在虚拟机数量上来之后既慢又容易漏。cloud-init 解决的是"首次开机配置"这一件事:它在虚拟机第一次启动的早期引导阶段运行,通过数据源(Datasource)拉取实例元数据(meta-data)与用户数据(user-data),再按固定的模块顺序把一段 YAML 配置翻译成创建用户、写公钥、改主机名、装软件包等具体系统动作。理解它只需要抓住一条因果链:数据源决定配置从哪来,启动阶段决定配置何时生效,配置模块决定 YAML 如何变成系统状态,实例 ID 决定这一切只做一次。本文从这条链的每一环讲起,再给出一套在 KVM 下完整可复现的 NoCloud 部署流程。

#原理层:cloud-init 为什么能在第一次开机把机器配好

#数据源(Datasource):配置从哪来

cloud-init 本身不知道配置放在哪里,它把这个职责抽象成"数据源"。每个云平台提供一种或几种数据源:AWS 的 EC2 metadata 服务、OpenStack 的 ConfigDrive、KVM 下最常用的 NoCloud 等。cloud-init 在 /etc/cloud/cloud.cfg 中维护一个数据源列表(datasource_list),启动早期的 ds-identify 探测脚本会根据当前环境的设备、标签、内核参数,从列表中挑出一个候选数据源:例如在客户机里发现一个卷标为 cidata 的光驱,就判定为 NoCloud,并把判断结果缓存在 /run/cloud-init/ 下供后续阶段使用。

NoCloud 数据源(Data Source NoCloud)的"种子"只需要两个文件:

  • user-data:用户要执行的配置——建用户、SSH 公钥、要装的包、要跑的命令;
  • meta-data:实例元数据——实例 ID、主机名等。

可选还有 vendor-data(镜像提供方写入的配置)与 network-config(网络配置)。KVM 下最常用的做法是把它俩打成一个 ISO 镜像挂成客户机的光驱,cloud-init 启动时读光驱,完成初始化。NoCloud 能识别的 seed 形式不止一种:

  1. 卷标为 cidata 的 ISO 或 vfat 设备(KVM 场景最常用):genisoimage 或 cloud-localds 产出的 ISO,或带 cidata 卷标的 FAT 分区;
  2. 客户机文件系统里的目录:把两个文件放到 /var/lib/cloud/seed/nocloud/(或 nocloud-net/),适合把配置直接烤进自建镜像的场景,不需要额外挂载;
  3. 内核参数指定 seed 来源:ds=nocloud;s=http://... 之类的 ds= 内核引导参数,适合 seed 托管在 HTTP 等远程位置的场景。

形式不同,但 cloud-init 读到的东西一样:meta-data 提供实例身份,user-data 提供初始状态。理解这一点,排查"seed 根本没生效"时就能沿着"光驱在不在 → 卷标对不对 → 文件在不在 → 字段全不全"的路径逐层检查。

#四个阶段:local / network / config / final 各干什么

cloud-init 被拆成四个阶段,分别在引导的不同时点运行,这样"读配置"和"应用配置"之间才能插入网络就绪等前提。用 systemd 的发行版对应四个 unit:

阶段触发时机典型动作
local(cloud-init-local.service)极早期,网络就绪前运行 ds-identify 探测并锁定数据源;若数据源在本地(如 NoCloud ISO),直接把 user-data/meta-data 读入并缓存;若数据源本身需要网络,则先做最小网络配置
network(cloud-init-network.service)基础网络已就绪应用数据源下发的网络配置(netplan、sysconfig、ifupdown 等),让实例的网络符合云端定义
config(cloud-init-config.service)网络就绪后执行大多数配置模块:创建用户与组、写 SSH 授权密钥与主机密钥、设主机名、设时区等不依赖外部服务的项
final(cloud-init-final.service)系统已基本可用,通常排在 multi-user.target 之后执行依赖完整系统的模块:包索引更新与软件包安装、runcmd、phone-home 等

理解这个顺序有几个实际用处:需要网络才能完成的动作(装包、从 HTTP 拉数据源)最早也要到 network/final 阶段才执行;而建用户、写密钥发生在 config 阶段。所以排查"用户建了但包没装上"这类问题时,只要看是哪个阶段失败的,就能定位是配置问题还是网络问题。想观察各阶段是否按序执行,可以查 unit 状态:

systemctl list-units 'cloud-init*' --all

应该能看到 cloud-init-local、cloud-init-network、cloud-init-config、cloud-init-final 四个 unit 按依赖顺序串起来。

#从 cloud-config 到系统配置:翻译发生在哪

user-data 的第一行 #cloud-config 不是注释,而是内容类型标记(类似 shebang),告诉 cloud-init 用 YAML 配置解析器来处理。解析结果不是"一次性执行",而是交给一组按顺序编排的配置模块(module),模块顺序定义在 /etc/cloud/cloud.cfg 的 cloud_config_modules 列表里。每个模块负责一类动作:

  • users_groups:按 users: 创建用户与组;列表里写 - default 表示保留发行版默认用户,没有 default 时发行版默认用户不会被创建;
  • ssh_authorized_keys:把 ssh_authorized_keys 写入对应用户的 ~/.ssh/authorized_keys;
  • ssh:管理 SSH host key 的生成策略与允许登录的密钥类型;
  • set_hostname:把 user-data 里的 hostname 或 meta-data 里的 local-hostname 写入系统;
  • timezone:把 user-data 里的 timezone 写入系统时区;
  • package_update_upgrade_install:按 package_update / package_upgrade / packages 执行包管理器的索引更新、升级与软件包安装;
  • runcmd:把命令列表交给 shell 按顺序执行。

也就是说,#cloud-config 里的每个键都对应一个模块的输入。写 user-data 的人不需要知道模块内部细节,但要理解"翻译是分模块、有顺序的":先建用户再写密钥、先设网络再装包,顺序错位的配置会在错误阶段报错。

#只做一次是怎么保证的:instance-id

cloud-init 把本次实例的 ID(来自 meta-data 的 instance-id 字段)缓存在 /var/lib/cloud/instance/ 下。每次启动它都会对比"缓存里的 instance-id"和"数据源给的 instance-id":相同则判定这还是同一个实例,绝大多数模块跳过;不同则判定是新实例,重新执行初始化。当前状态还能从 /run/cloud-init/status.json 读到结构化结果。

这是两条重要结论的根源:

  • runcmd 里的命令只在首次启动执行,之后不再运行——这是 cloud-init 的幂等边界,别指望它做日常运维;
  • 克隆模板后新机器会被当作新实例重新初始化,前提是数据源里的 instance-id 与旧实例不同(模板流程见下文)。

#操作层:NoCloud 完整可复现流程

#前置条件

以"宿主机 Linux + libvirt + virt-install + KVM 可用"为前提。命令行示例在 Debian/Ubuntu 宿主上编写,RHEL/CentOS 宿主的差异会单独说明。需要准备:

  • 宿主机有可用网络:已有桥接 br0,或使用 libvirt 默认 NAT 网络(network=default);
  • 客户机镜像必须是预装 cloud-init 的云镜像(如 Ubuntu Cloud Image、RHEL/CentOS Cloud Image、Generic Cloud Image)。普通桌面安装 ISO 不带 cloud-init,seed 挂上去也不会触发初始化;
  • 制作 seed 的工具:Debian/Ubuntu 装 cloud-image-utils(提供 cloud-localds)与 genisoimage;RHEL 系可用 EPEL 的 cloud-utils-growpart(提供 cloud-localds)加 genisoimage。
# Debian / Ubuntu
sudo apt update && sudo apt install -y cloud-image-utils genisoimage

# RHEL / CentOS(启用 EPEL 后)
sudo dnf install -y genisoimage cloud-utils-growpart

#写 user-data 与 meta-data

创建目录,写入两个文件。user-data 使用 cloud-config 格式:

#cloud-config
hostname: web01
users:
  - name: deploy
    groups: [sudo]
    sudo: ["ALL=(ALL) NOPASSWD:ALL"]
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...your-public-key
    lock_passwd: true
package_update: true
packages:
  - curl
  - vim
  - qemu-guest-agent
runcmd:
  - [systemctl, enable, --now, qemu-guest-agent]
  - [timedatectl, set-timezone, Asia/Shanghai]

meta-data 是纯键值文本:

instance-id: web01-001
local-hostname: web01

几个值得注意的细节:

  • #cloud-config 必须占第一行,缺了它 cloud-init 不会按 YAML 配置解析,而是当普通文本跳过;
  • instance-id 是必须字段,它决定"是否首次初始化";local-hostname 可选,作为主机名来源,缺省时主机名会回退到实例 ID;
  • lock_passwd: true:锁定密码登录,只允许密钥——这是"无密码初始状态"的标准做法;
  • sudo: ["ALL=(ALL) NOPASSWD:ALL"]:给 deploy 用户免密 sudo,会写成 /etc/sudoers.d/ 下的 drop-in。测试机可以,生产环境建议改成有密码或按需提权,别把 NOPASSWD ALL 变成默认配置;
  • 想要保留发行版默认用户(如 ubuntu、cloud-user),必须在 users: 列表里显式加一行 - default;
  • packages 与 package_update 在 final 阶段由发行版包管理器执行,package_update: true 先刷新包索引,RHEL 系(dnf)与 Debian 系(apt)都支持。

写完后先用 cloud-init 自带的 schema 校验器(cloud-init 19.2 以上支持)检查语法,避免把错误带到开机现场:

cloud-init schema --config-file user-data
# 无输出即通过;有错误会打印到具体行与原因

#制作 seed 镜像:两条路线

路线一:直接用云镜像工具 cloud-localds。它自动写入卷标 cidata;不提供 meta-data 时会自动生成一个只含 instance-id 的最小 meta-data,所以最少只需一个参数对:

cloud-localds seed.iso user-data meta-data
# 或省略 meta-data 让工具自动生成:
# cloud-localds seed.iso user-data
# 需要下发电网络配置时:
# cloud-localds --network-config=network-config.yaml seed.iso user-data meta-data

路线二:与原文一致,用 genisoimage 手动打包。关键参数是 -volid cidata:

mkdir -p /tmp/seed
cat > /tmp/seed/meta-data <<'EOF'
instance-id: web01-001
local-hostname: web01
EOF
# user-data 内容见上,同样放到 /tmp/seed/user-data

sudo genisoimage -output seed.iso -volid cidata -joliet -rock /tmp/seed

注意 -volid cidata:NoCloud 数据源靠这个卷标识别光盘(大小写不敏感,CIDATA 也可),写错了 cloud-init 会忽略它。这是最常见的失败原因之一。

#创建并首次启动虚拟机

用 virt-install 创建虚拟机,把 seed.iso 挂成光驱,--import 表示跳过安装流程、直接引导现有磁盘:

sudo virt-install \
  --name web01 \
  --memory 4096 --vcpus 4 \
  --disk path=/var/lib/libvirt/images/web01.qcow2,size=40,format=qcow2 \
  --disk path=/data/seed/seed.iso,device=cdrom \
  --os-variant rhel9 \
  --network bridge=br0,model=virtio \
  --import

说明:

  • --disk ...,device=cdrom 把 seed 挂成光驱;也可以先建好虚拟机,再用 virsh attach-disk 或 virt-manager 把 seed.iso 挂到光驱上;
  • 宿主没有 br0 时,把 --network bridge=br0,model=virtio 换成 --network network=default,model=virtio,用 libvirt 默认 NAT 网络,seed 与初始化逻辑不受影响;
  • 客户机是其他发行版时,用 osinfo-query os 查询合适的 --os-variant 取值;
  • 首次启动后这台机器就是一个已初始化实例,之后每次开机 cloud-init 都会因为 instance-id 相同而跳过重复初始化。

#验证层:初始化到底成没成

等虚拟机启动完成,在虚拟机里(或用 virsh console web01 串口连进去,连上后可能需要按一次回车):

sudo cloud-init status --wait        # 阻塞等待,直到 status: done
sudo cloud-init status --long        # 显示数据源与阶段详情
sudo cloud-init analyze show         # 看各阶段耗时
sudo cloud-init analyze blame        # 看每个模块耗时,定位慢点

cloud-init status --long 的典型结果:

status: done
datasource: DataSourceNoCloud [seed=/dev/sr0][dsmode=local]

判定标准:

  • status: done 表示本轮初始化成功完成;status: running 表示还在执行;status: error 表示某个模块失败;
  • --long 输出里的 DataSourceNoCloud [seed=/dev/sr0] 说明数据源被正确识别为 NoCloud 且来自光驱——如果这里显示 DataSourceNone 或数据源缺失,说明 seed 根本没被读进去,多半是卷标问题;
  • 结合 cloud-init analyze show 的耗时分布,可以快速判断卡在哪一阶段:网络阶段耗时高多半是数据源依赖网络,final 阶段耗时高多半是包安装。

再用 cloud-init query --all 查看合并后的配置树,确认用户、包等关键项确实进入了最终配置:

sudo cloud-init query --all

然后逐项验证初始化结果:

id deploy       # 用户存在
sudo -u deploy ssh-keygen -l -f /home/deploy/.ssh/authorized_keys   # 公钥文件有效、可读
hostname        # 主机名正确,应为 web01
systemctl is-active qemu-guest-agent   # 应为 active
timedatectl     # 时区正确,应为 Asia/Shanghai

说明:校验公钥用的是 ssh-keygen -l(列出公钥指纹);ssh-keygen -y 读取的是私钥文件,不适合直接验证 authorized_keys 内容。

日志在 /var/log/cloud-init.log,出错先看它;模块执行时的命令输出会出现在 /var/log/cloud-init-output.log(systemd 日志里也能看)。最常见的失败:seed ISO 卷标不对、user-data 是 UTF-8 带 BOM(cloud-init 直接不认)、公钥粘贴时换行被破坏。

#边界与坑

#只做首次启动,不做漂移管理

cloud-init 的默认行为是只在首次启动初始化。runcmd、建用户、装包这些动作不会在第二次开机重复执行,因此它不能用来纠正配置漂移——那是 Ansible 等配置管理工具的活。组合拳是:cloud-init 做最小初始化(身份、密钥、基础包),配置管理工具做持续运维。何时不该用它:已有成熟的配置管理链路、只需要拿到 IP 的纯计算实例,或需要不可变基础设施(改用预构建镜像、Ignition 等方案)的场景。

#模板化:克隆前必须清理

直接克隆一台已初始化的虚拟机,新机器会带着旧的 machine-id、旧的 SSH host key、旧的主机名。正确的模板流程:

  1. 虚拟机装好、cloud-init 跑完(status: done)。

  2. 关机前清理实例状态与本机身份:

    sudo cloud-init clean           # 清空实例缓存,可选加 --logs 一并清日志
    sudo rm -f /etc/machine-id /var/lib/dbus/machine-id
    sudo rm -f /etc/ssh/ssh_host_*
    sudo truncate -s 0 /etc/hostname
    # 若 /etc/hosts 里写死了旧主机名,一并删除对应行
    sudo sed -i '/web01/d' /etc/hosts

    清理后多数发行版会在下次启动时自动重新生成 machine-id 与 SSH host key;个别发行版不会自动生成 host key,可用 sudo ssh-keygen -A 手动补齐。

  3. 关机,用 virt-clone 克隆:

    sudo virt-clone --original web01 --name web02 --auto-clone
  4. 新机器首次启动时,因为数据源里的 instance-id 与旧实例缓存不一致,cloud-init 会把它当新实例重新初始化。

#安全边界:seed 是明文

user-data 里永远不要放密码和私钥:seed ISO 是个明文文件,谁拿到 ISO 谁就拿到里面的全部凭据。需要初始凭据时,放公钥而不是私钥;密码类需求交给首次登录后的配置管理或云平台的临时凭据机制。

#常见失败模式速查

  • 数据源没识别:status --long 显示 DataSourceNone → 查 ISO 卷标是否为 cidata/CIDATA,光驱是否真的挂上(lsblk 或 virt-manager 检查)。
  • user-data 解析失败:第一行必须是 #cloud-config;文件必须是 UTF-8 无 BOM(用 file user-data 检查,输出出现 with BOM 就重新保存)。
  • 公钥登录失败:authorized_keys 里公钥被换行截断 → 检查粘贴过程,确保公钥是一整行;同时确认用户 lock_passwd 没有锁掉密钥登录路径。
  • 包没装上:在 final 阶段失败,多半是网络问题或包源不可达;看 /var/log/cloud-init-output.log 里的包管理器报错。
  • 修改 seed 不生效:已初始化过的实例不会重读配置;改 seed 后必须 cloud-init clean,或换新的 instance-id 再重启,或者干脆删掉重建。

#网络配置要不要写在 seed 里

KVM 场景下,如果客户机用 libvirt 网络加 DHCP 就能拿到地址,network-config 可以不写,网络由镜像默认行为决定。需要静态 IP、自定义 DNS 或特定网卡命名时,才在 seed 里加 network-config(支持 cloud-init 的 v1 格式与 netplan 风格的 v2 格式),并在制作 seed 时用 cloud-localds --network-config=... 一并打入。

#收尾

cloud-init 把第一次开机从手工劳动变成了一条可重复的流水线:数据源取配置、四个阶段按序执行、模块把 YAML 翻译成系统状态、instance-id 决定只做一次。在 KVM 里值得记住的就三件事:seed 卷标写对、首次启动只此一次、模板克隆前先 cloud-init clean。再往前走,把上面这套流程写进 Packer 或 Ansible 的镜像构建阶段,配合配置管理工具做长期运维,虚拟机交付就可以完全脱离手工。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

KVM 虚拟机初始化:cloud-init 自动配置主机 · LXH·BLOG