Rocky Linux 10 搭建企业级 YUM 私服:DNF 镜像、自有 RPM 仓库、Docker 部署与性能调优

在 Rocky Linux、RHEL、AlmaLinux 等 RPM 系发行版中,大家习惯把内部软件源称为“YUM 私服”。不过从 Rocky Linux 10 开始,实际承担软件包解析、下载和依赖管理的是新一代 DNF/DNF5,YUM 更多是兼容性名称。因此,本文所说的 YUM 私服,本质上是一个符合 RPM-MD Repository 规范、可供 DNF 客户端访问的软件仓库服务。

企业内部搭建 YUM 私服,通常不是为了“再造一个下载站”,而是为了获得以下能力:

  • 将 Rocky Linux BaseOS、AppStream、Extras、CRB 等仓库镜像到内网;
  • 降低大量服务器重复下载 RPM 带来的公网流量和构建时间;
  • 在公网异常、跨境网络抖动或隔离网络中维持系统安装和升级能力;
  • 托管企业自己构建的 RPM;
  • 固定可安装的软件包集合,降低上游更新导致的环境漂移;
  • 对软件来源、签名、版本、发布和下线进行统一治理;
  • 为 CI/CD、Kubernetes 节点和批量装机提供稳定的软件源。

本文基于 Rocky Linux 10,采用以下技术栈:

1
2
3
4
5
6
reposync / dnf5 reposync:同步远程仓库
createrepo_c:为企业自有 RPM 生成仓库元数据
Nginx:通过 HTTP/HTTPS 发布仓库
systemd Timer:定时执行增量同步
GPG / rpmsign:验证或签名 RPM
Docker Compose:容器化运行 Nginx 和同步任务

一、YUM 私服的整体架构

建议将“官方镜像仓库”和“企业自有仓库”分开管理。

flowchart LR
    Rocky[Rocky Linux 官方镜像] -->|reposync| Sync[同步工作区]
    EPEL[EPEL 等可信第三方仓库] -->|reposync| Sync

    Build[企业 RPM 构建流水线] -->|上传已签名 RPM| Custom[企业自有 RPM 仓库]
    Custom -->|createrepo_c| Metadata[生成 RPM-MD 元数据]

    Sync --> Publish[发布目录]
    Metadata --> Publish

    Publish --> Nginx[Nginx HTTPS]
    Nginx --> Client1[Rocky Linux 10 服务器]
    Nginx --> Client2[CI/CD 节点]
    Nginx --> Client3[离线或内网服务器]

建议的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/srv/yum-sync/                     # reposync 工作目录,不直接对客户端暴露
└── rocky/
└── 10/
└── x86_64/
├── baseos/
├── appstream/
├── extras/
└── crb/

/srv/yum-repo/ # Nginx 发布目录
├── rocky/
│ └── 10/
│ └── x86_64/
│ ├── baseos/
│ ├── appstream/
│ ├── extras/
│ └── crb/
├── custom/
│ └── el10/
│ └── x86_64/
│ ├── Packages/
│ └── repodata/
└── keys/
├── RPM-GPG-KEY-Rocky-10
└── RPM-GPG-KEY-COMPANY

二、先理解三类仓库

2.1 官方镜像仓库

例如:

1
2
3
4
5
Rocky Linux 10 BaseOS
Rocky Linux 10 AppStream
Rocky Linux 10 Extras
Rocky Linux 10 CRB
EPEL 10

这类仓库已经由上游生成了完整元数据,可能包含:

  • primary:RPM 基础信息;
  • filelists:RPM 文件列表;
  • other:变更日志等信息;
  • comps:软件包组;
  • updateinfo:安全更新和公告信息;
  • 模块或其他扩展元数据;
  • SQLite/Zchunk/Zstandard 等压缩数据。

因此镜像官方仓库时,推荐使用:

1
reposync --download-metadata

不要在同步完成后无脑执行 createrepo_c 覆盖官方元数据。

否则可能丢失软件包组、安全公告、模块信息或其他附加元数据。

2.2 企业自有 RPM 仓库

企业自己上传 RPM 时,目录中通常只有:

1
2
example-app-1.0.0-1.el10.x86_64.rpm
example-agent-2.1.0-1.el10.noarch.rpm

这类目录没有 repodata,需要使用 createrepo_c 生成仓库元数据。

2.3 版本快照仓库

如果需要严格复现某个时间点的软件环境,仅同步“当前最新版”还不够。可以额外保留:

1
2
/srv/yum-repo/snapshots/2026-07-31/
/srv/yum-repo/snapshots/2026-08-31/

客户端固定到快照路径后,不会因为上游仓库更新而自动漂移。

这种模式适合:

  • 生产环境变更窗口;
  • 等保或审计环境;
  • 大规模 Kubernetes 节点升级;
  • 离线交付;
  • 需要可重复构建的基础镜像。

三、环境规划

本文使用以下示例:

配置项 示例值
操作系统 Rocky Linux 10 x86_64
主机名 repo01
仓库域名 yum.example.com
同步工作目录 /srv/yum-sync
发布目录 /srv/yum-repo
Nginx 用户 nginx
HTTP 端口 80
HTTPS 端口 443
默认架构 x86_64noarch
Rocky 主版本 10

检查系统:

1
2
3
4
cat /etc/rocky-release
uname -r
uname -m
hostnamectl

设置主机名和时区:

1
2
3
4
5
hostnamectl set-hostname repo01
timedatectl set-timezone Asia/Shanghai

timedatectl
chronyc tracking

Rocky Linux 10 的常用仓库 ID 包括:

1
2
3
4
baseos
appstream
extras
crb

其中 BaseOS、AppStream 和 Extras 默认启用,CRB 通常默认关闭。实际环境应以以下命令为准:

1
2
3
4
5
dnf repolist --all
dnf repoinfo baseos
dnf repoinfo appstream
dnf repoinfo extras
dnf repoinfo crb

四、容量与资源评估

仓库服务器主要消耗:

1
2
3
4
5
磁盘容量
磁盘读写吞吐
出口带宽
文件系统 inode
少量 CPU 和内存

Nginx 发布静态 RPM 的 CPU 开销通常不高,真正的大头是磁盘与网络。

建议起点:

规模 CPU 内存 数据盘 网络
HomeLab / 测试 2 核 2 GB 100 GB SSD 1 Gbps
小型团队 4 核 4 GB 500 GB SSD 1 Gbps
中大型内网 8 核 8~16 GB 1 TB 以上 SSD/NVMe 10 Gbps 或按并发评估

由于同步工作区和正式发布区可能同时存在,初始规划建议至少预留:

1
2
预计仓库总容量 × 2
+ 20%~30% 扩展空间

不要根据网上几年前的仓库容量直接下结论,应在实际同步后统计:

