
先问一个可能很多人都不太好意思承认的问题你写完的 Python 脚本到底是怎么“上线”的我见过不少项目本地跑得飞起部署全靠手动复制代码到服务器然后在终端里开着nohup或者screen挂着跑等下次更新代码又得重复一遍再小心翼翼地把旧进程杀掉。这个过程一次两次还好一旦项目进入持续迭代、多人协作、要稳定对外提供服务的阶段迟早会在某个周五晚上出事。所以这次想认真聊聊持续集成/持续部署CI/CDfor Python。简单说CI/CD 就是把“每次提交代码之后自动完成环境准备、依赖安装、自动化测试、打包然后一键把服务部署到目标机器”这一整套流程变成现实。它不是某个高深到只有大厂才有资格用的技术一个人维护的小项目也可以立刻受益。这篇内容适合三类人一是在写 Python 但还没接触过自动化部署的同学二是虽然跑通了某个 CI 工具、但不太清楚每一步为什么要这样配置的人三是准备把脚本工程化、给团队建立规范化流程的开发者。我会拆开讲清楚原理、给你能直接抄用的配置最后把我在实际部署中踩过的坑也一并列出来。1. 为什么 Python 项目尤其需要 CI/CD 自动化要说清楚这个问题得先承认一个现实Python 项目的运行环境天然地比其他语言更“脆”。其他语言要么是编译型、产物基本是单个二进制或明确的运行环境要么包管理机制相对统一而 Python 项目从解释器版本、依赖库版本到操作系统自带的环境任何一个环节不一致代码行为就可能完全跑偏。1.1 Python 依赖管理之痛大多数 Python 项目都会有一个requirements.txt或者pyproject.toml来声明依赖。麻烦在于很多人提交代码时不会刻意锁定依赖的精确版本。比如你写的是requests 2.20今天全新安装时拿到的可能是几天前刚发布的新版本而这个新版本可能悄悄改了内部行为你的代码刚好踩中毒了。本地跑了几十遍没事一部署到服务器就崩这种“薛定谔的依赖”问题靠人肉排查真的是灾难级别的体验。CI 系统给我最大的价值就是它每次都会在一个全新环境里重新安装所有依赖。注意是“全新环境”。这意味着只要配置正确每次跑出来的结果都是可复现的它模拟的是一个刚拿到你项目代码的陌生人最后运行出来的效果。如果你在本机能过、CI 挂掉那通常说明你的requirements.txt不完整或者用了本地特有的环境而这恰恰暴露了真实问题。1.2 “在我电脑上能跑”是最大的谎言这里多说一句环境的坑。Python 项目的环境问题不只是依赖版本解释器版本也是一个核心变量。同样一段代码在 Python 3.8 和 3.11 下运行字符串处理、类型推断、对一些标准库的行为都可能不同。还有一个经常被忽略的点有些第三方库比如涉及原生编译的lxml、psycopg2这类在不同操作系统、不同 Python 版本下的安装行为完全不同。所以我在实际项目里会用好“虚拟环境 锁定依赖版本 确认解释器版本”这三板斧。开发机上装好虚拟环境后必须把pip freeze的结果沉淀到依赖锁定文件里。这就像你去餐厅点外卖不能只说“来点菜”得明确列出每一道菜的固定种类和分量后厨才能稳定出餐。CI 环境就是这个后厨给它明确清单它才能保证端上来的菜每次都一样。1.3 CI/CD 在 Python 项目里到底做了什么展开讲一个标准的 Python CI/CD 流程通常包含以下环节代码检查跑一遍ruff、mypy这类工具检查语法错误、风格问题做静态类型检查依赖安装根据锁定文件安装依赖这一步是后续所有环节的基础自动化测试执行pytest或unittest收集测试报告和覆盖率构建产物对于需要分发的项目构建 wheel 包对于服务型项目这一步往往就是构建 Docker 镜像部署将测试通过的产物发布到 PyPI、推送到镜像仓库或者直接登录服务器更新线上服务。如果不做 CI/CD这五步你每次发布都得手动走一遍。你可能会说“我项目小手动也就十分钟”。但问题不在于这十分钟本身而在于人是最不可靠的环节——你会忘记跑测试也会在半夜部署时因为一个拼写错误而漏掉某一步。CI/CD 就是把这些步骤固化下来让它们每次都以相同的方式执行这不单是省时间更是给“发布”这个动作上了保险。2. 工具选型GitHub Actions、GitLab CI 与 Jenkins 的取舍聊完必要性很多人的下一个问题就是“用什么工具”。市面上的 CI/CD 工具不少但我建议你先冷静下来不要一味追逐热门。工具选型看的不是功能多而是和你的项目、团队规模、代码托管方式匹配。我自己前后用过很多种这里只聊最主流的三个也是我自己真正跑过项目的三个。2.1 GitHub Actions个人项目与中小团队的上手最优解如果你的代码托管在 GitHub 上那 GitHub Actions 几乎是零门槛的选择。它的核心概念是“工作流”本质就是一份 YAML 文件放在仓库的.github/workflows/目录下。只要文件提交上去GitHub 就会在指定的触发事件发生时自动执行。我个人最喜欢它的三点和仓库是原生集成的不需要单独部署服务actions/setup-python、actions/checkout这类现成组件已经把最麻烦的环境准备封装好了免费计划对公开仓库和大部分小团队来说完全够用跑测试不用额外花钱。它的 YAML 文件上手也很容易后面我会给完整例子。对于绝大多数刚开始接触 CI/CD 的 Python 项目来说从 GitHub Actions 开始是最稳妥的。2.2 GitLab CI 与自建 Jenkins 的适用场景如果你的代码仓库是公司自建的 GitLabGitLab CI 就是不二之选它和 GitLab 同样是无缝集成。和 GitHub Actions 最大的不同是你通常需要自己部署 Runner 来执行任务好处是 Runner 可以跑在内网、指向你自己控制的机器适合对代码安全要求较高的团队。配置文件是仓库根目录下的.gitlab-ci.yml概念上和 GitHub Actions 大同小异迁移成本不高。Jenkins 则是老牌中的老牌。插件生态极其庞大几乎所有你能想到的构建场景都有对应插件。但代价是维护成本非常高——服务器要自己管、插件要自己升级、构建节点要自己维护。如果你所在的团队已经有大量历史构建任务和 Jenkins 技能积累保留它是合理的但如果你是新项目新团队为了 Jenkins 再去搭一套基础设施我个人不太推荐。用一个不太严谨但很形象的类比Jenkins 像一套功能复杂的自我托管的操作系统GitHub Actions 更像手机上的应用商店装完就能用。没有特殊需求选后者更省心。2.3 我的选择标准不偏门不冷门先跑通再加戏这里想分享一个经验无论选哪个工具核心原则都是“先跑通再加戏”。我见过有人第一周就花大量时间研究 Jenkins 插件的高阶玩法结果连最简单的“代码提交后自动跑测试”都没跑通也见过花三天搭好复杂的自建 GitLab Runner 集群结果项目只有两个人开发、根本不需要这种规模。以我自己的项目为例大部分情况下我会优先选择 GitHub Actions——仓库在 GitHub 上配置代码化直接在仓库里管理零额外成本。等团队规模变大、对并发构建数量和自定义 CPU 架构有需求了再考虑加 Runner 或者切换到更灵活的自建方案。选型时可以在心里做一个快速清单代码托管在哪里优先选和托管平台原生的工具团队有没有专职运维没有的话别碰需要自建服务的 Jenkins对代码私有化程度要求高不高高就走自建 Runner。把这三点想明白你的选型基本就已经定下来了剩下的细节不重要。3. 一条能直接跑的 Python CI 流水线从代码提交到测试报告工具选好以后接下来就是把理念落实到配置文件里。这一章我直接给出可以完整跑通的 GitHub Actions 工作流然后一行行讲清楚每段配置的意图。你不需要理解所有语法细节照着改成自己的项目名和路径就行。3.1 工作流文件整体结构拆解先看一个最基本的.github/workflows/ci.ymlname: Python CI on: push: branches: [ main, dev ] pull_request: jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: [3.10, 3.11, 3.12] steps: - name: 拉取代码 uses: actions/checkoutv4 - name: 配置 Python uses: actions/setup-pythonv5 with: python-version: ${{ matrix.python-version }} cache: pip - name: 安装依赖 run: | pip install --upgrade pip pip install -r requirements-dev.txt - name: 静态检查 run: | ruff check src tests - name: 运行测试 run: | pytest tests/ --covsrc --cov-reportxml -q先说on这个字段。我配置的是当代码推送到main或dev分支时触发以及提交 Pull Request 时也会触发。很多人刚开始会把触发写得太宽比如所有分支都跑结果一个临时分支的代码 push 都会触发一堆构建浪费时间和构建额度。建议先收敛到主要分支加上 Pull Request 就够了——PR 之外谁都不需要在几十个并行的临时分支上消耗资源。matrix灰度矩阵的意义在于验证代码在多版本下都能跑。Python 项目最怕的就是“我本机 3.12 能过线上 3.8 挂了”matrix 就是从工具层面强制你考虑兼容性。如果你明确知道项目只在 3.11 上运行比如用了只有 3.11 才有的语法特性可以把矩阵收敛成一个版本减少一半的 CI 时间。3.2 环境准备与依赖安装的细节处理再来看依赖安装这块。我注意到很多教程会直接让新手写pip install -r requirements.txt然后就没了。这一步至少有三个细节第一先升级 pip 再装依赖。有些依赖只有在较新的 pip 下解析器才能正确处理。尤其遇到psycopg2、pandas这类对构建工具敏感的库时升级 pip 能省掉不少莫名其妙的报错。第二开发依赖和运行依赖一定要分开。我的习惯是准备两个文件requirements.txt装运行依赖requirements-dev.txt额外装上pytest、ruff、mypy这类只在开发阶段和 CI 阶段需要的工具。如果混在一起部署到生产环境时也会带上测试工具既臃肿又有安全风险。第三关注pip install -e .的场景。如果你的项目是标准 Python 包结构有pyproject.toml或setup.py我推荐在 CI 里用pip install -e .来安装项目本体。这样可以确保测试时导入的是你当前代码而不是依赖发布到 PyPI 上的旧版本——这是“跑的是本次提交的代码”这个 CI 前提的关键。另外setup-python内置了 pip 缓存支持。你只需要在with里写cache: pip它就会根据你的依赖锁定文件自动生成缓存 key。这个功能实测下来能让安装依赖的时间减少一半以上尤其在使用pandas、numpy这类体积较大的库时效果非常明显。3.3 自动化测试与覆盖率报告测试不是为了好看接下来说说测试环节。pytest tests/ --covsrc --cov-reportxml这条命令做了两件事跑全部测试再生成 XML 格式的覆盖率报告。XML 格式的好处是可以跟 GitHub Actions 的插件配合把覆盖率直接附在 Pull Request 的注释里让每次提交的“数据体检”一眼可见。我必须泼一盆冷水测试不是越多越好看也不是覆盖率越高就越安全。有一次我的上一个项目把一个核心模块的覆盖率推到了 95%但线上还是出了一次严重问题。原因是那 5% 没覆盖到的分支恰好是错误处理路径——测试全部走的是“正常情况”出问题时反而没人验证过。所以我的建议是先保证有 8-10 个真正有价值的用例再去看覆盖率数字。优先给核心业务逻辑、异常分支、边界条件写测试。一个覆盖率 70% 但关键路径都被测到的项目比一个覆盖率 95% 但全测的是边缘工具函数的项目可靠得多。还有一个很多人不知道的小技巧pytest配置可以放到pyproject.toml里不需要单独建一个pytest.ini[tool.pytest.ini_options] addopts -q --maxfail3 testpaths [tests]--maxfail3的意思是跑到第 3 个失败就停止。这能节省大量时间如果测试刚起跑就挂了 20 个你不需要等全部跑完才知道它有问题早失败早定位早修复。3.4 把“静态检查”关进 CI 里不要相信人的记性在测试之前我还习惯接入 ruff。ruff是近两年 Python 静态检查工具里的新生力量底层用 Rust 写的速度非常快一个文件往往只花几十毫秒。它可以直接替代flake8加isort加一部分autoflake的职责一个工具搞定格式检查和 import 排序。搭配使用方式很简单在项目根目录的pyproject.toml里写[tool.ruff] line-length 88 target-version py311 src [src, tests] [tool.ruff.lint] select [E, F, I, B, UP]评审几个我常用的规则项E是 PEP8 风格错误F是逻辑性错误比如定义了没用的变量、用了未导入的名称I是 import 顺序检查B是 bugbear 插件能发现常见的坑比如可变对象作为默认参数UP是提示使用更现代的 Python 语法。为什么要把它写进 CI因为人记性是不可靠的。你本地什么时候忘了跑 ruff、或者因为赶进度故意把它跳过了后续从代码 review 里提醒的成本很高而机器在提交时自动拦一道成本几乎为零。这也是 CI 赛道里我最喜欢的一个环节让机器承担“纠错纪律”的职责把人从琐碎里解放出来。4. 部署阶段从流水线产物到服务器上稳定运行的服务前四步做完了CI 到“自动测试通过”这一步已经闭环。但很多项目真正让人头疼的是后面的“CD”环节——部署。我见过太多项目 CI 做得漂漂亮亮部署却还是人肉 SSH 登录服务器、手动 git pull、然后重启服务的原始状态。这一章聊两套我实际用过的部署路径以及它们各自的适用场景和安全注意事项。4.1 部署方案对比SSH 直跑 VS 容器化先说最经典的 SSH 直跑方式。核心思路是CI 在测试通过后直接通过 SSH 连上服务器执行一串远程命令更新代码、安装依赖、重启服务。一个基本的部署任务片段是这样的deploy: needs: test if: github.ref refs/heads/main github.event_name push runs-on: ubuntu-latest steps: - name: 部署到生产服务器 uses: appleboy/ssh-actionv1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/myproject git pull origin main source .venv/bin/activate pip install -r requirements.txt pip install -e . --no-deps systemctl restart myproject这段配置的中心思想是“代码从 Git 拉取依赖在服务器本机安装服务由 systemd 托管”。它的优点是直观、容易理解服务器端运维人员也能很快掌握缺点是服务器状态并没有完全跟 CI 环境脱钩如果服务器上的 Python 版本或系统库和你 CI 跑的环境不一致部署上去可能还是会翻车。所以我自己在稍微正式一点的项目里会更倾向于另一条路先把应用打包进 Docker 镜像。CI 在测试通过后构建镜像并推到镜像仓库服务器上只需要拉取新镜像、重启容器服务器本身的系统环境几乎不再影响应用行为build-image: needs: test if: github.ref refs/heads/main runs-on: ubuntu-latest steps: - name: 构建并推送到镜像仓库 uses: docker/build-push-actionv5 with: push: true tags: registry.example.com/myproject:${{ github.sha }}用 Docker 带来的直接好处是你部署的就是 CI 里测试过的那份代码和那份依赖快照。服务器系统是 Ubuntu 22.04 还是 CentOS 7 都不重要了因为应用跑在独立容器里。对害怕环境不一致的开发者来说这个心智负担的降低是实打实的。4.2 服务器端的服务托管细节systemd 与反向代理不管走哪条部署路径服务托管都需要认真对待。Python 最常见的 Web 入口框架Django、Flask、FastAPI自带的服务只适合开发和调试直接暴露到公网是不行的通常我会用 Gunicorn 作为生产环境 WSGI 服务器再用 Nginx 做反向代理。一个成熟的 systemd 服务管理单元文件长这样[Unit] DescriptionMyProject Python Service Afternetwork.target [Service] Userdeploy Groupdeploy WorkingDirectory/opt/myproject EnvironmentFile/opt/myproject/.env ExecStart/opt/myproject/.venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 app:app Restartalways RestartSec5 [Install] WantedBymulti-user.target几个容易被忽略但很重要的细节EnvironmentFile指向一个存放.env配置的地方数据库连接串、API 密钥这些敏感内容放这里不进代码仓库ExecStart里用的是虚拟环境里绝对路径的 gunicorn而不是裸写gunicorn避免依赖 PATH 对不对Restartalways应对进程崩溃自动拉起RestartSec5避免挂掉以后立刻疯狂重启导致的不必要的负载。反向代理层的配置可以更表现主义地理解Nginx 监听 80/443 端口把请求转发到本机127.0.0.1:8000上的 Gunicorn。好处是 Nginx 可以统一处理 TLS 证书、静态文件、限流而不是把你的应用进程直接裸露在外网。4.3 让部署能够回滚比让部署成功更重要我要特别强调一件事尝到自动化部署的甜头以后很多项目会频繁推送更新这时候“回滚能力”反而成了最重要的课题。我的经验是部署到线上的动作永远不应该是什么不可逆的仪式。在 SSH 直跑的流程里回滚的基本套路是在服务器上用 Git 标签或固定提交号管理版本出问题时只需git reset --hard 上一个稳定tag然后重启服务。在 Docker 化流程里更简单旧镜像一直在镜像仓库里把容器的 tag 换成上一个 commit sha重启容器就完成了一键回滚。所以在设计 CD 流水线时我建议从一开始就考虑一个问题这个流程能让用户和运维在 5 分钟内回到上一个可用版本吗如果还不能先不要上线。5. 排错实录Python CI/CD 最常见的五个坑及排查思路任何一个自动化流程都不可能一出即顺。最后这一部分不打算讲怎么又稳又快地把流程跑通而是专门讲那些“别人没咋提前写、但我实际踩过”的坑。每个坑我都会说明表现、产生原因、以及排查思路让你在真碰到时正好能对上号。5.1 依赖冲突pip 报错雷区与锁定版本的必要性在 CI 里最常遇到的失败就是pip install阶段报依赖版本冲突典型报错是ERROR: Cannot install -r requirements-dev.txt (line 5) because these package versions have conflicting dependencies.这个报错信息在 pip 升级到新版解析器之后已经友好很多了详细列出了谁和谁冲突。但更常见的情况是本地能装成功CI 报冲突。原因往往是你本地虚拟环境里已经有了某个包的一个旧版本而requirements-dev.txt想装另一个新版本pip 会直接替你“升级”或“降级”但 CI 每次都是干净环境所有包都必须一次性解析到兼容状态梢子出问题就当场翻车。解决思路是先让本地安装和 CI 完全对齐。在 CI 里跑pip install -r requirements-dev.txt之前确保你本地也是从零创建的虚拟环境安装的同一个文件列表然后运行pip freeze requirements.lock把成功状态下的精确版本锁进文件。如果不做锁版本最危险的是requirements.txt里那种没有写死版本的依赖今天线上可能装到 A 版本明天自动变成 A1 版本谁也不知道会破坏什么。已经严格锁版本的项目这条坑基本就不会出现。5.2 本地能过 CI 挂“干净环境”的残酷真相这是新手最容易心态爆炸的一类问题。代码本地跑得好好的测试也过了push 上去 CI 就是挂。排查这件事的办法很直接在空白环境下完整复现一次所有安装步骤。具体操作是在你的项目目录下用完全全新的虚拟环境从头pip install一遍然后运行 pytest。如果你本地一跑就挂了说明你的项目根本依赖了你从来没写进requirements里的包—— 你本机可能因为历史原因装了某个依赖的依赖恰好能满足你的使用但在 CI 的干净环境里那个“隐形的包袱”并不存在。换句话说CI 挂掉不是 CI 刁难你反而是它在第一次站在“用户”的角度告诉你你的项目清单不完整。不要试图在 CI 里硬塞一行pip install xxx去凑最好的修复是找到代码里实际 import 但没写进清单的包把它补上。5.3 测试使用真实数据库/文件系统的连锁灾难这是很多人部署到线上以后才痛彻心扉的坑。测试阶段如果你在用例里直接连了本地数据库、或者读写了一个真实文件这种测试在 CI 里跑几乎必挂而且原因五花八门CI 环境没有那个数据库服务、文件路径不存在、数据在多次执行之间相互污染……更麻烦的是即使暂时能过这类测试也完全没有可重复性。现代化的做法是所有外部依赖全部 mock 掉测试跑的是单个纯 Python 进程里能完成的逻辑不依赖网络和磁盘状态。用pytest的monkeypatch或unittest.mock把外部 IO 替换成可控的假对象比硬在 CI 里搭一套数据库环境再跑测试要干净得多。我自己后来的经验是给测试分“快慢”两个层级每次 commit 时只跑不依赖外部服务的“快测试”而在独立的 night build 里跑那些需要真实数据库、外部 API 的“慢测试”。不要让慢测试阻塞每一次小改动。5.4 缓存命中与依赖锁定的权衡很多人为了省时间会在 CI 里启用依赖缓存。这确实是个好习惯但如果不小心它也容易造成一种隐性故障缓存永远命中导致你的流水线静悄悄地跑在“旧依赖”上。在 GitHub Actions 里actions/setup-python的cache: pip大致是按锁定文件的 hash 键来定缓存对象的。一旦你更改了requirements文件里的任何内容依赖的 key 发生了变化缓存就会失效重建。但如果你没有锁定文件而是每次用范围形式比如去声明缓存 key 的生成就可能失真——依赖库发布新版本后CI 却一直用旧缓存于是你在本地测试过的新版本功能CI 那边从来没见过。解决方案很简单永远优先使用锁文件或者精确版本。只要依赖声明写死了版本缓存 key 的稳定性就能保证CI 每次要么命中完全一致的旧缓存要么在依赖变更时自动重新安装不会出现静默漂移。5.5 服务器版本不匹配、密钥泄露与安全实践最后一个坑是关于 CI 配置文件里密钥的管理。我见过一个反面案例为了让本地测试方便某个同事把服务器密码直接硬编码在工作流文件里结果代码仓库被 fork密码泄漏线上服务被扫了一遍。这个教训非常直接——工作流文件是存在仓库里的凡是带密码、带身份的东西一律不要写进去。解密的关键是使用 CI 平台提供的 secrets 机制。在 GitHub Actions 里就是Settings - Secrets - Actions把 SSH 私钥、数据库密码、云厂商密钥都存在这里然后在 YAML 里通过${{ secrets.XXX }}引用它只在执行时被解密注入不会直接出现在日志里。日志也不可大意有时命令会隐式打印 secrets注意查看 CI 输出的每一条日志确认没有泄露。另外涉及StrictHostKeyCheckingno这类“首次连接自动接受指纹”的配置时方便归方便但在生产级别流程里我建议关闭这个选项改为把服务器指纹预先维护好。省几秒操作和整个基础设施以及未来你的职业声誉的安全相比完全不值得一提。最后分享一点我的个人体会做了这些年的 Python 项目我对 CI/CD 最大的体验是它不是给项目增加一堆流程的“负担”而是把整个团队或者说个人的发布行为从“靠记忆靠运气”转变成“有记录可查询、可重放、可回滚”的标准路径。它通过发现依赖没写全、测试不可重复、环境变量缺失这类问题教会你用更严谨的方式思考“维护一个稳定运行的系统需要什么”而不是随便写完代码就扔到网上。最后再分享一个小技巧在你本地正式配置任何一个 CI/CD 工作流之前可以先在一个临时目录里docker run容器把“干净环境安装依赖并跑测试”这个动作完整演练一遍。这样能极大缩短你在 CI 平台上的反馈循环毕竟每一次 push 去等远程构建结果都不如在本地先自检一遍来得快。整个流程不用一蹴而就从最小的一条工作流跑起来测试通过再加静态检查再看部署再看回滚——慢慢补全你的 Python 项目才会真正变得“耐折腾、可依赖”。