1. 为什么内网环境必须自建本地 yum 仓库
很多刚接触 Linux 运维的朋友,第一次遇到"服务器连不上外网、装不了软件"的场景时,第一反应是去改 DNS、配代理,折腾半天发现根本问题不在网络层,而在于这台机器压根就不允许访问公网。这种场景在金融、制造、能源这类对网络隔离有硬性要求的行业里非常普遍——生产网段和办公网段物理隔离,服务器只能在内网互通,任何外部源都碰不到。
这时候本地 yum 仓库就是刚需。它的本质是把一堆 RPM 包按照 yum 能识别的元数据格式组织起来,放在内网某台机器或者共享存储上,让其他机器通过 HTTP、FTP 或者本地文件路径去拉取。听起来简单,但真正落地时会遇到一堆细节问题:元数据怎么生成、GPG 校验怎么处理、多个仓库怎么优先级排序、客户端配置写在哪、缓存怎么清理。这些问题不解决,仓库建起来也是半残废。
我见过太多人把 ISO 挂载完、createrepo跑一遍就以为完事了,结果客户端yum install报 "Cannot find a valid baseurl",或者装完包之后依赖关系一团糟。根因往往出在仓库元数据和客户端配置的匹配上,而不是命令本身写错了。
这篇文章面向的是有一定 Linux 基础、需要在隔离环境里搭建软件分发体系的运维人员。我会从仓库的目录结构设计讲起,把 createrepo 的元数据生成逻辑、HTTP 服务的选型、客户端 repo 文件的写法、GPG 签名的处理、以及多仓库优先级管理这几个核心环节全部拆开讲透。每个步骤我都会说明"为什么这么做",而不是只给命令。看完之后你应该能独立设计一套适配自己内网环境的 yum 仓库方案,并且知道出问题时从哪里下手排查。
提示:本文所有操作基于 RPM 系发行版(CentOS、Rocky Linux、AlmaLinux、openEuler 等),Debian 系的 APT 仓库逻辑不同,不在讨论范围内。
2. 仓库目录结构设计与 RPM 包的组织逻辑
2.1 仓库的物理布局应该怎么规划
很多人建仓库时习惯把所有 RPM 包一股脑丢进一个目录,然后直接createrepo。小规模场景下这样确实能跑,但一旦包数量上到几百个、涉及多个软件栈,这种扁平结构就会变成灾难——你根本分不清哪个包属于哪个仓库,更新时也不知道该替换哪些文件。
我推荐的做法是按"仓库维度"分目录,每个仓库独立管理自己的 RPM 包和元数据。典型的目录结构长这样:
/var/www/html/repos/ ├── base/ │ ├── Packages/ # 存放所有 RPM 包 │ └── repodata/ # createrepo 生成的元数据 ├── extras/ │ ├── Packages/ │ └── repodata/ ├── custom-tools/ │ ├── Packages/ │ └── repodata/ └── epel/ ├── Packages/ └── repodata/这样分的好处是:每个仓库可以独立更新元数据,互不影响;客户端可以按需启用或禁用某个仓库;后续做权限控制或者迁移时,直接搬目录就行。
Packages目录这个名字不是强制的,但它是社区约定俗成的命名,很多自动化脚本默认会去这个路径找包。repodata目录则是createrepo自动生成的,里面包含repomd.xml、primary.xml.gz、filelists.xml.gz等元数据文件,绝对不能手动修改。
2.2 RPM 包的来源与筛选策略
包的来源通常有三种:系统 ISO 镜像、官方仓库同步、自己编译打包。不同来源的包在组织方式上有区别。
从 ISO 镜像提取是最常见的做法。挂载 ISO 之后,Packages目录下会有几千个 RPM 包,但你不需要全部拷贝——只挑业务需要的即可。比如一个典型的 Web 服务环境,可能只需要nginx、php、mysql相关的包加上它们的依赖。全部拷贝的话,一个完整 ISO 的包大概 4-5 GB,精简之后可能只有几百 MB,传输和同步效率差很多。
筛选依赖是个技术活。手动一个个找依赖容易漏,我的做法是先在一台能联网的同版本机器上用yumdownloader把目标包和依赖一起拉下来:
# 在联网机器上操作 yumdownloader --resolve --destdir=/tmp/packages nginx php mysql-server--resolve参数会自动解析并下载所有依赖包。下载完之后把/tmp/packages整个目录拷到内网仓库的Packages目录下即可。这样能保证依赖完整性,避免客户端装包时装到一半报缺依赖。
注意:
yumdownloader下载的包版本取决于当前机器的仓库配置。如果内网服务器的系统版本和联网机器不一致,下载的包可能不兼容。务必确保两边的大版本号一致(比如都是 Rocky Linux 8.x)。
如果是自己编译的 RPM 包,建议在包名和版本号上做好规范,比如myapp-1.2.3-1.el8.x86_64.rpm,其中el8标识适用的发行版大版本,x86_64是架构。这样后续排查兼容性问题时一眼就能看出来。
2.3 多架构与多版本包共存的处理
内网环境里经常同时存在 x86_64 和 aarch64 两种架构的服务器。这两种架构的 RPM 包不能混在同一个仓库目录里,否则createrepo生成的元数据会混乱,客户端可能拉到错误架构的包。
正确的做法是按架构再分一层目录:
/var/www/html/repos/ ├── base/ │ ├── x86_64/ │ │ ├── Packages/ │ │ └── repodata/ │ └── aarch64/ │ ├── Packages/ │ └── repodata/客户端配置时通过$basearch变量自动匹配当前架构:
baseurl=http://repo.internal/repos/base/$basearch/这样同一份 repo 配置文件可以同时适配两种架构的机器,不需要为每种架构单独写配置。
多版本共存的问题更微妙一些。比如你同时有nginx-1.20和nginx-1.24两个版本的包放在同一个仓库里,createrepo默认会把两个版本都索引进去,客户端yum install nginx时会装最新版本。这通常是期望行为,但如果你想让某些机器锁定旧版本,就需要在客户端用versionlock插件或者在 repo 配置里做限制。这个后面讲客户端配置时会详细说。
3. createrepo 元数据生成的核心机制与参数调优
3.1 createrepo 到底做了什么
很多人把createrepo当成一个黑盒命令,跑完就完事。但理解它到底生成了什么,对排查问题至关重要。
createrepo扫描指定目录下的所有 RPM 包,读取每个包的头部信息(名称、版本、依赖关系、文件列表、校验和等),然后生成一组 XML 文件放在repodata目录下。核心文件包括:
| 文件名 | 作用 |
|---|---|
repomd.xml | 仓库元数据的主索引,记录其他元数据文件的位置和校验和 |
primary.xml.gz | 包的基本信息:名称、版本、依赖、提供的能力 |
filelists.xml.gz | 每个包包含的文件列表 |
other.xml.gz | 包的变更日志等附加信息 |
primary.sqlite.bz2 | primary 数据的 SQLite 版本,加速查询 |
客户端执行yum install时,首先下载repomd.xml,然后根据里面的校验和去拉取对应的元数据文件,在本地建立缓存,最后根据依赖关系计算出需要安装哪些包。
这里有个关键点:repomd.xml里记录了每个元数据文件的校验和。如果你手动修改了primary.xml.gz但没更新repomd.xml,客户端会报校验失败。所以任何时候修改了 RPM 包,都必须重新跑createrepo更新元数据。
3.2 常用参数的实际意义
createrepo的参数不多,但有几个直接影响使用体验:
createrepo --database --update --workers 4 /var/www/html/repos/base/x86_64/--database:额外生成 SQLite 格式的元数据。yum 在查询时用 SQLite 比解析 XML 快得多,包数量多的时候差异非常明显。建议始终加上。--update:增量更新。只处理新增或变化的包,不重建整个元数据。包多的时候能省大量时间。--workers N:并行处理的线程数。默认是单线程,包多时加上这个参数能显著提速。一般设为 CPU 核心数即可。--checksum sha256:指定校验和算法。默认是 sha256,老版本可能是 sha1。如果客户端报校验算法不支持,可以显式指定。
还有一个容易被忽略的参数是--distro,用来指定发行版标识。某些客户端在解析仓库时会检查这个字段,不匹配可能拒绝使用。不过大多数场景下不设也没问题。
3.3 增量更新与全量重建的取舍
--update看起来很美,但它有个坑:如果你删除了某个 RPM 包,--update不会自动从元数据里移除对应的条目。时间长了,元数据里会残留一堆已经不存在的包的记录,客户端安装时可能报 404。
我的经验是:日常新增包用--update,但每隔一段时间(比如一个月)做一次全量重建。全量重建就是先删掉repodata目录,然后不带--update重新跑一遍:
rm -rf /var/www/html/repos/base/x86_64/repodata createrepo --database --workers 4 /var/www/html/repos/base/x86_64/这样能保证元数据和实际包文件完全一致。全量重建的时间取决于包的数量,几百个包大概几十秒,几千个包可能要几分钟。
提示:全量重建期间客户端如果正好在拉取元数据,可能拿到不完整的文件。生产环境建议在低峰期操作,或者先重建到一个临时目录,完成后原子性地替换。
3.4 元数据签名与 GPG 校验的处理
默认情况下,createrepo生成的元数据是不签名的。客户端如果开启了gpgcheck=1,会校验 RPM 包的签名,但元数据本身的签名是另一回事。
如果你在内网环境,安全要求没那么高,可以在客户端把gpgcheck设为 0 跳过校验。但这意味着任何被篡改的包都能被安装,风险自担。
更规范的做法是导入 RPM 包的 GPG 公钥到客户端,保持gpgcheck=1:
# 在客户端导入公钥 rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-xxx然后在 repo 配置里指定:
gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-xxx如果包是自己编译的、没有签名,那就只能gpgcheck=0。这种情况下建议至少对仓库目录做文件完整性监控,防止包被意外替换。
4. HTTP 服务选型与仓库发布
4.1 为什么推荐 HTTP 而不是 FTP 或 NFS
yum 支持多种协议访问仓库:http://、ftp://、file://、nfs://。选哪种取决于你的环境。
file://只适用于仓库和客户端在同一台机器上的场景,比如本机测试。跨机器用不了。
ftp://曾经很流行,但现在越来越多的发行版默认不安装 FTP 客户端库,而且 FTP 的被动模式在防火墙环境下经常出问题。不推荐。
nfs://适合内网已经有 NFS 共享的情况,但 NFS 的权限管理和性能调优比较麻烦,而且 yum 对 NFS 的支持不如 HTTP 稳定。
http://是最通用的选择。配置简单、防火墙规则好写、性能足够、调试方便(浏览器直接访问就能看目录)。我所有内网仓库都是用 HTTP 发布的。
4.2 Nginx 与 Apache 的取舍
两者都能胜任,选哪个看你的熟悉程度和现有环境。
Nginx 的优势是配置简洁、内存占用低、静态文件性能好。一个典型的仓库站点配置:
server { listen 80; server_name repo.internal; root /var/www/html/repos; autoindex on; autoindex_exact_size off; autoindex_localtime on; location / { try_files $uri $uri/ =404; } }autoindex on让浏览器能直接浏览目录,方便确认文件是否存在。autoindex_exact_size off让文件大小以 KB/MB 显示而不是字节数,更直观。
Apache 的优势是模块丰富、.htaccess 灵活,如果你的环境里已经有 Apache 在跑其他服务,直接加个 VirtualHost 就行:
<VirtualHost *:80> ServerName repo.internal DocumentRoot /var/www/html/repos <Directory /var/www/html/repos> Options Indexes FollowSymLinks AllowOverride None Require all granted </Directory> </VirtualHost>两者性能差异在仓库场景下可以忽略不计,因为 yum 的请求量本身就不大。选你顺手的就行。
4.3 防火墙与 SELinux 的坑
HTTP 服务跑起来之后,客户端访问不了,十有八九是防火墙或 SELinux 的问题。
防火墙放行 80 端口:
# firewalld firewall-cmd --permanent --add-service=http firewall-cmd --reload # iptables iptables -A INPUT -p tcp --dport 80 -j ACCEPTSELinux 的问题更隐蔽。默认情况下,/var/www/html目录的 SELinux 上下文是httpd_sys_content_t,HTTP 服务能正常读取。但如果你把仓库放在其他路径,比如/data/repos,就需要手动设置上下文:
semanage fcontext -a -t httpd_sys_content_t "/data/repos(/.*)?" restorecon -Rv /data/repos不设置的话,HTTP 服务会返回 403 Forbidden,而且错误日志里可能只写 "Permission denied",不熟悉 SELinux 的人很容易误以为是文件权限问题。
注意:
semanage命令来自policycoreutils-python-utils包,最小化安装的系统可能没有,需要先装上。
4.4 验证仓库可访问性
配置完成后,用 curl 从客户端机器验证:
curl -I http://repo.internal/repos/base/x86_64/repodata/repomd.xml返回 200 就说明 HTTP 层通了。如果返回 403,检查 SELinux 和文件权限;返回 404,检查路径和 Nginx/Apache 的 root 配置;连接超时,检查防火墙和网络连通性。
这一步看起来简单,但能快速定位问题出在哪一层,避免后面配了半天的 yum 客户端却发现问题根本不在客户端。
5. 客户端 repo 配置的写法与优先级管理
5.1 repo 文件的标准结构
客户端的仓库配置放在/etc/yum.repos.d/目录下,每个仓库一个.repo文件。一个完整的配置长这样:
[base] name=Base Repository baseurl=http://repo.internal/repos/base/$basearch/ enabled=1 gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-base metadata_expire=86400逐项说明:
[base]:仓库 ID,必须唯一。客户端报错时会显示这个 ID,方便定位。name:仓库的描述性名称,随便写,但建议写清楚用途。baseurl:仓库地址。$basearch会被替换成当前架构(x86_64 或 aarch64),$releasever会被替换成发行版大版本号(如 8 或 9)。enabled:是否启用。1 启用,0 禁用。gpgcheck:是否校验 RPM 包签名。gpgkey:公钥路径。可以是本地文件路径,也可以是 HTTP URL。metadata_expire:元数据缓存过期时间(秒)。默认是 6 小时(21600 秒),内网仓库更新不频繁的话可以设长一点,减少客户端拉取元数据的频率。
5.2 多仓库的优先级控制
当客户端配置了多个仓库,同一个包在多个仓库里都存在时,yum 需要决定从哪个仓库安装。默认行为是选版本最新的,但如果版本相同,就按仓库 ID 的字母顺序选。
这种默认行为有时候不符合预期。比如你有一个自定义仓库和一个基础仓库,都包含nginx,你希望优先从自定义仓库装,但自定义仓库的版本号可能比基础仓库低。这时候就需要用priority参数(需要yum-plugin-priorities插件):
[custom-tools] name=Custom Tools baseurl=http://repo.internal/repos/custom-tools/$basearch/ enabled=1 gpgcheck=0 priority=1 [base] name=Base Repository baseurl=http://repo.internal/repos/base/$basearch/ enabled=1 gpgcheck=1 priority=10priority数值越小优先级越高。custom-tools的 priority 是 1,base是 10,所以同名包会优先从custom-tools安装。
这个机制在管理自定义包时非常有用。比如你修改了某个官方包的编译参数,重新打包后放在自定义仓库里,通过 priority 确保客户端优先使用你的版本,而不是官方版本。
5.3 仓库的启用与临时禁用
日常运维中经常需要临时禁用某个仓库。比如排查依赖问题时,想排除某个仓库的干扰:
yum --disablerepo=extras install nginx或者临时只用一个仓库:
yum --disablerepo="*" --enablerepo=base install nginx这些命令行参数只对当前命令生效,不会修改配置文件。如果需要永久禁用某个仓库,把 repo 文件里的enabled=1改成enabled=0即可。
还有一个实用技巧是用yum repolist查看当前生效的所有仓库:
yum repolist -v-v会显示每个仓库的详细信息,包括 baseurl、包数量、元数据过期时间等。排查"为什么某个包找不到"时,第一步就是确认对应的仓库是否在列表里。
5.4 缓存管理与常见缓存问题
yum 会在本地缓存元数据和 RPM 包,默认路径是/var/cache/yum/(新版本可能是/var/cache/dnf/)。缓存能加速后续操作,但也会导致一些问题。
最常见的问题是:仓库更新了,但客户端还在用旧缓存,导致找不到新包或者装到旧版本。解决办法是清缓存:
yum clean all yum makecacheyum clean all清空所有缓存,yum makecache重新下载元数据并建立缓存。这两个命令建议在仓库更新后主动在客户端执行,或者通过配置管理工具批量推送。
另一个问题是缓存占满磁盘。/var/cache/yum/下的 RPM 包默认不会自动删除,时间长了可能占用几个 GB。可以设置keepcache=0让 yum 安装完包后自动删除缓存:
[main] keepcache=0这个配置写在/etc/yum.conf的[main]段里,对所有仓库生效。
6. 实战排查:仓库配好了但客户端装不了包
6.1 从报错信息反推问题层级
客户端装不了包时,报错信息是最重要的线索。我按常见程度列一下:
| 报错信息 | 可能原因 | 排查方向 |
|---|---|---|
Cannot find a valid baseurl | baseurl 写错或 HTTP 服务不可达 | curl 测试 URL 是否返回 200 |
repomd.xml: [Errno 14] HTTP Error 404 | 路径错误或 repodata 不存在 | 检查仓库目录下是否有 repodata |
repomd.xml signature could not be verified | GPG 校验失败 | 检查 gpgkey 路径和 gpgcheck 设置 |
No package xxx available | 包不在任何启用的仓库里 | yum repolist确认仓库,yum search搜索包名 |
Error: Package xxx requires yyy | 依赖缺失 | 确认依赖包是否在仓库里,或从其他仓库补充 |
排查的基本思路是从下往上:先确认网络层通不通,再确认 HTTP 层能不能拉到文件,再确认元数据是否有效,最后确认包和依赖是否完整。
6.2 一个真实的排查案例
之前帮一个团队排查过一个问题:他们的内网仓库配好了,大部分机器都能正常装包,但有几台新装的机器死活报Cannot find a valid baseurl。
第一步,在出问题的机器上 curl 仓库地址:
curl -I http://repo.internal/repos/base/x86_64/repodata/repomd.xml返回 200,说明网络和 HTTP 都没问题。
第二步,检查 repo 配置文件:
cat /etc/yum.repos.d/base.repo发现 baseurl 写的是http://repo.internal/repos/base/$releasever/$basearch/。问题来了:$releasever在新机器上被解析成了8.9,而仓库目录是按大版本8组织的,路径变成了/repos/base/8.9/x86_64/,自然 404。
解决办法有两种:一是把仓库目录改成按小版本组织,二是把 baseurl 里的$releasever去掉,直接写死8。考虑到后续小版本升级的兼容性,我建议用第二种,或者用$releasever_major变量(部分发行版支持)。
这个案例说明:$releasever的解析结果取决于发行版和版本,不同机器可能不一样。跨机器共享 repo 配置时,尽量用确定的值,避免依赖变量解析。
6.3 依赖地狱的破解思路
内网仓库最常见的顽疾是依赖缺失。你装 A 包,它依赖 B,B 又依赖 C,C 在仓库里没有,整个安装就卡住了。
破解思路是"先补全依赖,再建仓库"。具体做法:
- 在一台能联网的同版本机器上,用
yumdownloader --resolve下载目标包及其所有依赖。 - 把下载的包全部拷到内网仓库的
Packages目录。 - 重新跑
createrepo --update。 - 在客户端
yum clean all && yum makecache后重试。
如果依赖链特别深,可以用repoquery递归查询:
repoquery --requires --resolve --recursive nginx这个命令会列出 nginx 的所有递归依赖,方便你确认有没有遗漏。
还有一种情况是依赖的包在仓库里有,但版本不匹配。比如 A 需要 B >= 2.0,但仓库里只有 B 1.5。这种只能去找符合版本要求的 B 包,或者找 A 的旧版本。没有捷径。
6.4 元数据损坏的识别与修复
元数据损坏的表现通常是客户端报校验和错误,或者repomd.xml解析失败。原因可能是createrepo执行到一半被中断,或者磁盘写入异常。
识别方法是在服务端本地验证元数据:
cd /var/www/html/repos/base/x86_64/ createrepo --database --update .如果元数据损坏,这个命令会报错或者重新生成。更彻底的方式是删掉repodata全量重建。
修复之后,客户端必须清缓存才能生效:
yum clean all yum makecache不清缓存的话,客户端可能还在用旧的损坏元数据,问题依旧。
提示:如果仓库是通过 rsync 同步的,同步过程中元数据文件可能处于不一致状态。建议同步时先同步 Packages 目录,再同步 repodata 目录,或者同步完成后在目标端重新跑一次
createrepo --update。
7. 仓库的日常维护与自动化更新
7.1 包更新的标准流程
仓库不是建完就一劳永逸的,日常会有新包加入、旧包淘汰。一个规范的更新流程是这样的:
- 把新的 RPM 包放入
Packages目录。 - 删除需要淘汰的旧包(可选)。
- 执行
createrepo --update --database --workers 4 /path/to/repo。 - 验证
repomd.xml的时间戳已更新。 - 通知客户端清缓存(或等待缓存自动过期)。
第 5 步在大规模环境里是个痛点。几百台机器不可能手动去清缓存。解决方案有两种:一是把metadata_expire设短一点(比如 3600 秒),让客户端每小时自动刷新;二是通过配置管理工具(如 Ansible)批量推送yum clean all命令。
7.2 用脚本自动化元数据更新
手动跑createrepo容易忘,建议写个脚本封装:
#!/bin/bash REPO_BASE="/var/www/html/repos" REPOS=("base/x86_64" "base/aarch64" "extras/x86_64" "custom-tools/x86_64") for repo in "${REPOS[@]}"; do repo_path="$REPO_BASE/$repo" if [ -d "$repo_path/Packages" ]; then echo "Updating repo: $repo" createrepo --update --database --workers 4 "$repo_path" if [ $? -eq 0 ]; then echo " OK" else echo " FAILED" fi else echo "Skipping $repo: Packages directory not found" fi done echo "All repos updated at $(date)"把这个脚本放在 cron 里定时执行,或者用 inotify 监控Packages目录变化触发执行。后者更实时,但需要额外安装inotify-tools。
7.3 仓库容量规划与清理策略
仓库会随着时间不断膨胀。一个运行了两年的仓库,可能积累了几百个旧版本的包,占用几十 GB 空间。定期清理是必要的。
清理的原则是:保留最近 2-3 个版本的包,删除更早的。但要注意,如果有客户端锁定了旧版本,删除后会导致它们无法安装。所以清理前要确认没有客户端依赖被删的版本。
可以用repomanage工具辅助清理:
# 列出旧版本的包(保留最新 2 个版本) repomanage --old --keep 2 /var/www/html/repos/base/x86_64/Packages/ # 确认无误后删除 repomanage --old --keep 2 /var/www/html/repos/base/x86_64/Packages/ | xargs rm -f删除后记得重新跑createrepo更新元数据。
7.4 监控仓库健康状态
仓库出问题往往是被动的——等客户端报错了才知道。主动监控能提前发现问题。
监控项包括:
- HTTP 服务是否存活(curl 检查
repomd.xml返回 200) - 元数据文件的时间戳是否在预期范围内(比如每天至少更新一次)
- 磁盘空间是否充足(低于 20% 告警)
Packages目录下的包数量是否异常变化
这些检查用简单的 shell 脚本加 cron 就能实现,输出结果推送到监控系统或者邮件告警。不需要复杂的工具。
我个人在实际操作中的体会是:本地 yum 仓库这东西,搭建本身不难,难的是长期维护。元数据一致性、依赖完整性、客户端缓存同步这三个问题会反复出现。把更新流程脚本化、把监控做起来,能省掉大量救火时间。另外,仓库目录的权限和 SELinux 上下文一定要在搭建时就配置好,不要等出问题了再回头查——这类问题排查起来最费时间,因为报错信息往往指向错误的方向。