1
2
3
4
du -sh /srv/yum-sync/*
du -sh /srv/yum-repo/*
df -hT /srv
df -ih /srv

五、安装所需软件

更新系统:

1
2
3
dnf clean all
dnf makecache
dnf upgrade -y

安装基础工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dnf install -y \
nginx \
createrepo_c \
rsync \
curl \
wget \
jq \
vim \
tar \
gzip \
zstd \
findutils \
util-linux \
policycoreutils-python-utils \
firewalld

Rocky Linux 10 使用 DNF5。不同最小化镜像或兼容包组合中,reposync 插件包名称可能不同,因此可以使用以下兼容安装方式:

1
2
dnf install -y dnf5-plugins || \
dnf install -y dnf-plugins-core

确认命令:

1
2
3
4
5
dnf --version
dnf reposync --help
createrepo_c --version
nginx -v
rsync --version

如果 dnf reposync 不可用,但系统中存在独立的 dnf5 命令:

1
dnf5 reposync --help

后文脚本会自动判断应该使用 dnf reposync 还是 dnf5 reposync

六、创建目录与服务账户

创建同步账户:

1
2
3
4
5
useradd \
--system \
--home-dir /srv/yum-sync \
--shell /usr/sbin/nologin \
reposync

创建目录:

1
2
3
4
mkdir -p /srv/yum-sync
mkdir -p /srv/yum-repo
mkdir -p /srv/yum-repo/keys
mkdir -p /var/log/yum-repo

设置权限:

1
2
3
4
5
6
chown -R reposync:reposync /srv/yum-sync
chown -R reposync:reposync /srv/yum-repo
chown -R reposync:reposync /var/log/yum-repo

find /srv/yum-repo -type d -exec chmod 0755 {} \;
find /srv/yum-repo -type f -exec chmod 0644 {} \;

Nginx 只需要读取发布目录,不需要写权限。

验证:

1
2
namei -om /srv/yum-repo
sudo -u nginx test -r /srv/yum-repo

七、先手工同步 Rocky Linux 10 仓库

7.1 定义 reposync 兼容函数

创建临时 Shell 函数:

1
2
3
4
5
6
7
8
9
10
11
repo_sync() {
if dnf reposync --help >/dev/null 2>&1; then
dnf reposync "$@"
elif command -v dnf5 >/dev/null 2>&1 && \
dnf5 reposync --help >/dev/null 2>&1; then
dnf5 reposync "$@"
else
echo "未找到可用的 reposync 插件" >&2
return 1
fi
}

7.2 同步 BaseOS

1
2
3
4
5
6
7
8
9
10
11
mkdir -p /srv/yum-sync/rocky/10/x86_64/baseos

repo_sync \
--repo=baseos \
--download-path=/srv/yum-sync/rocky/10/x86_64/baseos \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch=x86_64 \
--arch=noarch

7.3 同步 AppStream

1
2
3
4
5
6
7
8
9
10
11
mkdir -p /srv/yum-sync/rocky/10/x86_64/appstream

repo_sync \
--repo=appstream \
--download-path=/srv/yum-sync/rocky/10/x86_64/appstream \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch=x86_64 \
--arch=noarch

7.4 同步 Extras

1
2
3
4
5
6
7
8
9
10
11
mkdir -p /srv/yum-sync/rocky/10/x86_64/extras

repo_sync \
--repo=extras \
--download-path=/srv/yum-sync/rocky/10/x86_64/extras \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch=x86_64 \
--arch=noarch

7.5 同步 CRB

CRB 即使默认关闭,也可以通过显式指定仓库进行同步:

1
2
3
4
5
6
7
8
9
10
11
mkdir -p /srv/yum-sync/rocky/10/x86_64/crb

repo_sync \
--repo=crb \
--download-path=/srv/yum-sync/rocky/10/x86_64/crb \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch=x86_64 \
--arch=noarch

检查结果:

1
2
3
find /srv/yum-sync/rocky/10/x86_64 -maxdepth 2 -type d
find /srv/yum-sync/rocky/10/x86_64/baseos/repodata -maxdepth 1 -type f | head
find /srv/yum-sync/rocky/10/x86_64/baseos -name '*.rpm' | head

7.6 为什么同时同步 x86_64 和 noarch

只写:

1
--arch=x86_64

会漏掉大量 noarch 包。

因此 x86_64 仓库通常至少要同步:

1
2
--arch=x86_64
--arch=noarch

否则客户端安装某些包时会出现依赖缺失。

7.7 不要随意使用 –newest-only

以下组合存在明显风险:

1
2
3
reposync \
--newest-only \
--download-metadata

原因是:

  • --newest-only 只下载最新 RPM;
  • 上游原始元数据仍可能引用旧版本 RPM;
  • 客户端解析元数据后请求旧包;
  • 私服上没有对应文件;
  • 最终出现下载失败。

如果确实只想保留最新 RPM,需要重新生成与本地文件集合一致的元数据:

1
createrepo_c --update 仓库目录

但这会丢失或改变上游附加元数据,因此不适合作为完整 Rocky 官方镜像的默认方案。

完整镜像应优先保留上游元数据,并避免使用 --newest-only

八、自动同步与安全发布脚本

直接让 Nginx 读取 reposync 正在写入的目录存在两个问题:

  1. 客户端可能在元数据更新一半时访问;
  2. RPM 和 repodata 可能短时间不一致。

因此建议:

1
2
3
4
远端仓库
-> 同步工作区
-> 发布目录
-> Nginx

8.1 创建同步脚本

创建 /usr/local/sbin/sync-rocky-repos.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
cat > /usr/local/sbin/sync-rocky-repos.sh <<'SCRIPT'
#!/usr/bin/env bash

set -Eeuo pipefail
umask 022

ROCKY_VERSION="${ROCKY_VERSION:-10}"
ARCH="${ARCH:-x86_64}"

SYNC_ROOT="${SYNC_ROOT:-/srv/yum-sync}"
PUBLISH_ROOT="${PUBLISH_ROOT:-/srv/yum-repo}"

LOG_DIR="${LOG_DIR:-/var/log/yum-repo}"
LOCK_FILE="${LOCK_FILE:-/run/lock/yum-repo-sync.lock}"

REPOSITORIES=(
baseos
appstream
extras
crb
)

mkdir -p "${LOG_DIR}"
mkdir -p "$(dirname "${LOCK_FILE}")"

exec 9>"${LOCK_FILE}"

if ! flock -n 9; then
echo "已有同步任务运行,本次退出"
exit 0
fi

LOG_FILE="${LOG_DIR}/sync-$(date +%F).log"
exec > >(tee -a "${LOG_FILE}") 2>&1

log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
}

reposync_run() {
if dnf reposync --help >/dev/null 2>&1; then
dnf reposync "$@"
elif command -v dnf5 >/dev/null 2>&1 && \
dnf5 reposync --help >/dev/null 2>&1; then
dnf5 reposync "$@"
else
log "ERROR: 未找到可用的 reposync 命令"
return 1
fi
}

publish_repo() {
local source_dir="$1"
local target_dir="$2"
local timestamp
local metadata_dir
local metadata_link_tmp

timestamp="$(date +%Y%m%d%H%M%S)"
metadata_dir="${target_dir}/.metadata/repodata-${timestamp}"
metadata_link_tmp="${target_dir}/.repodata-link-${timestamp}"

mkdir -p "${target_dir}"
mkdir -p "${target_dir}/.metadata"

if [[ ! -f "${source_dir}/repodata/repomd.xml" ]]; then
log "ERROR: ${source_dir} 缺少 repodata/repomd.xml"
return 1
fi

# 第一步:复制新增或发生变化的 RPM,但暂不删除发布区旧文件。
# 这样切换新元数据之前,新元数据引用的包已经存在。
rsync -aH \
--exclude='/repodata/' \
"${source_dir}/" \
"${target_dir}/"

# 第二步:将本次元数据复制到独立版本目录。
mkdir -p "${metadata_dir}"

rsync -aH --delete \
"${source_dir}/repodata/" \
"${metadata_dir}/"

test -f "${metadata_dir}/repomd.xml"

# 第三步:使用同一文件系统内的 rename 原子切换 repodata 符号链接。
ln -s ".metadata/$(basename "${metadata_dir}")" \
"${metadata_link_tmp}"

mv -Tf \
"${metadata_link_tmp}" \
"${target_dir}/repodata"

# 第四步:元数据切换后再删除上游已经不存在的 RPM。
# repodata 和历史元数据目录必须排除。
rsync -aH \
--delete-delay \
--exclude='/repodata' \
--exclude='/.metadata/' \
"${source_dir}/" \
"${target_dir}/"

# 仅保留最近 3 份元数据快照。
find "${target_dir}/.metadata" \
-mindepth 1 \
-maxdepth 1 \
-type d \
-name 'repodata-*' \
-printf '%T@ %p\n' \
| sort -nr \
| awk 'NR > 3 {$1=""; sub(/^ /, ""); print}' \
| xargs -r rm -rf --

log "发布完成:${target_dir}"
}

for repo in "${REPOSITORIES[@]}"; do
sync_dir="${SYNC_ROOT}/rocky/${ROCKY_VERSION}/${ARCH}/${repo}"
publish_dir="${PUBLISH_ROOT}/rocky/${ROCKY_VERSION}/${ARCH}/${repo}"

mkdir -p "${sync_dir}"
mkdir -p "${publish_dir}"

log "开始同步仓库:${repo}"

reposync_run \
--repo="${repo}" \
--download-path="${sync_dir}" \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch="${ARCH}" \
--arch=noarch

publish_repo "${sync_dir}" "${publish_dir}"

log "仓库完成:${repo}"
done

log "全部仓库同步完成"
SCRIPT

设置权限:

1
2
chmod 0750 /usr/local/sbin/sync-rocky-repos.sh
chown root:reposync /usr/local/sbin/sync-rocky-repos.sh

先手工运行:

1
2
sudo -u reposync \
/usr/local/sbin/sync-rocky-repos.sh

观察:

1
tail -f /var/log/yum-repo/sync-$(date +%F).log

8.2 关于原子切换

发布脚本使用了以下结构:

1
2
3
4
5
6
7
baseos/
├── Packages/
├── .metadata/
│ ├── repodata-20260731120000/
│ ├── repodata-20260731200000/
│ └── repodata-20260801040000/
└── repodata -> .metadata/repodata-20260801040000

Nginx 访问:

1
/baseos/repodata/repomd.xml

实际上会通过符号链接读取最新元数据。

切换时使用:

1
mv -Tf 新链接 repodata

在同一文件系统内,重命名操作是原子的,可以避免客户端读到半份元数据。

8.3 首次发布时的注意事项

如果目标目录中已经存在普通的 repodata 目录,而不是符号链接,应在维护窗口先迁移:

1
2
3
mv \
/srv/yum-repo/rocky/10/x86_64/baseos/repodata \
/srv/yum-repo/rocky/10/x86_64/baseos/repodata.backup

然后运行发布脚本。

确认:

1
2
readlink -f \
/srv/yum-repo/rocky/10/x86_64/baseos/repodata

九、使用 systemd Timer 定时同步

相比直接写 Cron,systemd Timer 具备:

  • 日志统一进入 Journal;
  • 可查看上次和下次执行时间;
  • 支持错过任务后补执行;
  • 服务失败状态更明确;
  • 更容易配置资源限制和安全沙箱。

9.1 创建 Service

创建 /etc/systemd/system/yum-repo-sync.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[Unit]
Description=Synchronize Rocky Linux RPM repositories
Documentation=https://dnf-plugins-core.readthedocs.io/en/latest/reposync.html
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=reposync
Group=reposync

ExecStart=/usr/local/sbin/sync-rocky-repos.sh

Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6

TimeoutStartSec=12h

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

ReadWritePaths=/srv/yum-sync
ReadWritePaths=/srv/yum-repo
ReadWritePaths=/var/log/yum-repo
ReadWritePaths=/run/lock

9.2 创建 Timer

创建 /etc/systemd/system/yum-repo-sync.timer

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Run Rocky Linux repository synchronization

[Timer]
OnCalendar=*-*-* 02,10,18:15:00
RandomizedDelaySec=15m
Persistent=true
Unit=yum-repo-sync.service

[Install]
WantedBy=timers.target

每天执行 3 次:

1
2
3
02:15
10:15
18:15

并随机延迟 0~15 分钟,避免多台镜像服务器同时冲击上游。

加载:

1
2
systemctl daemon-reload
systemctl enable --now yum-repo-sync.timer

检查:

1
2
systemctl list-timers yum-repo-sync.timer
systemctl status yum-repo-sync.timer

手工触发:

1
systemctl start yum-repo-sync.service

查看日志:

1
journalctl -u yum-repo-sync.service -n 300 --no-pager

十、配置 Nginx 发布仓库

10.1 创建 Nginx 配置

创建 /etc/nginx/conf.d/yum-repo.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
server {
listen 80;
server_name yum.example.com;

return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
http2 on;
server_name yum.example.com;

root /srv/yum-repo;

ssl_certificate /etc/nginx/ssl/yum.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/yum.example.com.key;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:YUM_SSL:20m;
ssl_session_timeout 1d;

access_log /var/log/nginx/yum-access.log;
error_log /var/log/nginx/yum-error.log warn;

sendfile on;
tcp_nopush on;
tcp_nodelay on;

keepalive_timeout 65;
keepalive_requests 10000;

open_file_cache max=10000 inactive=60s;
open_file_cache_valid 120s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}

location / {
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;

try_files $uri $uri/ =404;
}

location ~* \.(rpm|drpm)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
try_files $uri =404;
}

location ~* /repodata/ {
expires 5m;
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}

location ~ /\. {
deny all;
}
}

为什么 RPM 和元数据的缓存时间不同:

  • RPM 文件一旦发布通常不会变化,可以使用较长缓存;
  • repomd.xml 会随着同步更新,不应缓存过久;
  • 客户端首先读取元数据,再根据元数据下载 RPM;
  • 元数据缓存过长会导致客户端长期看不到新版本。

检查:

1
nginx -t

启动:

1
2
systemctl enable --now nginx
systemctl reload nginx

验证:

1
2
3
curl -I http://127.0.0.1/healthz
curl -I https://yum.example.com/healthz
curl -I https://yum.example.com/rocky/10/x86_64/baseos/repodata/repomd.xml

10.2 不要给 RPM 启用动态 gzip

RPM、XZ、Zstandard、Gzip 元数据已经压缩,再次动态压缩通常没有收益,只会浪费 CPU。

因此不需要为:

1
2
3
4
5
*.rpm
*.gz
*.xz
*.zst
*.bz2

开启 Nginx gzip。

10.3 SELinux 配置

不要直接关闭 SELinux。

为发布目录设置 Web 内容上下文:

1
2
3
4
5
6
semanage fcontext \
-a \
-t httpd_sys_content_t \
'/srv/yum-repo(/.*)?'

restorecon -Rv /srv/yum-repo

检查:

1
2
ls -ldZ /srv/yum-repo
ls -lZ /srv/yum-repo/rocky/10/x86_64/baseos/repodata

如果 Nginx 只读取静态文件,不需要给它写权限。

10.4 firewalld

1
2
3
4
5
6
systemctl enable --now firewalld

firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload

查看:

1
firewall-cmd --list-all

十一、复制 Rocky Linux GPG 公钥

Rocky 官方 RPM 已由 Rocky 的 GPG Key 签名。镜像服务器不应重新签名这些 RPM,只需要把原始 RPM 和元数据完整发布。

复制公钥:

1
2
3
4
5
6
cp \
/etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-10 \
/srv/yum-repo/keys/

chmod 0644 \
/srv/yum-repo/keys/RPM-GPG-KEY-Rocky-10

验证:

1
2
curl \
https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10

随机检查 RPM:

1
2
3
rpmkeys --checksig \
/srv/yum-repo/rocky/10/x86_64/baseos/Packages/*/*.rpm \
| head

