自建私有 PyPI:用 pypiserver + uv 把内部库理顺的实战笔记
古董级程序员,大厂出来后一直在创业公司,现在还在一线写 AI 相关的后端。更完整的技术记录写在微信公众号「字与码」:踩过的坑、换技术栈时的权衡、和这些年绕过的弯路,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
文中出现的具体服务名、包名、仓库名等均为脱敏后的示例,与线上真实命名无对应关系,仅便于说明流程。
内部代码多了以后,总会遇到「同一份工具库被三四个服务引用」的情况。最省事的做法是 git submodule,但两年下来,submodule 的副作用就暴露出来了:构建镜像要 git、CI 要多 checkout 一层、本地 clone 还要记得 --recursive、换分支时还容易让 submodule 停在一个奇怪的 SHA 上。
这篇把我们把公共库从 submodule 迁到私有 PyPI 的过程拆开写一遍,包括:
pypiserver怎么装、怎么配认证;- PEP 440 的版本号写法,选哪种才不会把
uv/pip的解析器搞糊涂; - 发布端:
setuptools-scm+ 嵌入_version.py,解决 Docker 里没git也能编辑安装的问题; - 消费端:
uv接私服的正确姿势,以及日常用得上的几个进阶命令; - Docker BuildKit secret 的两种写法、CI 把凭证喂进 build 时常见的失败模式。
下面这张图就是整条链路的缩影——发布端把包推上去,中间是一台带认证的 pypiserver,消费端按 uv.lock 拉版本:

为什么不再用 submodule
先把两种做法放在同一标准下比较。
如果算的是「公共库发了一版新的,下游各自要在自己的仓库里跟上」,无论 submodule 还是私服,都是 N 个下游、N 次提交,私服不会把 N 变成 1。lock 该更新还是要更新,PR 该开还是要开。不把这层说透,容易让人误以为迁到私服就不用再动依赖;实际上要动还是要动,差别在于改动落在哪、和谁绑定。
版本最好只跟 lock 走,不要跟「谁今天有没有拉公共库」走。 Submodule 也是钉到一个 SHA,但它和日常 pull、切分支、submodule update 缠在一起;人多习惯杂,两个环境指着不同 SHA,有时是故意保留旧版,有时是忘了同步,事后很难一眼分清。私服这边,索引里是已经打好的 wheel/sdist;运行时到底装哪一版,只看当前分支的 uv.lock,以及 CI 有没有用 --frozen 严格按 lock 安装。没有发布新版本、没有改 lock,环境不会因为上游多推了几个 commit 就自己变掉。
排查问题时,版本号加 lock 的 diff 比两行 submodule 指针好读。 两个服务行为不一致,翻 PR:shared-lib 从 1.0.1.post… 升到 1.0.5.post0 一眼能看清。换成两个 submodule 的 hash 差一截,中间夹了哪些变更,还要各自进子仓库查 git log。
本地、CI、镜像,尽量装同一份发布产物。 Submodule 是「目录即依赖」;镜像里少 copy 一层、或者 editable 安装和实际 wheel 内容不一致,本地和线上可能早就不是同一套文件。私服是一条线:构建产物进索引,uv sync 拉的就是那份包,少一层「装的究竟是源码树还是打好的包」的猜测。
上游误推了不兼容改动,不该让还没准备好的下游自动跟上。 公共库默认分支上先合并了破坏性变更,submodule 只要有人更新指针就可能立刻用上。私服路径下,旧环境仍按旧 lock 解析;要用新版,得有人发布新版本,再有人执行 uv lock --upgrade-package 并把变更合进主干——门槛在可审查的 PR 里,而不是跟着别人的推送节奏走。
所以「下游自己决定何时升级」,核心不是省几次操作,而是发布(产物进入索引)与采纳(改 lock、合并代码)分开了:升级次数不会少,但每次升级在 diff 里有据可查,也少几种「到底是谁的 submodule 停在哪个 SHA」扯不清的情况。
先把 pypiserver 跑起来
我们用 pypiserver 作为私服。它的定位很克制:一个 WSGI 应用,托管本地目录里的 .whl / .tar.gz,支持 htpasswd 基础认证,够团队内部用。想要更花哨的(细粒度权限、审计、前端 UI)可以上 devpi 或 Nexus,但代价是运维复杂度也跟着涨。
一份够用的 docker-compose
在一台内网机器上建目录:
mkdir -p ~/pypi/{auth,packages}
cd ~/pypi
用 htpasswd 生成一个账号(系统没这命令就 apt install apache2-utils / brew install httpd):
htpasswd -sc auth/.htpasswd uploader
# 输入并确认密码
-s 走 SHA1(passlib 能读),-c 是「创建」——第二次加用户别带 -c,否则会把文件覆盖。
然后写 docker-compose.yml:
services:
pypiserver:
image: pypiserver/pypiserver:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./auth:/data/auth:ro
- ./packages:/data/packages
# 上传/下载/列表 三类操作都要认证;想让 pull 对内网匿名就去掉 download,list
command: >
run -P /data/auth/.htpasswd
-a update,download,list
--hash-algo sha256
/data/packages
起服务:
docker compose up -d
curl -u uploader:**** http://<host>:8080/simple/
能看到 HTML 索引页就算装完了。文件直接落在 packages/ 目录,需要迁移/备份时整个目录打包就行——这点对运维特别友好。
关于 HTTPS
内网直接开 8080 通常是 OK 的,但不要把这个端口直接暴露到公网:htpasswd 的账号密码会以 Authorization: Basic 的形式走 HTTP。真的需要外网访问,要么放在 VPN 后面,要么前面挂一个 Nginx/Traefik 做 TLS 终结,pypiserver 官方 repo 的 docker-compose.yml 里有一份基于 Traefik + Let’s Encrypt 的示例可以直接改。
如果你像我们一样暂时只跑 HTTP,还要把 trusted-host 配进客户端,不然 uv/pip 会因为「不是 HTTPS」而拒绝连。后面消费端那节会讲。
版本号:PEP 440 够用就好
发包的前一步是想清楚「版本号怎么排序」。Python 这边的权威是 PEP 440,中文社区里经常被忽略,但踩坑几乎都踩在这上面。简化成一张速查表:
| 写法 | 含义 | 什么时候用 |
|---|---|---|
1.2.0 | final release | 正式发布 |
1.2.0.dev3 | 开发版,排在 1.2.0 之前 | CI 上每次 main 构建预发布 |
1.2.0a1 / 1.2.0b2 / 1.2.0rc1 | alpha / beta / rc | 需要对外暴露「这是预发」时 |
1.2.0.post1 | post-release,排在 1.2.0 之后,语义上是「同一版本的元数据修正」 | 修 README、补打 wheel 这类不改代码的情况 |
1.2.0+internal.4 | local version | 本地 / 下游打补丁,不能传 PyPI(含 pypiserver 也建议别上传) |
两条容易搞错的规则:
- post-release 不是「补丁版」。官方打包指南 2026 年 1 月更新过一条:post-release 不应用来发布 bug fix,bug fix 应该发一个新的 final release(比如
1.2.1)。把.postN当「临时补丁」用,版本解析器能接受,但语义上会骗下游「这只是 metadata 修正」,长期会乱。 - epoch(
1!1.0)已被官方标记为 discouraged。它原本是给「版本体系换轨道」用的(比如从日历版切换到语义版),但同一份指南建议优先改成「一个足够大的数字」避免混淆,比如直接跳到100.0。除非你有历史包袱,别动N!这个语法。
我们内部的约定:主库用 MAJOR.MINOR.PATCH;CI 上 main 分支每次构建自动追加 .postN(N 是距离上一次 tag 的 commit 数),配合 setuptools-scm 自动生成。写 pyproject.toml 的 requires 时就用标准 PEP 440 区间:
[project]
dependencies = [
# 兼容 1.x,接受 >=1.2,<2
"shared-lib ~= 1.2",
# 或更严:只允许 1.2.x
"cli-tool ~= 1.2.0",
]
~= 这个操作符很多人没用过,我推荐它:比 >=1.2,<2 短,语义也更明确。
发布端:setuptools-scm + 嵌入 _version.py
setuptools-scm 能从 git tag 自动推导版本号,用过的都说香。但它在 Docker 或上游作为「依赖」被编辑安装时,会去跑 git describe——镜像里没装 git 就直接炸。我们最初就踩了这个雷:
command git missing: [Errno 2] No such file or directory: 'git'
setuptools-scm was unable to detect version for /app/shared-lib.
解决办法不是在镜像里装 git——那只是绕过问题——而是在源码发布物里把版本号写进一个文件。setuptools-scm 支持 write_to:
[tool.setuptools_scm]
write_to = "shared_lib/_version.py"
fallback_version = "0.0.0+nogit"
然后在 shared_lib/__init__.py 里:
try:
from ._version import version as __version__
except ImportError:
__version__ = "0.0.0+nogit"
打包时,_version.py 会被写入并随 wheel 一起发出去。下游(无论是 pypiserver 上的 sdist 还是作为 submodule 的源码)在不跑 git 的情况下也能拿到正确版本号。fallback_version 是保底的兜底值,用于本地零 git 的边缘情况。
发布脚本我们写了一个 scripts/publish_private.py,核心流程是:
# 1. git fetch --tags --force 拉最新 tag(忽略失败,走 fallback)
# 2. python -m build --wheel --sdist
# 3. twine upload --repository-url http://<host>:8080/
# -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/*
CI 里把它接到 release-pypi.yml 上(两种触发:push: tags: v* 自动走,workflow_dispatch 手动触发)。这里有一个容易漏的坑:手动触发而没指定 version 时,我们之前默认跳过了「上传 wheel」,结果打完包没上去。修正方式是把「是否上传 wheel」和「是否打 GitHub Release」分成两个开关:
- name: Release metadata
id: meta
run: |
echo "do_upload=true" >> $GITHUB_OUTPUT
if [ -n "${{ inputs.version }}" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "do_github_release=true" >> $GITHUB_OUTPUT
else
echo "do_github_release=false" >> $GITHUB_OUTPUT
fi
「上传 wheel」永远为真,「打 GitHub Release」只在有 tag 时为真。
消费端:uv 接私服的正确姿势
客户端这一侧,我们全面用 uv。uv 相对 pip 的优势在这个场景尤其明显:内建 lock、按项目管理 venv、对多 index 的支持清晰。
pyproject.toml 怎么写
先在 [tool.uv.sources] 里指定某个包来自哪个 index:
[project]
dependencies = [
"shared-lib ~= 1.2",
"cli-tool ~= 1.2",
]
[tool.uv.sources]
shared-lib = { index = "internal" }
cli-tool = { index = "internal" }
[[tool.uv.index]]
name = "internal"
url = "http://pypi.internal.example.com:8080/simple"
explicit = true
[[tool.uv.index]]
# 公共包从镜像站装,走默认 index
url = "https://mirrors.aliyun.com/pypi/simple/"
default = true
两条小细节:
explicit = true:意思是这个 index 只给[tool.uv.sources]里显式指过的包用。否则 uv 会把内网地址也拿去解析公共包,既慢又可能拿到你不想要的东西。- HTTP 私服要配
UV_INSECURE_HOST:uv默认不让你走 HTTP。本机开发时直接export UV_INSECURE_HOST=pypi.internal.example.com,或在pyproject.toml里加allow-insecure-host。
凭证走环境变量:
export UV_INDEX_INTERNAL_USERNAME=uploader
export UV_INDEX_INTERNAL_PASSWORD=****
变量名规则是 UV_INDEX_<INDEX_NAME_UPPER>_USERNAME/PASSWORD,按你 [[tool.uv.index]] 里的 name 取。
下游仓库的依赖全景
我们内部最终的拓扑大概长这样——一个公共库,四五个消费方,一条「publish」回路:

uv.lock 把每一次升级都钉成一个明确的版本记录,谁升到哪个版本、什么时候升的,diff 一看便知。
uv 进阶:日常真正会用到的几个
用了半年多下来,踩出来的几个常用姿势:
uv sync --frozen:只按 lock 安装,不重新解析依赖。CI 和 Docker 里必须用这个,避免每次构建都跑依赖求解。uv sync --extra dev:装 optional-dependencies 里标记为dev的 extras。我们把 test/pytest/ruff 之类放devextra 里,生产镜像不装。uv lock --upgrade-package shared-lib:只升这一个包,其他依赖保持原样。新版公共库发上私服之后,下游就跑这条命令升到最新,而不是粗暴uv lock --upgrade。uv add 'shared-lib~=1.3':一步完成「加依赖 + 更新 lock」,比手改pyproject.toml再跑 lock 省事。uv run -- python -m myapp.cli:不激活 venv 直接跑,uv会自动把项目 venv 当解释器。CI 里特别好用。uv tool install cli-tool:把私服上的 CLI 包装成 isolated tool(类似pipx),全局一个入口命令,内部工具最爱这招。
还有一个容易忽略的点:uv sync 优先使用项目目录下的 .venv,但如果你预先设了 VIRTUAL_ENV 指向另一个 venv,它会尊重那个值。我被这个坑过——uv sync 执行完去全局 venv 里 uv pip list,当然看不到更新。真想让 uv 用当前激活的 venv,加 --active;真想让它回到项目 venv,就 unset VIRTUAL_ENV 再 sync。
Docker 里的 BuildKit secret
镜像构建要跑 uv sync,意味着构建上下文里必须能拿到私服的用户名/密码。最糟糕的做法是 ENV PYPI_PASSWORD=... 或 ARG——这俩都会留在镜像层里,任何拉下来的人 docker history 就能看到。
正确做法是 BuildKit 的 --mount=type=secret:凭证以临时文件的形式出现在构建期 /run/secrets/<id>,构建结束即销毁,不落入任何 layer。
Dockerfile 怎么写
我们把 uv sync 这一步抽成脚本 docker-uv-sync.sh,让所有服务镜像共用:
#!/bin/sh
set -eu
u=$(tr -d '\n\r' < /run/secrets/pypi_user)
p=$(tr -d '\n\r' < /run/secrets/pypi_pass)
install -d -m 700 /root
printf 'machine pypi.internal.example.com\nlogin %s\npassword %s\n' "$u" "$p" > /root/.netrc
chmod 600 /root/.netrc
export UV_INDEX_INTERNAL_USERNAME="$u"
export UV_INDEX_INTERNAL_PASSWORD="$p"
export UV_INSECURE_HOST=pypi.internal.example.com
cd /app
uv sync ${UV_SYNC_ARGS:---frozen --no-dev}
rm -f /root/.netrc
Dockerfile 里的调用长这样:
COPY docker/docker-uv-sync.sh /app/docker-uv-sync.sh
RUN chmod +x /app/docker-uv-sync.sh
COPY pyproject.toml uv.lock /app/
RUN --mount=type=secret,id=pypi_user \
--mount=type=secret,id=pypi_pass \
/app/docker-uv-sync.sh
src= 还是 env=:两种写法的区别
docker build --secret 有两种源:
--secret id=pypi_user,env=PYPI_USERNAME:从当前进程的环境变量读--secret id=pypi_user,src=/tmp/pypi_user:从文件读
表面上 env= 更干净,但在部分较旧的 BuildKit / Docker 版本上,env= 的传递链有兼容问题——我们在阿里云 ECS 上就遇到过 cannot open /run/secrets/pypi_user: No such file。稳妥做法是在部署脚本里把环境变量落到临时文件,再用 src= 喂进去:
PYPI_USER_FILE=$(mktemp)
PYPI_PASS_FILE=$(mktemp)
printf '%s' "$PYPI_USERNAME" > "$PYPI_USER_FILE"
printf '%s' "$PYPI_PASSWORD" > "$PYPI_PASS_FILE"
trap 'rm -f "$PYPI_USER_FILE" "$PYPI_PASS_FILE"' EXIT
docker buildx build \
--secret id=pypi_user,src="$PYPI_USER_FILE" \
--secret id=pypi_pass,src="$PYPI_PASS_FILE" \
-f Dockerfile \
-t myapp:latest .
一张图看懂凭证流
凭证从「GitHub Secrets」到「镜像里的 uv sync」中间经过 4 段转发,其中任何一环掉链子都会让 build 失败。画出来大概是这样:

CI:双通道各自的坑
我们的 CI 分两种典型场景:
场景 A:在 GitHub runner 上 build & push 镜像。 直接用 docker/build-push-action@v6,把 secrets 通过它的 secrets: 输入注入即可:
- uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
tags: registry.example.com/myapp:latest
secrets: |
pypi_user=${{ secrets.PYPI_USERNAME }}
pypi_pass=${{ secrets.PYPI_PASSWORD }}
场景 B:SSH 到生产机上本地 build(我们用 appleboy/ssh-action)。SSH 默认不转发自定义环境变量,必须显式声明:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PYPI_USERNAME,PYPI_PASSWORD
script: |
cd /opt/myapp
./scripts/deploy.sh
envs: 这行特别容易漏——漏了之后 deploy.sh 里 $PYPI_USERNAME 就是空的,docker build 那边立刻报「缺少凭证」。我们被这个坑卡过半个小时,最后发现日志里早就有 缺少 PYPI_USERNAME / PYPI_PASSWORD,只是第一眼没看到。
另一个容易忽略的地方:生产机本身不要把 PyPI 凭证写进 ~/.bashrc 或 profile。凭证只在 CI 临时注入,构建完即消失。万一机器被接管,攻击者最多拿到一个没有密钥的部署脚本。
几个从生产里学到的坑
这几条是按「吃过几次亏」的顺序排的。
1. COPY . . 会把本地 .venv 带进容器。 你本地 uv sync 生成了 .venv/,然后 COPY 覆盖了容器里刚装好的 .venv,导致 python 可执行路径对不上。解决办法:仓库根必须有一个 .dockerignore,排除 .venv/、__pycache__/、.git/、前端 build 产物等。
2. 在镜像里做 uv sync 的兜底。 我们有一个 base 镜像预装公共依赖,然后子镜像 FROM base。但 base 的层缓存可能比子镜像的 uv.lock 旧,子镜像再做一次 uv sync --frozen 是很划算的兜底——版本没变就是 no-op,版本变了能立刻追上。不加这步,偶尔会出现「base 里是 1.2.0、子镜像 lock 里是 1.2.1,但运行时用的是 base 的 1.2.0」这种幽灵 bug。
3. 验证 wheel 时带上 --no-deps。 CI 里发布完 wheel,为了确认 wheel 本身没坏,会走一遍:
pip install --no-deps "dist/*.whl"
python -c "import importlib.metadata as m; print(m.version('mypkg'))"
不加 --no-deps 的话,pip 会去公网解析 wheel 的所有依赖,而你的私有依赖在公网 PyPI 上当然找不到,直接报 ERROR: Could not find a version that satisfies the requirement shared-lib。加了之后只验本包能否正确安装与 import,要快得多,也不会误判。
4. pypiserver 支持覆盖上传,但不推荐。 加 --overwrite 虽然方便返工,但一旦下游有环境已经装了旧的同名版本,缓存里的 hash 会和服务器对不上,uv sync 会直接失败。正解是每次改 bug 都发新版号,即使只动了一行 README。
5. 私服上要留好备份。 packages/ 目录里的 wheel 文件就是全部家当,我们把它和 htpasswd 一起每天 rsync 到内部 NAS。之前试过一次「clean 重装 pypiserver 容器」,忘记 bind mount 路径,差点把整个内部包版本历史搞没——幸好备份还在。
小结
这套组合——pypiserver + setuptools-scm + uv + BuildKit secret——不是什么时髦技术,把它们串起来写在一起,是因为团队迁移到私服的过程中,每一个环节都会各自冒出一两个坑,单独搜都能找到答案,但合在一起跑就容易互相遮住。这篇希望能让下一个接手的人少走点弯路。
如果你所在的团队还在用 submodule 管公共代码,上面那张依赖图是一个值得考虑的目标状态:一个私服把发布者和消费者解耦,每个下游按自己的节奏升级,uv.lock 把谁在用什么版本写得清清楚楚。配套做完之后,修一行公共代码从「连夜改四个仓库」变成「打个 tag,消费方按需升」。
参考文档:
- PEP 440:Version Identification and Dependency Specification
- Python 打包指南的版本号章节:Versioning
- pypiserver 官方 repo(README +
docker-compose.yml) - uv 文档 里
sources、index、sync --frozen的章节 - Docker BuildKit build secrets 文档