为什么要自建内网 APT 仓库
如果你手上有三台以上的服务器,就一定经历过这种场面:一批机器要装同一个软件,于是你在每台机器上敲一遍 apt update && apt install -y 某个包。表面上看没问题,直到有一天官方源抖动、或者某个包被上游下架、或者你要装的是一份自己编译的 deb——这时候你才发现,把"装软件"这件事完全托付给公网第三方,是一件多么不踏实的事。
自建 APT 仓库解决的正是这类问题。它把 deb 包集中托管在一台你完全掌控的机器上,其他服务器把它当成一个普通的 apt 源来用。带来的直接收益有四条:
- 版本可控:你决定服务器上跑哪个版本的软件,而不是被上游"最新即最好"推着走。生产环境锁版本,是减少"升级后突然跑不起来"的第一道防线。
- 离线可用:内网机器、被墙的机器、安全组只放行内网的机器,照样能装包。
- 速度快:内网千兆/万兆拉包,比公网快一两个数量级,批量部署时体验差别巨大。
- 合规与审计:每个 deb 都经过你的服务器,装了什么一目了然,也方便做签名校验与留存。
本文用 reprepro 来搭,原因是它足够轻、足够透明——一个本地目录 + 一个命令,所有元数据都是明文文件,出了问题用 ls 就能看懂。相比 aptly 那种"藏一个数据库在后面"的方案,reprepro 更适合个人站长和小团队。
先搞清 APT 仓库到底由什么构成
很多人一上来就跑命令,结果配完了 apt update 报一堆看不懂的错。原因是没有先建立"仓库由哪些文件组成"的心智模型。一个最小的、能被 apt 认出来的仓库,包含三层东西:
- 包的实体:也就是
.deb文件本身,放在某个目录下(通常按pool/组织)。 - 索引(metadata):apt 真正读的不是 deb,而是索引文件。核心是
Packages.gz(未压缩叫Packages),它列出了每个包的名字、版本、依赖、文件名、校验和。apt 靠它决定"有哪些包可装、装哪个版本、要去哪个路径下载"。 - Release 与签名:
Release文件描述了某个发行版/组件/架构组合下所有索引的校验和。它旁边的Release.gpg(或InRelease)是它的 GPG 签名。apt 之所以信任一个源,就是因为能拿本地信任的 GPG 公钥验证这个签名。
这三层对应的正是 apt 源配置文件里那行的结构:
deb [signed-by=/etc/apt/keyrings/myrepo.gpg] http://192.168.1.10/debian bookworm main
拆开看:deb 是类型,[signed-by=...] 指定用哪个公钥验签,URL 是仓库根路径,bookworm 是发行版代号(distribution),main 是组件(component)。apt 会去请求 URL/dists/bookworm/Release,再根据 Release 里记录的路径去找 URL/dists/bookworm/main/binary-amd64/Packages.gz。任何一个环节对不上(目录名拼错、组件名不一致、签名验不过),都会在 apt update 阶段就直接失败。
理解这个结构后,reprepro 做的事情就很好理解了:你把 deb 丢给它,它负责按这套目录规范和索引格式自动生成文件,让你不用手工去 gzip 和写校验和。
安装 reprepro 并初始化一个仓库
在作为仓库服务器的机器上(Debian/Ubuntu 均可):
apt install -y reprepro gnupg
准备一个目录作为仓库根,比如 /srv/aptrepo,并给它建好对应的发行版目录结构。reprepro 需要一个配置文件 conf/distributions 来描述"有哪些发行版、每个发行版的组件和架构"。先建目录:
mkdir -p /srv/aptrepo/conf mkdir -p /srv/aptrepo/db
然后写 /srv/aptrepo/conf/distributions:
Origin: MyCompany Label: MyCompany Internal Codename: bookworm Architectures: amd64 Components: main Description: Internal APT repository for Debian bookworm SignWith: internal-repo-key
这里每个字段都有实际含义,不要乱写:
Codename必须和你客户端sources.list里的发行版代号完全一致(bookworm不是Bookworm)。这是最常出错的地方之一。Components列出的组件名,必须和客户端行里的组件名一致。Architectures写你要托管的架构。只写amd64就不会生成 arm64 的索引,客户端如果是别的架构会提示"找不到包"。SignWith指定签名用的 GPG 密钥 ID 或名称,下面会生成。
有多个发行版(比如同时服务 bookworm 和 bullseye),就在这个文件里写多段,每段用空行隔开。
生成 GPG 密钥并让仓库签名
apt 默认要求仓库签名,否则会报 NO_PUBKEY 或者直接拒绝。所以先给仓库造一把专用的 GPG 密钥,不要用别人的、也不要用服务器的 root 主密钥——一把专钥专用,泄露了也好撤换。
gpg --batch --gen-key <<EOF %no-protection Key-Type: RSA Key-Length: 4096 Name-Real: internal-repo-key Name-Email: repo@example.internal Expire-Date: 0 %commit EOF
%no-protection 表示私钥不设密码,这样 reprepro 在 cron 里自动签名时不会卡在交互输入上。代价是私钥以明文存在 ~/.gnupg,所以这台机器本身的安全等级要提高:限制登录、把仓库写权限只给专门的用户。如果在意这一点,可以给私钥设密码,再用 gpg-agent 缓存;但对个人站长来说,%no-protection + 机器加固是更实际的取舍。
生成后确认密钥存在,并记住它的指纹:
gpg --list-secret-keys --keyid-format LONG
输出里那串 40 位十六进制就是指纹。把 conf/distributions 里的 SignWith 改成这把密钥的 ID 或 Name-Real(internal-repo-key),reprepro 就能找到它。为了以后换机器方便,建议导出私钥备份:
gpg --export-secret-keys internal-repo-key > /root/repo-key.sec gpg --export --armor internal-repo-key > /srv/aptrepo/repo-key.pub
后者(公钥)后面要拷到每台客户端,前者(私钥)离线保存,别和仓库一起放在被备份的目录里。
把 deb 包加进仓库
reprepro 添加包的基本命令非常直白:
cd /srv/aptrepo reprepro --basedir /srv/aptrepo includedeb bookworm /path/to/foo_1.2.3_amd64.deb
它会自动把 deb 复制到 pool/ 下合适的位置,更新 Packages 索引,重新生成 Release 并签名。成功后你 ls 一下 /srv/aptrepo/dists/bookworm/,应该能看到 Release、InRelease、main/binary-amd64/Packages.gz 这一整套文件。
几个实用变体:
# 移除某个包 reprepro --basedir /srv/aptrepo remove bookworm foo # 一次加多个(用通配) reprepro --basedir /srv/aptrepo includedeb bookworm /tmp/debs/*.deb # 查看仓库里有哪些包 reprepro --basedir /srv/aptrepo list bookworm
如果你想托管的 deb 是从别的机器上 apt download 下来的,那些包往往带着版本号后缀。reprepro 会根据 deb 内部的 control 信息解析出包名与版本,不用你改名。
用 Nginx 把仓库发出去
仓库是纯静态文件,用 Nginx 托管最合适。一个最小配置:
server {
listen 80;
server_name repo.example.internal;
root /srv/aptrepo;
autoindex on;
# apt 会请求 gz 索引,确保不被错误地当成文本改写
types {
application/x-gzip gz;
}
location / {
try_files $uri $uri/ =404;
}
}autoindex on 只是为了浏览器里能直接看目录,apt 实际不用它。真正要注意的是不要让 Nginx 去修改这些文件的响应体(比如某些压缩或改写模块),否则校验和会对不上,apt 会报 Hash Sum mismatch。如果前面套了 CDN,也务必确认索引文件没有被缓存到过期——镜像源索引更新后客户端拿到的必须是新的 Release,否则签名与索引不匹配。
客户端怎么接进来
在每台客户端上,先把仓库公钥放进"可信密钥"目录。推荐用独立的 keyrings 目录,而不是老式的 apt-key add(apt-key 已被 Debian 标记为废弃,会污染全局信任):
mkdir -p /etc/apt/keyrings gpg --dearmor < repo-key.pub > /etc/apt/keyrings/myrepo.gpg chmod 644 /etc/apt/keyrings/myrepo.gpg
然后写源文件 /etc/apt/sources.list.d/myrepo.list:
deb [signed-by=/etc/apt/keyrings/myrepo.gpg] http://repo.example.internal/ bookworm main
注意 URL 结尾最好带斜杠,组件名和发行版代号要和仓库配置严格对应。接着:
apt update apt-cache policy 你的包名
apt-cache policy 的输出会显示每个可用版本来自哪个源。如果只看到官方源、没有你自建的源,说明源文件没被读到或发行版/组件名对不上。如果看到源但报签名错误,就去查 keyring 路径和公钥是否正确。
几个最容易踩的坑
自建仓库的坑,九成出在"名字对不上"和"签名"这两件事上,逐一列清楚:
- 发行版代号大小写/拼写不符:客户端写
Bookworm、仓库配置写bookworm,apt 会去请求一个不存在的dists/Bookworm/,报 404。这个错和"仓库里没这个包"表现不同,注意区分。 - 组件名不一致:配置里是
main,客户端写stable,同样 404。 - 架构不匹配:你只导入 amd64 包并只声明了 amd64 架构,客户端是 arm64 就会说找不到。
- 公钥没换行/没 dearmor:直接把 ASCII-armored(
-----BEGIN PGP-----那串)公钥当二进制用,或反之,apt 直接报无法解析。记住客户端要的是gpg --dearmor后的二进制格式。 - 私钥密码导致签名失败:cron 里跑 reprepro 时若私钥有密码且没有 agent,签名会静默失败或卡住,仓库索引停留在旧版本。用
%no-protection或配好 gpg-agent 可避免。 - Hash Sum mismatch:多半是中间有代理/CDN 缓存了旧索引,或者拷贝仓库时漏了文件。清掉客户端
/var/lib/apt/lists/里的缓存重试,并检查仓库文件是否完整。 - Nginx 目录权限:Nginx 的 worker 用户(通常是
www-data)必须有读权限进到/srv/aptrepo每一级目录。用namei -l /srv/aptrepo/dists/bookworm/Release逐级查 x 位,比猜权限快得多。
自动化与日常维护
仓库搭好后,把它纳入日常运维的关键是"导入由脚本做、签名由 cron 保证"。一个简单的导入脚本骨架:
#!/bin/bash
set -euo pipefail
REPO=/srv/aptrepo
DIST=bookworm
STAGING=/var/tmp/debs
# 校验每个 deb 都是合法的 deb(避免半截下载的文件进仓库)
for d in "$STAGING"/*.deb; do
dpkg-deb --info "$d" >/dev/null
done
reprepro --basedir "$REPO" includedeb "$DIST" "$STAGING"/*.deb
echo "已导入 $(ls "$STAGING"/*.deb | wc -l) 个包"dpkg-deb --info 会在文件损坏时非零退出,配合 set -e,能让一个坏文件挡住整批导入而不是混进仓库。这是很值得加的一步——一个损坏的 deb 进了仓库,所有客户端 pull 时都会受影响。
日常检查清单可以固定成三条:
reprepro list bookworm确认包清单符合预期。curl -sI http://repo.../dists/bookworm/Release或检查Release里的Date字段,确认索引是新的。- 在一台闲置客户端上跑
apt update && apt install --dry-run 你的包做一次冒烟,确保端到端可用。
什么时候该用它、什么时候别用
自建 APT 仓库不是所有场景都值得。它适合:需要锁定版本、需要离线/内网分发、需要在多台机器上批量部署同一批自定义 deb 的场合。它不适合:单台机器、只装一两个包、且公网源稳定的场景——这时候你只是在给自己增加一个需要维护的组件,反而多了一个可能出错的地方。
判断标准很简单:如果你每次都要重复"在多台机器上装同样的包"这个动作,且对版本或网络有要求,那就值得建;如果只是偶尔装一下,直接 apt install 更省事。技术选型的第一条永远是"解决真实痛点",而不是"看起来很专业"。