1. CI/CD 到底是什么以及它解决的是什么问题CI/CD 在 Python 项目里说白了就是把你“手工提交代码、跑测试、打包、发布”这一串动作变成自动化的流水线。持续集成CI管的是代码每次变更后自动校验依赖能不能装、测试过不过、代码风格有没有跑偏持续部署CD管的是校验通过之后自动把产物推到一个可以运行的环境里让用户或系统拿到最新版本。为什么要强调“for Python”因为 Python 项目有它特殊的痛点依赖解析和锁定的坑、多版本解释器兼容性、虚拟环境管理、构建 wheel 包、发布到私有或公网仓库……这些环节如果不固定流程靠人肉去做几乎必然出错。典型的翻车场景是某天下午改了一行配置本地测试莫名其妙通过了部署时在服务器上装依赖又把锁文件弄乱最后线上跑的是个没引入新依赖但行为已变的“伪新包”。这类问题不是写码能力问题而是流程问题CI/CD 正是用来堵住这类流程漏洞的。这篇文章面向的是已经能独立写 Python 代码、但还没有系统性搭建过自动化流水线的开发者也适合小团队里负责工程效率的同学。我会从方案选型讲起再到具体的流水线设计、部署实施最后把常见的坑挨个摆出来全程给可复现的示例你甚至可以直接把配置改改拿走用。2. 流水线设计Python 项目自动化的关键拆解2.1 触发机制与流程结构我在设计流水线时第一件事就是明确“什么时候跑”。最常见的是 push 触发和 pull request 触发。push 触发适合直接维护主干分支的场景但更推荐的是 PR 触发开发把分支推到远端流水线立刻跑一遍全量检查把结果反馈在 PR 里没通过就不能合入。这等于把“质量门禁”前移到了代码评审之前审代码的人不用一边看 diff 一边担心“这分支测试到底过没过”。流水线的结构通常分三段安装与静态检查、自动化测试、构建与发布。安装与静态检查负责把环境准备出来跑格式和类型检查自动化测试负责单元测试、集成测试必要时再做覆盖率统计构建与发布负责在验证通过后打包产物并推送到目标环境。这三段串行执行的好处是逻辑清晰、排错容易前面挂了后面直接不跑能节省大量等待时间。2.2 Python 依赖管理在流水线里的两种姿势依赖管理是整个 Python 流水线最容易出问题的地方。我见过太多项目直接写一个 requirements.txt里面全是裸版本号比如requests2.25。这种写法在流水线里就是定时炸弹某天依赖库发了一个不兼容的新版CI 重新解析时直接装出新环境测试全红原因是“两天前还是好的”但实际上谁也没改代码。合理的做法有两条路线一是用 pip-tools 或 uv 生成锁文件把每个依赖的精确版本和传递依赖哈希全部固定在 lock 文件里二是用 Poetry它本身就带了 lock 机制。无论是哪种目的都一样让 CI 里每次安装依赖得到的环境与本地开发和线上生产一致可复现。我建议小型项目直接用 uv它比 pip 快得多而且自带锁文件已经有 Poetry 在用的团队也没必要强迁诗意的语法已经足够舒服。依赖安装这一步有两个实操要点一是必须显式声明虚拟环境CI 里通常会把pip install指定到项目虚拟环境路径二是缓存依赖下载目录。不缓存的话每次跑流水线都从 PyPI 拉全量依赖一个中等规模项目动辄多等一两分钟缓存之后能压到十几秒整体收益非常明显。2.3 质量关卡代码风格、类型检查与自动化测试Python 的动态类型让它开发很爽但也让“接错参数”这种错误在运行期才炸。所以我在流水线里固定的第二道关卡是类型检查。Mypy 基本是标配而basedpyright基于 Pyright也值得注意它支持更多新版语法。这里的一个建议是先让流水线只跑新增文件的检查或者逐步增量收紧否则历史债务会让第一个月无比痛苦。代码风格检查现在基本就是 Ruff 的天下。Ruff 比当年的 Flake8 快好几个数量级而且内置了格式化器。我一般把 lint 和 format 检查合在同一个 job 里执行省去一次环境启动的等待。注意Ruff 的 rules 别一上来就全开建议先用E4、E7、E9和F这类“错误级”规则起步后续根据团队偏好逐渐追加。自动化测试层面pytest 是绕不开的核心。关键的不仅是“跑测试”而是三层测试怎么配单元测试要快调用外部服务的管道要带 mock集成测试开始连数据库、Redis 这类基础设施CI 里用服务容器比如 GitHub Actions 的services配置端到端测试可以放到部署后单独跑。不同测试的定位不同对一个中小型项目来说单元测试 核心路径集成测试已经能覆盖大部分风险。还有覆盖率。覆盖率不是越高越好但也不能没有。我的做法是在流水线中设定一个阈值比如fail_under 80低于就直接失败。但不要对每个模块都一刀切容易导致团队为了指标去写无意义的断言。可以用# pragma: no cover显式排除掉命令行入口、异常兜底等难以覆盖的代码保证覆盖率数字确实“有用”。2.4 多版本兼容矩阵策略怎么选Python 项目一旦要对外发布就必须面对 3.9、3.10、3.11、3.12 甚至 3.13 并存的情况。矩阵策略就是在流水线里同时跑多个 Python 版本每个版本都执行一遍测试。配置方式非常简单在 CI 声明strategy.matrix.python-version列表即可。但有一个很多人没注意到的点矩阵里的 Python 版本应该和你真正声明的支持范围一致。比如项目requires-python 3.9那矩阵里就该有 3.9 和最新版本中间缺版本可以省但边界版本必须覆盖。如果你的项目只在公司内部用解释器版本统一那就不用开矩阵省下大量流水线时间。先想清楚这个前提比抄配置更重要。3. 实操落地一行行配置可用的 CI 管线3.1 从零搭建一个 Git 托管平台的 CI为了方便大多数读者这一节以 GitHub Actions 为例其他平台如 GitLab CI 或自建 Runner 体系的逻辑高度相似改个 DSL 语法和 runner 名称就能平移。这个例子是一个比较完整的 3 段流水线包含依赖安装、静态检查、矩阵测试和构建上传四个 jobname: python-ci on: push: branches: [ main, develop ] pull_request: concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 cache: uv - name: Install dependencies run: | pip install uv uv venv .venv uv pip install -e .[dev] - name: Run formatter check run: uv run ruff format --check . - name: Run linter run: uv run ruff check . - name: Run type checker run: uv run mypy src test: needs: lint runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.9, 3.12] steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies run: | pip install --upgrade pip pip install -e .[dev,test] - name: Run tests run: pytest tests/ -n auto --covsrc --cov-fail-under80 - name: Upload coverage report uses: actions/upload-artifactv4 with: name: pytest-coverage-${{ matrix.python-version }} path: coverage.xml build-wheel: needs: test if: github.event_name push runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - name: Build wheel and sdist run: | pip install build python -m build - name: Upload distribution artifacts uses: actions/upload-artifactv4 with: name: dist path: dist/这段配置里有几个细节值得展开说说。concurrency是为了避免同一个分支连续提交时同时跑多个相同的流水线浪费计算资源也容易产生状态竞争。发现新提交后旧的执行会被取消这对体验提升很大。fail-fast: false的意思是矩阵某个版本测试失败不立即中断其他版本的测试这样一次能看到所有版本的失败原因不用修一个版本再等下一轮。cache: uv和cache: pip是平台自带缓存机制它会把依赖缓存目录映射到 runner 的持久化存储里。这里需要注意缓存 key 默认是根据锁文件或 requirements 内容生成的如果依赖没变走缓存如果变了自动重新拉取。你不需要手工算 hash但前提是文件路径是平台默认能识别的那几个。如果项目用 requirements.txt 但文件名自定义了就得用cache-dependency-path参数显式告诉 platform。3.2 构建发布阶段wheel 包与私有仓库推送测试通过了代码也该发布了。发布方式取决于你的项目是构建分发包给别的系统用还是直接部署为可运行服务。先讲分发包的构建。上面的build-wheel里用了python -m build它会同时生成 wheel 和源码包。创建自己的 index 所以只管推送即可。这里要留意的是版本号不能每次还是 0.1.0否则自动发布会让版本覆盖或被拒绝。建议在流水线里根据 tag 或提交号动态生成版本号或者干脆只在打 tag 时触发发布 job。发布 job 一般是这样的模式当 push 一个v*tag 时构建产物并推送到制品库release: needs: test if: startsWith(github.ref, refs/tags/v) runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - name: Build package run: | pip install build python -m build - name: Publish to internal index run: | pip install twine twine upload --repository-url ${{ secrets.PYPI_INTERNAL_URL }} \ -u ${{ secrets.PYPI_USERNAME }} \ -p ${{ secrets.PYPI_PASSWORD }} \ dist/*如果发布到平台自带的 Release步骤也差不多无非是把上传对象改成 GitHub Release 的 API。需要注意permissions这个字段平台默认的GITHUB_TOKEN权限是收窄的如果不显式声明contents: write后面想创建 release 会被拒绝。4. 部署那块从流水线产物到正在运行的服务4.1 两种部署路线的取舍部署阶段Python 服务有两条主流路线打包成 Docker 镜像部署或者直接把代码通过 SSH 推到服务器用 systemd 或进程管理工具拉起。我个人的建议是只要团队对容器不是完全陌生优先走 Docker 镜像。镜像把 Python 运行环境、系统依赖、代码打包成一个不可变产物部署到哪都一样回滚也简单——重新跑一个旧 tag 就行。另一条路的优势是轻量一个 VPS 也能跑不需要容器编排问题在于服务器环境漂移很容易出现这台机器装了什么系统包、那个环境 Python 版本很旧全靠运维记忆迟早踩坑。4.2 Docker 多阶段构建的实战写法Python 镜像构建最大的痛点是“镜像太大”和“依赖装得太慢”。多阶段构建可以同时解决这两个问题第一阶段负责装依赖和构建第二阶段只拷入最小运行时。# syntaxdocker/dockerfile:1 FROM python:3.12-slim AS builder WORKDIR /app COPY pyproject.toml uv.lock ./ RUN pip install uv uv sync --frozen --no-dev COPY src ./src RUN uv build FROM python:3.12-slim AS runtime ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 WORKDIR /app COPY --frombuilder /app/.venv ./.venv COPY --frombuilder /app/dist/*.whl ./ ENV PATH/app/.venv/bin:$PATH RUN pip install --no-cache-dir ./*.whl COPY deploy/entrypoint.sh /entrypoint.sh RUN chmod x /entrypoint.sh EXPOSE 8000 CMD [/entrypoint.sh]这里有一个很关键的点第一阶段的uv sync --frozen会把.venv装好第二阶段直接拷贝这个虚拟环境。这样不仅避免了在最终镜像里再跑一遍pip install带来的网络依赖风险还能显著减小层体积。uv提供的二进制虚拟环境自带解释器链接在python:3.12-slim基础上拷贝是可行的只要基础镜像的 Python 版本对得上就行。PYTHONDONTWRITEBYTECODE和PYTHONUNBUFFERED是 Python 容器里老生常谈的两个环境变量前者禁止写__pycache__后者让日志不缓冲、实时输出到 stdout方便容器编排平台采集日志。新手往往漏掉后者导致服务在容器里“没反应”其实是日志攒着没输出。4.3 镜像 tag 策略不要出现 latest 满天飞部署环境里最危险的一件事就是所有人都在用latesttag。今天构建的latest和一个月前的latest可能相差几十个提交一旦拉新镜像出问题连“上一个能跑的是哪个”都说不清。我在生产环境里的 tag 策略是组合式语义化版本-短提交号-构建时间例如1.4.2-a1b2c3d-20250315。短提交号保证了可溯源时间戳保证唯一性语义化版本方便人读。CD 流程里可以基于 Git tag 生成镜像 tag也可以通过一个递增计数器。更重要的是流水线里要把标签写入deploy.yaml或制品元数据方便后续追踪“哪个镜像跑在哪个环境”。4.4 无 Docker 场景的部署自动化如果你还在管理裸机或虚拟机上跑 Python 服务部署自动化也有相对成熟的方案。先在服务器上装好 Python 和虚拟环境工具然后流水线里用 SSH 执行部署脚本脚本核心只有三件事拉代码、装依赖、重启服务。示例脚本大概这样set -euo pipefail PROJECT_DIR/srv/myapp cd $PROJECT_DIR git pull --ff-only origin main uv sync --frozen systemctl restart myapp.service注意--ff-only是为了防止服务器上的代码被手工改过导致合并冲突一旦冲突就失败退出避免把不知情的脏状态带上线。systemd 服务文件里要配好WorkingDirectory和ExecStart最好在 service 里加上Restartalways和RestartSec3这样进程只要崩了就能自动拉起。流水线里使用 SSH 前记得把 SSH 私钥存成 Secret不要躺在仓库里。4.5 数据库迁移与多环境部署的衔接Python 后端只要用了 ORM 或数据库迁移工具Django 自带、SQLAlchemy 项目用 AlembicCD 就一定要处理迁移问题。最简单的做法是在部署前跑一个单独的 migration job比如uv run alembic upgrade head但这个操作要放在哪一步很有讲究。应该在启动新版本前执行还是在新版本启动后执行二者各有利弊先迁移老代码可能因为 schema 变了炸先启动新代码可能因为 schema 没迁移而炸。我的经验是如果你的迁移都是“可回滚的加法变更”加表、加字段可以启动前跑一旦涉及改字段类型这类破坏性变更就需要更复杂的分阶段发布至少要把改代码和改 schema 拆成两个版本交替发。流水线里把 migration 锁在发布 job 里并且设置timeout-minutes防止卡死是一个必要的兜底。另外不要把 migration 和业务启动放在同一个命令里逐个执行比如在入口脚本里又启动又迁移。生产环境多副本同时启动时两个实例同时跑 migration 会互相打架。迁移只应由 CD 触发单调性执行应用启动时不做任何 DDL。5. 常见问题与排查技巧实录5.1 依赖锁文件与 CI 环境的隐性不一致CI 环境默认是干净的但“干净”不等于和本地一致。我曾经遇到一个问题本地用 macOSPython 3.11依赖装好测试通过CI 用 UbuntuPython 3.12同样的 lock 文件装出来却报某个 C 扩展编译错误。原因很简单不同平台的 wheel 不同纯 Python 包没问题但带二进制的依赖如pydantic-core就会暴露平台差异。排查思路是先对比两边的pip freeze再确认锁文件是否真的是--platform无关的可复现锁。uv在生成锁文件时可以指定--universal让锁文件兼容多个平台如果做不到建议 CI 和本地统一用 Linux 容器环境跑依赖安装比硬刚差异省事得多。5.2 测试偶发失败与重试策略分段执行多次测试时偶尔会遇到某一条用例第一次跑挂了、什么都没改重跑又过了。这在集成测试里尤其频繁原因通常是外部服务不稳定、端口占用、测试数据没清理干净。绝不能视而不见但也不必一开始就全禁止重试。我的处理方式是分两类对于纯单元测试绝不允许重试挂了就是逻辑有问题对于集成测试可以在 CI 里配置允许“每个 job 最多重试 1 次”且要输出完整的失败日志。重试本身是工具不是豁免符关键还是得去重试时拿到日志看出是偶发还是真 bug。平台提供的retry按钮失效时说明按钮只能重新跑整轮失败现场已经被覆盖。如果一段时间内总在某个用例上不稳定就在代码里加上更细致的 fixture 隔离而不要依赖 CI 重试。5.3 缓存不命中与流水线缓慢很多人设置了缓存之后发现流水线并没有提速。排查方法平台缓存的“key”是否真的和依赖文件变化挂钩。比如依赖文件是不定时更新的你用了某个 Git 提交号作为 key 的一部分那么每次都会 miss等于没缓存相反如果 key 太宽泛比如只用requirements.txt路径依赖更新后缓存也照样命中导致装的是旧依赖CI 结果失真。spring 的做法是主 key 用锁文件内容的 hash比如uv-${{ hashFiles(uv.lock) }}restore key 用uv-允许小范围回退到旧缓存。这样依赖没变时秒级命中依赖变了时用全新的 key 从零拉取。5.4 并发部署与分支保护两个开发者各自合入不同分支如果 CD 在主干上自动部署两个构建接踵而至后一个部署会把前一个覆盖掉。这种状况在流水线里要在两个层面防第一个层面是concurrency按环境和分支分组同一个部署组同时只跑一个第二个层面是环境保护规则把生产环境的保护分支设置为只允许 main 或 tag 触发。分支保护的配置同样重要仓库设置里把 main 分支设为“需要状态检查通过才能合并”并且勾选对应的 CI job。这能保证没有任何代码能在流水线失败的情况下进入主干。这个门禁是 CI 的最后一道防线一定要配置在仓库端而不是只在流水线里写if: always()之类的兜底。5.5 遇到 CI 不跑或者状态没上报怎么办流水线是配置出来的配置错了自然就静默失败。最常见的是 YAML 语法错误平台会直接拒绝加载并标注错误行这还比较友好更隐蔽的是事件条件写错比如把on:配成了on:但缩进不对平台直接忽略该配置推代码却没有流水线启动。我在配置完流水线后一定会故意 push 一个空提交看它到底跑不跑这是最笨也最稳的验证方式。还有种情况是状态没上报到 PR。你明明跑了 CI但 PR 下面看不到检查项。原因是当前 job 的事件触发条件与 PR 事件不匹配比如只在 push 时触发PR 当然是空的。把pull_request加到触发事件里就能解决。对于一个“流水线配置得很完美”但是 PR 上没有任何状态的项目先看这个。6. 我的一些实操心得调试流水线的时间成本往往比写业务代码还高。踩过一轮坑之后我现在固定养成这么几个习惯流水线 YAML 里每个 job 都设置timeout-minutes防止网络卡死或测试死循环把队列占满所有需要手动调整的中间产物比如覆盖率报告、测试日志都尽可能用上传 artifact 的方式保留方便事后回溯而不是靠本地重跑任何一条规则在加进流水线之前先确认它“真的能抓到 bug”不然它只会变成一个让人麻木的背景噪音。如果项目刚起步我的建议是不要一步到位堆出最全的流水线先把“装依赖、跑 pytest、构建包”这三个最小环节跑通稳定之后再加 Ruff 和 Mypy再加覆盖率阈值再加矩阵和 CD。每加一道关卡都要让团队看到它挡掉过一次事故这样大家才会信任这套系统否则流水线只会沦为“红色管理员天天点重试”的摆设。CI/CD 最核心的收益不是自动化本身而是把“环境差异”和“人为疏漏”从交付链路里大幅挤掉。你只要顺着这个目标每一步都追问“这个流程人肉执行会有什么不确定性”流水线的设计思路就会自己长出来。