尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python项目CI/CD实战:基于GitLab CI与Docker的自动化流水线指南

Python项目CI/CD实战:基于GitLab CI与Docker的自动化流水线指南 我最早认真搞 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.0B 同事的环境是 Python 3.8pandas 还是 1.5两个版本 API 有差异代码在谁那儿都能跑合并起来就挂。没有自动化流程这类问题只能在集成阶段暴露而“集成阶段”往往就是上线那一刻。第二个问题是环境漂移。测试环境、预发环境、生产环境的系统版本、Python 版本、系统级依赖库比如 Pillow 需要 libjpeglxml 需要 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 CIGitHub 项目用 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-under80如果某次提交把覆盖率拉低了流水线直接红逼着开发者补测试。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:passworddb:5432/test before_script: - pip install -r requirements-dev.txt --index-url https://pypi.tuna.tsinghua.edu.cn/simple script: - pytest --covapp --cov-fail-under80 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 StrictHostKeyCheckingno 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 --frombuilder /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参数是--covapp --cov-fail-under80。有人觉得阈值定 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 的核心规则查语法错误和不用的 importW 是警告级别的风格问题I 是 import 排序B 是 bugbear能查出一些隐蔽的 bug 写法UP 是 pyupgrade会自动检查可以升级到新语法的地方。Ddocstring我选择忽略因为强制每个人写文档字符串容易引发无意义的争论反而破坏氛围。3.3 部署阶段环境切换与灰度发布部署是流水线的最后一公里也是坑最多的地方。我用 Docker 部署时分的三步拉镜像、停旧容器、起新容器。上面 YAML 里 SSH 执行的那一串命令本质就是这三步。这里有一个非常关键的教训容器重启部署有停顿窗口而且没有回滚机制。如果新版本启动失败容器会一直重启服务就挂了。我在部署脚本里一般会加上启动后的健康检查确认服务正常响应后再结束 job。ssh -o StrictHostKeyCheckingno 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_TIMEOUT60增加超时时间。现象二报错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配置好几个常用 hookrepos: - 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: manualdeploy: 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 流水线最后加一个 jobtrigger_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_TOKENIMAGE_TAG$CI_COMMIT_SHORT_SHA对这种架构核心原则是职责分明GitLab CI 负责 CIJenkins 负责 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 没那么玄乎核心就是把重复的事情交给机器把判断的事情留给人剩下的跑起来再说。
返回列表