ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内网Linux软件分发:本地DNF仓库+NFS共享部署实战

内网Linux软件分发:本地DNF仓库+NFS共享部署实战

先交代一下背景。当时我接手的内网环境有几十台同版本的Linux服务器,网络策略不允许直接访问外部软件源,每次装软件都成了一项体力活:在一台能联网的机器上手动扒依赖包,再通过U盘或scp逐台拷,结果不是缺了这个包,就是版本对不上。装个nginx看起来一条命令的事,实际手动处理能折腾一下午。后来我在仓库机上搭了一个本地DNF仓库,再用NFS把整个仓库目录共享出去,从此所有内网机器装软件都回到了最原始的体验:dnf install。这篇就把“本地DNF仓库+NFS共享”这套方案的完整部署过程写出来,包括每个命令为什么这么用、关键参数怎么选,以及实际部署里我踩过的一些坑。

1. 为什么偏偏选“DNF仓库+NFS”这个组合

1.1 部署前的软件分发困局

先说痛点。内网机器装软件,最麻烦的不是软件本身,而是它的依赖链。拿nginx举例,我们以为只需要一个rpm,实际上它会依赖一堆运行库,这些库又可能依赖更基础的glibc、perl模块。手动下载时,你必须把整棵依赖树理清楚,否则在客户端执行rpm -ivh,第一句就是“Package X is needed by Y”,心态很容易崩。

U盘拷贝也是看似简单实则坑多。依赖顺序错了不行,架构不匹配更麻烦。你花半天拷过去的包,别人机器一执行就报错,最后还是得逐台排查。面对几十台机器,这种低效是不能接受的。所以你需要一个“软件源”:先把所有软件包集中放到一台机器上,生成元数据,客户端通过包管理器去读取、解析依存关系,从而自动完成安装。

1.2 三种向客户端分发仓库的做法

集中放好了软件包,接下来的问题是怎么让客户端拿到。常见分发手段有三种,我直接对比一下:

分发方式配置复杂度DNF/源适配度适合场景
HTTP需要额外维护Web服务,规划URL路径高,标准做法大规模集群、需要对外开放
FTP需要配置vsftpd,被动模式还要放行大段端口中低,体验一般老旧环境、个别遗留系统
NFS只需nfs-utils,导出目录即可极高,挂载后就是本地路径10~100台内网机器,最省心

HTTP当然是非常标准的方案,很多公共镜像站就是HTTP。但问题在于,内网环境里你要多维护一个Web服务,仓库文件多、体积大,还得关注下载完整性和并发,配置成本被抬高了。FTP就更不用提,被动模式在防火墙规则里简直是个噩梦,你永远不知道它要开多少端口。

NFS的优势在于:客户端把它挂载成一个本地目录,dnf根本不需要感知网络协议,repo文件里的baseurl直接写成file://指向挂载点就行。这种“把远程目录当本地目录用”的思路,让整个客户端侧的配置变得极其简单。局域网内读取速度也远高于HTTP,基本是磁盘速率。

1.3 这套组合的信息流是怎么串联的

整个方案的信息流并不复杂:

外部官方软件源 → 仓库机(reposync同步RPM + createrepo_c生成元数据) → NFS导出/repo目录 → 客户端挂载到/mnt/rpmrepo → 客户端dnf读取本地repo文件完成安装。

你会发现它把两个问题拆开了:软件包的组织由reposync和createrepo负责,软件包的分发由NFS负责。任何一层出问题,都能单独定位,不必为了排查一个硬件故障去翻整个Web服务日志。

适用规模上,一台仓库机带几十台内网机器完全没问题。客户端通常只是短暂读取软件包,不会持续打满带宽。如果你的内网规模到了几百台,NFS单点也可能会成为瓶颈,那时候再考虑加一层HTTP镜像不迟。现阶段,这个组合就是在“省事”和“够用”之间最平衡的答案。

2. 动手前先做环境规划:很多配置失败都源于这个环节

