自建私有 PyPI:用 pypiserver + uv 把内部库理顺的实战笔记
原创 · 约 39 分钟阅读 · 阅读 --

自建私有 PyPI:用 pypiserver + uv 把内部库理顺的实战笔记

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在还在一线写 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-lib1.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.0final release正式发布
1.2.0.dev3开发版,排在 1.2.0 之前CI 上每次 main 构建预发布
1.2.0a1 / 1.2.0b2 / 1.2.0rc1alpha / beta / rc需要对外暴露「这是预发」时
1.2.0.post1post-release,排在 1.2.0 之后,语义上是「同一版本的元数据修正」修 README、补打 wheel 这类不改代码的情况
1.2.0+internal.4local version本地 / 下游打补丁,不能传 PyPI(含 pypiserver 也建议别上传)

两条容易搞错的规则:

  1. post-release 不是「补丁版」。官方打包指南 2026 年 1 月更新过一条:post-release 不应用来发布 bug fix,bug fix 应该发一个新的 final release(比如 1.2.1)。把 .postN 当「临时补丁」用,版本解析器能接受,但语义上会骗下游「这只是 metadata 修正」,长期会乱。
  2. 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 接私服的正确姿势

客户端这一侧,我们全面用 uvuv 相对 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

两条小细节:

  1. explicit = true:意思是这个 index 只给 [tool.uv.sources] 里显式指过的包用。否则 uv 会把内网地址也拿去解析公共包,既慢又可能拿到你不想要的东西。
  2. HTTP 私服要配 UV_INSECURE_HOSTuv 默认不让你走 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」回路:

私有 PyPI 下的模块依赖图

uv.lock 把每一次升级都钉成一个明确的版本记录,谁升到哪个版本、什么时候升的,diff 一看便知。

uv 进阶:日常真正会用到的几个

用了半年多下来,踩出来的几个常用姿势:

  • uv sync --frozen只按 lock 安装,不重新解析依赖。CI 和 Docker 里必须用这个,避免每次构建都跑依赖求解。
  • uv sync --extra dev:装 optional-dependencies 里标记为 dev 的 extras。我们把 test/pytest/ruff 之类放 dev extra 里,生产镜像不装。
  • 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 失败。画出来大概是这样:

BuildKit secret 从 GitHub 到 uv sync 的流向

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,消费方按需升」。

参考文档: