KVM 虚拟机初始化:cloud-init 自动配置主机
用 cloud-init 自动创建用户、写入 SSH 公钥和安装基础软件,减少 KVM 虚拟机手工配置。
#开篇:解决的问题与最底层的机制
给每一台 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 形式不止一种:
- 卷标为
cidata的 ISO 或 vfat 设备(KVM 场景最常用):genisoimage或cloud-localds产出的 ISO,或带cidata卷标的 FAT 分区; - 客户机文件系统里的目录:把两个文件放到
/var/lib/cloud/seed/nocloud/(或nocloud-net/),适合把配置直接烤进自建镜像的场景,不需要额外挂载; - 内核参数指定 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、旧的主机名。正确的模板流程:
虚拟机装好、cloud-init 跑完(
status: done)。关机前清理实例状态与本机身份:
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手动补齐。关机,用
virt-clone克隆:sudo virt-clone --original web01 --name web02 --auto-clone新机器首次启动时,因为数据源里的 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 条还没有留言,来说点什么吧。