2.1 仓库机的系统、磁盘和目录规划

仓库机本身不需要多高的计算配置,1核2G做纯软件仓库都绰绰有余,但磁盘一定要给足。官方软件源的体积,baseos和appstream加起来就是几十GB,这还不算extras,以及后续持续增量的updates。我的建议是按100GB起步准备,如果用LVM管理就更灵活,以后空间不够可以直接扩容逻辑卷。

文件系统建议选择xfs。目录结构最好是按repo id进行规划,这样reposync的--destdir参数可以直接落位。我习惯的标准结构是这样的:

/repo ├── baseos │ ├── Packages │ └── repodata ├── appstream │ ├── Packages │ └── repodata └── extras

如果内网机器混用了x86_64和aarch64两种架构,不要把两套包塞进同一个目录。建议在/repo下面再按架构分层,比如/repo/x86_64/baseos、/repo/aarch64/baseos,否则你以后一定会被“为什么包在客户端装不上”的问题逼疯。

系统版本推荐直接用Rocky Linux或AlmaLinux这类RHEL兼容系,dnf是默认包管理器,reposync、createrepo_c这些工具的生态足够成熟。仓库机的发行版本最好和客户端保持一致的大版本,避免元数据格式差异带来的小问题。

2.2 防火墙、SELinux和主机名解析

动手配NFS之前,有三样东西必须先确认,否则后面不管怎么调exports都会失败。

第一是固定IP和主机名解析。仓库机必须用固定IP,并建议在每台客户端的/etc/hosts里写一条记录,比如:

192.168.10.10 repo-server

NFS对反向解析很敏感,如果客户端无法反查到服务器的主机名,mount操作可能特别慢甚至超时。这个我排障时遇到过不少次,先加hosts往往能解决一半诡异问题。

第二是防火墙。RHEL系内网机器一般开着firewalld,NFS不只是2049端口,还依赖111端口的rpcbind,以及mountd服务。先查看当前状态:

systemctl status firewalld firewall-cmd --list-services

第三是SELinux。执行getenforce看看输出,如果为Enforcing也不用紧张,NFS相关布尔值默认是开放的,但你心里要有数,后文我会专门写一个SELinux挡路的排查案例。这里先记住:不要在没看完日志的情况下直接setenforce 0,那是最差的做法。

仓库机还需要能正常访问外部官方软件源,这是同步的前提。执行dnf repolist能列出baseos、appstream等仓库ID,就说明基础环境没问题。

3. 先搭出DNF仓库本身:同步、分组和元数据生成

3.1 安装包同步工具

仓库机上需要两个工具:reposync负责从远程源拉取软件包,createrepo_c负责生成本地元数据。在RHEL系8+系统中,命令很简单:

dnf install -y createrepo_c dnf-utils

reposync命令由dnf-utils提供。这里有个细节:createrepo_c是C语言实现的版本,比早期基于Python的createrepo快很多,大仓库全量生成元数据时差距尤其明显。你不需要再去找什么额外仓库来安装,默认源里就有。

3.2 用reposync从官方源同步软件包

同步前先确认仓库机的仓库ID,执行dnf repolist查看。不同发行版的repo id可能不一样,有的叫baseos,有的叫rhel-9-for-x86_64-baseos-rpms,以实际输出为准。同步命令这样写:

reposync \ --repoid=baseos \ --repoid=appstream \ --repoid=extras \ --download-metadata \ --downloadcomps \ --destdir=/repo

每个参数都值得说明一下。--repoid指定同步哪些仓库,可以重复使用;--download-metadata会下载远程仓库的元数据,关系到后面createrepo;--downloadcomps是重中之重,没有它你后续就只能用dnf install安装单个包,无法使用dnf groupinstall做组安装;--destdir指定RPM文件的落盘根目录,reposync会自动按repo id生成子目录。

