mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6562 字
18 分钟
如何制作一个标准的产品镜像
2023-06-29

某安全厂商向客户交付产品时,每台服务器都要运维人员手动安装操作系统、配置环境、部署应用,50 台机器需要整整一周。制作标准 ISO 产品镜像后,插入 U 盘即可自动完成全量安装,50 台机器半天搞定。

但构建产品 ISO 不仅是跑对 xorriso 参数。不理解 ISO 的启动流程,不知道 Anaconda 在安装的每个阶段做什么,不清楚 kickstart 指令和安装阶段的对应关系,就可能陷入盲目复制命令、面对启动失败无从下手的困境。本文从第一性原理出发,覆盖 ISO 内部结构、Anaconda 安装机制、构建流水线、安全加固和 CI/CD 的全链路。

Important

本文基于 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 内部大致如下:

graph TD ISO["ISO 根目录"] --> ISOLINUX["isolinux/"] ISO --> EFI["EFI/"] ISO --> IMAGES["images/"] ISO --> LIVEOS["LiveOS/"] ISO --> PACKAGES["Packages/"] ISO --> REPODATA["repodata/"] ISO --> KS["ks.cfg"]

顶层目录只有七个。其中 isolinux/EFI/ 分别服务于 BIOS 和 UEFI 两种启动路径,Packages/repodata/ 服务于 Anaconda 的包安装阶段。展开来看:

graph TD ISOLINUX["isolinux/"] --> ISOLINUX_BIN["isolinux.bin<br>BIOS 引导加载程序"] ISOLINUX --> ISOLINUX_CFG["isolinux.cfg<br>BIOS 启动菜单配置"] ISOLINUX --> VMLINUZ["vmlinuz<br>安装内核"] ISOLINUX --> INITRD["initrd.img<br>初始内存盘"] EFI["EFI/BOOT/"] --> GRUBX64["grubx64.efi<br>UEFI 引导加载程序"] EFI --> GRUB_CFG["grub.cfg<br>UEFI 启动菜单配置"] IMAGES["images/"] --> EFIBOOT["efiboot.img<br>UEFI 启动分区镜像"] IMAGES --> INSTALL["install.img<br>安装环境 squashfs"] LIVEOS["LiveOS/"] --> SQUASHFS["squashfs.img<br>LiveOS 根文件系统"] PACKAGES["Packages/"] --> RPMS["*.rpm<br>所有软件包"] REPODATA["repodata/"] --> REPOXML["repomd.xml 等<br>仓库元数据"]

每个目录服务于不同的消费者:

目录消费者作用
isolinux/BIOS 固件BIOS 启动时读取的引导加载程序和内核
EFI/BOOT/UEFI 固件UEFI 启动时读取的 GRUB 引导程序
images/Anaconda安装环境的 squashfs 镜像和 EFI 启动分区镜像
LiveOS/Anaconda(LiveCD 模式)Live 环境的根文件系统
Packages/Anaconda 包安装阶段所有待安装的 RPM 包
repodata/Anaconda 包安装阶段仓库元数据,包含包列表和依赖关系
ks.cfgAnaconda无人值守安装配置文件

关键认知:ISO 内部存在两套独立的启动路径(BIOS 和 UEFI),它们读取不同的目录、使用不同的引导程序。这就是为什么构建 ISO 时需要同时处理 isolinux/EFI/ 两个目录。

2.2 启动流程:BIOS vs UEFI#

理解启动流程是理解 xorriso 参数的前提。BIOS 和 UEFI 的启动路径完全不同:

graph LR subgraph BIOS路径 B_POWER["加电"] --> B_MBR["读取 MBR<br>前 446 字节"] B_MBR --> B_ISOLINUX["加载 isolinux.bin"] B_ISOLINUX --> B_CFG["读取 isolinux.cfg"] B_CFG --> B_KERNEL["加载 vmlinuz + initrd"] B_KERNEL --> B_ANACONDA["启动 Anaconda"] end subgraph UEFI路径 U_POWER["加电"] --> U_GPT["读取 GPT 分区表"] U_GPT --> U_EFI["查找 EFI 系统分区"] U_EFI --> U_GRUB["加载 grubx64.efi"] U_GRUB --> U_CFG["读取 grub.cfg"] U_CFG --> U_KERNEL["加载 vmlinuz + initrd"] U_KERNEL --> U_ANACONDA["启动 Anaconda"] end

