内网机器连不上公网、某批 rpm 要反复装十几台机器、官方源突然改地址------这三种情况都指向同一件事:自己造一个 yum 源。这篇先讲清一个源到底由什么组成,再讲最容易卡住的一步------包和依赖怎么凑齐(拿 docker-ce 的 29 个依赖做实例),然后给 Linux 侧
createrepo的五步做法,最后给 Windows 侧的做法:不装 createrepo,用一个纯 Python 脚本直接生成元数据,客户端用起来和官方源没有任何区别。
本文概要:
| 项目 | 内容 |
|---|---|
| 文章主题 | 自建 yum 软件源:依赖闭包与离线下载、目录规范与 repodata 元数据、Linux 侧 createrepo 与 Windows 侧纯 Python 生成、HTTP 托管与客户端 .repo 配置 |
| 所属类别 | Linux 运维 → 软件包管理 → 自定义仓库 |
| 知识关键字 | createrepo · repodata · repomd.xml · primary.xml.gz · baseurl · gpgcheck · yum makecache · repotrack · --downloadonly |
主线是四件事:凑齐包 → 摆好目录 → 生成清单 → 托管出去 。Linux 和 Windows 两条路的差别只在第二步用的工具不同,产物完全一样;客户端那边只认 baseurl 指向的 repodata/repomd.xml,不问你是怎么造出来的。
一、一个源由什么组成
yum 源不是「一个放着 rpm 的共享目录」。它必须包含两样东西:
- rpm 包本身;
- 一份描述这些包的清单 ,放在
repodata/目录里。
只放 rpm 不放清单,客户端会报 Cannot find a valid baseurl for repo 或 repomd.xml: HTTP Error 404------它找不到清单,就认为这个地址不是源。
清单由四个文件组成:
| 文件 | 内容 | 缺了会怎样 |
|---|---|---|
repomd.xml |
入口,记录另外三个文件的文件名、校验和、大小 | 直接报 404 或校验失败 |
<hash>-primary.xml.gz |
每个包的 name / arch / version / checksum / 依赖关系 | 无法算依赖 |
<hash>-filelists.xml.gz |
包内文件清单 | yum provides /usr/bin/xxx 失效 |
<hash>-other.xml.gz |
变更日志等附加信息 | yum 会告警,一般不影响装 |
文件名前面的 hash 由文件内容算出。内容一变,hash 就变 ------这正是 yum 判断「本地缓存过期没有」的依据,也是为什么改完源必须让客户端 yum clean all。
二、Linux 侧:createrepo 五步
2.1 摆好目录
bash
[root@docker ~]# mkdir -p /mnt/repo/Packages
# 把要提供的 rpm 全拷进去(依赖包也得在,见第三节)
[root@docker ~]# cp /root/rpms/*.rpm /mnt/repo/Packages/
[root@docker ~]# ls /mnt/repo/Packages | wc -l
62
Packages/ 这个名字不是强制的,但全部堆在根目录会很难维护,社区惯例是单开一层。createrepo 会递归扫描,放哪一层都能识别。
2.2 装 createrepo
bash
[root@docker ~]# yum install -y createrepo
离线环境 要手工把依赖补齐。createrepo 需要这四个包,缺一个都装不上:
bash
[root@docker ~]# ls /root/createrepo-deps/
createrepo-0.9.9-28.el7.noarch.rpm
deltarpm-3.6-3.el7.x86_64.rpm
libxml2-python-2.9.1-6.el7.5.x86_64.rpm
python-deltarpm-3.6-3.el7.x86_64.rpm
# 一条命令一起装,让 rpm 自己解依赖顺序
[root@docker ~]# rpm -Uvh /root/createrepo-deps/*.rpm
2.3 生成元数据
bash
[root@docker ~]# createrepo /mnt/repo
Spawning worker 0 with 62 pkgs
Workers Finished
Saving Primary metadata
Saving file lists metadata
Saving other metadata
Generating sqlite DBs
Sqlite DBs complete
跑完多出一个 repodata/ 目录,里面除了三个 XML,还有 sqlite.bz2 版本(老版本 yum 用来加速查询)。
2.4 加了新包之后:增量更新
bash
[root@docker ~]# createrepo --update /mnt/repo
--update 只处理新增或有变化的包,比全量重建快得多。加了包不重新跑这一条,新包在客户端永远看不到------这是自建源最常见的「装了没生效」。
2.5 托管出去:四种方式
| 方式 | baseurl 写法 | 适用 |
|---|---|---|
| 本地目录 | baseurl=file:///mnt/repo |
只给本机用,如光盘源 |
| Python 临时服务 | baseurl=http://10.1.8.102:8000 |
临时验证,一条命令就起 |
| nginx | baseurl=http://10.1.8.102/repo |
生产常用,支持并发与限速 |
| httpd | baseurl=http://10.1.8.102/repo |
已有 Apache 环境时顺手用 |
最省事的临时验证:
bash
[root@docker ~]# cd /mnt && python -m http.server 8000
nginx 只需要一个 root 指向:
nginx
server {
listen 80;
server_name 10.1.8.102;
location /repo {
alias /mnt/repo;
autoindex on;
}
}
📌
alias后面不要 漏掉结尾的斜杠匹配规则:写成location /repo/ { alias /mnt/repo/; }更稳,否则/repo少一层斜杠会 404。
2.6 客户端配置与验证
bash
[root@docker-cli ~]# cat > /etc/yum.repos.d/local-repo.repo <<'EOF'
[local-repo]
name=Local Repo from docker
baseurl=http://10.1.8.102/repo
enabled=1
gpgcheck=0
EOF
[root@docker-cli ~]# yum clean all
[root@docker-cli ~]# yum makecache
local-repo | 3.6 kB 00:00:00
(1/4): local-repo/group_gz | 153 kB 00:00:00
(2/4): local-repo/primary_db | 3.3 MB 00:00:00
元数据缓存已建立
yum makecache 成功就等于源通了。再确认能查到包:
bash
[root@docker-cli ~]# yum list available | grep docker-ce
docker-ce.x86_64 26.1.4-1.el7 local-repo
⚠️ 注意 :依赖包必须一起放进源里。只放
docker-ce一个包,客户端装的时候会去别的源找container-selinux、selinux-policy这些依赖;那台机器没有外网,就报requires xxx, but none of the providers can be installed。到底要放哪些包、怎么一次下全、怎么验证一个不缺,是下一节的全部内容。
三、把包凑齐:docker-ce 的 29 个依赖怎么来
上一节第一步写「依赖包也得在」,这五个字落地时最容易卡住。自建源装不上,九成不是清单写错,是包少了一个。这一节拿 docker-ce 26.1.4 从零走一遍:怎么算清要下哪些、用哪条命令下、怎么验证一个都不缺。
3.1 先算清依赖闭包
一个包会要求别的包,被要求的包又要求别的,递归到底得到的完整集合叫依赖闭包 。它才是你要凑齐的东西------不是你在 yum install 后面写的那两个包名。