首次同步会比较慢,几十分钟到几个小时都有可能,取决于网络和仓库大小。不过reposync默认是增量同步,中途断了重跑会续传,已经下载好的包不会被重复拉取。

如果你的场景只需要某个特定软件加它的全部依赖,用reposync全量同步就太重了。更合适的做法是:

dnf download --resolve --alldeps --destdir=/srv/myrepo nginx

这个命令会下载nginx以及依赖树里的所有rpm,适合做一个几十MB的小仓库。但今天聊的是标准仓库方案,所以我按下不表。

3.3 生成repodata元数据

同步完成后,仓库目录里只有一堆rpm文件,客户端dnf还不能直接用,必须生成repodata元数据。命令如下:

cd /repo/baseos createrepo_c --update --groupfile /repo/baseos/comps.xml .

--update表示在已有元数据上做增量更新,这一点在后面的定时任务里非常关键,没有它每次都会全量重建,浪费大量时间。--groupfile用于指定分组文件,对应就是刚才reposync拉下来的comps.xml。如果reposync之后你发现目录下没有comps.xml,可以去官方源下载对应版本的comps文件放进目录,再重新执行createrepo。

对appstream和extras目录也要分别执行一次createrepo_c。完成后检查一下:

ls /repo/baseos/repodata/

出现repomd.xml就说明元数据生成成功。dnf只认repodata目录,如果没有这个目录,客户端repo文件写得再对也无济于事。

4. 用NFS共享仓库:exports参数决定成败

4.1 安装并启动NFS服务端

仓库机安装nfs-utils,并启动服务:

dnf install -y nfs-utils systemctl enable --now nfs-server systemctl status nfs-server

用rpcinfo确认关键服务是否在线:

rpcinfo -p | grep -E 'nfs|mountd|rpcbind'

应该能看到portmapper、nfs、mountd相关信息。需要说明的是,NFSv4的主体传输走2049端口,但mountd和rpcbind依然会参与到挂载流程中,所以在防火墙规划时必须一并考虑。

4.2 /etc/exports的推荐写法和参数解读

NFS的核心配置是/etc/exports文件。我的推荐写法是把整个/repo目录导出给内网段:

/repo 192.168.10.0/24(rw,sync,no_wdelay,no_root_squash)

参数含义如下表:

参数含义我的建议
rw / ro客户端可读可写 / 只读纯安装场景ro也够;留rw方便后续客户端侧自动化推送
sync / async写操作是否同步落盘建议sync,避免异常掉电丢数据
wdelay / no_wdelay是否合并多次写请求延迟落盘仓库场景影响不大,默认即可
root_squash / no_root_squash客户端root是否映射为nobody内网可信且需要写权限可no_root_squash;只读场景保持root_squash更安全
anonuid / anongid匿名用户映射的本地ID跨机写共享时用来统一权限

很多教程默认推荐root_squash,这在安全上是对的。但如果你希望通过NFS让多台机器一起往仓库里写内容(比如某些自动化脚本直接从挂载点推送RPM包),root会被映射成nobody,然后你会发现写入时一直permission denied。所以到底选no_root_squash还是root_squash,取决于你愿不愿意在内网可信环境里用一部分安全冗余换操作便捷。

改完exports后,让配置生效并验证:

exportfs -r exportfs -v

输出里能看到当前导出的目录和参数,说明配置已经被NFS服务端加载。

4.3 客户端挂载并验证

客户端侧同样要装nfs-utils:

dnf install -y nfs-utils showmount -e 192.168.10.10

showmount能列出服务器导出的目录,说明网络和rpcbind是通的。然后创建挂载点并挂载:

mkdir -p /mnt/rpmrepo mount -t nfs -o nfsvers=4.2 192.168.10.10:/repo /mnt/rpmrepo df -hT /mnt/rpmrepo

df输出能看到远程文件系统,就说明挂载成功。接下来把挂载固化到/etc/fstab,我推荐的写法是:

192.168.10.10:/repo /mnt/rpmrepo nfs4 _netdev,nfsvers=4.2,soft,timeo=30,retrans=2 0 0

这里有几个关键点。_netdev必须加,告诉系统这块挂载依赖网络,启动时不要一开始就强行尝试;soft和timeo、retrans是应对网络抖动的参数,仓库是只读场景,用soft比硬挂载合适得多,这个坑后面单独展开。

提醒一句:先在命令行手动挂载验证过,再写进fstab。如果直接写fstab,出了错你可能要跑到机房用管理口去抢救系统。

5. 客户端DNF配置与首次安装验证

5.1 写本地repo文件

客户端配置的核心,是在/etc/yum.repos.d/目录下新建一个local.repo文件。内容这样写:

[local-baseos] name=Local BaseOS Repository (NFS) baseurl=file:///mnt/rpmrepo/baseos enabled=1 gpgcheck=0 [local-appstream] name=Local AppStream Repository (NFS) baseurl=file:///mnt/rpmrepo/appstream enabled=1 gpgcheck=0 [local-extras] name=Local Extras Repository (NFS) baseurl=file:///mnt/rpmrepo/extras enabled=1 gpgcheck=0

baseurl的前缀,常见的有file://和dir://两种写法,dnf对本地路径都是认的,我用file://更多。这里最容易犯的错是路径写错。如果不确定挂载点下有哪些子目录,先用ls /mnt/rpmrepo确认,再写repo文件。

gpgcheck=0是我对内网仓库的态度:既然已经改成离线环境,包都来自可信官方源的同步,再花精力配置GPG公钥链路对多数团队属于过度设计。如果你有强烈的安全审计要求,那就把官方公钥装好、gpgkey路径配好,也不是不行,只是维护成本会高一些。

5.2 清理缓存并确认仓库列表

配置完repo文件后,依次执行:

dnf clean all dnf makecache dnf repolist

repolist输出里能看到local-baseos、local-appstream等仓库,说明dnf已经读取到本地NFS仓库的元数据。此时如果用外网源下发过缓存,最好干净清理一次,防止dnf优先使用旧缓存的元数据。

如果你的客户端系统源文件还没禁用,且它还能访问到外部源,有可能出现本地源和外部源同时竞争的情况。想让dnf铁了心只用本地源,可以临时用这个写法:

dnf --disablerepo='*' --enablerepo='local-*' install nginx

更彻底的办法是把/etc/yum.repos.d/下系统自带的repo文件移到一个备份目录,只留local.repo。离线内网机器我就推荐这个做法,一了百了。

5.3 分别验证单包安装和组安装

验证环节我习惯分两步走。第一步单包安装:

dnf install -y nginx

第二步验证组安装,这一步能检查出你是否真的同步了comps文件:

dnf groupinstall "Development Tools"

如果报找不到组,基本可以断定是同步时漏了--downloadcomps,或者createrepo时没加--groupfile。回到仓库机重新处理即可。这两个验证都通过,说明仓库从软件包到元数据到分组信息全链路都没问题。

6. 整套方案里最隐蔽的坑:按现象、排查、解决整理

6.1 防火墙全放行了nfs,客户端却一直mount超时

这是我第一次部署时遇到的怪问题。防火墙里明明把nfs服务加进去了,客户端showmount -e也能正常列出导出目录,但真正执行mount命令时,卡了十几秒然后直接timeout。

排查链路是这样的:先看服务端监听端口,执行ss -lntp | grep -E '2049|111|20048',确认rpcbind和mountd端口都活着。然后检查防火墙,firewall-cmd --list-services,发现我只加了nfs服务。问题就出在这:firewalld里的nfs服务通常只覆盖2049端口,但NFS挂载流程里rpcbind的111端口和mountd的端口也必须放行。

正确的放行姿势是:

firewall-cmd --permanent --add-service=nfs firewall-cmd --permanent --add-service=rpc-bind firewall-cmd --permanent --add-port=20048/tcp firewall-cmd --reload