如果仓库结构没有字母分层:

1
2
3
4
5
6
find \
/srv/yum-repo/rocky/10/x86_64/baseos \
-name '*.rpm' \
-print0 \
| xargs -0 -r rpmkeys --checksig \
| head

十二、配置 Rocky Linux 10 客户端

12.1 创建私服 Repo 文件

创建 /etc/yum.repos.d/company-rocky10.repo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[company-rocky10-baseos]
name=Company Rocky Linux 10 - BaseOS
baseurl=https://yum.example.com/rocky/10/$basearch/baseos/
enabled=1
gpgcheck=1
repo_gpgcheck=0
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10
metadata_expire=6h
skip_if_unavailable=False

[company-rocky10-appstream]
name=Company Rocky Linux 10 - AppStream
baseurl=https://yum.example.com/rocky/10/$basearch/appstream/
enabled=1
gpgcheck=1
repo_gpgcheck=0
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10
metadata_expire=6h
skip_if_unavailable=False

[company-rocky10-extras]
name=Company Rocky Linux 10 - Extras
baseurl=https://yum.example.com/rocky/10/$basearch/extras/
enabled=1
gpgcheck=1
repo_gpgcheck=0
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10
metadata_expire=6h
skip_if_unavailable=False

[company-rocky10-crb]
name=Company Rocky Linux 10 - CRB
baseurl=https://yum.example.com/rocky/10/$basearch/crb/
enabled=0
gpgcheck=1
repo_gpgcheck=0
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10
metadata_expire=6h
skip_if_unavailable=False

