
做了这么多年研发我越来越觉得真正让一支团队拉开差距的不是谁的代码写得花而是谁能把“代码提交”到“功能上线”这段路管得足够稳。第二十一章要聊的 CI/CD 最佳实践光看名字很像概念科普实际上它是一套可复用的工程方法论覆盖 GitLab CI/CD 里的 Docker 镜像构建与自动化部署也包括用 Jenkins 做 Python 项目的持续发布更包括从业务视角判断“什么时候该自动、什么时候该让人确认”。这篇文章主要写给两类人一是准备搭第一条流水线的工程师二是已经被线上发布问题折磨过、想系统性优化发布流程的团队。我会直接讲我在设计流水线时真正关心的问题包括为什么这么设计、参数按什么标准定、故障怎么排查。部分内容属于常规文档里不会写、但实际一踩一个准的坑建议一边读一边对照你手上的项目。1. 先说清楚CI/CD 到底在解决什么问题1.1 从手工发布到流水线我们到底在哪个环节浪费时间很多团队一开始并不是不想做自动化而是觉得“现在也能发布就是偶尔出点问题”。但这里有个隐藏成本手工发布一次往往等于从代码合并开始把测试、构建、部署、验证全部人工执行一遍。哪怕一个人再熟练也难免漏掉某一步。我见过最典型的手工发布流程是这样的开发在本地跑通测试把代码推到主干然后登到服务器上拉代码、装依赖、重启服务。第一次成功第二次开始出问题要么是服务器上还有残留的旧进程要么是依赖版本和本地不一致。出现“本地能跑服务器跑不了”之后团队的第一反应不是去修环境而是继续在服务器上手工补包、改配置。等到这个服务器彻底乱掉才想起应该用脚本、用容器、用流水线。CI/CD 要解决的首先就是把这些环节从“靠人记忆”变成“靠流程保证”。代码提交后自动执行单元测试测试通过后自动构建镜像镜像推送到仓库后再触发部署部署完成后自动检查健康状态。整个过程每一步都有日志、有产物、有版本号出了问题也知道该看哪一环。1.2 最佳实践不是工具清单而是决策逻辑很多人一提到 CI/CD 最佳实践第一反应是“用什么工具、跑什么命令”。但工具只是载体真正重要的是决策逻辑哪个环节需要自动化哪个环节需要人工确认哪个环节失败后必须中断后续流程。我一般会先问团队三个问题一次代码合并从提交到上线理想时间是多久如果超过半小时瓶颈在哪里哪些检查必须通过才允许合并哪些风险必须阻塞发布线上出问题时回滚一个版本需要多长时间是否回滚后还能拿到当时的日志和产物这三个问题的答案基本决定了你的流水线长什么样。比如团队规模小、迭代快可能只需要“测试 构建 部署”三段一旦涉及多环境、多服务依赖就要在中间插入扫描、迁移、灰度、确认等环节。所谓最佳实践不是把所有高级功能都堆上去而是让每个环节都有明确的目标和退出条件。多一个阶段就多一次失败的可能。所以每加一个流水线阶段我都会先问自己一句这个阶段防的是什么风险如果防不住还不如不加。1.3 先定目标再选工具不然会被工具带着跑工具选型是很多团队最纠结的部分。GitLab CI、Jenkins、GitHub Actions 各有拥趸但工具本身不应该成为决策的起点。我习惯先把目标写清楚再倒推工具需求。比如你的代码已经在 GitLab 上并且希望“代码提交、测试、镜像构建、部署”尽量在一个平台里闭环那 GitLab CI/CD 是最省事的。如果公司已有大量 Jenkins 任务代码仓库五花八门统一迁到新的 CI 平台成本很高那继续用 Jenkins 更现实。如果项目在 GitHub 上且不想自己维护 Runner那 GitHub Actions 的托管实例体验确实很流畅。我在多个项目里交叉用过这三类工具最后的结论是没有绝对最好的工具只有和你的团队状态最匹配的方案。选型最大的坑是团队明明只有一个简单项目却为了“技术先进性”引入了整套复杂的发布平台最后流水线本身成了最需要维护的“线上系统”。2. 整体架构设计与方案选型2.1 一套可落地的流水线主干长什么样我习惯把流水线主干收敛成五个阶段变更检查、测试、构建、部署、验证。无论用 GitLab CI 还是 Jenkins核心骨架都差不多。变更检查负责最基础的代码健康度包括格式检查、静态检查、依赖安全检查。测试阶段跑单元测试和必要的集成测试并把测试报告作为质量门禁。构建阶段生成可发布的产物对后端服务来说通常是 Docker 镜像。部署阶段把产物发布到目标环境这一步可以自动也可以设计成人工确认。验证阶段则是对健康接口、核心链路做冒烟检查确认服务真的起来了而不是容器起了但应用已经崩溃。这个主干看起来简单但能挡住一大部分问题。我见过不少流水线只有“构建 部署”两个阶段结果测试永远在本地跑镜像每次都在服务器上现构。这不是 CI/CD这只是把手工操作换了一个地方执行。2.2 工具选型GitLab CI、Jenkins 还是 GitHub Actions这里给一张我常用的对比表方便你根据自己情况判断维度GitLab CI/CDJenkinsGitHub Actions与代码仓库集成度高仓库内维护.gitlab-ci.yml中需要额外配置代码仓库 Webhook高仓库内维护 workflow 文件平台维护成本中需要维护 Runner高Jenkins 主节点、插件、权限都要维护低托管 Runner 开箱即用插件生态够用常用功能内置非常丰富适合复杂编排丰富且有大量社区 Action私有化部署支持支持企业版支持有限最佳使用场景GitLab 仓库为主的团队已有大量 Jenkins 任务、异构技术栈较多的团队GitHub 开源项目或云上团队我的选择标准很简单代码已经在一个平台里优先用那个平台的 CI如果公司有运维团队且 Jenkins 已经跑了很多年不要为了“新”而强行迁移如果是开源项目直接用 GitHub Actions 最省心。2.3 环境与仓库规划把环境当产品一样管环境规划最容易犯的错误是“开发环境、测试环境、生产环境各配一次环境变量然后靠运维手工同步”。这样做的后果是环境之间总会有细微差异很多问题只在生产环境复现。我的建议是代码仓库只维护一份流水线定义环境之间的差异全部通过变量和部署目标配置来隔离。例如在 GitLab CI 里给每个环境单独配置environment并定义不同的部署地址、密钥引用、资源配置。分支策略我比较推荐主干开发加短生命周期分支合并请求触发的流水线跑测试和构建主干和标签触发的流水线再进入部署阶段。不需要为了“看起来规范”就搞一套 develop、release、hotfix 满天飞的分支模型。分支越多CI/CD 需要覆盖的组合就越多排查问题时的成本也随之上升。2.4 部署策略滚动、蓝绿还是直接覆盖部署策略不是越高级越好。直接杀掉旧容器、起新容器的方式最简单但停机时间不可控。滚动更新可以做到不停机但一次滚动需要关注新老实例的兼容性。蓝绿部署回滚方便但需要双倍资源。我在团队规模不大时会优先做“滚动更新 健康检查 保留最近 N 个镜像版本”。部署脚本先拉取新镜像重新启动容器然后反复请求健康检查接口直到新实例就绪。如果健康检查一直失败自动把容器回滚到上一个可用版本。这套方案不需要 Kubernetes也能覆盖大多数单体服务和简单微服务场景。任何部署策略第一步都是保证“能回滚”。如果没有回滚能力谈论蓝绿、金丝雀都没有意义。3. 核心实践拆解构建、镜像、制品、部署3.1 代码接入检查与测试门禁很多团队的流水线虽然配了测试阶段但测试失败并不会真正阻止合并。原因往往是代码仓库的合并权限没有和流水线结果绑定导致每次都是“流水线红了照样 merge”。这件事必须在仓库设置层面卡死在 GitLab 项目设置里开启“Pipelines must succeed”保证合并请求只有流水线通过才能合并。测试覆盖率也可以设置最低阈值但覆盖率只能作为底线不能作为质量的全部。比如 Python 项目使用 pytest 和 pytest-covpytest --covapp --cov-fail-under80 --junitxmlreport.xml这段命令同时做了三件事跑测试、统计覆盖率、给出 JUnit 格式的报告。覆盖率低于 80%命令直接返回非零状态流水线失败。这里的 80 不是拍脑袋定的而是我观察大多数业务项目后觉得“低到守不住高到写测试的成本让人抗拒”的一个折中点。3.2 Docker 镜像构建的优化细节Docker 镜像构建看起来只是把代码打进镜像里但里面有不少细节值得优化。先说一个最常见的坑直接把整个项目目录复制进镜像并且没有.dockerignore。这样很容易把本地的.venv、.git、测试缓存都打进去镜像又大又不安全。一个比较稳的 Python 后端镜像我通常会写成这样FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt FROM python:3.11-slim WORKDIR /app COPY --frombuilder /install /usr/local COPY app ./app RUN useradd -r app chown -R app:app /app USER app EXPOSE 8000 CMD [python, -m, gunicorn, app.main:app, -b, 0.0.0.0:8000]这里的核心思路是多阶段构建。第一阶段只安装依赖第二阶段复制已安装好的依赖和业务代码。这样最终镜像里不会残留编译工具链和临时文件。使用python:3.11-slim而不是python:3.11-alpine是因为 slim 版本调试更方便很多 Alpine 上需要额外编译的psycopg2、numpy也能更省事。追求极致镜像大小可以上 Alpine但你要接受部分依赖可能安装失败的代价。.dockerignore至少应该包含这些内容.git .venv __pycache__ *.pyc .pytest_cache .coverage report.xml3.3 版本号与镜像 Tag 的规范镜像 Tag 是发布追溯的关键。我看到很多刚接触容器化的团队习惯用latest作为生产镜像 Tag这是非常危险的做法。latest会被覆盖一旦推送一个新镜像旧版本就无法找回回滚时只能靠运气。我推荐的 Tag 规范是非发布分支用分支名-短提交号发布标签用版本号。例如合并请求构建产物mr-123主干构建产物main-abc1234正式发布版本v1.2.0在 GitLab CI 里可以直接使用预定义变量IMAGE_TAG${CI_COMMIT_SHORT_SHA} if [ -n $CI_COMMIT_TAG ]; then IMAGE_TAG$CI_COMMIT_TAG fi注意镜像 Tag 一旦推到镜像仓库就不要覆盖。本地可以反复使用latest但生产环境使用的每个 Tag 都必须唯一。覆盖 Tag 意味着丢失历史版本的可追溯性回滚时根本不知道线上跑的是哪段代码。3.4 自动化部署与回滚机制自动化部署的脚本核心不只是“把容器跑起来”还要在跑起来之后做健康检查。我见过太多部署脚本执行成功但服务实际不可用的情况问题就出在没有等待服务就绪就直接退出。一个简单可用的部署脚本大致是这样的#!/usr/bin/env bash set -euo pipefail IMAGE$1 PREV_IMAGE$2 docker pull $IMAGE docker rm -f app || true docker run -d --name app \ -p 127.0.0.1:8000:8000 \ --restart unless-stopped \ -e DB_URL$DB_URL \ $IMAGE for i in $(seq 1 30); do if curl -fsS http://127.0.0.1:8000/health /dev/null; then echo deploy ok exit 0 fi sleep 2 done echo health check failed, rollback docker rm -f app || true docker run -d --name app \ -p 127.0.0.1:8000:8000 \ --restart unless-stopped \ -e DB_URL$DB_URL \ $PREV_IMAGE exit 1这段脚本里有两个关键点。一是set -euo pipefail保证任何一步出错都停止。二是健康检查重试 60 秒超过就自动回滚到上一个镜像。实际使用时健康检查接口不能只返回 200它应该真正检查数据库连接、依赖服务连通性等核心依赖。3.5 密钥管理和权限控制流水线里最怕的是密钥被写进代码、写进镜像、或者打到日志里。尤其在 Docker 构建时不要把数据库密码、云厂商密钥作为ARG传进去因为镜像分层后这些值有可能被翻出来。正确做法是把敏感信息放到 CI/CD 平台的安全变量里。GitLab 里可以配置受保护变量和掩码变量Jenkins 里可以用 Credentials Binding 插件。运行时需要密钥就在容器启动时通过环境变量注入而不是写入镜像层。比如docker run -d --name app \ -e DB_PASSWORD$DB_PASSWORD \ $IMAGE权限控制方面我建议把流水线的部署权限绑定到特定分支和标签。只有主干和正式标签才能触发生产部署合并请求流水线只跑测试和构建不碰生产环境。这样可以避免开发者在任意分支上误触发发布。4. 实操两个可以直接落地的流水线案例4.1 GitLab CI 实现 Python 项目的镜像构建与自动化部署GitLab CI 的好处是配置文件和代码在同一个仓库逻辑清晰可审查。下面是一个 Python 项目比较完整的.gitlab-ci.yml覆盖测试、镜像构建、部署三个阶段。stages: - test - build - deploy variables: DOCKER_BUILDKIT: 1 IMAGE_NAME: registry.example.com/project/api test: stage: test image: python:3.11-slim before_script: - pip install -r requirements-dev.txt script: - pytest --covapp --cov-fail-under80 --junitxmlreport.xml artifacts: when: always expire_in: 1 day reports: junit: report.xml build: stage: build image: docker:24.0.7 services: - docker:24.0.7-dind script: - | if [ -n $CI_COMMIT_TAG ]; then IMAGE_TAG$CI_COMMIT_TAG else IMAGE_TAG${CI_COMMIT_BRANCH}-${CI_COMMIT_SHORT_SHA} fi docker build -t $IMAGE_NAME:$IMAGE_TAG . docker push $IMAGE_NAME:$IMAGE_TAG deploy: stage: deploy image: alpine:3.19 before_script: - apk add --no-cache curl openssh-client script: - scp deploy.sh deployprod:/opt/app/deploy.sh - ssh deployprod cd /opt/app ./deploy.sh $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:prev-tag environment: name: production url: https://api.example.com rules: - if: $CI_COMMIT_TAG when: manual这个配置里有几个细节值得解释。测试阶段的artifacts.reports.junit可以让 GitLab 在合并请求页面直接展示测试结果。构建阶段使用了 Docker 官方的docker:dind服务也就是 Docker in Docker 模式Runner 里的 CI 任务可以调用 Docker 命令。部署阶段用when: manual和标签触发意味着打一个版本标签后需要人工在 GitLab 页面上确认发布避免手滑。4.2 Jenkins 实现 Python 服务的自动化发布什么时候用 Jenkins 更合适如果你的团队已经有 Jenkins 基础而且希望把测试、构建、部署都统一编排到一个 Jenkinsfile 里下面是一个最小可用的流水线。pipeline { agent any environment { IMAGE registry.example.com/project/api IMAGE_TAG ${env.GIT_COMMIT.take(8)} } stages { stage(Test) { steps { sh python -m venv venv sh . venv/bin/activate pip install -r requirements-dev.txt sh . venv/bin/activate pytest --junitxmlreport.xml } post { always { junit report.xml } } } stage(Build Image) { steps { sh docker build -t ${IMAGE}:${IMAGE_TAG} . sh docker push ${IMAGE}:${IMAGE_TAG} } } stage(Deploy) { input { message 确认发布到生产 ok 发布 } steps { sh ssh deployprod ./deploy.sh ${IMAGE}:${IMAGE_TAG} ${IMAGE}:prev-tag } } } }Jenkins 和 GitLab CI 最大的区别在于Jenkins 更像一个可自由编排的“自动化调度中心”几乎任何命令都能通过sh跑但也正因如此它很容易变成一座怎么搬都搬不动的“老房子”。我的建议是Jenkins 的每个阶段尽量只干一件事并且把脚本抽到仓库里的独立文件里而不是全部堆在 Jenkinsfile 里。4.3 部署脚本与健康检查怎么写才不算假成功我发现很多团队的部署脚本是“提交任务成功”而不是“服务成功”。所谓提交任务成功只是把容器启动了没有检查进程是否存活、接口是否可用、依赖是否连上。健康检查要避免两点。第一只检查 HTTP 返回码不检查业务核心链路。第二健康检查接口只是一个固定的字符串没有真正触达数据库或缓存。比较稳妥的做法是在服务里专门提供一个/health接口内部执行以下检查数据库连接执行一次SELECT 1缓存连接执行一次 key 读写关键配置检查必要的环境变量是否缺失对应 Flask 或 FastAPI 项目可以写一个这样的小接口部署脚本轮询它就行。如果健康检查返回 200才算真正部署成功。4.4 参数与超时设置依据流水线的每个阶段最好都有超时时间。没有超时的流水线一旦卡住会长时间占用 Runner 或 Jenkins Agent浪费资源不说还会掩盖真正的问题。在 GitLab CI 中可以给每个任务设置超时test: timeout: 10 minutes在 Jenkins Pipeline 中可以用timeout包住某个阶段stage(Test) { timeout(time: 10, unit: MINUTES) { steps { ... } } }超时时间怎么定我的经验是按“正常耗时 x 3”再加上一定余量。比如测试平时跑 3 分钟超时给 10 分钟镜像构建平时跑 5 分钟超时给 20 分钟。这样既不会被偶发慢网络卡死也不会让小问题拖上一个小时才被发现。5. 常见问题与排查技巧实录5.1 踩坑现场Docker in Docker 连接不上用 GitLab CI 构建 Docker 镜像时最常见的问题是“Cannot connect to the Docker daemon”。90% 的情况是 Runner 上的任务没有正确启用 Docker 服务。解决方式是在.gitlab-ci.yml里加build: image: docker:24.0.7 services: - docker:24.0.7-dind同时要在 Runner 的配置文件里保证特权模式开启。否则dind服务能起来但同一个任务里执行 docker 命令时会找不到 daemon。5.2 问题速查表现象可能原因处理思路流水线测试阶段报模块找不到依赖没装全或虚拟环境没有激活检查requirements-dev.txt和PATHDocker 构建很慢每次都重新装依赖没有利用 Docker 层缓存或COPY . .位置靠前先 COPY 依赖文件再 COPY 源代码部署显示成功但服务不可用健康检查没有覆盖核心依赖增强/health检查轮询超时后自动回滚镜像仓库出现大量latestTag 策略不合理改为不可变 Tag按 commit 或版本号命名流水线卡住不动任务在等待输入或 Runner 并发不足配置超时时间检查 Runner 状态从最佳实践模板拷贝初始化内容失败文件权限、缓存残留或环境变量问题先看详细日志不要急着改模板或重装工具密钥出现在镜像日志里把密钥当普通变量输出到控制台密钥使用掩码变量避免echo5.3 三个高频误区把流水线当成了军备竞赛第一个误区是“流水线通过就等于部署成功”。实际上流水线只代表你定义的那些检查点通过不代表业务真的正常。如果测试覆盖很弱检查项只停留在语法层面那再绿的流水线也可能把问题放过去。第二个误区是“测试环境不需要和生产环境一致”。很多问题都是环境差异造成的。比如测试环境用的是 SQLite生产环境用 MySQL某个查询在测试环境跑得好好的到生产环境就慢到超时。我的建议是至少把数据库、依赖服务的版本保持一致哪怕资源规格小一点。第三个误区是“阶段越多越规范”。我曾见过一个团队的流水线有几十个阶段但每个阶段只是套一层壳真实效率极低。流水线应该是“能少则少缺了会出事才加”。阶段越多平均发布耗时越长出故障的概率也越高。6. 从技术实践到业务视角的延伸6.1 从业务视角看 CI/CD为什么业务方也在关心很多人觉得 CI/CD 是技术团队内部的事业务方不会关心。但实际做电商、做交易类系统时业务方非常在意发布时间、回滚速度、灰度范围。比如商品模块上线一个价格调整功能如果发布失败导致线上价格错乱用户投诉和资损会立刻传导到业务侧。所以业务视角下的最佳实践和工程师视角并不冲突只是表达方式不同。业务方关心的是“能不能快速上线且不出事故”工程师关心的是“构建稳不稳、测试全不全、部署是否可回滚”。把这些目标翻译成流水线能力就是三件事自动化测试覆盖核心场景、生产发布保留人工确认点、回滚能力必须随时可用。6.2 电商商品模块给我的启发最佳实践要从变更链路看拿电商商品模块举例它不只是 CRUD还牵扯到库存、价格、上下架状态、运营后台的配置变更。一个商品字段调整可能影响搜索、详情页、购物车、订单履约。如果只把单元测试跑绿就发布风险依然很高。这一块给我的启发是CI/CD 的“测试门禁”不能只测自己服务还应该包含模块之间的契约测试。比如商品服务改了一个返回字段格式依赖它的库存服务或订单服务的测试也要在流水线里跑一遍。因为商品模块的业务链路太长了任何一个上游字段变化都可能在下游炸开。把跨模块的集成测试加入流水线比事后补多少监控都更有价值。6.3 后续可以这样扩展如果团队已经跑通了基础流水线接下来可以做三件事把发布过程的可观测指标做起来比如每次部署后的错误率、耗时、核心接口成功率把发布记录和需求、工单关联起来做到任意一次发布都能反查变更内容最后是把开发自助能力做上去让一个普通开发也能通过流水线安全地把特性发布到灰度环境。我个人在实际操作中的体会是CI/CD 越成熟就越应该让人觉得“无聊”。如果发布不再惊心动魄说明流程真的把风险挡住了。最后再分享一个小技巧不管用 GitLab CI 还是 Jenkins先做一次完整的回滚演练把“上一个版本的镜像 Tag 用什么、健康检查失败后怎么切回来”写成文档这份文档比任何高深的流水线配置都值钱。