如果你发现服务器的mountd端口不是固定的20048,先看rpcinfo -p的输出再对号放行。固定端口这一步,可以在NFS的配置里把mountd端口设死,减少后续不可控因素。

6.2 挂载成功但dnf报repomd.xml无法获取

这个坑比上一个更隐蔽。客户端mount成功后,ls能看到RPM包,但执行dnf makecache时报错,大概意思是无法获取repodata/repomd.xml。第一反应是仓库机权限有问题,但仓库机本地检查目录权限一切正常。

排查时要分几个方向。先看客户端挂载是否正常:mount | grep rpmrepo。再看SELinux:getenforce显示Enforcing,那就去翻审计日志:

ausearch -m avc -ts recent | tail -20

如果日志里有denied记录且关键词和远程目录读取相关,基本就是SELinux拦截。处理顺序一定要对:先确认具体的avc报告,再执行setsebool -P nfs_export_all on,或通过semanage调整目录的文件上下文。不要一来就setenforce 0,那是拿生产安全给操作便利买单。

顺带说一句,我把这个问题放在“坑”里,是因为很多教程默认SELinux不会拦NFS,但实际上不同版本、不同目录位置,策略表现会有差异。你只需要掌握看日志定位的方法,就不会慌。

6.3 硬挂载遇到网络抖动,yum命令卡到怀疑人生

NFS客户端默认的挂载模式是hard。什么叫hard?就是NFS请求发出后,服务端没回应,客户端会无限重试,进程进入不可中断的休眠状态。我遇到过仓库机临时重启维护,客户端那边没察觉到,有人执行dnf install,命令就卡在那里,按Ctrl+C都没反应,ps看进程状态是D。

对软件源共享来说,这完全没有必要。仓库是只读数据,就算软挂载超时中断,最多报个错,不会产生脏数据。在fstab里合理使用soft模式:

192.168.10.10:/repo /mnt/rpmrepo nfs4 _netdev,nfsvers=4.2,soft,timeo=30,retrans=2 0 0

timeo=30表示超时300毫秒重试,retrans=2表示重试2次,这样NFS一点问题最多熬几十秒就会报错,而不是无限期卡死。

已经卡死了怎么救?先尝试umount -l /mnt/rpmrepo做懒卸载。如果进程仍然处于D状态,最省事的办法是重启客户端,不要在生产上硬踢进程,反而可能把文件系统搞出问题。这里也补充一句,如果挂载的是生产数据盘,我不建议用soft模式,因为写入过程中断可能造成数据不一致;只有软件源这种纯读场景,soft才合适。

6.4 卸载提示target is busy,以及fstab顺序的坑

想卸载挂载点时,经常收到target is busy的提示。第一次碰到以为是哪个进程在安装软件,后来排查才发现,十有八九是自己shell的当前目录还停在挂载点里。执行lsof +D /mnt/rpmrepo或fuser -mv /mnt/rpmrepo,能看出谁占用了。如果只是自己终端,cd回根目录后再umount就干净了;有进程占用就fuser -km /mnt/rpmrepo,确认没问题后再卸载。

fstab还有一个经典坑:如果你没写_netdev,系统启动时会在网络服务还没就绪的阶段就去挂载NFS,然后整个开机流程卡在“a start job is running for ...”等超时。几十台机器,如果fstab都是复制粘贴的,你就要一台一台救。这也是我为什么反复强调把_netdev写进标准化配置里。

6.5 同步仓库时,客户端看到了半成品

仓库不是一成不变的,你会定期reposync更新。但如果同步直接写到/repo共享目录里,RPM包和repodata正处在变化过程中,恰好有客户端执行dnf操作,它可能读到不完整的元数据,报一些莫名其妙的校验错误。

更稳的做法是先在一个隐藏目录里同步,完成后rsync到正式目录。比如:

reposync --repoid=baseos --destdir=/repo/.sync rsync -a --delete /repo/.sync/baseos/ /repo/baseos/

这样NFS导出的还是/repo/baseos,但内容在极短的时间内原地切换,客户端基本感知不到更新过程。定时任务里我建议都按这个思路来,仓库机维护时就能做到对客户端无感。

7. 定时更新与仓库瘦身:维护期该做的事

7.1 一个可以直接改用的同步脚本

仓库不是搭完就一劳永逸,官方源持续在更新,安全补丁、软件新版本都要跟进。我习惯写一个脚本,配合crontab在半夜执行。下面是能直接改用的版本:

#!/bin/bash set -euo pipefail LOG=/var/log/repo-sync.log REPO_BASE=/repo REPOS=("baseos" "appstream" "extras") exec >> "${LOG}" 2>&1 echo "==== sync start $(date '+%F %T') ====" for repo in "${REPOS[@]}"; do echo "---- sync ${repo} ----" reposync \ --repoid=${repo} \ --download-metadata \ --downloadcomps \ --destdir=${REPO_BASE} if [ -f "${REPO_BASE}/${repo}/comps.xml" ]; then createrepo_c --update --groupfile "${REPO_BASE}/${repo}/comps.xml" "${REPO_BASE}/${repo}" else createrepo_c --update "${REPO_BASE}/${repo}" fi done echo "==== sync end $(date '+%F %T') ===="

注意脚本里的set -euo pipefail,任何一个环节失败都让脚本退出并写进日志,你才能及时发现问题。crontab添加定时任务:

0 2 * * 0 /usr/local/bin/sync_repo.sh

选择周日凌晨,一是离线客户端负载低,二是避免和业务高峰撞车。同步完成后检查日志,重点看每个repo有没有报错,repomd.xml的时间戳是不是最近一次执行生成。

7.2 仓库瘦身:怎么清理旧版本还不影响安装

reposync会精确同步上游仓库,但上游仓库里的旧版本RPM如果一直留着,磁盘空间会越来越紧张。清理思路不是随手删,而是利用repomanage识别旧包。安装一次dnf-utils后:

repomanage --old --keep=2 /repo/baseos/Packages | head -50

先看输出,确认列出的都是旧版本,再执行删除。删除之后一定要重新createrepo_c --update,否则客户端看到的元数据和实际文件就对不上了。

有一点务必注意:内核包不要删得太狠。至少要保留当前运行内核和上一个可用内核,免得哪天新内核出问题,回滚都找不到包。repomanage的--keep参数就是一种保护,不过你真要严谨,最好单独写个排除列表,把kernel开头的包排除在外。

7.3 维护期的几个实用习惯

维护这套系统时间长了,我总结出几个可以让你少走弯路的习惯。

第一,repo文件的注释和命名要规范。在local.repo里把“这个源指向的NFS服务器IP、挂载路径、同步周期”都写清楚。当时折腾的人多了,我自己也记不住哪台机器用的是哪个版本,后来全靠注释撑过排查。

第二,做任何大版本升级前,经验值是在一台客户端上先验证,再考虑批量推送repo文件。NFS版本协商、exports参数兼容性这些,都可能在新旧客户端上表现不一致。

第三,仓库机本身要纳入监控。NFS共享是单点,仓库机挂了,所有客户端安装都会中断。脚本跑完发日志、设置好磁盘空间告警,比什么都重要。不要等到客户端反馈装不了软件,才发现仓库机磁盘满了。

最后聊点维护体会。这套方案我自己用了很久,最大的感受是把“软件源”和“分发网络”两个问题拆开之后,排障逻辑特别清晰:源出问题就查reposync和createrepo,分发出问题就查exports和防火墙。另外,客户端/etc/yum.repos.d/local.repo里我强烈建议写上源说明,注明仓库机IP、挂载路径和同步周期。三年后你或接手的同事再看到这个文件,会感谢你留下的这行注释。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进