29 个包按「谁提供」算账,是这样三档:
| 来源 | 包数 | 体积 | 典型包 |
|---|---|---|---|
docker-ce.repo |
6 | 113 MB | 含你要装的 docker-ce 27 MB 和 cli 15 MB,另有 containerd.io 35 MB、buildx 14 MB、compose 13 MB、rootless 9 MB |
CentOS 7 base / extras |
23 | 10 MB | container-selinux、selinux-policy、policycoreutils-python、iptables、libcgroup |
| CentOS 7 minimal 自带 | --- | 不用下 | glibc、systemd、bash、python 2.7、tar、sed、sqlite |
顺带解释一个容易看糊涂的地方:E:\repo\docker\Packages 里其实有 62 个包,多了 33 个 php、nginx、redis 相关的东西。一个源放几个软件栈都行,客户端只按包名取,不会因为同目录有 php 就把 docker 装错。这 29 个只是 docker-ce 这一条依赖链上的量。
两个反直觉的地方值得单独说。
一是那三个插件包跑不掉。 docker-ce-cli 对 docker-buildx-plugin 和 docker-compose-plugin 的依赖在 rpm 头里标的是强制(flags 为 0x0,不是 Recommends),所以 buildx 和 compose 加起来 26 MB,你不想装也得下------占了总量的五分之一。
二是 docker-ce 与 rootless-extras 互为依赖。 docker-ce 要求 docker-ce-rootless-extras,后者又反过来要求 docker-ce,形成环。环本身 rpm 能处理,但下载工具遇到环容易截断 :只写 docker-ce 去下,可能拿到一个缺 rootless 的半套。两个一起写在命令行里就没有这个问题。
3.2 三条下载命令,差别在「看不看本机已装」
| 命令 | 来自 | 下依赖 | 看本机已装 | 适合 |
|---|---|---|---|---|
yumdownloader --resolve |
yum-utils | 是,但只解析一层 | 会 | 临时补一两个包 |
yum install --downloadonly |
yum 自带 | 是,走完整解析 | 会 | 主力方案,配干净机器 |
repotrack |
yum-utils | 是,整棵依赖树 | 不看 | 一次下全,不怕多 |