这里路径固定使用主版本 10,避免客户端 $releasever 被解析为 10.010.1 或其他值后与服务端目录不一致。

12.2 测试私服,不立即关闭官方仓库

先执行:

1
2
3
4
5
6
7
dnf clean all
rm -rf /var/cache/dnf

dnf \
--disablerepo='*' \
--enablerepo='company-rocky10-*' \
makecache

查看:

1
2
3
4
dnf \
--disablerepo='*' \
--enablerepo='company-rocky10-*' \
repolist

测试查询:

1
2
3
4
dnf \
--disablerepo='*' \
--enablerepo='company-rocky10-*' \
info nginx

测试安装:

1
2
3
4
dnf \
--disablerepo='*' \
--enablerepo='company-rocky10-*' \
install -y tree

确认下载 URL 指向:

1
https://yum.example.com/

12.3 正式切换

确认私服完整可用后,再关闭系统原始仓库。

DNF5 环境可以使用:

1
2
3
4
dnf config-manager setopt baseos.enabled=0
dnf config-manager setopt appstream.enabled=0
dnf config-manager setopt extras.enabled=0
dnf config-manager setopt crb.enabled=0

如果当前版本不支持该语法,应通过:

1
dnf config-manager --help

确认本机语法,或直接在 /etc/yum.repos.d/ 中创建 override 配置。

切换后:

1
2
3
4
dnf clean all
dnf makecache
dnf repolist
dnf upgrade --refresh

12.4 批量分发 Repo 文件

可以将 Repo 配置打成 RPM,例如:

1
company-repos-1.0.0-1.el10.noarch.rpm

由 RPM 安装:

1
2
/etc/yum.repos.d/company-rocky10.repo
/etc/pki/rpm-gpg/RPM-GPG-KEY-COMPANY

相比 Ansible 直接复制文件,Repo RPM 的优势是:

  • 有版本号;
  • 可以升级和回滚;
  • 可以查询来源;
  • 可审计;
  • 适合批量交付。

十三、搭建企业自有 RPM 仓库

13.1 创建目录

1
2
3
4
5
6
7
8
mkdir -p \
/srv/yum-repo/custom/el10/x86_64/Packages

chown -R reposync:reposync \
/srv/yum-repo/custom

chmod -R 0755 \
/srv/yum-repo/custom

上传 RPM:

1
2
3
scp \
example-app-1.0.0-1.el10.x86_64.rpm \
repo01:/srv/yum-repo/custom/el10/x86_64/Packages/

检查包信息:

1
2
rpm -qpi \
/srv/yum-repo/custom/el10/x86_64/Packages/example-app-1.0.0-1.el10.x86_64.rpm

检查签名:

1
2
rpmkeys --checksig \
/srv/yum-repo/custom/el10/x86_64/Packages/example-app-1.0.0-1.el10.x86_64.rpm

13.2 首次生成元数据

1
2
3
4
createrepo_c \
--database \
--checksum sha256 \
/srv/yum-repo/custom/el10/x86_64

检查:

1
2
3
4
5
ls -lah \
/srv/yum-repo/custom/el10/x86_64/repodata

test -f \
/srv/yum-repo/custom/el10/x86_64/repodata/repomd.xml

13.3 增量更新元数据

新增或替换 RPM 后:

1
2
3
4
5
createrepo_c \
--update \
--database \
--checksum sha256 \
/srv/yum-repo/custom/el10/x86_64

--update 会复用已有元数据,通常比全量扫描更快。

13.4 控制 workers

createrepo_c 本身已经会并行处理大量任务,不需要盲目把线程数调到很大。

可以限制在 4~8 个 Worker:

1
2
3
4
5
6
createrepo_c \
--update \
--workers 4 \
--database \
--checksum sha256 \
/srv/yum-repo/custom/el10/x86_64

对于机械盘或共享存储,线程过多可能让随机 I/O 更差。

13.5 Rocky Linux 10 元数据压缩变化

Rocky/RHEL 10 的 createrepo_c 默认可能使用 Zstandard 生成元数据。对于 Rocky Linux 10 客户端,这是合理选择。

如果同一个自有仓库还要兼容更老的 EL 客户端,应先验证其 DNF/libsolv 是否支持对应压缩格式。需要保守兼容时,可以显式使用 Gzip:

1
2
3
4
5
createrepo_c \
--update \
--compress-type=gz \
--checksum sha256 \
/srv/yum-repo/custom/el10/x86_64

不要只看“压缩率更高”,兼容性比省下几 MB 元数据更重要。

十四、为企业自有 RPM 配置 GPG 签名

gpgcheck=1 校验的是 RPM 包签名。

repo_gpgcheck=1 校验的是仓库元数据签名。

两者不是一回事。

flowchart TD
    Client[DNF 客户端] --> Meta[下载 repomd.xml]
    Meta -->|repo_gpgcheck| MetaSig[校验 repomd.xml.asc]
    Meta --> RPM[下载 RPM]
    RPM -->|gpgcheck| RPMSig[校验 RPM 内嵌签名]

14.1 创建 GPG Key

安装:

1
dnf install -y gnupg2 rpm-sign

生成签名密钥:

1
2
3
4
5
6
gpg \
--quick-generate-key \
'Company RPM Repository <rpm@example.com>' \
rsa3072 \
sign \
2y

查看:

1
gpg --list-secret-keys --keyid-format LONG

导出公钥:

1
2
3
4
5
gpg \
--armor \
--export \
'Company RPM Repository' \
> /srv/yum-repo/keys/RPM-GPG-KEY-COMPANY

14.2 配置 rpmsign

创建签名账户的 ~/.rpmmacros

1
2
%_signature gpg
%_gpg_name Company RPM Repository

签名:

1
2
3
rpmsign \
--addsign \
/srv/yum-repo/custom/el10/x86_64/Packages/*.rpm

校验:

1
2
3
rpmkeys \
--checksig \
/srv/yum-repo/custom/el10/x86_64/Packages/*.rpm

CI/CD 中不应把私钥直接塞进普通构建镜像。更合理的做法包括:

  • 使用专门签名节点;
  • 使用硬件密钥或 HSM;
  • 使用短生命周期签名凭证;
  • 构建与签名分离;
  • 对签名操作保留审计日志。

14.3 签名仓库元数据

每次执行 createrepo_c 后,repomd.xml 都可能改变,需要重新签名:

1
2
3
4
5
6
7
gpg \
--batch \
--yes \
--armor \
--detach-sign \
--local-user 'Company RPM Repository' \
/srv/yum-repo/custom/el10/x86_64/repodata/repomd.xml

生成:

1
repodata/repomd.xml.asc

14.4 客户端配置

创建 /etc/yum.repos.d/company-custom.repo

1
2
3
4
5
6
7
8
9
[company-custom-el10]
name=Company Custom RPM - EL10
baseurl=https://yum.example.com/custom/el10/$basearch/
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-COMPANY
metadata_expire=5m
skip_if_unavailable=False

验证:

1
2
3
4
5
6
dnf clean metadata

dnf \
--disablerepo='*' \
--enablerepo='company-custom-el10' \
makecache

十五、自有仓库发布脚本

创建 /usr/local/sbin/publish-custom-rpm.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
cat > /usr/local/sbin/publish-custom-rpm.sh <<'SCRIPT'
#!/usr/bin/env bash

set -Eeuo pipefail
umask 022

REPO_DIR="${REPO_DIR:-/srv/yum-repo/custom/el10/x86_64}"
GPG_NAME="${GPG_NAME:-Company RPM Repository}"
WORKERS="${WORKERS:-4}"

exec 9>/run/lock/custom-rpm-publish.lock

if ! flock -n 9; then
echo "已有发布任务运行"
exit 0
fi

find "${REPO_DIR}/Packages" \
-type f \
-name '*.rpm' \
-print0 \
| xargs -0 -r rpmkeys --checksig

if [[ -d "${REPO_DIR}/repodata" ]]; then
createrepo_c \
--update \
--workers "${WORKERS}" \
--database \
--checksum sha256 \
"${REPO_DIR}"
else
createrepo_c \
--workers "${WORKERS}" \
--database \
--checksum sha256 \
"${REPO_DIR}"
fi

rm -f "${REPO_DIR}/repodata/repomd.xml.asc"

gpg \
--batch \
--yes \
--armor \
--detach-sign \
--local-user "${GPG_NAME}" \
"${REPO_DIR}/repodata/repomd.xml"

test -f "${REPO_DIR}/repodata/repomd.xml"
test -f "${REPO_DIR}/repodata/repomd.xml.asc"

echo "企业自有 RPM 仓库发布完成"
SCRIPT

chmod 0750 \
/usr/local/sbin/publish-custom-rpm.sh

正式生产中,应先把新 RPM 上传到暂存目录,完成签名、恶意软件扫描、依赖检查和验收后,再移动到正式 Packages 目录。

十六、同步 EPEL 仓库

安装 EPEL Repo 配置:

1
dnf install -y epel-release

查看实际 Repo ID:

1
dnf repolist --all | grep -i epel

同步:

1
2
3
4
5
6
7
8
9
10
11
mkdir -p /srv/yum-sync/epel/10/x86_64/epel

repo_sync \
--repo=epel \
--download-path=/srv/yum-sync/epel/10/x86_64/epel \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch=x86_64 \
--arch=noarch

EPEL 与 Rocky 官方仓库不是同一个项目,建议:

  • 独立目录;
  • 独立 Repo 文件;
  • 独立 GPG Key;
  • 独立同步和变更策略;
  • 不要把所有仓库混进一个目录重新生成元数据。

十七、版本快照与冻结发布

在线镜像会不断变化。生产环境若要求“测试和生产安装到完全相同版本”,应建立快照。

如果文件系统支持 Reflink,例如 XFS reflink:

1
2
3
4
5
6
7
8
9
SNAPSHOT_DATE="$(date +%F)"

mkdir -p \
"/srv/yum-repo/snapshots/${SNAPSHOT_DATE}"

cp -a \
--reflink=always \
/srv/yum-repo/rocky \
"/srv/yum-repo/snapshots/${SNAPSHOT_DATE}/"

如果不支持,会报错,不要悄悄退化成全量复制:

1
2
cp --help | grep reflink
xfs_info /srv | grep reflink

17.2 使用 LVM Snapshot

如果仓库数据位于独立 LVM LV,可以在同步完成后创建文件系统级快照,再挂载为只读目录。

17.3 客户端固定快照

1
2
3
4
5
6
[company-rocky10-baseos-20260731]
name=Company Rocky Linux 10 BaseOS Snapshot 2026-07-31
baseurl=https://yum.example.com/snapshots/2026-07-31/rocky/10/$basearch/baseos/
enabled=1
gpgcheck=1
gpgkey=https://yum.example.com/keys/RPM-GPG-KEY-Rocky-10

更新流程可以设计为:

flowchart LR
    Upstream[上游仓库] --> Mirror[滚动镜像]
    Mirror --> Snapshot[创建日期快照]
    Snapshot --> Test[测试环境验证]
    Test --> Approve{审批}
    Approve -- 通过 --> Prod[生产环境切换快照]
    Approve -- 不通过 --> Reject[丢弃或修复]

十八、Nginx 性能调优

18.1 Worker 配置

编辑 /etc/nginx/nginx.conf

1
2
3
4
5
6
7
8
9
user nginx;
worker_processes auto;
worker_rlimit_nofile 131072;

events {
worker_connections 8192;
multi_accept on;
use epoll;
}

理论最大连接数近似为:

1
worker_processes × worker_connections

但连接数还受到:

  • 文件句柄;
  • 内核端口;
  • 客户端带宽;
  • 磁盘吞吐;
  • TLS 性能;
  • 上游交换机和网卡;

共同限制。

18.2 sendfile

静态 RPM 下载适合开启:

1
2
sendfile on;
tcp_nopush on;

Nginx 可通过内核发送文件,减少用户态拷贝。

但如果仓库目录位于某些 NFS、FUSE 或特殊分布式文件系统上,sendfile 可能存在兼容或一致性问题,需要根据存储实现验证。

18.3 open_file_cache

仓库中有大量 RPM 和元数据文件,开启文件描述信息缓存可以减少重复 stat

1
2
3
4
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 120s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

不要设置无限大。仓库文件数量可能达到数十万,缓存本身也消耗内存。

18.4 TLS 会话缓存

1
2
ssl_session_cache shared:YUM_SSL:20m;
ssl_session_timeout 1d;

可以降低客户端大量短连接时重复 TLS 握手的成本。

18.5 HTTP/2

DNF 客户端是否利用 HTTP/2 取决于其 HTTP 库和版本,但启用 HTTP/2 对浏览器、其他客户端和未来兼容没有坏处。

18.6 限速与带宽治理

如果防止单台服务器占满带宽,可以配置:

1
2
3
4
location ~* \.(rpm|drpm)$ {
limit_rate_after 10m;
limit_rate 50m;
}

这里 50m 表示单连接约 50 MB/s,不是总带宽。

企业网络中更推荐在:

  • 网关;
  • 交换机;
  • QoS;
  • CDN;
  • 负载均衡器;

做整体带宽治理。

十九、DNF 客户端调优

编辑 /etc/dnf/dnf.conf

1
2
3
4
5
6
7
8
9
[main]
gpgcheck=True
installonly_limit=3
clean_requirements_on_remove=True
best=True
skip_if_unavailable=False
max_parallel_downloads=10
timeout=30
retries=5

19.1 max_parallel_downloads

内网私服可以适当提高并行下载:

1
max_parallel_downloads=10

不要在数百台机器同时更新时全部设置为极高值。

例如:

1
2
3
500 台服务器
× 每台 20 个并行下载
= 最多 10000 个并行请求

这可能把仓库服务器、交换机或存储直接打趴。跑得快不是问题,大家同时跑得快才是问题。

更合理的做法是:

  • 客户端并行数保持 5~10;
  • 批量升级分批执行;
  • 使用 Ansible serial
  • Kubernetes 节点滚动升级;
  • 给更新任务增加随机延迟。

19.2 metadata_expire

内部滚动镜像:

1
metadata_expire=6h

企业自有高频发布仓库:

1
metadata_expire=5m

冻结快照仓库:

1
metadata_expire=never

冻结仓库内容不再变化,没有必要反复请求元数据。

19.3 fastestmirror

客户端只有一个企业私服时,启用 fastestmirror 没有实际价值,反而可能增加探测开销。

19.4 skip_if_unavailable

生产基础仓库建议:

1
skip_if_unavailable=False

如果私服不可用,应让更新明确失败,而不是悄悄跳过仓库后得到一个“看似成功、实际没有更新”的结果。

二十、系统与磁盘调优

20.1 文件句柄

创建 /etc/systemd/system/nginx.service.d/limits.conf

1
2
[Service]
LimitNOFILE=131072

加载:

1
2
systemctl daemon-reload
systemctl restart nginx

检查:

1
systemctl show nginx -p LimitNOFILE

系统级配置:

1
2
3
4
5
6
7
8
cat > /etc/sysctl.d/99-yum-repo.conf <<'EOF'
fs.file-max = 2097152
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 8192
vm.swappiness = 1
EOF

sysctl --system

不要复制几十项来历不明的 TCP 参数。现代内核的默认值并不蠢,错误调优通常比不调更差。

20.2 文件系统

推荐:

  • XFS 或经过验证的 ext4;
  • 独立逻辑卷;
  • SSD/NVMe;
  • 保留 20% 以上可用空间;
  • 持续监控 inode;
  • 大规模镜像考虑 Reflink 或快照能力;
  • 同步工作区与发布区可在同一高容量文件系统,便于原子 rename。

检查:

1
2
3
4
5
findmnt /srv
df -hT /srv
df -ih /srv
iostat -xz 1
iotop

20.3 挂载参数

可以考虑:

1
noatime

减少访问时间更新,但应结合文件系统和运维规范评估。

不要使用会导致 Nginx 或同步工具异常的奇怪挂载参数,也不要把仓库放在可靠性未知的廉价对象网关挂载盘上。

20.4 page cache

Nginx 静态文件性能高度依赖 Linux Page Cache。

因此仓库服务器内存不应只看 Nginx RSS。即使 Nginx 只使用几百 MB,剩余内存也会缓存热门 RPM。

检查:

1
2
free -h
vmstat 1

不要看到 buff/cache 很大就认为“Linux 内存泄漏”。

二十一、同步任务调优

21.1 增量同步

reposync 会避免重复下载本地已有 RPM,因此稳定运行后的日常流量通常远低于首次全量同步。

21.2 避免任务重叠

脚本使用:

1
flock

保证同一时间只有一个同步任务。

如果不加锁,上一次同步尚未结束,下一次任务再次启动,可能导致:

  • 重复下载;
  • 临时文件冲突;
  • 元数据切换异常;
  • 带宽和磁盘被双倍占用。

21.3 降低 I/O 优先级

systemd Service 中设置:

1
2
3
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6

让仓库下载服务优先于后台同步任务。

21.4 错峰执行

使用:

1
RandomizedDelaySec=15m

防止多台镜像服务器在整点同时同步。

21.5 不要过度同步

Rocky 官方仓库没必要每分钟同步。

常见策略:

仓库类型 推荐频率
Rocky BaseOS/AppStream 每 6~12 小时
Extras/CRB 每 6~12 小时
EPEL 每 4~8 小时
企业自有 RPM 发布事件触发
冻结快照 不再同步

二十二、Docker 部署方案

Docker 方案由两个部分组成:

1
2
repo-web:Nginx 容器,只读发布仓库
repo-sync:Rocky Linux 10 工具容器,按需执行 reposync

不建议在一个容器里同时运行:

1
Nginx + Cron + reposync + SSH

一个容器硬塞四个进程,最终得到的不是“简单”,而是一只穿着 Docker 外套的虚拟机。

二十三、Rocky Linux 10 安装 Docker Engine

卸载冲突包:

1
2
3
4
5
6
7
8
9
10
dnf remove -y \
docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-engine \
podman-docker

安装仓库管理工具:

1
dnf install -y dnf-plugins-core

添加 Docker RHEL 仓库:

1
2
3
dnf config-manager \
--add-repo \
https://download.docker.com/linux/rhel/docker-ce.repo

安装:

1
2
3
4
5
6
dnf install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin

启动:

1
systemctl enable --now docker

验证:

1
2
3
docker version
docker compose version
docker info

不要配置:

1
-H tcp://0.0.0.0:2375

未启用 TLS 和认证的 Docker Remote API 基本等于把宿主机 root 权限暴露到网络。

二十四、创建 Docker 项目

创建目录:

1
2
3
4
5
6
mkdir -p /opt/yum-repo-docker/sync
mkdir -p /opt/yum-repo-docker/nginx
mkdir -p /data/docker/yum-repo/sync
mkdir -p /data/docker/yum-repo/repository

cd /opt/yum-repo-docker

24.1 同步容器 Dockerfile

创建 /opt/yum-repo-docker/sync/Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FROM rockylinux/rockylinux:10

RUN dnf -y install \
dnf5-plugins \
createrepo_c \
rsync \
findutils \
util-linux \
ca-certificates \
curl \
|| dnf -y install \
dnf-plugins-core \
createrepo_c \
rsync \
findutils \
util-linux \
ca-certificates \
curl \
&& dnf clean all \
&& rm -rf /var/cache/dnf

COPY sync-rocky-repos.sh /usr/local/bin/sync-rocky-repos.sh

RUN chmod 0755 /usr/local/bin/sync-rocky-repos.sh

ENTRYPOINT ["/usr/local/bin/sync-rocky-repos.sh"]

注意 Shell 中 &&|| 的优先组合容易造成误解。更严谨的 Dockerfile 可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM rockylinux/rockylinux:10

RUN set -eux; \
if ! dnf -y install dnf5-plugins; then \
dnf -y install dnf-plugins-core; \
fi; \
dnf -y install \
createrepo_c \
rsync \
findutils \
util-linux \
ca-certificates \
curl; \
dnf clean all; \
rm -rf /var/cache/dnf

COPY sync-rocky-repos.sh /usr/local/bin/sync-rocky-repos.sh

RUN chmod 0755 /usr/local/bin/sync-rocky-repos.sh

ENTRYPOINT ["/usr/local/bin/sync-rocky-repos.sh"]

推荐使用第二种。

24.2 容器同步脚本

创建 /opt/yum-repo-docker/sync/sync-rocky-repos.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/usr/bin/env bash

set -Eeuo pipefail
umask 022

ROCKY_VERSION="${ROCKY_VERSION:-10}"
ARCH="${ARCH:-x86_64}"

SYNC_ROOT="${SYNC_ROOT:-/sync}"
PUBLISH_ROOT="${PUBLISH_ROOT:-/repository}"

REPOSITORIES="${REPOSITORIES:-baseos appstream extras crb}"

reposync_run() {
if dnf reposync --help >/dev/null 2>&1; then
dnf reposync "$@"
elif command -v dnf5 >/dev/null 2>&1; then
dnf5 reposync "$@"
else
echo "未找到 reposync" >&2
exit 1
fi
}

for repo in ${REPOSITORIES}; do
sync_dir="${SYNC_ROOT}/rocky/${ROCKY_VERSION}/${ARCH}/${repo}"
publish_dir="${PUBLISH_ROOT}/rocky/${ROCKY_VERSION}/${ARCH}/${repo}"

mkdir -p "${sync_dir}"
mkdir -p "${publish_dir}"

echo "同步 ${repo}"

reposync_run \
--repo="${repo}" \
--download-path="${sync_dir}" \
--norepopath \
--download-metadata \
--delete \
--gpgcheck \
--arch="${ARCH}" \
--arch=noarch

test -f "${sync_dir}/repodata/repomd.xml"

# 容器方案保留为容易理解的发布方式。
# 正式高并发环境可替换为前文的原子元数据切换函数。
rsync -aH \
--delete-delay \
"${sync_dir}/" \
"${publish_dir}/"
done

echo "全部仓库同步完成"

设置权限:

1
2
chmod 0755 \
/opt/yum-repo-docker/sync/sync-rocky-repos.sh

24.3 Nginx 配置

创建 /opt/yum-repo-docker/nginx/default.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
server {
listen 80;
server_name _;

root /usr/share/nginx/html;

access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;

sendfile on;
tcp_nopush on;

keepalive_timeout 65;
keepalive_requests 10000;

open_file_cache max=10000 inactive=60s;
open_file_cache_valid 120s;
open_file_cache_min_uses 2;

location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}

location / {
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
try_files $uri $uri/ =404;
}

location ~* \.(rpm|drpm)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
try_files $uri =404;
}

location ~* /repodata/ {
expires 5m;
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}

location ~ /\. {
deny all;
}
}

生产环境 HTTPS 可以:

  • 在宿主机 Nginx 终止 TLS;
  • 使用 Traefik;
  • 使用 HAProxy;
  • 在容器 Nginx 中挂载证书;
  • 由企业负载均衡器终止 TLS。

24.4 创建 compose.yaml

创建 /opt/yum-repo-docker/compose.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
services:
repo-web:
image: nginx:stable-alpine
container_name: yum-repo-web
restart: unless-stopped
ports:
- "8080:80"
volumes:
- /data/docker/yum-repo/repository:/usr/share/nginx/html:ro,Z
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro,Z
healthcheck:
test:
- CMD-SHELL
- wget -qO- http://127.0.0.1/healthz | grep -q ok
interval: 30s
timeout: 5s
retries: 3
ulimits:
nofile:
soft: 131072
hard: 131072
mem_limit: 512m
cpus: 2.0

repo-sync:
build:
context: ./sync
image: company/rocky10-reposync:1.0.0
profiles:
- sync
environment:
ROCKY_VERSION: "10"
ARCH: "x86_64"
REPOSITORIES: "baseos appstream extras crb"
SYNC_ROOT: /sync
PUBLISH_ROOT: /repository
volumes:
- /data/docker/yum-repo/sync:/sync:Z
- /data/docker/yum-repo/repository:/repository:Z
network_mode: bridge
mem_limit: 4g
cpus: 4.0

说明:

  • repo-web 长期运行;
  • repo-sync 使用 Compose Profile,不会随 docker compose up -d 自动启动;
  • 需要同步时执行一次;
  • 仓库数据保存在宿主机;
  • :Z 为 SELinux 分配私有容器标签;
  • 正式环境应固定经过测试的镜像版本或 Digest,而不是永久依赖可漂移标签。

24.5 构建并启动

1
2
3
4
5
cd /opt/yum-repo-docker

docker compose config
docker compose build repo-sync
docker compose up -d repo-web

检查:

1
2
3
docker compose ps
docker compose logs -f repo-web
curl http://127.0.0.1:8080/healthz

执行首次同步:

1
2
3
4
5
docker compose \
--profile sync \
run \
--rm \
repo-sync

查看目录:

1
2
3
4
find \
/data/docker/yum-repo/repository/rocky/10/x86_64 \
-maxdepth 2 \
-type d

验证:

1
2
curl -I \
http://127.0.0.1:8080/rocky/10/x86_64/baseos/repodata/repomd.xml

二十五、使用 systemd 定时运行 Docker 同步任务

创建 /etc/systemd/system/docker-yum-repo-sync.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Description=Synchronize Rocky repositories with Docker Compose
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target

[Service]
Type=oneshot
WorkingDirectory=/opt/yum-repo-docker

ExecStart=/usr/bin/docker compose --profile sync run --rm repo-sync

TimeoutStartSec=12h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6

创建 /etc/systemd/system/docker-yum-repo-sync.timer

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Run Docker based YUM repository synchronization

[Timer]
OnCalendar=*-*-* 02,10,18:30:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

启用:

1
2
systemctl daemon-reload
systemctl enable --now docker-yum-repo-sync.timer

查看:

1
2
systemctl list-timers docker-yum-repo-sync.timer
journalctl -u docker-yum-repo-sync.service -n 200 --no-pager

二十六、Docker 方案生产化调优

26.1 不要把仓库放在容器可写层

错误方式:

1
RPM 全部保存在容器内部

容器删除后数据也可能丢失,而且 OverlayFS 不适合承载大量长期静态制品。

正确方式:

1
2
volumes:
- /data/docker/yum-repo/repository:/usr/share/nginx/html:ro

26.2 固定镜像

快速实验可以使用:

1
image: nginx:stable-alpine

生产环境建议将经过验证的版本固定为:

1
image: nginx:<明确版本>-alpine

或者固定 Digest:

1
image: nginx@sha256:<digest>

26.3 只读挂载

Nginx 只需要读取:

1
:ro

同步容器才有仓库写权限。

26.4 限制资源

Nginx:

1
2
mem_limit: 512m
cpus: 2.0

同步容器:

1
2
mem_limit: 4g
cpus: 4.0

同步任务可能需要更多内存和 CPU,但真正瓶颈通常还是磁盘和网络。

26.5 容器日志

为 Docker 配置日志轮转:

1
2
3
4
5
6
7
8
9
10
11
cat > /etc/docker/daemon.json <<'EOF'
{
"log-driver": "local",
"log-opts": {
"max-size": "50m",
"max-file": "5"
}
}
EOF

systemctl restart docker

不要让容器日志把系统盘写满——这类故障的幽默之处在于,仓库 RPM 一个没坏,服务却因为几 GB 访问日志先去世了。

二十七、权限控制与内网安全

27.1 IP 白名单

内部仓库可以在 Nginx 中限制网段:

1
2
3
4
5
6
7
8
location / {
allow 192.168.9.0/24;
allow 10.0.0.0/8;
deny all;

autoindex on;
try_files $uri $uri/ =404;
}

27.2 Basic Auth

安装:

1
dnf install -y httpd-tools

创建密码文件:

1
2
3
htpasswd -c \
/etc/nginx/.htpasswd \
repo-reader

Nginx:

1
2
3
4
5
6
7
location /custom/ {
auth_basic "Company RPM Repository";
auth_basic_user_file /etc/nginx/.htpasswd;

autoindex on;
try_files $uri $uri/ =404;
}

客户端 URL 可以携带账号密码,但会带来配置文件泄漏风险。更推荐:

  • 内网访问;
  • VPN;
  • mTLS;
  • 短期凭证;
  • 网关鉴权;
  • IP 白名单。

27.3 不允许匿名上传

Nginx 仓库目录应只读发布。

RPM 上传应通过:

  • CI/CD;
  • SCP/SFTP;
  • API 网关;
  • 制品审批流水线;
  • 专用发布账户;

完成。

不要开放 WebDAV 让所有人随手把 RPM 扔进仓库。软件供应链不是网盘。

二十八、备份与恢复

28.1 哪些内容必须备份

官方滚动镜像可以重新同步,优先级相对较低。

必须重点备份:

1
2
3
4
5
6
7
8
企业自有 RPM
GPG 私钥及吊销证书
GPG 公钥
Repo 配置
同步和发布脚本
Nginx 配置
冻结快照
审计记录

28.2 备份目录

1
mkdir -p /backup/yum-repo

备份企业自有仓库:

1
2
3
4
5
6
7
tar \
--xattrs \
--acls \
-czf /backup/yum-repo/custom-$(date +%F-%H%M%S).tar.gz \
-C /srv/yum-repo \
custom \
keys

备份配置:

1
2
3
4
5
6
7
tar \
-czf /backup/yum-repo/config-$(date +%F-%H%M%S).tar.gz \
/etc/nginx \
/etc/systemd/system/yum-repo-sync.service \
/etc/systemd/system/yum-repo-sync.timer \
/usr/local/sbin/sync-rocky-repos.sh \
/usr/local/sbin/publish-custom-rpm.sh

28.3 GPG 私钥

导出私钥时必须加密保存:

1
2
3
4
5
6
7
8
gpg \
--armor \
--export-secret-keys \
'Company RPM Repository' \
> /root/company-rpm-private-key.asc

chmod 0600 \
/root/company-rpm-private-key.asc

然后放入受控的 Secret Vault、离线介质或加密备份系统。

不要把私钥和 RPM 仓库放在同一台服务器上长期裸奔。仓库被入侵后,如果签名私钥也在,攻击者就同时拿到了“伪造包”和“盖公章”的能力。

28.4 恢复验证

恢复后必须执行:

1
2
3
4
5
6
dnf clean all
dnf makecache
dnf repoquery --available
dnf download 测试包
dnf install 测试包
rpmkeys --checksig 下载后的RPM

只看 Nginx 返回 200 不代表仓库可用。真正的验收是 DNF 能解析依赖并完成安装。

二十九、监控与告警

29.1 基础监控

监控指标至少包括:

1
2
3
4
5
6
7
8
9
磁盘使用率
inode 使用率
Nginx 2xx/4xx/5xx
下载流量
请求延迟
同步任务状态
最后成功同步时间
repomd.xml 更新时间
GPG 校验失败数量

29.2 最后同步时间

同步成功后写入:

1
2
date -Is \
> /srv/yum-repo/.last-successful-sync

Nginx 禁止访问点文件时,可以额外发布:

1
2
date -Is \
> /srv/yum-repo/status/last-successful-sync.txt

监控系统定期检查:

1
2
curl -fsS \
https://yum.example.com/status/last-successful-sync.txt

29.3 仓库完整性检查

检查所有 repomd.xml

1
2
3
4
5
find \
/srv/yum-repo \
-path '*/repodata/repomd.xml' \
-type f \
-print