两条路径在”加载 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-mbrBIOS: 读取 MBR将 MBR 引导代码写入 ISO 的前 446 字节,让 BIOS 能从 ISO 启动
-b isolinux/isolinux.binBIOS: 加载 isolinux.bin指定 El Torito 的第一启动镜像(BIOS 路径)
-c isolinux/boot.catBIOS: El Torito 启动目录指定 El Torito 启动目录文件的位置
-eltorito-alt-boot声明第二启动项告诉 ISO 接下来定义的是另一个启动镜像,用于 UEFI 路径
-e images/efiboot.imgUEFI: 加载 EFI 启动分区指定 UEFI 启动分区镜像(第二启动项)
-isohybrid-gpt-basdatUEFI: 读取 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-00A0C93EC93BUEFI: 读取 GPT 分区表追加 GPT 分区表并将 ISO 主分区类型标记为 EFI System Partition,兼容所有 UEFI 固件
Tip

记住这张映射表,后面第四章写 xorriso 命令时,每个参数都能在这里找到对应。不是在背参数,是在描述启动流程。

三、Anaconda 与 Kickstart:安装流程的控制器#

Kickstart 不是独立的安装工具,它是 Anaconda 安装程序的配置接口。不理解 Anaconda 的工作阶段,写 kickstart 就是在填表:知道有这个字段,不知道它什么时候被消费、填错了会怎样。

3.1 Anaconda 的安装阶段#

Anaconda 是 Red Hat 系 Linux 的安装程序,从 ISO 启动后接管整个安装过程。它的工作分为明确的阶段:

graph TD START["ISO 启动<br>vmlinuz + initrd 加载"] --> LOADER["Stage 1: Loader<br>最小化环境"] LOADER --> |"找到安装源<br>和 kickstart"| STAGE2["Stage 2: Anaconda 主程序<br>加载 squashfs 运行环境"] STAGE2 --> |"读取 kickstart<br>配置网络和磁盘"| PKG["包安装阶段<br>yum/dnf 安装软件包"] PKG --> |"%packages 定义"| POST["Post-install 阶段<br>执行 %post 脚本"] POST --> |"%post 定义"| BOOTLOADER["引导装载程序安装<br>安装 GRUB"] BOOTLOADER --> FIRSTBOOT["Firstboot<br>首次启动配置"] style LOADER fill:#f9f,stroke:#333 style STAGE2 fill:#bbf,stroke:#333 style PKG fill:#bfb,stroke:#333 style POST fill:#fbf,stroke:#333

每个阶段的职责和输入输出:

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 的阶段被分批消费。理解这个对应关系,才能知道指令的顺序为什么重要、哪些指令可以省略、哪些指令放错了位置会导致安装失败。

sequenceDiagram participant KS as Kickstart 文件 participant L as Stage 1 Loader participant A as Stage 2 Anaconda participant P as 包安装 participant POST as Post-install Note over L: 内核启动参数 inst.ks= 指定 kickstart 位置 L->>KS: 读取 install/url/cdrom Note over L: 确定安装源 L->>A: 加载 Anaconda 主程序 A->>KS: 读取 keyboard/lang/timezone Note over A: 配置本地化 A->>KS: 读取 network Note over A: 配置网络 A->>KS: 读取 zerombr/clearpart/part/logvol Note over A: 配置磁盘分区 A->>KS: 读取 auth/rootpw Note over A: 配置认证 A->>KS: 读取 firewall/selinux Note over A: 配置安全策略 A->>KS: 读取 bootloader Note over A: 配置引导装载程序 A->>P: 传递 %packages 列表 P->>KS: 读取 %packages ... %end Note over P: 安装软件包 POST->>KS: 读取 %post ... %end Note over POST: 执行安装后脚本

Anaconda 如何找到 kickstart 文件?通过内核启动参数 inst.ks=。这个参数需要同时写入 isolinux.cfg(BIOS 路径)和 grub.cfg(UEFI 路径):

isolinux.cfg 中添加 kickstart 参数
append initrd=initrd.img inst.ks=cdrom:/ks.cfg inst.stage2=hd:LABEL=PRODUCT-ISO quiet
grub.cfg 中添加 kickstart 参数
linux /isolinux/vmlinuz inst.ks=cdrom:/ks.cfg inst.stage2=hd:LABEL=PRODUCT-ISO quiet
initrd /isolinux/initrd.img

inst.ks=cdrom:/ks.cfg 告诉 Anaconda 从光盘根目录读取 ks.cfginst.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 模式更快更可靠
text
Important