三条命令的正经写法:
bash
# 方式一:yumdownloader,必须带 --resolve,否则只下主包
[root@docker ~]# yumdownloader --resolve --destdir=/root/rpms docker-ce docker-ce-cli
# 方式二:yum 自带,走完整解析
[root@docker ~]# yum install --downloadonly --downloaddir=/root/rpms docker-ce docker-ce-cli
# 方式三:repotrack,不看本机状态
[root@docker ~]# repotrack -p /root/rpms docker-ce docker-ce-cli
前两条有个共同的坑:它们按「本机缺什么」来决定下什么 。本机已经装过 container-selinux,它就不会下------可你要带走的是给干净机器用的。所以真正的前提是下一节那件事:找一台什么都没装过的机器去下。
3.3 拿容器当下载机
最干净的下载机是一个刚拉起来、什么都没装过的 centos:7 容器:
bash
[root@docker ~]# mkdir -p /root/rpms
[root@docker ~]# docker run -it --rm -v /root/rpms:/rpms centos:7 bash
# 容器里:先确认版本和目标机一致
[root@3f9a1c7b2d10 /]# cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
[root@3f9a1c7b2d10 /]# yum install -y yum-utils
[root@3f9a1c7b2d10 /]# yum-config-manager --add-repo \
https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
[root@3f9a1c7b2d10 /]# yum install --downloadonly --downloaddir=/rpms \
docker-ce docker-ce-cli
[root@3f9a1c7b2d10 /]# ls /rpms | wc -l
29
exit 出来,29 个包已经在宿主机的 /root/rpms 里。容器是全新环境,--downloadonly 找不到任何「已装」记录,不会有包被跳过。
版本必须对齐。 下载机和目标机的小版本要一致,7.9 对 7.9。差一个小版本就可能出现下载机拉的包要求 glibc 2.17-317、而目标机只到 2.17-307,装的时候报 Requires: libc.so.6(GLIBC_2.14)(64bit) 这种看起来像「系统太老」的错。目标机如果是 7.6,就用 centos:7.6.1810 的镜像,或者在下载命令上加 --releasever=7.6.1810。
3.4 断网装一遍,才算凑齐
包下完不等于凑齐。唯一可靠的验证是在一台连不上任何源的机器上装一遍:
bash
[root@docker ~]# docker run -it --rm --network none -v /root/rpms:/rpms centos:7 bash
# 容器里只留一个本地源
[root@b7e2d90f1a4c /]# cat > /etc/yum.repos.d/local.repo <<'EOF'
[local]
name=Local test
baseurl=file:///rpms
enabled=1
gpgcheck=0
EOF
[root@b7e2d90f1a4c /]# yum --disablerepo='*' --enablerepo=local install -y docker-ce
Installed:
docker-ce.x86_64 3:26.1.4-1.el7
Dependency Installed:
container-selinux.noarch 2:2.119.2-1.911c772.el7_8
containerd.io.x86_64 1.6.33-3.1.el7
...
Complete!
两个参数是关键:--network none 让容器物理断网 ,--disablerepo='*' 把容器自带的源全关掉。这样 yum 只剩 file:///rpms 一个来源,能装完就证明这 29 个包互相自洽、一个不缺。
这一步顺带把清单也验了:repodata 如果是坏的(XML 截断、校验和对不上),这里会先报解析错或 404,根本走不到装包。
3.5 还是缺包:按报错形态反查
| 报错长这样 | 缺的是什么 | 怎么找 |
|---|---|---|
Requires: container-selinux >= 2:2.9 |
某个包 | yum provides 'container-selinux' |
Requires: /usr/bin/docker-init |
某个文件 | yum provides /usr/bin/docker-init |
Requires: libc.so.6(GLIBC_2.14)(64bit) |
库的版本 | yum provides '*libc.so.6*' |
第三种最容易误判:查得到提供者、但装上还是不满足。那不是缺包,是下载机和目标机版本不一致------回去按 3.3 对齐版本重下。
📌
yum provides依赖filelists.xml.gz,而第四节那个纯 Python 脚本造的源里没有文件条目,所以拿它查文件会查不到。查包名和库名不受影响,因为它们写在primary.xml.gz里。
3.6 打包带到内网
bash
[root@docker ~]# cd /root && tar czf docker-ce-offline-26.1.4.tar.gz rpms/
[root@docker ~]# ls -lh docker-ce-offline-26.1.4.tar.gz
-rw-r--r-- 1 root root 126M Nov 5 14:20 docker-ce-offline-26.1.4.tar.gz
128 MB 压完 126 MB------rpm 内部已经是压缩过的包,别指望 tar 再瘦身。
到了内网,把 29 个 rpm 丢进 E:\repo\docker\Packages\,双击 update-repo.bat 重建清单,这条链就闭合了。四个环节分别在哪台机器做:
| 环节 | 机器 | 做什么 |
|---|---|---|
| 下载 | 有外网的 CentOS 7(容器即可) | 三条命令任选,下全 29 个 |
| 验证 | 同一台,断网 | --network none 装一遍 |
| 建源 | Windows 或内网 Linux | 拷进 Packages/,跑生成脚本 |
| 使用 | 内网客户端 | 配 .repo,yum install |
一句话收这一节:判断一个源做没做对,不看 repodata 里有几个文件,看断网机器能不能装上。
四、Windows 侧:没有 createrepo 也能造