使用 DNF 从服务端自身验证:

1
2
3
4
dnf \
--repofrompath=repo-check,https://yum.example.com/custom/el10/x86_64/ \
--repo=repo-check \
makecache

29.4 Nginx 日志统计

1
2
3
4
5
awk '{print $9}' \
/var/log/nginx/yum-access.log \
| sort \
| uniq -c \
| sort -nr

统计下载最多的 RPM:

1
2
3
4
5
6
awk '$7 ~ /\.rpm$/ {print $7}' \
/var/log/nginx/yum-access.log \
| sort \
| uniq -c \
| sort -nr \
| head -20

三十、常见故障排查

30.1 repomd.xml 404

检查 URL:

1
2
curl -I \
https://yum.example.com/rocky/10/x86_64/baseos/repodata/repomd.xml

检查服务端:

1
2
3
4
5
ls -lah \
/srv/yum-repo/rocky/10/x86_64/baseos/repodata

readlink -f \
/srv/yum-repo/rocky/10/x86_64/baseos/repodata

常见原因:

  • baseurl 路径多了一层或少了一层;
  • --norepopath 与目录设计不一致;
  • Nginx root 配错;
  • 符号链接失效;
  • SELinux 拒绝访问;
  • 同步没有成功。

30.2 No available modular metadata