cdromurl 是互斥的。如果 ISO 内包含完整的 Packages 目录和 repodata,用 cdrom。如果 ISO 只包含引导程序,软件包需要从网络仓库拉取,用 url。产品 ISO 几乎都用 cdrom,因为离线部署是核心需求。

Stage 2 消费的指令

# 键盘和语言
keyboard us
lang 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 --activate
network --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=sha512
rootpw --iscrypted $6$rounds=4096$salt$hash
# 分区配置
# zerombr: 初始化所有磁盘的 MBR(会清除现有分区表,确认目标机器无重要数据)
# clearpart --all: 清除所有分区
# 配置错误:分区太小会导致安装失败或运行时磁盘满
zerombr
clearpart --all --initlabel
part /boot --fstype=xfs --size=500
part pv.01 --size=1 --grow
volgroup vg_main pv.01
logvol / --vgname=vg_main --size=1 --grow --name=lv_root
logvol 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=ssh
selinux --enforcing

关于 SELinux 的策略选择:--enforcing 直接启用强制模式,是最安全的做法,但可能导致某些产品应用因权限拒绝而无法运行。更稳妥的渐进策略是先 --permissive(只记录不拒绝),收集审计日志后用 audit2allow 生成策略规则,确认无拒绝后再切换到 --enforcing。产品镜像的 SELinux 策略应该在测试环境中充分验证后再固化。

包安装阶段消费的指令

%packages
@core # 核心安装组(CentOS 7)
# RHEL 8+ 使用 @^minimal-environment 环境组语法
chrony # NTP 时间同步
curl
wget
vim
bash-completion
# 产品专属包
product-agent
product-dashboard
%end

@core 是核心软件组(group),以 @ 开头。RHEL 8+ 使用 @^minimal-environment 环境组(environment group)语法,以 @^ 开头。单个包直接写名称。用 - 前缀排除包:-postfix

Post-install 阶段消费的指令

%post --log=/root/ks-post.log
# 在已安装的系统中执行
# 这里可以:创建用户、配置服务、写入产品配置
echo "Installation completed" >> /root/completed
systemctl enable chronyd
systemctl 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/nvme0n1
DISK_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-include
else
echo "logvol / --vgname=vg_main --size=1 --grow --name=lv_root" > /tmp/part-include
fi
%end

%pre 在 Stage 2 之前执行,此时磁盘尚未分区,可以用来动态生成分区方案。生成的配置通过 %include /tmp/part-include 引入主 kickstart 文件。

Tip

ksvalidator 只检查 kickstart 语法,不检查语义(比如分区是否合理、包是否存在)。更实用的验证方式是用 ksflatten 解析所有 %include 后生成完整文件,再在虚拟机中实际测试安装。

四、构建流水线:从原版 ISO 到产品 ISO#

先看全貌,再逐步展开。构建产品 ISO 的完整流水线如下:

graph TD ORIG["原版 ISO"] --> MOUNT["挂载 ISO<br>复制全部内容"] MOUNT --> COPY["iso-root/ 工作目录"] COPY --> EXTRACT["提取 squashfs<br>挂载 rootfs"] COPY --> REPO["准备离线仓库<br>reposync + createrepo"] EXTRACT --> CHROOT["chroot 定制 rootfs<br>安装产品包/配置"] REPO --> REPO_COPY["仓库复制到<br>iso-root/Packages/"] CHROOT --> CLEANUP["清理 rootfs<br>缓存/日志/临时文件"] CLEANUP --> REPACK["重新打包 squashfs"] REPACK --> REPLACE["替换 iso-root 中的<br>squashfs 镜像"] REPLACE --> XORRISO["xorriso 生成 ISO"] REPO_COPY --> XORRISO XORRISO --> VERIFY["验证<br>挂载检查 + 虚拟机测试"] VERIFY --> |"通过"| DONE["product.iso"] VERIFY --> |"失败"| DEBUG["调试<br>检查启动日志"] style ORIG fill:#f9f,stroke:#333 style DONE fill:#bfb,stroke:#333 style DEBUG fill:#fbb,stroke:#333

流水线有两条并行轨道:rootfs 定制和仓库准备。它们在 xorriso 打包阶段汇合。每一步的输入和输出:

步骤输入输出常见失败点
挂载 ISO原版 ISO 文件iso-root/ 目录ISO 损坏、挂载点权限
提取 squashfsiso-root/LiveOS/squashfs.imgrootfs 目录树squashfs 路径因版本不同
chroot 定制rootfs + 产品文件 + 本地仓库定制后的 rootfschroot 内缺少仓库配置
准备离线仓库外部仓库配置本地 RPM + repodatareposync 网络中断
重新打包 squashfs定制后的 rootfs(ext4 镜像 + squashfs 容器)新 squashfs.imgrootfs.img 格式错误(应为 ext4 而非 squashfs)
xorriso 生成 ISOiso-root/ + 新 squashfsproduct.isoefiboot.img 路径错误
验证product.iso通过/失败启动失败、包安装失败

4.1 准备离线软件仓库#

离线仓库的核心原则:自包含。仓库中必须包含所有 RPM 包及其依赖,repodata 必须与包列表一致,baseurl 必须指向本地路径而非远程镜像。这一步不依赖原版 ISO,可与 4.2 并行,甚至提前做。

当前文章中有一个常见错误:local.repobaseurl 指向外部镜像(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 指向本地路径,不是远程 URL
cat > /etc/yum.repos.d/local.repo <<EOF
[local-baseos]
name=Local BaseOS
baseurl=file:///mnt/local-repo/baseos/
enabled=1
gpgcheck=0
[local-appstream]
name=Local AppStream
baseurl=file:///mnt/local-repo/appstream/
enabled=1
gpgcheck=0
[local-extras]
name=Local Extras
baseurl=file:///mnt/local-repo/extras/
enabled=1
gpgcheck=0
EOF
# 验证:只启用本地仓库,检查包列表是否完整
yum --disablerepo="*" --enablerepo="local-*" list available

将仓库嵌入 ISO 目录结构有两种方式:

  1. 替换原版 ISO 的 Packages/ 和 repodata/:将本地仓库的 RPM 和 repodata 直接复制到 iso-root/Packages/iso-root/repodata/,替换原版内容。Anaconda 安装时从 cdrom 读取,不需要额外配置。适合仓库不太大的场景。
  2. 在 ISO 中创建独立仓库目录:在 iso-root/ 下创建 repositories/ 目录,放入仓库,然后在 kickstart 中用 repo --name=extras --baseurl=file:///run/install/repo/repositories/extras 引用。适合需要保留原版仓库同时添加额外仓库的场景。
Note

上面 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 压缩打包的,需要逐层解压。

graph TD ISO["原版 ISO"] --> |"mount -o loop"| MNT["/mnt/orig-iso/"] MNT --> SQUASH7["CentOS 7<br>LiveOS/squashfs.img"] MNT -.-> |"RHEL 8+<br>images/install.img<br>内部结构不同, 本文不展开"| SQUASH8["非本文路径"] SQUASH7 --> |"mount -o loop"| SQUASH_MNT["/mnt/squashfs/"] SQUASH_MNT --> ROOTFS["LiveOS/rootfs.img<br>ext4 镜像"] ROOTFS --> |"mount -o loop"| ROOTFS_MNT["/mnt/rootfs/"] ROOTFS_MNT --> |"复制到工作目录"| WORKDIR["/tmp/rootfs/<br>可定制的目录树"] style ISO fill:#f9f,stroke:#333 style WORKDIR fill:#bfb,stroke:#333 style SQUASH8 fill:#eee,stroke:#999,stroke-dasharray: 5 5

虚线节点表示 RHEL 8+ 的 install.img 内部不再嵌套 ext4 rootfs.img,沿用 4.4 的”套 ext4 再打 squashfs”逻辑对 8+ 不成立,需要另行处理。本文后续打包步骤仅针对 CentOS 7。

具体操作:

# 1. 挂载原版 ISO
mount -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/
Warning

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/dev
mount --bind /proc /tmp/rootfs/proc
mount --bind /sys /tmp/rootfs/sys
# systemd 的 systemctl enable 需要读取 cgroup
mount --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 BaseOS
baseurl=file:///mnt/local-repo/baseos/
enabled=1
gpgcheck=0
EOF
# 3. 注入产品文件(bind mount 产品目录到 chroot 内)
mkdir -p /tmp/rootfs/opt/product/
mount --bind /path/to/product-files/ /tmp/rootfs/opt/product/

现在可以 chroot 操作了:

# 进入 chroot
chroot /tmp/rootfs /bin/bash
# 在 chroot 内安装产品包
yum install -y product-agent product-dashboard
# 复制产品配置
cp /opt/product/product.conf /etc/product.conf
cp /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/
# 退出 chroot
exit

chroot 完成后,卸载所有挂载:

umount /tmp/rootfs/opt/product/
umount /tmp/rootfs/mnt/local-repo/
umount /tmp/rootfs/sys/fs/cgroup
umount /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_SIZE
mkfs.ext4 -F /tmp/new-rootfs.img
# 2. 挂载 ext4 镜像并复制内容
mkdir -p /tmp/rootfs_mnt
mount -o loop /tmp/new-rootfs.img /tmp/rootfs_mnt
cp -a /tmp/rootfs/. /tmp/rootfs_mnt/
umount /tmp/rootfs_mnt
# 3. 将 ext4 镜像放入 LiveOS 目录结构,再打包外层 squashfs
mkdir -p /tmp/new-squashfs/LiveOS/
cp /tmp/new-rootfs.img /tmp/new-squashfs/LiveOS/rootfs.img
mksquashfs /tmp/new-squashfs/ /tmp/new-squashfs.img -comp xz -no-progress
cp /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 根目录
Caution

常见陷阱:

  • efiboot.img 路径必须相对于 ISO 根目录,不是宿主机路径。如果文件在 /tmp/iso-root/images/efiboot.img,参数写 -e images/efiboot.img

  • 缺少 -rock 会导致 Unix 文件权限和符号链接丢失,安装后系统可能无法启动。

  • ISO 卷标(-V 参数)必须与 isolinux.cfggrub.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/iso
ls -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 启动进入 Shellefiboot.img 损坏或路径错误挂载 ISO 检查 images/efiboot.img 是否存在
Anaconda 找不到安装源卷标不匹配检查 -V 参数与 isolinux.cfg 中的 LABEL=
Anaconda 找不到 kickstartinst.ks= 参数缺失或路径错误检查 isolinux.cfggrub.cfg 中的启动参数
包安装失败:找不到包repodata 与 Packages 不一致createrepo_c --update 重新生成元数据
包安装失败:依赖缺失仓库不完整repoclosure 检查依赖闭环
%post 脚本失败脚本语法错误或命令不存在查看已安装系统的 /root/ks-post.log
安装后无法启动GRUB 安装失败检查 bootloader 指令和分区配置

五、安全加固:别把秘密打进镜像#

产品 ISO 的安全风险不只是”系统是否加固”,更隐蔽也更危险的是:构建过程中无意间把密钥、证书、历史命令等敏感信息打进了镜像。这些信息随 ISO 分发给所有客户,一旦泄露无法召回。

5.1 构建过程中的安全风险#

graph LR BUILD["构建环境"] --> |"SSH 主机密钥"| CHROOT["chroot rootfs"] BUILD --> |"bash 历史"| CHROOT BUILD --> |"/etc/pki 私钥"| CHROOT BUILD --> |"yum repo 中的<br>内部 URL"| CHROOT BUILD --> |"构建服务器地址<br>日志中"| CHROOT CHROOT --> ISO["product.iso"] ISO --> |"分发给客户"| CUSTOMER["客户环境"] CUSTOMER --> |"提取敏感信息"| LEAK["泄露"] style BUILD fill:#fbb,stroke:#333 style LEAK fill:#fbb,stroke:#333 style ISO fill:#f9f,stroke:#333

泄露点清单:

泄露点位置风险
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 boot
ConditionPathExistsGlob=!/etc/ssh/ssh_host_*_key
Before=sshd.service
[Service]
Type=oneshot
ExecStart=/usr/bin/ssh-keygen -A
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
chroot /tmp/rootfs systemctl enable ssh-keygen.service

这个模式可以推广:任何每台机器应该唯一的信息(SSH 密钥、自签名证书、机器 ID)都不应该打进 ISO,而应该在首次启动时生成。

5.2 Kickstart 安全配置与权衡#

SELinux 策略

直接 --enforcing 是最安全的,但产品应用可能因 SELinux 拒绝而无法运行。渐进策略:

  1. 安装时 --permissive,只记录不拒绝
  2. 在测试环境中运行产品全量功能测试
  3. 收集 AVC 拒绝并生成策略:ausearch -m avc,USER_AVC -ts recent | audit2allow -M product_policy
  4. 安装策略模块:semodule -i product_policy.pp
  5. 确认无新增拒绝后切换 --enforcing

audit2allow 生成的策略是对所有被拒调用的放行,等价于局部关闭 SELinux。模块装上去前要人工逐条审,只保留产品确实需要的那部分调用,否则这步等于变相 permissive,留着不如不留。

如果产品是安全类产品(防火墙、IDS、WAF),SELinux enforcing 是合规硬性要求,没有选择余地。

防火墙配置

kickstart 的 firewall 指令只接受 --service= 预定义服务别名(如 sshhttp),不支持 --port= 指定任意端口。开放自定义端口要在 %post 里用 firewall-offline-cmd(安装时 firewalld 尚未运行,必须用 offline 变体):

# kickstart 中仍只放预定义服务
firewall --enabled --service=ssh
# %post 中开放产品自定义端口
%post
firewall-offline-cmd --add-port=8080/tcp
firewall-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 # 忽略广播 ping
net.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 = 1
kernel.perf_event_paranoid = 2

挂载选项同样要处理。/tmp/dev/shm 默认挂载不带 noexec,攻击者拿到任意写权限后可以在那里落盘可执行文件。产品 ISO 应在 /etc/fstab%post 中显式加固:

# /tmp 与 /dev/shm 加 nosuid,nodev,noexec
tmpfs /dev/shm tmpfs defaults,nosuid,nodev,noexec 0 0
tmpfs /tmp tmpfs defaults,nosuid,nodev,noexec 0 0

5.3 安装后安全验证#

ISO 安装完成后,用自动化脚本验证安全基线:

#!/bin/bash
# security-check.sh - 安装后安全验证脚本
PASS=0
FAIL=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++))
fi
done
# 挂载选项:/tmp 和 /dev/shm 必须 noexec,nosuid,nodev
for 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
done
done
# 内核硬化参数
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 2
check_sysctl kernel.kptr_restrict 2
check_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++))
fi
done
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 GBGitHub Actions 单个 artifact 上限 2 GB(压缩后),GitHub Release 附件上限 2 GB
构建时间createrepo_c 和 mksquashfs 是 CPU 密集操作完整构建可能需要 30-60 分钟
磁盘空间中间产物(rootfs、squashfs、ISO)可达 20+ GBGitHub Actions runner 磁盘约 14 GB 可用
可重复性ISO 元数据中的时间戳、squashfs 压缩的不确定性同一源码构建两次,SHA-256 不同,影响合规审计

6.2 构建策略#

graph LR TRIGGER["Tag 触发构建"] --> CACHE["恢复缓存<br>本地仓库"] CACHE --> REPOSYNC["reposync<br>增量同步"] REPOSYNC --> BUILD["构建 ISO<br>chroot + xorriso"] BUILD --> SCAN["Trivy 漏洞扫描"] SCAN --> |"通过"| CHECKSUM["生成 SHA-256"] SCAN --> |"失败"| ALERT["通知 + 阻断"] CHECKSUM --> UPLOAD["上传制品<br>S3/MinIO/GitHub Release"] UPLOAD --> SAVE["保存缓存<br>本地仓库"] style CACHE fill:#bbf,stroke:#333 style SAVE fill:#bbf,stroke:#333 style ALERT fill:#fbb,stroke:#333

GitHub Actions 工作流(ISO 专用):

.github/workflows/build-iso.yml
name: Build Product ISO
on:
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 上传
Tip

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.cfgLABEL= 一致isoinfo -d -i product.iso | grep Volume
kickstart 不执行inst.ks= 参数缺失isolinux.cfggrub.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.logcat /root/ks-post.log
安装后无法启动GRUB 安装失败检查 bootloader 指令和分区配置进入救援模式检查 /boot/grub2/
安装后 SSH 无法连接SSH 密钥被删除但未配置重新生成添加 ssh-keygen.servicesystemctl 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 Shellgrub.cfg 中内核路径错误检查 vmlinuzinitrd.img 路径在 GRUB Shell 中 ls 检查

参考资料#

  • Anaconda 源码仓库 - Anaconda 安装程序源码,理解安装阶段的权威来源。loader 如何解析 inst.stage2、挂载 squashfs、切换到主程序,可看 pyanaconda/ 下的 startuppayloaddnf 相关模块
  • xorriso 使用手册 - GNU xorriso 命令行选项详解
  • El Torito 可启动 CD-ROM 规范 - El Torito 启动标准的技术说明
  • Trivy 安全扫描 - Aqua Security 漏洞扫描工具,支持文件系统扫描

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

如何制作一个标准的产品镜像
https://blog.souloss.cn/posts/deploy/deploy-how-to-build-standard-product-image/
作者
Souloss
发布于
2023-06-29
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时