createrepo 是 Linux 工具,Windows 上没有。但元数据格式是公开的------只要按格式生成 XML,客户端就认。这条路适合:只有 Windows 机器、不想为造一次元数据装一堆依赖、或者要放在共享盘上随时重建。
4.1 先看已经跑起来的那套布局
一个实际在用的结构长这样:
E:\repo\
├── docker\ ← 一个源(对应一个 baseurl)
│ ├── Packages\ ← 62 个 rpm
│ │ ├── docker-ce-26.1.4-1.el7.x86_64.rpm
│ │ ├── docker-ce-cli-26.1.4-1.el7.x86_64.rpm
│ │ ├── containerd.io-1.6.33-3.1.el7.x86_64.rpm
│ │ ├── container-selinux-2.119.2-1.911c772.el7_8.noarch.rpm
│ │ └── ...
│ └── repodata\ ← 脚本生成的清单
│ ├── repomd.xml
│ ├── primary.xml.gz
│ ├── filelists.xml.gz
│ └── other.xml.gz
├── createrepo-deps\ ← 四个离线依赖包,给 Linux 侧备用
├── make_repodata.py ← 生成器本体(单文件,只用标准库)
├── update-repo.bat ← Windows 上一键重建
├── update-repo.sh ← Linux 上一键重建(优先用 createrepo)
├── docker-ce.repo ← 公网源(对照用)
└── docker-ce-network.repo ← 客户端模板(内网源)
一个子目录 = 一个源。 想再加一个 PHP 源,就再建 E:\repo\php\Packages\,各自有独立的 repodata,客户端配两个 .repo 段即可。
4.2 生成器做了什么
make_repodata.py 的核心动作只有三步:
- 解析 rpm 的二进制头 :先读 lead 魔数
\xed\xab\xee\xdb,跳过签名头,再定位主 header。然后按 tag 取值,拿到 name、version、arch、依赖、provides 等字段; - 拼出三个 XML :
primary.xml.gz(含rpm:provides/rpm:requires)、filelists.xml.gz、other.xml.gz; - 算校验和写
repomd.xml:每个 gz 的 sha256、解压后大小、时间戳,一项都不能少。
用法就一条:
cmd
python make_repodata.py E:\repo\docker
输出:
found 62 rpms
+ docker-ce-26.1.4-1.el7.x86_64.rpm epoch=0
+ docker-ce-cli-26.1.4-1.el7.x86_64.rpm epoch=0
...
OK -> E:\repo\docker\repodata
⚠️ 注意 :这个纯 Python 实现有意省掉了
filelists.xml.gz里的文件条目(只写了包名和版本),所以yum provides /usr/bin/xxx在它造的源上查不出东西 。装包、算依赖都不受影响------因为那两件事只靠primary.xml.gz。如果确实需要provides,就在任意一台 Linux 上用createrepo重建一次,把整个repodata/拷回来。
4.3 一键重建
Windows 上双击即可:
bat
@echo off
rem update-repo.bat -- rebuild yum repodata for the E:\repo\docker source
set "REPO=%~dp0docker"
where python >nul 2>nul
if errorlevel 1 (
echo [ERROR] python not found in PATH
pause
exit /b 1
)
python "%~dp0make_repodata.py" "%REPO%"
if errorlevel 1 (
echo [ERROR] repodata generation FAILED
) else (
echo [OK] repodata updated in docker\repodata\
)
pause
Linux 上跑的是同名 .sh,逻辑更聪明一点:有 createrepo 就优先用它,没有才回落到 Python 脚本。
bash
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="${1:-$SCRIPT_DIR/docker}"
if command -v createrepo >/dev/null 2>&1; then
createrepo --update "$REPO_DIR"
elif command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1; then
python3 "$SCRIPT_DIR/make_repodata.py" "$REPO_DIR" 2>/dev/null || \
python "$SCRIPT_DIR/make_repodata.py" "$REPO_DIR"
else
echo "[错误] 没有 createrepo 也没有 python"
echo " 可先装离线包: rpm -Uvh $SCRIPT_DIR/createrepo-deps/*.rpm"
exit 1
fi
3.4 把 E:\repo 发布出去
Windows 上同样要起一个 HTTP 服务,把 E:\repo 映射成 /repo。三种做法任选:
| 做法 | 命令 | 说明 |
|---|---|---|
| Python 临时服务 | cd /d E:\repo && python -m http.server 80 |
最快,关掉窗口就停 |
| nginx for Windows | root 指向 E:/repo,location /repo { alias E:/repo; } |
常驻,支持并发 |
| 已有 IIS | 新建站点,物理路径 E:\repo |
适合已经是 Windows 服务器的环境 |
起完在浏览器访问 http://10.1.8.100/repo/docker/repodata/repomd.xml,能看到 XML 内容就说明托管通了。
4.5 客户端配置模板
bash
# 把 <SERVER_IP> 换成 Windows 那台的地址,例如 10.1.8.100
[root@docker-cli ~]# cat > /etc/yum.repos.d/docker-ce-local.repo <<'EOF'
[docker-ce-local]
name=Docker CE Local
baseurl=http://10.1.8.100/repo/docker
enabled=1
gpgcheck=0
EOF
[root@docker-cli ~]# yum clean all
[root@docker-cli ~]# yum makecache
同时把原来的公网 docker-ce 源挪走,避免两个源抢同一个包:
bash
[root@docker-cli ~]# mkdir -p /etc/yum.repos.d/bak
[root@docker-cli ~]# mv /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/bak/
4.6 验收:真的装上
bash
[root@docker-cli ~]# yum install -y docker-ce
Installing:
docker-ce x86_64 3:26.1.4-1.el7 docker-ce-local 28 M
Installing for dependencies:
containerd.io x86_64 1.6.33-3.1.el7 docker-ce-local 37 M
...
[root@docker-cli ~]# docker --version
Docker version 26.1.4, build b87b14e
Installing for dependencies 里那些包全都来自 docker-ce-local------说明依赖也一起被源覆盖了,这台机器不需要外网。
五、给源加签名(可选)
gpgcheck=0 省事,但等于放弃校验。要开校验就得对 repomd.xml 签名:
bash
# 1) 在源服务器上生成密钥(一路回车即可,保护密码可以留空)
[root@docker ~]# gpg --gen-key
# 2) 对 repomd.xml 做分离签名,生成 .asc
[root@docker ~]# gpg --detach-sign --armor --output /mnt/repo/repodata/repomd.xml.asc \
/mnt/repo/repodata/repomd.xml
# 3) 导出公钥,放到客户端的信任目录
[root@docker ~]# gpg --export -a "zhb repo" > /etc/pki/rpm-gpg/RPM-GPG-KEY-local
客户端改成:
ini
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-local
之后每次 createrepo --update 都要重新签一次 ,否则签名对不上,报 repomd.xml signature could not be verified。
六、排错速查