常见原因是重新执行 createrepo_c 时丢失上游模块元数据。

解决思路:

  • 官方镜像使用 --download-metadata
  • 不要重建官方仓库元数据;
  • 检查同步目录中是否包含附加元数据;
  • 清理客户端缓存后重试。

Rocky Linux 10 已不再沿用旧版 DNF Module 架构,但在同时维护 EL8/EL9 仓库时,这个问题仍需注意。

30.3 软件包存在但客户端找不到

检查:

1
2
3
find \
/srv/yum-repo/custom/el10/x86_64 \
-name 'example-app*.rpm'

检查元数据更新时间:

1
2
stat \
/srv/yum-repo/custom/el10/x86_64/repodata/repomd.xml

重新生成:

1
2
3
createrepo_c \
--update \
/srv/yum-repo/custom/el10/x86_64

客户端:

1
2
3
dnf clean metadata
dnf makecache --refresh
dnf repoquery example-app

30.4 GPG check FAILED

检查 RPM:

1
rpmkeys --checksig package.rpm

检查客户端导入的 Key:

1
2
rpm -qa 'gpg-pubkey*'
rpm -qi gpg-pubkey

重新导入:

1
2
rpm --import \
https://yum.example.com/keys/RPM-GPG-KEY-COMPANY

不要通过:

1
gpgcheck=0

长期掩盖签名问题。那不是修复,是把烟雾报警器拆了。

30.5 Metadata file does not match checksum

可能原因:

  • 同步时客户端正好访问;
  • CDN/Nginx 缓存了旧元数据;
  • repomd.xml 和数据文件来自不同同步批次;
  • 反向代理改写或缓存策略错误。

客户端临时处理:

1
2
3
dnf clean all
rm -rf /var/cache/dnf
dnf makecache --refresh

服务端应检查原子发布和缓存配置,而不是要求所有客户端每天手工清缓存。

30.6 Curl 200,但 DNF 失败

HTTP 200 只能证明文件存在。

进一步执行:

1
2
3
4
dnf \
--repofrompath=test,https://yum.example.com/custom/el10/x86_64/ \
--repo=test \
makecache

检查:

  • 元数据 XML;
  • Checksum;
  • RPM 路径;
  • 签名;
  • HTTPS 证书链;
  • Content-Length;
  • 代理缓存。

30.7 同步任务占满磁盘

检查:

1
2
3
4
5
6
7
8
du -xh \
/srv/yum-sync \
/srv/yum-repo \
| sort -h \
| tail -30

df -hT
df -ih

常见原因:

  • 工作区和发布区双份存储;
  • 快照没有生命周期;
  • 历史元数据未清理;
  • 同步了 Debug、Source、ISO 等不需要的仓库;
  • Docker 日志没有轮转;
  • --delete 没有生效;
  • EPEL 或第三方仓库范围过大。

30.8 reposync 漏包

如果仅指定:

1
--arch=x86_64

可能漏掉 noarch

应同时指定:

1
2
--arch=x86_64
--arch=noarch

30.9 证书错误

客户端错误:

1
2
SSL certificate problem
certificate verify failed

应修复:

  • 证书 SAN;
  • 完整证书链;
  • 企业 CA 分发;
  • 客户端系统时间;
  • DNS 解析。

不要长期配置:

1
sslverify=0

三十一、生产环境检查清单

系统

  • Rocky Linux 10 已安装安全更新;
  • Chrony 同步正常;
  • SELinux 保持 Enforcing;
  • firewalld 只开放必要端口;
  • 仓库使用独立数据盘;
  • 磁盘容量和 inode 已监控;
  • 文件句柄上限已验证;
  • 同步任务设置了 flock

仓库

  • BaseOS、AppStream、Extras 已完整同步;
  • 需要时同步 CRB;
  • x86_64 和 noarch 均已同步;
  • 官方仓库使用上游原始元数据;
  • 没有把 --newest-only 与原始完整元数据错误混用;
  • 自有 RPM 使用 createrepo_c
  • 自有 RPM 已签名;
  • 客户端启用了 gpgcheck=1
  • 元数据需要签名时启用了 repo_gpgcheck=1
  • 发布过程避免半成品元数据暴露。

Nginx

  • 使用 HTTPS;
  • sendfile 已根据存储类型验证;
  • RPM 和元数据使用不同缓存时间;
  • 未对已压缩 RPM 做无意义动态 gzip;
  • 访问日志已轮转;
  • 健康检查可用;
  • 敏感仓库已配置访问控制。

Docker

  • 仓库数据不在容器可写层;
  • Nginx 目录只读挂载;
  • 同步容器按需运行;
  • 镜像版本或 Digest 已固定;
  • SELinux Volume Label 正确;
  • Docker 日志已限制;
  • 未开放未认证的 2375 端口。

备份与恢复

  • 自有 RPM 已备份;
  • GPG 私钥已离线或加密备份;
  • Repo、Nginx 和同步脚本已备份;
  • 冻结快照已备份;
  • 已执行真实 DNF 恢复验证;
  • 已记录最后成功同步时间。

三十二、总结

在 Rocky Linux 10 上搭建企业级 YUM 私服,核心不是安装一个 Nginx,然后把 RPM 放进去,而是建立一条完整的软件供应链:

1
2
3
4
5
6
7
8
9
可信上游
-> GPG 校验
-> 增量同步
-> 一致性发布
-> HTTPS 分发
-> 客户端签名校验
-> 快照冻结
-> 监控
-> 备份恢复

最终建议如下:

  1. Rocky 官方仓库使用 reposync --download-metadata 完整镜像;
  2. 不要用 createrepo_c 随意覆盖官方仓库元数据;
  3. 企业自有 RPM 单独建仓并生成元数据;
  4. x86_64 仓库必须同时考虑 noarch
  5. 不要错误混用 --newest-only 和上游完整元数据;
  6. 通过工作区与发布区隔离同步过程;
  7. 使用原子方式切换 repodata
  8. 使用 systemd Timer 而不是堆叠不可观测的 Cron;
  9. 对 RPM 和仓库元数据分别建立 GPG 校验;
  10. Docker 中让 Nginx 只读,仓库数据持久化到宿主机;
  11. 用快照解决“今天能装,明天版本变了”的环境漂移;
  12. 定期进行真实安装和恢复演练。

YUM 私服平时安静得像一台普通文件服务器,一旦它失效,批量装机、系统升级、镜像构建和节点扩容会一起停下来。它不是最显眼的基础设施,但往往是那种坏一次,所有人就都记住它名字的基础设施。

参考资料


Rocky Linux 10 搭建企业级 YUM 私服:DNF 镜像、自有 RPM 仓库、Docker 部署与性能调优
https://allendericdalexander.github.io/2026/07/31/devops/linux/rocky/rocky-linux-10-yum-dnf-private-repository/
作者
AtLuoFu
发布于
2026年7月31日
许可协议