我最早认真搞 Python 项目的 CI/CD,是因为一个线上事故:本地跑得好好的爬虫服务,一上服务器就报缺依赖,版本还不一致,最后查出来是某个人在自己机器上 pip install 了最新版覆盖了 requirements 里的版本。那次之后我就明白,Python 项目如果没有一套自动化的构建、测试、部署流程,迟早会在某个深夜被自己埋的雷炸醒。
这篇文章不聊那些花里胡哨的概念,只聊怎么把 CI/CD 落到 Python 项目上。我会用 GitLab CI + Docker 这条主流路线做主线,穿插对比 Jenkins 和 GitHub Actions 的取舍,把流水线怎么设计、.gitlab-ci.yml怎么配、镜像怎么构建、部署怎么自动化一步步讲清楚。适合刚接手团队工程化建设、或者想把自己个人项目从“本地跑通”升级到“自动发布”的 Python 开发者,尤其是搞爬虫、数据分析、Web 后端的朋友,这套方案可以直接抄作业。
1. 整体设计与方案选型
1.1 为什么 Python 项目必须引入 CI/CD
很多人觉得 Python 脚本嘛,本地跑通丢服务器上 crontab 就完事了,搞 CI/CD 是过度工程。我一开始也这么想,直到被几个问题反复折磨。
第一个问题是依赖地狱。Python 的包管理本身就很灵活,灵活到容易失控。A 同事用 Python 3.9 开发,本机装的 pandas 是 2.0,B 同事的环境是 Python 3.8,pandas 还是 1.5,两个版本 API 有差异,代码在谁那儿都能跑,合并起来就挂。没有自动化流程,这类问题只能在集成阶段暴露,而“集成阶段”往往就是上线那一刻。
第二个问题是环境漂移。测试环境、预发环境、生产环境的系统版本、Python 版本、系统级依赖库(比如 Pillow 需要 libjpeg,lxml 需要 libxml2)很难保持一致。今天测环境好的版本,明天生产装不上,这种问题靠人工排查非常痛苦。
第三个问题是没人愿意做重复劳动。每次发布都要 SSH 上服务器、拉代码、建虚拟环境、装依赖、重启服务,一套流程下来十几分钟,发布频率一高,人就成了人肉部署机,而且容易漏步骤。
CI/CD 解决的就是这三件事:把依赖和环境的确定性锁死,把构建、测试、部署这些重复操作变成自动化流水线,让每一次提交都走同一条标准化的路。对 Python 项目来说,CI/CD 还可以顺带做 lint、类型检查、覆盖率统计,相当于给代码质量上了一道自动闸门。
1.2 方案对比:GitLab CI、Jenkins、GitHub Actions 怎么选
主流的 CI/CD 工具我基本都用过,这里直接说我个人的选择逻辑,给正在选型的读者一个参考。
如果你的代码托管在 GitLab,无脑选 GitLab CI。它是 GitLab 内置能力,不需要额外搭建服务,只需要在仓库根目录放一个.gitlab-ci.yml文件,GitLab Runner 会自动发现并执行。Runner 可以注册到本机、K8s 集群或者 Docker 环境里。最大的优势是“代码即配置”,流水线跟代码一起版本管理,改流水线也要走 MR,变更可追溯。
Jenkins 是老牌选手,胜在插件生态极其丰富,什么场景都有插件兜底。缺点是太重了,需要单独部署 Jenkins 服务,维护成本高,Pipeline 脚本如果是用 Groovy 写的,学习曲线也比较陡。团队里如果已经有专人运维 Jenkins,而且历史项目都在上面,那继续用它没问题;但新建 Python 项目,我不太建议再引入这个重量级工具。
GitHub Actions 体验非常好,尤其是开源项目,直接用 GitHub 托管的 Runner,免费额度对个人项目都够用。它的 marketplace 有大量现成 action,比如装 Python、缓存依赖、发布到 PyPI,都是几分钟配置完事。缺点就是绑定 GitHub,如果代码托管在自建 GitLab 或者 Gitea,那就用不了。
我的建议很简单:代码在哪,就用哪家的 CI。GitLab 项目用 GitLab CI,GitHub 项目用 GitHub Actions,只有在需要复杂流水线编排、多项目统一管控时才考虑 Jenkins。后面的实操部分以 GitLab CI + Docker 为例,这套思路迁移到 GitHub Actions 也就是换个 YAML 语法的事。
2. 流水线核心配置拆解
2.1 工作流设计:从提交到部署的完整链路
一段合理的 Python CI/CD 流水线,至少应该包含几个固定动作:代码检查、单元测试、构建镜像、推送镜像、部署。我习惯用 stages 把它拆成清晰阶段。
stages: - lint - test - build - deploy每个阶段都是独立 job,同一阶段的 job 默认并行执行,不同阶段按顺序执行。这样设计的好处是,代码检查挂了就不会走到测试,测试挂了就不会走到构建,每一道关卡都在入口拦截问题。
对于 lint 阶段,Python 项目我一般跑ruff和mypy。ruff 是目前最快的 Python linter,集成了 pyflakes、pycodestyle、isort 等工具的能力,一条命令搞定;mypy 做静态类型检查,对于代码量上去之后维护性提升非常明显。这两个工具都支持在 pre-commit 里本地跑,CI 里再跑一遍作为硬性校验。
test 阶段跑pytest,并生成覆盖率报告。这里有个细节:覆盖率阈值建议直接在配置里卡死,比如--cov-fail-under=80,如果某次提交把覆盖率拉低了,流水线直接红,逼着开发者补测试。
build 阶段做的事情是构建 Docker 镜像。这一步的关键在于用不用缓存、怎么打标签、推到哪个仓库。镜像仓库我用的 GitLab Container Registry,跟项目绑定,权限天然隔离,不用额外配置。
deploy 阶段根据分支走不同逻辑:main 分支部署到生产,develop 分支部署到测试环境。实现方式可以是 SSH 到服务器拉镜像重启容器,也可以是 helm 更新到 K8s 集群,看团队的部署环境。
2.2 .gitlab-ci.yml 关键配置与参数说明
.gitlab-ci.yml是 GitLab CI 的灵魂文件,刚开始写的时候有几个参数很容易疏忽,我这里逐一说明。
先看一个最基础的模板,直接基于这个改就行:
image: docker:24.0.7 variables: DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: "/certs" PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache" stages: - lint - test - build - deploy cache: key: "$CI_COMMIT_REF_SLUG" paths: - .pip-cache/ - .venv/ lint: stage: lint image: python:3.11-slim before_script: - pip install ruff mypy --index-url https://pypi.tuna.tsinghua.edu.cn/simple script: - ruff check . - mypy app/ test: stage: test image: python:3.11-slim services: - name: postgres:14-alpine alias: db variables: DATABASE_URL: "postgresql://postgres:password@db:5432/test" before_script: - pip install -r requirements-dev.txt --index-url https://pypi.tuna.tsinghua.edu.cn/simple script: - pytest --cov=app --cov-fail-under=80 artifacts: paths: - htmlcov/ expire_in: 7 days build: stage: build script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA only: - main - develop deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client script: - chmod 600 $SSH_PRIVATE_KEY - ssh -o StrictHostKeyChecking=no root@$DEPLOY_HOST " docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA && docker stop app || true && docker rm app || true && docker run -d --name app -p 8000:8000 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA " only: - main几个关键点拆开说。
image字段指定 job 运行的基础镜像。全局配置的image是所有 job 的默认值,但每个 job 可以覆盖。lint 和 test 用python:3.11-slim就够了,build 和 deploy 这些跟 Docker/SSH 打交道的 job 则需要各自的工具镜像。
services字段是 GitLab CI 的特色功能,可以在 job 运行时拉起一个辅助容器。比如测试需要 PostgreSQL,就声明一个postgres服务,应用代码可以通过别名db访问它。这比在 CI 里手工安装数据库服务省事太多。
variables字段定义环境变量。PIP_CACHE_DIR很重要,把 pip 缓存路径指到项目目录下,配合cache字段实现跨 job 的依赖缓存,能极大加速流水线。DOCKER_TLS_CERTDIR是 Docker-in-Docker 模式需要的,不设置的话 docker 命令可能报证书错误。
cache和artifacts是容易混淆的两个东西。cache是跨 job、跨流水线的原始文件缓存,一般放 pip 缓存、虚拟环境这些可再生的数据;artifacts是 job 产出的结果文件,比如测试报告、覆盖率 HTML,可以被后续 job 下载或在 GitLab 页面直接浏览,有保存时间限制。
3. 实操:Docker 镜像构建与自动化部署
3.1 构建阶段:依赖安装与缓存策略
Python 项目打 Docker 镜像,最核心的是 Dockerfile 怎么写。很多人图省事直接一条pip install -r requirements.txt,结果镜像几个 GB,构建一次七八分钟,部署起来拉镜像也痛苦。我从实战角度给出一个推荐的 Dockerfile 模板。
FROM python:3.11-slim AS builder WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ python3-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --prefix=/install -r requirements.txt \ --index-url https://pypi.tuna.tsinghua.edu.cn/simple FROM python:3.11-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /install /usr/local COPY . . EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这里用了多阶段构建,第一阶段的镜像叫builder,负责安装编译依赖并 pip install。因为很多 Python 包(比如 numpy、pandas、pydantic-core)在 slim 镜像里需要 gcc、python3-dev 才能编译,装完就没用了,没必要留在最终镜像里。第二阶段从 scratch 开始,只把/install目录拷贝过来,这样最终镜像只包含运行需要的 Python 包和源码,体积可以小 40%~60%。
依赖装的时候我额外加了一行--index-url,指向镜像源。国内网络环境从官方 PyPI 拉包经常超时,流水线一红一大片,用镜像源是最直接的解决方法。
镜像打标签我用的是$CI_COMMIT_SHORT_SHA,也就是提交 ID 的前 8 位。这个标签的好处是唯一且可追溯,这个镜像对应哪次提交一目了然。如果需求是不停更新latest标签,我会在构建完当前版本后再打一个latest标签,同时推送,方便部署时用固定标签。
3.2 测试阶段:单元测试、代码规范与覆盖率
测试阶段容易被忽视,但对 Python 项目来说,这正是 CI/CD 最有价值的部分。我见过太多“能跑就行”的代码,上线三天就出幺蛾子。在流水线里把测试做扎实,能拦截大量低级错误。
单元测试运行 pytest 时,有几个配置细节值得留意。第一个是pytest.ini或pyproject.toml中的 testpaths,明确指定测试目录,避免 pytest 去扫描那些不相干的目录。第二个是 conftest.py 的 fixture 设计,数据库连接的 fixture、HTTP 请求 mock 的 fixture,都应该放在 conftest 里统一管理,测试用例只关注业务断言。
覆盖率我用 pytest-cov,参数是--cov=app --cov-fail-under=80。有人觉得阈值定 80% 太高,小项目没必要。我的经验是,初始可能达不到,但把阈值写上去之后团队会有意识补测试,两个月后覆盖率自然就上去了。如果一开始就不设阈值,覆盖率就永远不会有。
代码规范这块,ruff check .会自动读取 pyproject.toml 中的 [tool.ruff] 配置。我个人的推荐配置是这样:
[tool.ruff] line-length = 100 target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP"] ignore = ["D"]选中的规则里,E 和 F 是 pycodestyle 和 pyflakes 的核心规则,查语法错误和不用的 import;W 是警告级别的风格问题;I 是 import 排序;B 是 bugbear,能查出一些隐蔽的 bug 写法;UP 是 pyupgrade,会自动检查可以升级到新语法的地方。D(docstring)我选择忽略,因为强制每个人写文档字符串容易引发无意义的争论,反而破坏氛围。
3.3 部署阶段:环境切换与灰度发布
部署是流水线的最后一公里,也是坑最多的地方。我用 Docker 部署时分的三步:拉镜像、停旧容器、起新容器。上面 YAML 里 SSH 执行的那一串命令,本质就是这三步。
这里有一个非常关键的教训:容器重启部署有停顿窗口,而且没有回滚机制。如果新版本启动失败,容器会一直重启,服务就挂了。我在部署脚本里一般会加上启动后的健康检查,确认服务正常响应后再结束 job。
ssh -o StrictHostKeyChecking=no root@$DEPLOY_HOST " docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA && docker stop app || true && docker rm app || true && docker run -d --name app --restart unless-stopped \ -p 8000:8000 \ -e DATABASE_URL='$DATABASE_URL' \ $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA && sleep 5 && curl -f http://localhost:8000/healthz "curl -f是关键,健康检查接口返回非 2xx 状态码时,curl 会返回非零退出码,SSH 命令也就会失败,GitLab 会把这个 job 标记为失败,提醒部署有问题。当然这种简单检查只适用于单机部署;如果你的服务在 K8s 上,那需要的是 helm upgrade 或者 kubectl rollout restart,配合 readinessProbe 和 livenessProbe,实现滚动更新,服务全程不中断。
环境切换方面,我建议用环境变量而非在代码里写死配置。不同的部署环境(测试、预发、生产)通过 GitLab CI 的 environment 变量传递数据库地址、密钥、API Key 这些敏感信息。.gitlab-ci.yml里只写变量名,值在 GitLab 项目的 Settings -> CI/CD -> Variables 里配置,并用 Masked 和 Protected 属性保护。
4. 常见问题与排查技巧实录
4.1 依赖安装慢或失败的经典场景
Python 流水线最常见的红,绝对是 pip install 阶段。网络超时、包找不到、编译失败,原因五花八门,有些是环境问题,有些是配置问题。
现象一:流水线耗时特别长,卡在 pip install。大概率是没走镜像源,或者 pip 缓存没有生效。解决方法是给 pip 加--index-url,或者设置PIP_INDEX_URL环境变量指向镜像源。个人项目网速还不错的话,也可以设置PIP_DEFAULT_TIMEOUT=60增加超时时间。
现象二:报错Could not find a version that satisfies the requirement。这通常是 requirements.txt 里锁的版本和 pip 源里的版本不一致,或者源没有同步最新版本。先检查镜像源有没有这个包,再确认本地的 pip 版本不要太老。有些包名区分大小写,pip install Pillow写成pillow也能装,但requirements.txt里统一规范大小写更安全。
现象三:error: command 'gcc' failed with exit code 1。这是典型的需要编译的场景,Python slim 镜像里没有编译工具链。解决方案是先 apt-get 安装build-essential和python3-dev,或者干脆换成带编译能力的运行镜像。使用多阶段构建时,编译工具只装在第一阶段,最终镜像里不留,所以构建慢一点也能接受。
4.2 缓存失效与镜像构建卡顿
cache字段配置了 pip 缓存,但流水线依然慢得离谱,这是很多人遇到过的困惑。缓存失效有几类原因。
最典型的是 cache key 设计不合理。如果 key 是整个流水线共用一个值,那任何分支的首次构建都会把其他分支的缓存挤掉。我的做法是 key 用$CI_COMMIT_REF_SLUG,分支名作为 key,这样 main 分支和 feature 分支各有各的缓存,互不干扰。
另一个情况是 requirements.txt 频繁变更。只要依赖文件一变,pip 就必须重新解析依赖树,缓存的命中率会下降。一个稳妥的做法是升级依赖时只在 MR 中改 requirements,不要随手在本地乱加包,保证依赖变更可审查。
镜像构建卡顿的问题,先看是否命中了 Docker 层缓存。Dockerfile 中的指令顺序很重要,把不常变的 COPY(比如 requirements.txt)放在前面,把经常变的 COPY . 放在后面,这样只改业务代码时,依赖安装层可以复用缓存。如果每次构建都从零开始装依赖,那就说明 Docker 层缓存没有生效,检查一下 Docker 的 storage driver 是不是 overlay2,以及 build context 是否过大。.dockerignore里没有把.venv、__pycache__、.git排除的话,构建上下文可能有几百 MB,严重影响构建速度。
4.3 权限、端口与网络问题
部署阶段报权限错误的场景非常常见,而且报错信息往往看不懂。举例来说,SSH 登录服务器时提示Permission denied (publickey),第一反应是检查 GitLab 变量SSH_PRIVATE_KEY是否配置为全部内容,包括-----BEGIN OPENSSH PRIVATE KEY-----和结尾。我一开始只复制了中间部分,排查了半小时。
服务器上的目标目录如果权限不对,docker pull 或者 docker run 也可能失败。部署用户在~/.docker/config.json里的认证信息如果过期,拉取私有镜像仓库会报pull access denied。处理方式是确认部署用户已执行docker login并保持凭证有效,或者把服务器的 Docker 配置成允许当前用户直接操作,把用户加入 docker 组。
端口冲突也是一个高频问题。docker run -p 8000:8000启动报port is already allocated,是上一个容器没删干净。部署脚本里要先执行docker stop app || true和docker rm app || true,注意|| true的作用是忽略“容器不存在”的报错,让脚本继续走。如果不加这个容错,第一次部署或者容器已被手动删除时,脚本会在这里中断。
网络层面的坑主要集中在 GitLab Runner 所在的机器访问不了镜像仓库,或者服务器访问不了外网。前者检查网络策略,后者注意构建时所有 apt-get、pip 下载会把流量算到服务器出口,如果是云服务器,公网带宽不足照样会超时。
5. 流水线的进阶扩展
5.1 用 pre-commit 与 CI 形成双重校验
CI 里的 lint 和 test 已经能拦截问题,但等提交推上去再发现错误,多少有点晚。更好的闭环是让开发者在本地提交前就跑一遍同样的检查。pre-commit 这个工具可以做到这点。
在项目根目录放一个.pre-commit-config.yaml,配置好几个常用 hook:
repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.4 hooks: - id: ruff - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy additional_dependencies: - pydantic - sqlalchemy - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort args: ["--profile", "black"]开发者在本地pre-commit install之后,每次 git commit 都会先跑一遍 lint,有问题直接拦截在本地;推到远端 CI 再跑一遍,双保险。这个习惯养成之后,CI 红灯的频率会大幅下降。
5.2 多环境部署与回滚策略
生产环境部署不是每次都一把梭就行,合理的做法是给 deploy 阶段配置环境保护。GitLab CI 里可以在deployjob 中设置environment与when: manual:
deploy: stage: deploy image: alpine:latest environment: name: production when: manual only: - main加了when: manual之后,流水线到 deploy 阶段会暂停,需要有权限的人手动点击执行,这样可以对部署时机做人工控制,避免每提交一次自动发一次生产。预发环境则可以用only: develop自动部署,满足快速验证的需求。
关于回滚,Docker 部署时保留上一个镜像的标签非常关键。我部署时用$CI_COMMIT_SHORT_SHA作为标签,部署前把上一版本 SHA 写在 Release Note 里;需要回滚时,直接手动执行docker run拉起旧 SHA 的镜像即可。如果觉得手动太麻烦,可以再加一个 rollback job,输入指定的镜像标签重新部署。
5.3 结合 Jenkins 的团队级流水线
前面主要是 GitLab CI 的实践,但如果你所在团队有运维沉淀,Jenkins 也会是绕不开的一环。我在团队里见过一种混合模式:开发侧用 GitLab CI 完成 lint、test、build 镜像、推送镜像,最后触发一个 Jenkins job 执行 CD 环节,比如更新 K8s manifest、执行数据库迁移、调用部署平台 API。
GitLab CI 触发 Jenkins 的标准做法是通过 webhook 或者 Jenkins 的远程构建接口。GitLab 流水线最后加一个 job:
trigger_jenkins: stage: deploy image: alpine:latest variables: JENKINS_URL: "https://jenkins.example.com/job/your-job/buildWithParameters" script: - apk add --no-cache curl - curl -X POST "$JENKINS_URL?TOKEN=$JENKINS_TOKEN&IMAGE_TAG=$CI_COMMIT_SHORT_SHA"对这种架构,核心原则是职责分明:GitLab CI 负责 CI,Jenkins 负责 CD,触发参数只传必要信息(比如镜像标签),不要在端到端链路里传无关数据。维护复杂度会上升,但如果你有大量历史遗留项目挂在 Jenkins 上,这种渐进式演进比一刀切重写好得多。
6. 我踩过的坑和现在的习惯做法
CI/CD 配好之后感觉一劳永逸?那是错觉。真正推了几个月之后,我反而形成了几个比较“保守”的习惯。
第一,任何涉及流水线的改动,都要遵循“先小步试,再批量推”的原则。比如升级 Python 镜像版本从 3.10 到 3.11,不要一次性改所有项目;先在团队里挑一个依赖树最复杂的项目试跑,pass 之后再同步到其他仓库。Python 版本升级导致第三方包编译失败是我遇到过最多的集体罢工现场。
第二,流水线里的脚本和 Dockerfile 也要定期维护。有人觉得 CI 配置写好了就不动了,实际上 Python 包的持续更新、镜像基础版本的 CVE 修复、GitLab Runner 的版本升级,都会影响流水线稳定性。我给自己定的节奏是每季度花半天时间统一检查一遍。
第三,日志要留着。GitLab 的 job 日志默认只保留一段时间,如果排查很久之前的问题,日志可能早就没了。我习惯在每个部署 job 最后加一个上传日志的步骤,把部署过程的完整输出归档到 object storage,后续排查线上问题时不至于抓瞎。
最后再分享一个小技巧:在本地开发机上装一个act工具可以模拟 GitHub Actions 本地执行,在 Jenkins 环境里也有jenkins-cli调试手段。但 GitLab CI 没有完美的本地模拟方案,所以我的土办法是:在项目里保留一个scripts/目录,把.gitlab-ci.yml中每个 job 的 script 段落抽成独立 shell 脚本,本地直接执行脚本模拟 CI 行为。这样流水线配置简洁,本地也能复现 CI 的大部分逻辑,排查问题效率翻倍。
这些经验是踩了不少坑换来的,希望你能少走弯路。Python 的 CI/CD 没那么玄乎,核心就是把重复的事情交给机器,把判断的事情留给人,剩下的,跑起来再说。