某安全厂商向客户交付产品时,每台服务器都要运维人员手动安装操作系统、配置环境、部署应用,50 台机器需要整整一周。制作标准 ISO 产品镜像后,插入 U 盘即可自动完成全量安装,50 台机器半天搞定。
但构建产品 ISO 不仅是跑对 xorriso 参数。不理解 ISO 的启动流程,不知道 Anaconda 在安装的每个阶段做什么,不清楚 kickstart 指令和安装阶段的对应关系,就可能陷入盲目复制命令、面对启动失败无从下手的困境。本文从第一性原理出发,覆盖 ISO 内部结构、Anaconda 安装机制、构建流水线、安全加固和 CI/CD 的全链路。
本文基于 CentOS 7 / RHEL 7 编写。RHEL 8+ 的安装环境结构有显著变化:squashfs 路径从 LiveOS/ 变为 images/install.img,kickstart 语法差异较大(zerombr 已弃用、@^minimal-environment 环境组语法仅在 RHEL 8+ 可用,CentOS 7 应使用 @core),dnf 替代 yum 作为包管理器。文中涉及版本差异的地方会单独标注,若你使用 RHEL 8+,需要对应调整。
一、为什么需要产品 ISO 镜像
产品 ISO 镜像解决的核心问题:消除”每台机器手动配置”带来的一致性风险和人力成本。它提供三项不可替代的能力:
- 裸机可启动:ISO 是唯一能直接在裸金属服务器上启动安装的镜像格式。虚拟机镜像(qcow2、VMDK)需要虚拟化平台,容器镜像需要运行时,ISO 不依赖任何中间层。
- 全栈固化:从内核版本到应用配置,整个软件栈锁定在一个文件中。每次版本升级自主管控操作系统层面的所有组件,不存在”基础镜像悄悄更新导致上层应用崩溃”的风险。
- 离线可交付:ISO 自包含所有安装所需的软件包和仓库,目标机器不需要联网。这在气隙环境(air-gapped)、内网隔离、合规审计场景下是硬性需求。
典型应用场景:数据中心批量装机、离线部署、版本固化、合规审计、OEM 预装、等保合规。
二、ISO 镜像的内部结构
动手构建之前,先搞清楚 ISO 里面装了什么、为什么这样组织。理解了结构,后面看到 xorriso 的参数就不会觉得是一串魔法咒语。
2.1 目录结构
一张可启动的 CentOS/RHEL ISO 内部大致如下:
顶层目录只有七个。其中 isolinux/ 和 EFI/ 分别服务于 BIOS 和 UEFI 两种启动路径,Packages/ 和 repodata/ 服务于 Anaconda 的包安装阶段。展开来看:
每个目录服务于不同的消费者:
| 目录 | 消费者 | 作用 |
|---|---|---|
isolinux/ | BIOS 固件 | BIOS 启动时读取的引导加载程序和内核 |
EFI/BOOT/ | UEFI 固件 | UEFI 启动时读取的 GRUB 引导程序 |
images/ | Anaconda | 安装环境的 squashfs 镜像和 EFI 启动分区镜像 |
LiveOS/ | Anaconda(LiveCD 模式) | Live 环境的根文件系统 |
Packages/ | Anaconda 包安装阶段 | 所有待安装的 RPM 包 |
repodata/ | Anaconda 包安装阶段 | 仓库元数据,包含包列表和依赖关系 |
ks.cfg | Anaconda | 无人值守安装配置文件 |
关键认知:ISO 内部存在两套独立的启动路径(BIOS 和 UEFI),它们读取不同的目录、使用不同的引导程序。这就是为什么构建 ISO 时需要同时处理 isolinux/ 和 EFI/ 两个目录。
2.2 启动流程:BIOS vs UEFI
理解启动流程是理解 xorriso 参数的前提。BIOS 和 UEFI 的启动路径完全不同:
两条路径在”加载 vmlinuz + initrd”之后汇合,此后都由 Anaconda 接管。差异在于固件如何找到引导程序:
- BIOS:读取磁盘第一个扇区的 MBR(Master Boot Record),MBR 中的引导代码指向
isolinux.bin。El Torito 规范定义了 CD-ROM 的可启动标准,BIOS 通过读取 El Torito 启动目录(boot catalog)找到引导镜像。 - UEFI:读取 GPT(GUID Partition Table)分区表,找到 EFI 系统分区(ESP),从中加载
grubx64.efi。UEFI 不使用 MBR,也不依赖 El Torito。
一张 ISO 要同时支持两种启动方式,就必须同时包含 MBR 引导代码和 GPT 分区表。这就是 xorriso 参数的来源:
| xorriso 参数 | 对应启动步骤 | 作用 |
|---|---|---|
-isohybrid-mbr | BIOS: 读取 MBR | 将 MBR 引导代码写入 ISO 的前 446 字节,让 BIOS 能从 ISO 启动 |
-b isolinux/isolinux.bin | BIOS: 加载 isolinux.bin | 指定 El Torito 的第一启动镜像(BIOS 路径) |
-c isolinux/boot.cat | BIOS: El Torito 启动目录 | 指定 El Torito 启动目录文件的位置 |
-eltorito-alt-boot | 声明第二启动项 | 告诉 ISO 接下来定义的是另一个启动镜像,用于 UEFI 路径 |
-e images/efiboot.img | UEFI: 加载 EFI 启动分区 | 指定 UEFI 启动分区镜像(第二启动项) |
-isohybrid-gpt-basdat | UEFI: 读取 GPT 分区表 | 追加 GPT 分区表,默认将分区类型标记为 Microsoft Basic Data。部分宽松固件仍能启动,严格 UEFI 实现可能拒绝 |
要确保严格 UEFI 兼容性,需要将 ISO 主分区的 GPT 类型改为 EFI System Partition(ESP)。xorriso 的 -isohybrid-gpt-esp 选项在多数发行版版本中不可用,正确做法是用 -iso_mbr_part_type 传入 ESP 的 GUID:
| xorriso 参数 | 对应启动步骤 | 作用 |
|---|---|---|
-isohybrid-gpt-basdat + -iso_mbr_part_type C12A7328-F81F-11D2-BA4B-00A0C93EC93B | UEFI: 读取 GPT 分区表 | 追加 GPT 分区表并将 ISO 主分区类型标记为 EFI System Partition,兼容所有 UEFI 固件 |
记住这张映射表,后面第四章写 xorriso 命令时,每个参数都能在这里找到对应。不是在背参数,是在描述启动流程。
三、Anaconda 与 Kickstart:安装流程的控制器
Kickstart 不是独立的安装工具,它是 Anaconda 安装程序的配置接口。不理解 Anaconda 的工作阶段,写 kickstart 就是在填表:知道有这个字段,不知道它什么时候被消费、填错了会怎样。
3.1 Anaconda 的安装阶段
Anaconda 是 Red Hat 系 Linux 的安装程序,从 ISO 启动后接管整个安装过程。它的工作分为明确的阶段:
每个阶段的职责和输入输出:
Stage 1: Loader
内核和 initrd 加载后进入的最小化环境。initrd 中包含一个精简的 Anaconda loader,它的任务只有一个:找到安装源和 kickstart 文件,然后加载完整的 Anaconda 运行环境。
loader 怎么从 inst.stage2 走到完整的 Anaconda:内核参数 inst.stage2=hd:LABEL=PRODUCT-ISO 指定安装环境 squashfs 的位置,loader 按 ISO 卷标定位到 LiveOS/squashfs.img,挂载后在其中找到 LiveOS/rootfs.img(ext4 镜像),经 device-mapper 的写时复制机制挂为可写根,最后 exec 到 squashfs 里的 Anaconda 主程序。卷标对不上、squashfs 路径错位、ext4 rootfs 缺失,任一环断了都会卡在 loader 报”找不到安装环境”。
- 输入:内核启动参数(
inst.ks=、inst.repo=、inst.stage2=) - 输出:定位到安装源和 kickstart,加载 squashfs 中的 Anaconda 主程序
- 失败表现:找不到安装源会进入交互模式要求手动指定;找不到 kickstart 会进入图形/文本安装界面
Stage 2: Anaconda 主程序
从 squashfs 加载的完整运行环境。此时 Anaconda 读取 kickstart 文件,按照指令配置网络、磁盘分区、时区等。如果 kickstart 缺少必要指令,Anaconda 会暂停等待用户输入。
- 输入:kickstart 文件、安装源(本地仓库或网络仓库)
- 输出:分区方案、网络配置、语言/键盘设置
- 失败表现:分区指令错误会导致安装中止;网络配置错误会导致后续包安装失败
包安装阶段
Anaconda 调用 yum/dnf 按照 %packages 列表安装软件包。这是耗时最长的阶段。
- 输入:
%packages段定义的包列表、安装源中的 RPM 包和 repodata - 输出:安装到目标磁盘的软件包
- 失败表现:缺少依赖会导致安装失败;仓库 repodata 损坏会导致包列表无法解析
Post-install 阶段
所有包安装完成后,Anaconda 在已安装的系统中执行 %post 脚本。这是注入产品定制配置的位置。
- 输入:
%post脚本内容 - 输出:已安装系统中的定制文件和服务
- 失败表现:
%post脚本执行失败不会中止安装,但会导致产品功能异常。日志记录在/root/ks-post.log
引导装载程序安装
Anaconda 在目标磁盘的 MBR/EFI 分区安装 GRUB。kickstart 中的 bootloader 指令控制这一步。
Firstboot
首次启动时的初始配置。kickstart 中 firstboot --disable 可以跳过这一步,产品镜像通常需要禁用。
3.2 Kickstart 在 Anaconda 中的位置
Kickstart 指令不是被一次性读取的,而是按 Anaconda 的阶段被分批消费。理解这个对应关系,才能知道指令的顺序为什么重要、哪些指令可以省略、哪些指令放错了位置会导致安装失败。
Anaconda 如何找到 kickstart 文件?通过内核启动参数 inst.ks=。这个参数需要同时写入 isolinux.cfg(BIOS 路径)和 grub.cfg(UEFI 路径):
append initrd=initrd.img inst.ks=cdrom:/ks.cfg inst.stage2=hd:LABEL=PRODUCT-ISO quietlinux /isolinux/vmlinuz inst.ks=cdrom:/ks.cfg inst.stage2=hd:LABEL=PRODUCT-ISO quietinitrd /isolinux/initrd.imginst.ks=cdrom:/ks.cfg 告诉 Anaconda 从光盘根目录读取 ks.cfg。inst.stage2=hd:LABEL=PRODUCT-ISO 指定安装环境 squashfs 的位置,PRODUCT-ISO 是 ISO 的卷标。
3.3 关键配置项详解
按 Anaconda 阶段组织 kickstart 配置,每项说明被哪个阶段消费、配置错误会怎样、为什么推荐这个值。
Stage 1 消费的指令
# 安装模式(Stage 1 确定安装类型)install# url 指定网络安装源,cdrom 指定本地安装源# 产品 ISO 通常用 cdrom,因为离线部署cdrom
# 文本模式安装(Stage 1 决定安装界面类型)# 产品镜像不需要图形界面,text 模式更快更可靠textcdrom 和 url 是互斥的。如果 ISO 内包含完整的 Packages 目录和 repodata,用 cdrom。如果 ISO 只包含引导程序,软件包需要从网络仓库拉取,用 url。产品 ISO 几乎都用 cdrom,因为离线部署是核心需求。
Stage 2 消费的指令
# 键盘和语言keyboard uslang zh_CN.UTF-8
# 网络配置# --bootproto=dhcp 适合大多数场景# 如果产品需要固定 IP,用 --bootproto=static --ip=10.0.0.100 --netmask=255.255.255.0# 配置错误:网络不通会导致包安装失败(url 模式)或 %post 脚本中网络操作失败network --bootproto=dhcp --device=eth0 --activatenetwork --hostname=product-server
# 时区timezone Asia/Shanghai --utc
# 认证# --enableshadow 使用 shadow 密码,--passalgo=sha512 使用 SHA-512 哈希# rootpw --iscrypted 后面跟的是密码哈希,不是明文# 生成哈希:python3 -c "import crypt; print(crypt.crypt('YourPassword', crypt.mksalt(crypt.METHOD_SHA512)))"auth --enableshadow --passalgo=sha512rootpw --iscrypted $6$rounds=4096$salt$hash
# 分区配置# zerombr: 初始化所有磁盘的 MBR(会清除现有分区表,确认目标机器无重要数据)# clearpart --all: 清除所有分区# 配置错误:分区太小会导致安装失败或运行时磁盘满zerombrclearpart --all --initlabelpart /boot --fstype=xfs --size=500part pv.01 --size=1 --growvolgroup vg_main pv.01logvol / --vgname=vg_main --size=1 --grow --name=lv_rootlogvol swap --vgname=vg_main --size=4096 --name=lv_swap
# 引导装载程序# --location 省略时默认 mbr(BIOS 把 GRUB 装到 MBR)# UEFI 安装时 GRUB2-EFI 装到 ESP,该选项对 UEFI 路径无意义# 不需要显式处理两种固件:Anaconda 在安装时按目标系统的启动模式分流bootloader --append="rhgb quiet"
# 安全配置firewall --enabled --service=sshselinux --enforcing关于 SELinux 的策略选择:--enforcing 直接启用强制模式,是最安全的做法,但可能导致某些产品应用因权限拒绝而无法运行。更稳妥的渐进策略是先 --permissive(只记录不拒绝),收集审计日志后用 audit2allow 生成策略规则,确认无拒绝后再切换到 --enforcing。产品镜像的 SELinux 策略应该在测试环境中充分验证后再固化。
包安装阶段消费的指令
%packages@core # 核心安装组(CentOS 7)# RHEL 8+ 使用 @^minimal-environment 环境组语法chrony # NTP 时间同步curlwgetvimbash-completion# 产品专属包product-agentproduct-dashboard%end@core 是核心软件组(group),以 @ 开头。RHEL 8+ 使用 @^minimal-environment 环境组(environment group)语法,以 @^ 开头。单个包直接写名称。用 - 前缀排除包:-postfix。
Post-install 阶段消费的指令
%post --log=/root/ks-post.log# 在已安装的系统中执行# 这里可以:创建用户、配置服务、写入产品配置echo "Installation completed" >> /root/completedsystemctl enable chronydsystemctl enable product-agent# 如果 systemctl 不可用,Anaconda 的 %post 在目标系统 chroot 中执行# 可改用:ln -s /usr/lib/systemd/system/product-agent.service /etc/systemd/system/multi-user.target.wants/%end%post 脚本在已安装系统的 chroot 中执行,可以访问已安装的文件和命令。--log 参数将输出记录到指定文件,调试时查看 /root/ks-post.log。注意 %post 的 chroot 环境与构建时的 chroot 不同,Anaconda 会自动挂载必要的文件系统,但 systemd 可能不完全可用,建议对关键服务同时准备 systemctl enable 和手动创建符号链接两种方式。
%pre 脚本(Stage 2 之前执行)
%pre# 在 Anaconda 读取 kickstart 之前执行# 用途:动态检测硬件、生成分区方案# 例如:检测磁盘大小,动态生成分区配置# 注意:/dev/sda 按目标硬件调整,NVMe 盘是 /dev/nvme0n1DISK_SIZE=$(lsblk -bndo SIZE /dev/sda)if [ "$DISK_SIZE" -gt 107374182400 ]; then # 磁盘大于 100GB,给根分区更多空间 echo "logvol / --vgname=vg_main --size=50000 --name=lv_root" > /tmp/part-includeelse echo "logvol / --vgname=vg_main --size=1 --grow --name=lv_root" > /tmp/part-includefi%end%pre 在 Stage 2 之前执行,此时磁盘尚未分区,可以用来动态生成分区方案。生成的配置通过 %include /tmp/part-include 引入主 kickstart 文件。
ksvalidator 只检查 kickstart 语法,不检查语义(比如分区是否合理、包是否存在)。更实用的验证方式是用 ksflatten 解析所有 %include 后生成完整文件,再在虚拟机中实际测试安装。
四、构建流水线:从原版 ISO 到产品 ISO
先看全貌,再逐步展开。构建产品 ISO 的完整流水线如下:
流水线有两条并行轨道:rootfs 定制和仓库准备。它们在 xorriso 打包阶段汇合。每一步的输入和输出:
| 步骤 | 输入 | 输出 | 常见失败点 |
|---|---|---|---|
| 挂载 ISO | 原版 ISO 文件 | iso-root/ 目录 | ISO 损坏、挂载点权限 |
| 提取 squashfs | iso-root/LiveOS/squashfs.img | rootfs 目录树 | squashfs 路径因版本不同 |
| chroot 定制 | rootfs + 产品文件 + 本地仓库 | 定制后的 rootfs | chroot 内缺少仓库配置 |
| 准备离线仓库 | 外部仓库配置 | 本地 RPM + repodata | reposync 网络中断 |
| 重新打包 squashfs | 定制后的 rootfs(ext4 镜像 + squashfs 容器) | 新 squashfs.img | rootfs.img 格式错误(应为 ext4 而非 squashfs) |
| xorriso 生成 ISO | iso-root/ + 新 squashfs | product.iso | efiboot.img 路径错误 |
| 验证 | product.iso | 通过/失败 | 启动失败、包安装失败 |
4.1 准备离线软件仓库
离线仓库的核心原则:自包含。仓库中必须包含所有 RPM 包及其依赖,repodata 必须与包列表一致,baseurl 必须指向本地路径而非远程镜像。这一步不依赖原版 ISO,可与 4.2 并行,甚至提前做。
当前文章中有一个常见错误:local.repo 的 baseurl 指向外部镜像(https://mirror.centos.org/...),这不是离线仓库,只是本地配置文件指向远程源。真正的离线仓库搭建流程:
# 1. 在联网环境中同步仓库(这一步需要外网)mkdir -p /mnt/local-repo/{baseos,appstream,extras}reposync --repoid=baseos -p /mnt/local-repo/reposync --repoid=appstream -p /mnt/local-repo/
# 2. 生成 repodata(这一步不需要外网)createrepo_c /mnt/local-repo/baseos/createrepo_c /mnt/local-repo/appstream/
# 3. 添加自定义 RPM 包mkdir -p /mnt/local-repo/extras/Packages/cp /path/to/product-1.0.0.el7.x86_64.rpm /mnt/local-repo/extras/Packages/createrepo_c --update /mnt/local-repo/extras/
# 4. 验证仓库可用性(在离线环境中)# baseurl 指向本地路径,不是远程 URLcat > /etc/yum.repos.d/local.repo <<EOF[local-baseos]name=Local BaseOSbaseurl=file:///mnt/local-repo/baseos/enabled=1gpgcheck=0
[local-appstream]name=Local AppStreambaseurl=file:///mnt/local-repo/appstream/enabled=1gpgcheck=0
[local-extras]name=Local Extrasbaseurl=file:///mnt/local-repo/extras/enabled=1gpgcheck=0EOF
# 验证:只启用本地仓库,检查包列表是否完整yum --disablerepo="*" --enablerepo="local-*" list available将仓库嵌入 ISO 目录结构有两种方式:
- 替换原版 ISO 的 Packages/ 和 repodata/:将本地仓库的 RPM 和 repodata 直接复制到
iso-root/Packages/和iso-root/repodata/,替换原版内容。Anaconda 安装时从cdrom读取,不需要额外配置。适合仓库不太大的场景。 - 在 ISO 中创建独立仓库目录:在
iso-root/下创建repositories/目录,放入仓库,然后在 kickstart 中用repo --name=extras --baseurl=file:///run/install/repo/repositories/extras引用。适合需要保留原版仓库同时添加额外仓库的场景。
上面 local.repo 中的 baseurl=file:///mnt/local-repo/... 仅用于 chroot 构建阶段(通过 bind mount 访问)。安装时 Anaconda 通过 kickstart 中的 cdrom 指令读取 ISO 内的 Packages/ 和 repodata/,不需要这个 repo 文件。不要将构建阶段的 repo 配置文件打进 ISO。
自定义 RPM 包的依赖解析:createrepo_c 只生成元数据,不检查依赖完整性。用 repoclosure(来自 yum-utils 包)验证依赖闭环:
repoclosure --repo=local-baseos --repo=local-appstream --repo=local-extras如果有未满足的依赖,输出会列出缺失的包。这些包也需要同步到仓库中。
4.2 挂载原版 ISO 并提取 rootfs
这一步的目标:从原版 ISO 中提取出可定制的根文件系统。ISO 中的 rootfs 不是直接暴露的目录树,而是被 squashfs 压缩打包的,需要逐层解压。
虚线节点表示 RHEL 8+ 的 install.img 内部不再嵌套 ext4 rootfs.img,沿用 4.4 的”套 ext4 再打 squashfs”逻辑对 8+ 不成立,需要另行处理。本文后续打包步骤仅针对 CentOS 7。
具体操作:
# 1. 挂载原版 ISOmount -o loop CentOS-7-x86_64-DVD-2009.iso /mnt/orig-iso/
# 2. 复制 ISO 全部内容到工作目录(后续修改都在工作目录中进行)rsync -a /mnt/orig-iso/ /tmp/iso-root/
# 3. 挂载 squashfs(CentOS 7 路径)mount -o loop /mnt/orig-iso/LiveOS/squashfs.img /mnt/squashfs/
# 4. 挂载 rootfs.img(squashfs 内部包含 rootfs 镜像)mount -o loop /mnt/squashfs/LiveOS/rootfs.img /mnt/rootfs/
# 5. 复制 rootfs 到工作目录rsync -a /mnt/rootfs/ /tmp/rootfs/CentOS 7 和 RHEL 8+ 的 squashfs 路径不同。CentOS 7 的 squashfs 在 LiveOS/squashfs.img,内部嵌套 LiveOS/rootfs.img。RHEL 8+ 的安装环境在 images/install.img(也是 squashfs 格式),内部结构不同。操作前先 ls /mnt/orig-iso/ 确认目录结构。
4.3 chroot 定制 rootfs
rootfs 是操作系统启动后的完整目录结构。定制 rootfs 就是在基础系统上注入产品专属的软件包和配置文件。chroot 是最直接的方式:切换到 rootfs 目录树中执行命令,就像在目标系统中操作一样。
chroot 之前需要做三件事:挂载虚拟文件系统、配置仓库、注入产品文件。
# 1. 挂载虚拟文件系统(chroot 内的程序需要这些)mount --bind /dev /tmp/rootfs/devmount --bind /proc /tmp/rootfs/procmount --bind /sys /tmp/rootfs/sys# systemd 的 systemctl enable 需要读取 cgroupmount --bind /sys/fs/cgroup /tmp/rootfs/sys/fs/cgroup
# 2. 配置仓库(chroot 内的 yum/dnf 需要知道从哪里装包)# 将前面准备的离线仓库 bind mount 到 chroot 内mount --bind /mnt/local-repo /tmp/rootfs/mnt/local-repo
# 在 chroot 内创建仓库配置cat > /tmp/rootfs/etc/yum.repos.d/local.repo <<EOF[local-baseos]name=Local BaseOSbaseurl=file:///mnt/local-repo/baseos/enabled=1gpgcheck=0EOF
# 3. 注入产品文件(bind mount 产品目录到 chroot 内)mkdir -p /tmp/rootfs/opt/product/mount --bind /path/to/product-files/ /tmp/rootfs/opt/product/现在可以 chroot 操作了:
# 进入 chrootchroot /tmp/rootfs /bin/bash
# 在 chroot 内安装产品包yum install -y product-agent product-dashboard
# 复制产品配置cp /opt/product/product.conf /etc/product.confcp /opt/product/startup.sh /usr/local/bin/chmod +x /usr/local/bin/startup.sh
# 创建 systemd 服务cp /opt/product/product-agent.service /etc/systemd/system/systemctl enable product-agent
# 如果 cgroup 挂载有问题导致 systemctl 报错,可以手动创建符号链接:# ln -s /etc/systemd/system/product-agent.service /etc/systemd/system/multi-user.target.wants/
# 退出 chrootexitchroot 完成后,卸载所有挂载:
umount /tmp/rootfs/opt/product/umount /tmp/rootfs/mnt/local-repo/umount /tmp/rootfs/sys/fs/cgroupumount /tmp/rootfs/{dev,proc,sys}rootfs 定制 vs kickstart %post 的决策框架
两种方式都能在安装后的系统中添加内容,但适用场景不同:
| 场景 | 用 rootfs chroot | 用 kickstart %post |
|---|---|---|
| 安装额外 RPM 包 | ✅ chroot 内有完整 yum/dnf | ❌ %post 中装包依赖安装源配置 |
| 修改系统配置文件 | ✅ 直接编辑 | ✅ 也可以 |
| 创建 systemd 服务 | ✅ systemctl enable 可用 | ⚠️ 需要手动创建符号链接 |
| 配置目标机器身份(主机名、SSH 密钥) | ❌ 这些应该是每台机器唯一的 | ✅ %post 可以用变量 |
| 运行需要目标磁盘的操作 | ❌ chroot 中磁盘尚未分区 | ✅ %post 在安装后执行 |
原则:需要包管理器的操作放 chroot,需要目标系统唯一性的操作放 %post。
清理 rootfs
chroot 定制后,rootfs 中会残留缓存、日志和临时文件。这些必须在打包前清理:
# 基础清理(减小镜像体积)chroot /tmp/rootfs /bin/bash -c " yum clean all rm -rf /var/cache/yum/ rm -rf /tmp/* rm -rf /var/log/anaconda/* rm -f /root/.bash_history"安全相关的清理(SSH 密钥、证书等)在第五章详细讨论。
4.4 重新打包并生成 ISO
rootfs 定制完成后,需要重新打包 squashfs,然后用 xorriso 生成 ISO。
重新打包 rootfs
CentOS 7 的 LiveOS/rootfs.img 是一个 ext4 文件系统镜像。Anaconda 启动时通过 device-mapper 的写时复制(copy-on-write)机制将其作为可写块设备挂载。因此重新打包时必须创建 ext4 镜像,不能直接用 mksquashfs 压缩目录(squashfs 是只读的,Anaconda 无法将其当作可写块设备挂载)。
# 1. 创建 ext4 镜像并填充定制后的 rootfs# 估算 rootfs 大小,留出 30% 余量ROOTFS_SIZE=$(du -sm /tmp/rootfs | awk '{print int($1 * 1.3)}')dd if=/dev/zero of=/tmp/new-rootfs.img bs=1M count=$ROOTFS_SIZEmkfs.ext4 -F /tmp/new-rootfs.img
# 2. 挂载 ext4 镜像并复制内容mkdir -p /tmp/rootfs_mntmount -o loop /tmp/new-rootfs.img /tmp/rootfs_mntcp -a /tmp/rootfs/. /tmp/rootfs_mnt/umount /tmp/rootfs_mnt
# 3. 将 ext4 镜像放入 LiveOS 目录结构,再打包外层 squashfsmkdir -p /tmp/new-squashfs/LiveOS/cp /tmp/new-rootfs.img /tmp/new-squashfs/LiveOS/rootfs.imgmksquashfs /tmp/new-squashfs/ /tmp/new-squashfs.img -comp xz -no-progresscp /tmp/new-squashfs.img /tmp/iso-root/LiveOS/squashfs.img关键步骤:先创建 ext4 镜像填充内容,再放入 LiveOS/ 目录打包为 squashfs。squashfs 只是外层压缩容器,Anaconda 需要的是里面的 ext4 可写镜像。
xorriso 生成 ISO
现在回到第二章的映射表,每个参数都能找到对应的启动步骤:
xorriso -as mkisofs \ -o /tmp/product.iso \ -V "PRODUCT-ISO" \ # --- BIOS 启动路径 --- -isohybrid-mbr /usr/share/syslinux/isohdpfx.bin \ # 写入 MBR 引导代码 -c isolinux/boot.cat \ # El Torito 启动目录 -b isolinux/isolinux.bin \ # 第一启动镜像(BIOS) -no-emul-boot \ # 不模拟软盘/硬盘 -boot-load-size 4 \ # 加载 4 个 512 字节扇区(2KB),El Torito 规范要求的模拟引导大小 -boot-info-table \ # 写入引导信息表 # --- UEFI 启动路径 --- -eltorito-alt-boot \ # 声明第二启动项 -e images/efiboot.img \ # 第二启动镜像(UEFI) -no-emul-boot \ # 不模拟软盘/硬盘 -isohybrid-gpt-basdat \ # 追加 GPT 分区表 -iso_mbr_part_type C12A7328-F81F-11D2-BA4B-00A0C93EC93B \ # 设置分区类型为 EFI System Partition # --- 文件系统扩展 --- -rock \ # Rock Ridge 扩展(保留 Unix 权限) -joliet \ # Joliet 扩展(长文件名支持) -joliet-long \ # 更长的 Joliet 文件名 -output-charset utf-8 \ # 字符编码 /tmp/iso-root/ # ISO 根目录常见陷阱:
efiboot.img路径必须相对于 ISO 根目录,不是宿主机路径。如果文件在/tmp/iso-root/images/efiboot.img,参数写-e images/efiboot.img。缺少
-rock会导致 Unix 文件权限和符号链接丢失,安装后系统可能无法启动。ISO 卷标(
-V参数)必须与isolinux.cfg和grub.cfg中的LABEL=一致,否则 Anaconda 找不到安装源。UEFI 启动要求
efiboot.img不超过 FAT32 的 4GB 限制。如果 ISO 内容超过 4GB,需要使用iso_level=3扩展。
4.5 验证与调试
ISO 生成后,不要直接拿去装机。先在虚拟机中验证。
验证清单
# 1. 挂载 ISO 检查内容完整性mount -o loop /tmp/product.iso /mnt/isols -la /mnt/iso/
# 检查关键文件是否存在test -f /mnt/iso/isolinux/isolinux.bin && echo "BIOS 引导: OK" || echo "BIOS 引导: MISSING"test -f /mnt/iso/EFI/BOOT/grubx64.efi && echo "UEFI 引导: OK" || echo "UEFI 引导: MISSING"test -f /mnt/iso/ks.cfg && echo "Kickstart: OK" || echo "Kickstart: MISSING"test -d /mnt/iso/Packages && echo "软件包目录: OK" || echo "软件包目录: MISSING"test -d /mnt/iso/repodata && echo "仓库元数据: OK" || echo "仓库元数据: MISSING"
# 2. 检查 kickstart 语法ksvalidator /mnt/iso/ks.cfg
# 3. 检查 ISO 结构信息isoinfo -d -i /tmp/product.iso | grep -E "Volume id|El Torito|Joliet"
# 4. 计算校验和sha256sum /tmp/product.iso > product.iso.sha256
umount /mnt/iso虚拟机测试安装
# BIOS 模式测试virt-install \ --name test-bios \ --ram 2048 \ --vcpus 2 \ --disk size=20 \ --cdrom /tmp/product.iso \ --os-variant centos7 \ --noautoconsole
# UEFI 模式测试(需要 OVMF 固件)virt-install \ --name test-uefi \ --ram 2048 \ --vcpus 2 \ --disk size=20 \ --cdrom /tmp/product.iso \ --os-variant centos7 \ --boot uefi \ --noautoconsole
# 查看安装过程virsh console test-bios常见启动失败的调试
| 现象 | 可能原因 | 调试方法 |
|---|---|---|
| BIOS 启动黑屏 | MBR 引导代码缺失或损坏 | isoinfo -d -i product.iso 检查 El Torito 信息 |
| UEFI 启动进入 Shell | efiboot.img 损坏或路径错误 | 挂载 ISO 检查 images/efiboot.img 是否存在 |
| Anaconda 找不到安装源 | 卷标不匹配 | 检查 -V 参数与 isolinux.cfg 中的 LABEL= |
| Anaconda 找不到 kickstart | inst.ks= 参数缺失或路径错误 | 检查 isolinux.cfg 和 grub.cfg 中的启动参数 |
| 包安装失败:找不到包 | repodata 与 Packages 不一致 | createrepo_c --update 重新生成元数据 |
| 包安装失败:依赖缺失 | 仓库不完整 | repoclosure 检查依赖闭环 |
| %post 脚本失败 | 脚本语法错误或命令不存在 | 查看已安装系统的 /root/ks-post.log |
| 安装后无法启动 | GRUB 安装失败 | 检查 bootloader 指令和分区配置 |
五、安全加固:别把秘密打进镜像
产品 ISO 的安全风险不只是”系统是否加固”,更隐蔽也更危险的是:构建过程中无意间把密钥、证书、历史命令等敏感信息打进了镜像。这些信息随 ISO 分发给所有客户,一旦泄露无法召回。
5.1 构建过程中的安全风险
泄露点清单:
| 泄露点 | 位置 | 风险 |
|---|---|---|
| SSH 主机密钥 | /etc/ssh/ssh_host_* | 所有客户机器使用相同密钥,可中间人攻击 |
| bash 历史记录 | /root/.bash_history | 暴露构建过程中的操作和路径 |
| 私钥和证书 | /etc/pki/、/root/*.pem | 直接泄露加密密钥 |
| yum 仓库中的内部 URL | /etc/yum.repos.d/*.repo | 暴露内部服务器地址和目录结构 |
| 日志中的构建信息 | /var/log/anaconda/、/var/log/yum.log | 暴露构建环境和时间 |
| 临时文件 | /tmp/、/var/tmp/ | 可能包含调试信息或中间产物 |
必须清理的项目(不清理就是安全漏洞):
# 在 chroot 清理阶段执行chroot /tmp/rootfs /bin/bash -c " # SSH 主机密钥(必须删除,首次启动时重新生成) rm -f /etc/ssh/ssh_host_*
# bash 历史 rm -f /root/.bash_history cat /dev/null > /root/.bash_history 2>/dev/null
# 私钥和证书 rm -f /etc/pki/tls/private/*.key rm -f /root/*.pem /root/*.key
# 日志 rm -rf /var/log/anaconda/* rm -f /var/log/yum.log find /var/log -name '*.log' -exec truncate -s 0 {} \;"建议清理的项目(减小镜像体积,降低信息暴露):
chroot /tmp/rootfs /bin/bash -c " # 包管理器缓存 yum clean all rm -rf /var/cache/yum/ rm -rf /var/cache/dnf/
# 临时文件 rm -rf /tmp/* rm -rf /var/tmp/*
# 仓库配置中的内部 URL # 仅删除包含内部服务器信息的 repo 文件 # 若产品需要从内网仓库更新,保留经过处理的、无内部 URL 的 repo 文件 # rm -f /etc/yum.repos.d/internal-*.repo"SSH 密钥的首次启动重新生成
删除 SSH 密钥后,首次启动时需要重新生成,否则 SSH 服务无法启动。创建一个 systemd 服务:
# 在 chroot 中创建首次启动服务cat > /tmp/rootfs/etc/systemd/system/ssh-keygen.service <<EOF[Unit]Description=Generate SSH host keys on first bootConditionPathExistsGlob=!/etc/ssh/ssh_host_*_keyBefore=sshd.service
[Service]Type=oneshotExecStart=/usr/bin/ssh-keygen -ARemainAfterExit=yes
[Install]WantedBy=multi-user.targetEOF
chroot /tmp/rootfs systemctl enable ssh-keygen.service这个模式可以推广:任何每台机器应该唯一的信息(SSH 密钥、自签名证书、机器 ID)都不应该打进 ISO,而应该在首次启动时生成。
5.2 Kickstart 安全配置与权衡
SELinux 策略
直接 --enforcing 是最安全的,但产品应用可能因 SELinux 拒绝而无法运行。渐进策略:
- 安装时
--permissive,只记录不拒绝 - 在测试环境中运行产品全量功能测试
- 收集 AVC 拒绝并生成策略:
ausearch -m avc,USER_AVC -ts recent | audit2allow -M product_policy - 安装策略模块:
semodule -i product_policy.pp - 确认无新增拒绝后切换
--enforcing
audit2allow 生成的策略是对所有被拒调用的放行,等价于局部关闭 SELinux。模块装上去前要人工逐条审,只保留产品确实需要的那部分调用,否则这步等于变相 permissive,留着不如不留。
如果产品是安全类产品(防火墙、IDS、WAF),SELinux enforcing 是合规硬性要求,没有选择余地。
防火墙配置
kickstart 的 firewall 指令只接受 --service= 预定义服务别名(如 ssh、http),不支持 --port= 指定任意端口。开放自定义端口要在 %post 里用 firewall-offline-cmd(安装时 firewalld 尚未运行,必须用 offline 变体):
# kickstart 中仍只放预定义服务firewall --enabled --service=ssh
# %post 中开放产品自定义端口%postfirewall-offline-cmd --add-port=8080/tcpfirewall-offline-cmd --add-port=8443/tcp%end原则:只开放产品运行必需的端口。ISO 会被部署到你无法控制的环境中,默认拒绝比默认开放更安全。
内核参数硬化的权衡
# 通用安全参数net.ipv4.ip_forward = 0 # 禁止 IP 转发net.ipv4.conf.all.send_redirects = 0 # 禁止发送 ICMP 重定向net.ipv4.conf.default.accept_redirects = 0 # 禁止接受 ICMP 重定向net.ipv4.icmp_echo_ignore_broadcasts = 1 # 忽略广播 pingnet.ipv4.conf.all.log_martians = 1 # 记录异常包但 ip_forward = 0 对网络类产品(路由器、防火墙、VPN 网关)是错误的。这类产品必须开启 IP 转发。安全参数不是”一律关闭”,而是根据产品角色选择。
清单里容易漏的几项(安全厂商交付尤其敏感):
# 地址空间随机化,关闭会大幅放大本地提权和 RCE 利用面kernel.randomize_va_space = 2# 内核指针不暴露给非特权用户,阻碍内核漏洞利用kernel.kptr_restrict = 2# 普通用户不能读 dmesg,避免泄露内核地址布局kernel.dmesg_restrict = 1kernel.perf_event_paranoid = 2挂载选项同样要处理。/tmp 和 /dev/shm 默认挂载不带 noexec,攻击者拿到任意写权限后可以在那里落盘可执行文件。产品 ISO 应在 /etc/fstab 或 %post 中显式加固:
# /tmp 与 /dev/shm 加 nosuid,nodev,noexectmpfs /dev/shm tmpfs defaults,nosuid,nodev,noexec 0 0tmpfs /tmp tmpfs defaults,nosuid,nodev,noexec 0 05.3 安装后安全验证
ISO 安装完成后,用自动化脚本验证安全基线:
#!/bin/bash# security-check.sh - 安装后安全验证脚本
PASS=0FAIL=0
# SELinux 模式if [ "$(getenforce)" = "Enforcing" ]; then echo "[PASS] SELinux: Enforcing" ((PASS++))else echo "[FAIL] SELinux: $(getenforce)" ((FAIL++))fi
# 防火墙状态if systemctl is-active firewalld >/dev/null 2>&1; then echo "[PASS] Firewall: Active" ((PASS++))else echo "[FAIL] Firewall: Inactive" ((FAIL++))fi
# 开放端口echo "[INFO] Open ports:"ss -tlnp | grep LISTEN
# SSH 密钥唯一性if [ -f /etc/ssh/ssh_host_rsa_key ]; then KEY_MD5=$(md5sum /etc/ssh/ssh_host_rsa_key | awk '{print $1}') echo "[INFO] SSH RSA key MD5: $KEY_MD5" echo "[INFO] Verify this differs from other deployed machines"fi
# 敏感文件检查for f in /root/.bash_history /root/*.pem /root/*.key; do if [ -f "$f" ]; then echo "[FAIL] Sensitive file found: $f" ((FAIL++)) fidone
# 挂载选项:/tmp 和 /dev/shm 必须 noexec,nosuid,nodevfor m in /tmp /dev/shm; do opts=$(findmnt -no OPTIONS "$m" 2>/dev/null) for need in noexec nosuid nodev; do if ! echo "$opts" | grep -qw "$need"; then echo "[FAIL] $m missing mount option: $need" ((FAIL++)) fi donedone
# 内核硬化参数check_sysctl() { val=$(sysctl -n "$1" 2>/dev/null) if [ "$val" != "$2" ]; then echo "[FAIL] sysctl $1 = ${val:-unset}, expected $2" ((FAIL++)) fi}check_sysctl kernel.randomize_va_space 2check_sysctl kernel.kptr_restrict 2check_sysctl kernel.dmesg_restrict 1
# 不必要的服务UNNECESSARY="avahi-daemon cups bluetooth"for svc in $UNNECESSARY; do if systemctl is-enabled "$svc" >/dev/null 2>&1; then echo "[FAIL] Unnecessary service enabled: $svc" ((FAIL++)) fidone
echo ""echo "Results: $PASS passed, $FAIL failed"Trivy 也可以扫描 rootfs 目录(不只是 Docker 镜像):
# 扫描 rootfs 目录中的已知漏洞trivy fs /tmp/rootfs/
# 只显示高危和严重漏洞trivy fs --severity HIGH,CRITICAL /tmp/rootfs/六、CI/CD 与可重复构建
ISO 构建进入 CI/CD 后,会遇到容器构建不会碰到的问题。
6.1 ISO 构建的 CI 挑战
| 挑战 | 具体表现 | 影响 |
|---|---|---|
| 制品体积 | ISO 文件 4-8 GB | GitHub Actions 单个 artifact 上限 2 GB(压缩后),GitHub Release 附件上限 2 GB |
| 构建时间 | createrepo_c 和 mksquashfs 是 CPU 密集操作 | 完整构建可能需要 30-60 分钟 |
| 磁盘空间 | 中间产物(rootfs、squashfs、ISO)可达 20+ GB | GitHub Actions runner 磁盘约 14 GB 可用 |
| 可重复性 | ISO 元数据中的时间戳、squashfs 压缩的不确定性 | 同一源码构建两次,SHA-256 不同,影响合规审计 |
6.2 构建策略
GitHub Actions 工作流(ISO 专用):
name: Build Product ISOon: push: tags: ['v*']
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
# 缓存本地仓库,避免每次全量 reposync - name: Cache local repo uses: actions/cache@v4 with: path: /tmp/local-repo key: repo-${{ hashFiles('repo-config/*.repo') }} restore-keys: | repo-
# 构建 ISO(在隔离环境中执行,避免污染 runner) - name: Build ISO run: | sudo bash scripts/build-iso.sh
# 漏洞扫描 - name: Trivy filesystem scan uses: aquasecurity/trivy-action@master with: scan-type: 'fs' scan-ref: '/tmp/rootfs' severity: 'HIGH,CRITICAL' exit-code: '1'
# 生成校验和 - name: Generate checksum run: | sha256sum product.iso > product.iso.sha256
# 上传到 S3(ISO 太大,GitHub Release 有 2GB 限制) - name: Upload to S3 uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # 后续用 aws s3 cp 上传ISO 制品存储方案选择:
S3/MinIO:适合内部 CI,无大小限制,支持版本管理
GitHub Release:适合开源项目,但附件上限 2 GB,大 ISO 需要分卷压缩
Artifactory/Nexus:适合企业级制品管理,支持 RPM 仓库和 ISO 统一管理
可重复构建
ISO 默认不可重复:xorriso 在 ISO 元数据中写入当前时间戳,mksquashfs 的 xz 压缩在不同 CPU 上可能产生不同输出。如果合规审计要求”同一源码构建出相同 ISO”,需要:
# xorriso 固定时间戳xorriso -as mkisofs \ -o /tmp/product.iso \ --modification-date=2026010100000000 \ ... 其他参数 ...
# mksquashfs 固定压缩参数mksquashfs /tmp/rootfs/ /tmp/new-rootfs.img \ -comp xz \ -Xbcj x86 \ -b 1M \ -no-progress \ -all-root \ -no-xattrs即使固定了参数,不同版本的 mksquashfs 和 xz 库仍可能产生不同输出。真正的 bit-for-bit 可重复构建需要锁定工具链版本,在固定版本的容器化构建环境中执行。
七、常见问题与排查
| 问题 | 原因 | 解决方案 | 诊断命令 |
|---|---|---|---|
| ISO 无法启动(BIOS) | MBR 引导代码缺失 | 检查 -isohybrid-mbr 参数和 isohdpfx.bin 路径 | isoinfo -d -i product.iso |
| ISO 无法启动(UEFI) | 缺少 EFI 分区或 GPT 类型不正确 | 检查 -e images/efiboot.img、-isohybrid-gpt-basdat 和 -iso_mbr_part_type | 挂载 ISO 检查 EFI/BOOT/ |
| Anaconda 找不到安装源 | 卷标不匹配 | 确保 -V 参数与 isolinux.cfg 中 LABEL= 一致 | isoinfo -d -i product.iso | grep Volume |
| kickstart 不执行 | inst.ks= 参数缺失 | 在 isolinux.cfg 和 grub.cfg 中添加 inst.ks=cdrom:/ks.cfg | 查看启动菜单配置 |
| 包安装失败:找不到包 | repodata 与 Packages 不一致 | createrepo_c --update 重新生成元数据 | repoclosure --repo=local-baseos |
| 包安装失败:依赖缺失 | 仓库不完整 | reposync 时加 --download-comps 下载组信息 | yum deplist product-pkg |
| %post 脚本失败 | 脚本语法错误或命令不存在 | 检查 /root/ks-post.log | cat /root/ks-post.log |
| 安装后无法启动 | GRUB 安装失败 | 检查 bootloader 指令和分区配置 | 进入救援模式检查 /boot/grub2/ |
| 安装后 SSH 无法连接 | SSH 密钥被删除但未配置重新生成 | 添加 ssh-keygen.service | systemctl status sshd |
| ISO 过大无法刻录 | 超过单层 DVD 4.7 GB | 使用 dual-layer 或拆分仓库 | du -sh /tmp/iso-root/ |
| chroot 内 yum 报错 | 仓库配置缺失或 bind mount 失败 | 检查 /etc/yum.repos.d/ 和 mount 状态 | chroot /tmp/rootfs yum repolist |
| squashfs 权限丢失 | 缺少 Rock Ridge 扩展 | xorriso 加 -rock 参数 | 挂载 ISO 检查文件权限 |
| UEFI 启动进入 GRUB Shell | grub.cfg 中内核路径错误 | 检查 vmlinuz 和 initrd.img 路径 | 在 GRUB Shell 中 ls 检查 |
参考资料
- Anaconda 源码仓库 - Anaconda 安装程序源码,理解安装阶段的权威来源。loader 如何解析
inst.stage2、挂载 squashfs、切换到主程序,可看pyanaconda/下的startup、payload与dnf相关模块 - xorriso 使用手册 - GNU xorriso 命令行选项详解
- El Torito 可启动 CD-ROM 规范 - El Torito 启动标准的技术说明
- Trivy 安全扫描 - Aqua Security 漏洞扫描工具,支持文件系统扫描
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






