首页 / 资讯中心 / 文章详情

Python CI/CD最小闭环:GitHub Actions+Docker部署

Python CI/CD最小闭环:GitHub Actions+Docker部署 ★ FEATURED ARTICLE
两年前我刚接手一个内部Python工具时发布流程是这样本地跑一遍测试SSH登录服务器git pull然后手动重启服务。听起来还行问题在于——我本地跑测试用的是Python 3.11服务器上是3.8requirements.txt里没有锁版本pip install时某天自动升级了一个依赖的minor版本服务直接起不来。排查了半小时最后发现是Pydantic v2的兼容问题。这类事故在Python项目里几乎人人遇过。缺的从来不是某个具体操作而是一条把“本地开发—测试—交付—部署”串起来的流水线。这篇文章想分享的就是我给自己项目搭的这套CI/CD最小闭环不碰K8s不碰Jenkins不额外买服务器只用GitHub Actions Git Docker或systemd把发布从“手动碰运气”变成“自动可回滚”。如果你正被“我这能跑啊怎么服务器上就挂了”折磨这整套东西应该能直接抄作业。1. 为什么Python项目尤其需要一条“从本地到生产”的流水线1.1 Python项目的两个“动态地狱”先聊一个经常被忽视的事实Java或Go项目编译失败基本发不出去编译器在提交阶段就帮你拦截了大量低级错误。Python没有这层保护语法错误、缩进错误、导入路径写错、类型不匹配全部要等到运行那一刻才暴露。更要命的是依赖管理。Java有Maven/Gradle的锁文件Go有go.sumPython生态虽然也有锁机制但大量项目还在用裸的requirements.txt里面写着numpy1.21这种“薛定谔版本”。今天装是1.26下个月装可能就变2.0了。本地环境因为缓存和历史安装可能一直用旧版本CI或服务器一跑全新安装立马炸。这两个“动态地狱”叠加在一起导致的结果就是你在本地反复验证“能跑”换台机器就变成“跑不了”。而CI/CD的价值恰恰是在一个从头到尾干净的环境里执行一套完全一致的安装、测试、构建流程把所有“碰运气”的成分挤出去。1.2 最小闭环的取舍边界哪些先自动哪些缓一缓很多人一听CI/CD脑中浮现的是K8s、蓝绿发布、灰度流量、Prometheus监控全家桶——然后直接被劝退。我一开始也这样后来才想明白所谓“最小闭环”就是把发布链路里最高频、最痛苦的三个环节自动化提交前本地检查lint 冒烟测试推送到主干后的自动验证与打包CI验证通过后的自动部署与回滚CD至于高可用、多节点负载均衡、自动扩缩容那是业务量上来之后的事对绝大多数中小项目和内部工具来说用不上也养不起。先通一条路比修一条十车道的高速公路重要得多。1.3 一个可以被完整复现的示例项目后面所有配置我都会围绕一个示例项目来讲一个FastAPI写的最小Web服务带一个/healthz健康检查接口、一个简单的pytest用例、一个Python 3.11的运行环境。项目就一个app/main.py、requirements.txt、tests/目录以及后面逐步加上的Dockerfile、.github/workflows/ci.yml、Makefile。这套组合可以原样套到Flask、Django、脚本工具、数据处理服务上差别只在启动命令和依赖内容。你自己动手时拿任意外部项目对照着改即可。2. 本地先“锁死”开发环境决定CI会不会翻车CI里跑的每一条命令本质上都是本地命令的自动化复刻。如果本地环境一团乱麻指望CI帮你理清是不可能的。所以我搭这套流水的第一步不是写任何配置文件而是先把本地“锁死”。2.1 解释器版本pyenv与.python-versionPython 3.8、3.9、3.10、3.11之间的差异远比想象中大。dict合并、类型注解的|语法、asyncio行为变化都会在不同版本间产生微妙差异。为了让本地、CI、服务器三处都使用同一个解释器版本我信任的是pyenv.python-version文件。在项目根目录放一个.python-version内容只有一行3.11.9。配合pyenv进入目录后就自动切换到对应版本。这个文件同时会被GitHub Actions识别——通过actions/setup-python的python-version-file参数。提示不要只依赖python命令因为系统自带的Python版本你可能根本改不动。用pyenv锁定项目级版本是成本最低且最不易出错的方式。2.2 依赖锁定从requirements.txt到锁文件裸的requirements.txt是万恶之源。我的做法是引入pip-tools用requirements.in声明顶层依赖再生成requirements.txt作为完整锁文件。requirements.in里只写直接依赖fastapi uvicorn[standard] pytest然后用两条命令生成锁文件pip-compile requirements.in -o requirements.txt pip-compile requirements.in -o requirements-dev.txt --extra dev生成出来的requirements.txt会把每个传递依赖的版本精确锁住。从此CI、服务器、新同事拉代码后pip install -r requirements.txt装出来的环境和我本地完全一致。这才是“可复现”的基础。2.3 Makefile把散落的命令收敛成标准动作有了版本和依赖还不够关键是让团队以及未来的你不用翻README就能跑命令。我把所有常用操作收进一个Makefile.PHONY: install lint test build PYTHON ? python install: pip install -r requirements-dev.txt lint: ruff check . test: pytest -q build: docker build -t myapp:local .之后的日常就是make lint、make test、make build。CI里的步骤命令也和本地完全一样彻底消除“本地一套命令、CI另一套命令”的割裂感。3. CI三连击lint、test、build在GitHub Actions的落地本地标准化做完接下来就是重头戏CI。3.1 先想清楚触发条件和Job分工我见过不少人把CI写得又臭又长一个job里塞满几十个step最后连作者自己都不愿意看。最小闭环的原则是“三个job各干一件事”lint检查代码风格和明显错误test跑单元测试build构建Docker镜像并推送触发条件我设置成两种push到main分支时以及创建pull_request时。前者是发布前奏后者是给PR把关。如果只想跑一种删掉另一个即可。3.2 一个能直接抄的CI工作流这份配置我实测过多次基本拿来就能用name: ci on: push: branches: [main] pull_request: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version-file: .python-version cache: pip cache-dependency-path: requirements-dev.txt - run: pip install -r requirements-dev.txt - run: ruff check . test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version-file: .python-version cache: pip cache-dependency-path: requirements-dev.txt - run: pip install -r requirements-dev.txt - run: pytest -q build: needs: [lint, test] if: github.ref refs/heads/main runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Login to GitHub Container Registry run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Build and push image run: | IMAGEghcr.io/${{ github.repository }}/myapp docker build -t $IMAGE:${{ github.sha }} -t $IMAGE:latest . docker push $IMAGE:${{ github.sha }} docker push $IMAGE:latest注意几个关键细节needs: [lint, test]保证build只在检查都通过后进行。if: github.ref refs/heads/main避免PR构建的镜像推上去产生垃圾。cache: pip配合cache-dependency-path让pip依赖在多次运行间走缓存CI时间能缩减一半以上。3.3 缓存、矩阵并行与几个常见细节缓存是CI提速的关键。GitHub Actions的actions/setup-python自带pip缓存上一份配置里已经用了。如果你用Poetry、uv这类工具缓存逻辑要做对应调整但原则一致锁文件不变依赖缓存就直接复用。矩阵并行matrix在模板项目里很常见但最小闭环不建议一开始就上。我见过太多人把Python 3.9、3.10、3.11全放进矩阵CI跑一次十几分钟结果项目里根本用不到低版本特性。先用python-version-file锁死当前解释器版本等确实需要多版本兼容时再加矩阵也不迟。另外一个细节GitHub Actions默认的GITHUB_TOKEN权限在仓库设置里需要调整为read: packageswrite: packages否则推送ghcr.io镜像时会被拒绝。这个小坑能卡掉不少人。4. 交付物不只是代码Docker镜像与最小发布产物CI跑完lint和test接下来要解决“交付什么”的问题。这里我明确建议交付镜像而不是交付代码。4.1 为什么要用镜像而不是把代码直接拖到服务器早期我试过在服务器上git pull、建虚拟环境、pip install一条龙。看起来也没毛病直到发生两件事一次是服务器上pip因为网络问题装到一半失败环境残了另一次是某个系统级依赖libgl1缺失numpy加载报错排查了整整半天。镜像的好处是把“操作系统级别的依赖 Python依赖 代码 启动参数”一次性打包成不可变产物。CI里构建成功意味着这个镜像在全世界任何一台相同架构的服务器上都能跑。这才是真正的“一次构建到处运行”。4.2 多阶段构建把镜像从500MB压到150MB先看能直接用的DockerfileFROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt FROM python:3.11-slim RUN useradd --create-home appuser WORKDIR /app COPY --frombuilder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY --frombuilder /usr/local/bin /usr/local/bin COPY . . USER appuser EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这里做两件事第一在两个阶段间只复制Python包和可执行文件把构建中间产物pip缓存、临时编译文件全丢掉第二用非root用户运行避免容器里root权限过高的问题。体积对比很直观单阶段pip install后再COPY代码镜像大概500MB-700MB多阶段构建能压到150MB左右。传输快、启动快、攻击面还小。提示如果服务需要curl、ps之类命令做健康检查或排障记得在最终阶段apt-get install这些工具并在同一层RUN里清理apt缓存。4.3 镜像版本标签与仓库认证CI推送产物的正确姿势标签策略我用的很简单commit SHA作为唯一版本标识latest作为便捷引用。生产环境永远不要裸用latest否则你根本不知道当前跑的是哪一天的代码。通过完整SHA任何时候都能把镜像和代码提交对应起来。推送镜像需要认证。用GitHub Container Registryghcr.io时CI里用内置的GITHUB_TOKEN即可echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin仓库名会自动关联到当前项目权限控制也省心。如果你用Docker Hub或私有Harbor换成对应的用户名和密钥即可原理一样。5. 生产侧最后一公里服务器端部署与回滚镜像构建并推送成功万事俱备只欠部署。这最后一步我试过两条路线各有适用场景。5.1 两条路线Docker Compose vs systemd如果你的服务器内存不低于2GB且项目本身就是无状态Web服务我推荐Docker Compose。如果项目是长驻脚本、定时任务或者服务器内存只有1GB、跑Docker都吃紧那systemd 虚拟环境反而更轻。两条路线的对比维度Docker Composesystemd venv环境隔离完全隔离基本隔离部署速度拉镜像秒级需重新pip install分钟级回滚换镜像tag即可需切代码版本并重装依赖资源占用镜像容器有一定损耗更低学习成本需要懂Docker更接近传统运维我现在的默认选择是Docker Compose理由只有一个回滚足够快。下面以Compose路线为主讲清部署链路systemd路线我会给一个极简变体。5.2 部署动作的完整链路拉取、替换、重启、健康检查服务器上准备一份docker-compose.ymlservices: app: image: ghcr.io/yourname/myapp:latest ports: - 8000:8000 restart: always environment: - TZAsia/Shanghai然后在服务器上写好部署脚本deploy.sh#!/usr/bin/env bash set -euo pipefail cd /srv/myapp docker compose pull app docker compose up -d app docker image prune -f # 健康检查 for i in {1..30}; do if curl -sf http://localhost:8000/healthz; then echo deploy ok exit 0 fi sleep 2 done echo health check failed, rolling back docker compose up -d app --no-deps --force-recreate app-previous 2/dev/null || true exit 1CI/CD另一端的做法是在GitHub Actions里新增一个deployjobneeds build通过SSH在服务器上执行这个脚本。密钥放仓库的Secrets里deploy: needs: build runs-on: ubuntu-latest steps: - name: Deploy via SSH uses: appleboy/ssh-actionv1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_KEY }} script: | cd /srv/myapp bash deploy.sh注意set -euo pipefail必须写在脚本顶部。没有它前一个命令失败后脚本照样往下走很可能出现“以为部署成功实际服务是挂的”的假象。5.3 回滚发布失败时如何在60秒内退回去回滚是生产环境最容易被忽视的功能。没有回滚方案的自动部署就是定时炸弹。Docker Compose路线的回滚很简单保留上一版镜像tag出问题后把docker-compose.yml里的镜像tag改回上一个SHA再docker compose up -d即可。几秒钟的事。systemd路线的回滚稍微麻烦git checkout 上版本commit重新跑一次pip install再restart服务。如果依赖没变化速度也还快依赖有变化就要花一两分钟重装。我的建议是无论自动部署脚本里带不带自动回滚都必须在本地文档里写清楚“人工回滚”的步骤。自动回滚逻辑写得越复杂越容易在关键时刻出错。保留一个人工可执行的一行命令往往是最可靠的后备方案。6. 全流程走查从git push到线上可访问的90秒配置都写完了光看不练不行。我完整走一遍你感受下整个链路的节奏。6.1 一条新代码走完全程的关键节点时间线假设我修复了一个bug工作流程是这样的本地跑make lint和make test全绿。git push到main。GitHub Actions自动触发lint job在30秒内跑完ruff检查。test job启动pytest跑完单元测试约50秒。lint和test都通过后build job开始构建镜像并推送ghcr.io约40秒。deploy job通过SSH连上服务器执行deploy.sh拉取新镜像、重建容器、健康检查。访问https://api.example.com/healthz返回200。整个过程从push到线上可访问大约90秒。与前两年手动操作相比等pytest跑完、SSH、pull、重启快则五分钟遇到依赖变动更是遥遥无期。自动化之后我push完喝口水再回来新版本已经在线了。6.2 发布后的“黄金五分钟”应该盯哪些东西自动化部署上线不代表可以甩手不管。我的习惯是发布后五分钟内依次看三样东西应用日志docker compose logs --tail200 app重点看有没有启动报错、请求异常堆栈。健康检查除了/healthz如果有业务指标接口也看一眼确认核心路径正常。错误率如果接入了Sentry或自建错误监控看最近五分钟的error级别事件。不要只盯“服务能不能访问”这一件事。很多故障是慢慢浮现的比如数据库连接池耗尽、内存缓慢增长。黄金五分钟的观察能让你在用户发现之前就收到信号。7. 散落在各个环节里的坑以及我踩过之后的做法配置跑通不难真正磨人的是那些间歇性、只在特定环境出现的问题。我把这些年踩过、并且成功绕过的坑归纳成三类。7.1 环境差异类问题依赖、时区、路径最典型的就是“本地能跑、CI挂了”或“CI能过、服务器挂了”。原因基本都出在环境差异上依赖版本漂移没锁版本时本地用旧缓存CI全新安装依赖一升级就炸。解法是前面说的锁文件。时区不一致容器默认UTC服务器可能是本地时区。日志时间看起来“快8小时”排查问题时让人一头雾水。解法是在Compose或Dockerfile里显式设置TZ。工作目录不一致代码里用相对路径读取配置文件CI或systemd启动时工作目录不是项目根目录直接FileNotFoundError。解法是代码里基于__file__定位绝对路径。7.2 数据变更类问题迁移、回滚与字段兼容代码回滚是容易的数据变更回滚是困难的。最典型的场景新版代码里给数据库表加了一个非空字段部署之后发现业务逻辑有bug要回滚。代码回滚了但数据库结构已经变了旧代码直接报错。我的建议有三条数据库变更永远走迁移脚本且迁移脚本要能向前兼容加字段带默认值不加必填约束。部署前先备份数据库回滚时最多恢复到上一分钟。涉及破坏性数据变更时宁可手动操作也不要塞进自动部署流程里。7.3 成本与维护这套闭环运行一年的真实体会说点更实在的。这套GitHub Actions方案跑一个Python项目每月配额消耗大约在2000-3000分钟。GitHub免费额度是2000分钟/月私有仓库如果频繁push和PR可能不够用。我的处理方式是把build job限制在main分支触发PR只跑linttest能省下不少分钟数。服务器成本方面一台2GB内存的入门云主机每月几十块跑两个小项目绰绰有余。比起那点服务器开销省下的时间才是真正值钱的——以前每次上线我都要预留半小时现在整个过程不占我注意力代码push完了该干嘛干嘛。最后说一点这套“最小闭环”不会一直叫“最小”。随着项目变大你可能逐步需要加自动化测试覆盖率门禁、多环境staging/production隔离、监控告警。但底子打好了这些都是水到渠成的增量动作。眼下最值得做的就是先把这条从本地到生产的路打通让“上线”从一件需要鼓起勇气才能做的事变成日常开发的一个普通终点。
阅读完成 · 觉得有帮助?
咨询建站