| 报错 / 现象 | 原因 | 处理 |
|---|---|---|
Cannot find a valid baseurl for repo: xxx |
baseurl 写错、服务没起、或防火墙挡了 | 浏览器直接访问 baseurl 验证 |
repodata/repomd.xml: HTTP Error 404 |
repodata/ 没生成,或不在 baseurl 那一层 |
确认 repomd.xml 在 <baseurl>/repodata/ 下 |
repomd.xml parser error |
XML 生成不完整(脚本被中断) | 重跑生成脚本 |
requires xxx, but none of the providers can be installed |
依赖包没进源 | 在能装的机器上抓全 Installing: 列表 |
| 加了新包,客户端看不到 | 没重新生成元数据 | createrepo --update 或重跑脚本,再 yum clean all |
| 客户端仍用旧缓存 | yum 缓存没清 |
yum clean all && yum makecache |
repomd.xml signature could not be verified |
更新后没重新签名 | 重新 gpg --detach-sign |
⚠️ 注意 :客户端和源服务器的时间差太大 也会导致校验失败(HTTP 校验带 If-Modified-Since)。内网机器记得对时,或直接把
gpgcheck关掉先跑通再开。
📚 参考资料(官方文档)
- createrepo 项目主页:https://github.com/rpm-software-management/createrepo_c
- yum 仓库配置项 baseurl / gpgcheck:https://man7.org/linux/man-pages/man5/yum.conf.5.html
- repomd.xml 元数据格式说明:https://rpm-software-management.github.io/rpm/manual/repodata.html
- RPM 包格式与 header tag 列表:https://rpm-software-management.github.io/rpm/manual/tags.html
- nginx 作为文件服务器的 alias 配置:https://nginx.org/en/docs/http/ngx_http_core_module.html#alias
附:命令英文全称速查表
| 命令 / 名词 | 英文全称 | 中文 |
|---|---|---|
createrepo |
create repository | 创建仓库(生成元数据) |
repodata |
repository data | 仓库数据(清单目录) |
repomd |
repository metadata | 仓库元数据入口 |
baseurl |
base URL | 基准地址 |
gpgcheck |
GNU Privacy Guard check | 签名校验开关 |
makecache |
make cache | 建立元数据缓存 |
rpm |
RPM Package Manager | RPM 包管理器 |
alias |
--- | 路径别名(nginx 指令) |
自建源这件事,本质上只要求你凑齐两样:包 和清单 。清单的生成工具可以换(
createrepo或一个 Python 脚本),托管方式可以换(nginx、httpd、Python 的 http.server、共享盘挂载),客户端只认<baseurl>/repodata/repomd.xml这一条路径。换包必须重建清单,换地址必须改客户端.repo,其余都是排错。
