我给 New API 搭了一套能回退的 CI/CD:Gitee Go、Docker 与蓝绿部署

最开始部署 New API 时,更新一次代码就要登录服务器、停容器、拉新代码、重新构建,再把服务启动起来。偶尔改个小功能,前后折腾几分钟不算什么;线上已经有人在用时,这几分钟就不太好接受了。

我想要的不是一套看起来很复杂的发布平台,而是一个足够稳妥的流程:代码放到自己的仓库,准备好版本后点一下构建,服务器自动启动新版本,确认没问题再把流量切过去;新版本不正常时,能够尽快切回旧版本。

最后落地的是一套小而实用的方案:Gitee 保存代码,Gitee Go 负责流水线,Docker 镜像放在镜像仓库,运行环境是另一家云厂商的 Linux 服务器,Nginx 负责入口切换。服务器不需要和流水线在同一家云上。

从代码提交到线上访问的发布流程

图 1:一次发布从代码到线上请求的完整链路。

先把几个概念分开

这次最容易绕进去的地方,是把“源码在哪里”“镜像放在哪里”和“服务跑在哪里”当成了一件事。其实它们可以完全分开:

位置 负责什么 这次的选择
代码仓库 保存 Dockerfile、源码和流水线配置 Gitee
构建服务 根据代码构建镜像 Gitee Go
镜像仓库 保存带版本号的 Docker 镜像 私有镜像仓库
运行服务器 启动容器、提供 API 雨云香港 Linux 服务器
流量入口 把域名请求转给当前节点 Nginx

上游项目是开源的,我没有把服务器上的容器目录误当成“源码”。正确的做法是把上游仓库拉到本地,再推到自己的 Gitee 仓库;以后更新时,从上游同步代码,检查自己的改动,再推送到自己的仓库。

这里的 Gitee 只是代码和流水线入口,服务器是不是阿里云并不影响流程。镜像仓库可以和服务器分属不同厂商,只要服务器能访问镜像仓库,流水线的 Agent 能在服务器上执行 Docker 命令即可。

为什么没有让每次 push 都自动部署

个人项目最现实的限制是构建时长。每次提交都自动构建,试验性提交、改错后的补丁也会消耗额度,而且很容易出现几条流水线排队,旧版本反过来覆盖新版本的情况。

所以我把“自动”放在了合适的位置:

  • 顶层流水线触发:手动。平时 push 只更新仓库,不创建流水线。
  • 构建阶段:手动。准备好一个版本后,在 Gitee Go 点一次运行。
  • 蓝绿部署阶段:自动。镜像构建成功后,自动执行部署。

这样日常操作只有一个明确的发布开关:代码可以连续改很多次,准备好了再手动运行最新版本的流水线;构建通过后,部署会自动接上,不需要再点第二次。

个人项目的发布开关

图 2:把构建保留为人工确认,把部署接在构建成功之后。

流水线的结构可以概括成下面这样。示例中的仓库地址和凭证都是占位符,不能直接照抄到生产环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
stages:
- name: 构建镜像
trigger: manual
steps:
- step: build@docker
repository: registry.example.com/team/new-api
username: <从凭证管理注入>
password: <从凭证管理注入>
tag: "newapi:${GITEE_PIPELINE_BUILD_NUMBER}"
dockerfile: ./Dockerfile
context: "."
isCache: true

- name: 蓝绿部署
trigger: auto
steps:
- step: shell@agent
script: |
set -e
IMAGE="registry.example.com/team/new-api/newapi:${GITEE_PIPELINE_BUILD_NUMBER}"
bash /opt/newapi/bin/newapi-blue-green.sh "$IMAGE"
gitClone: "false"

triggers:
trigger: manual

真实配置里,镜像仓库密码、服务器登录凭证和数据库密码都应该放到 Gitee 的凭证或变量管理里。把密码直接写进 YAML,哪怕仓库是私有的,也不值得冒这个险。

蓝绿部署到底做了什么

蓝绿部署并不神秘。对单台服务器来说,就是准备两个端口、两个容器:一个承载当前流量,另一个用来启动新版本。我的两个端口示例是 13000 和 13001,它们轮流充当线上节点和备用节点。

一次发布的顺序是:

  1. 读取 Nginx 当前指向的端口,确定哪个是在线节点。
  2. 在另一个端口启动新镜像,挂载同一份运行环境和数据目录。
  3. 轮询新节点的 /api/status,最多等待一段时间。
  4. 健康检查返回成功后,修改 Nginx 的 proxy_pass,执行 nginx -t,再平滑重载 Nginx。
  5. 通过域名再次访问健康接口,确认公网入口确实已经指向新节点。
  6. 等待一个短暂的宽限时间,再停止旧容器;旧容器不立即删除,方便回退。
蓝绿部署切换前后的节点状态

图 3:新节点先预热,入口最后切换;数据目录与版本切换分开处理。

这个顺序很重要。先停旧容器再启动新容器,只是“重启”;先启动新容器、确认新容器可用,再改入口,才有机会把中断时间压到很短。

脚本里有几条我认为必须保留的保险:

  • 新节点健康检查失败,直接删除失败容器,不改 Nginx。
  • Nginx 配置检查失败,恢复切换前的备份。
  • 公网健康检查失败,同样恢复旧配置。
  • 用锁文件防止两条流水线同时操作同一台机器。
  • 数据目录、环境文件和日志目录放在容器外,不随镜像覆盖。

因此,蓝绿切换保护的是“版本替换”,不是数据库迁移。数据库结构变更仍然要单独设计兼容的迁移步骤,不能因为用了蓝绿部署就默认安全。

服务器上为什么不再拉代码

我实际遇到过一次很典型的失败:流水线任务设置了 gitClone: false,但脚本却写成了执行工作区里的 ./deploy/newapi-blue-green.sh。服务器没有源码,自然报:

1
install: cannot stat './deploy/newapi-blue-green.sh': No such file or directory

解决方法有两个:让任务拉取仓库,或者把部署脚本作为服务器上的固定运维文件。这里选择了后者,因为部署只需要一个镜像地址,不需要把完整源码复制到生产机。脚本预先安装到类似下面的位置:

1
/opt/newapi/bin/newapi-blue-green.sh

流水线只负责传入本次构建出的镜像地址,服务器脚本负责启动容器和切 Nginx。这样职责更清楚,也减少了“工作区里有没有文件”这种隐性依赖。

一次发布具体怎么走

以后我的操作顺序是:

  1. 在本地修改代码,先做能做的检查。
  2. 提交并 push 到 Gitee。这个动作不会自动部署。
  3. 如果还要继续改,就继续提交;不要为每一个中间版本单独发布。
  4. 确认要上线时,在 Gitee Go 手动运行最新流水线。
  5. 构建阶段把镜像打成带流水线编号的标签,例如 newapi:42,并推到私有镜像仓库。
  6. 构建成功后,蓝绿部署阶段自动执行。
  7. 打开站点做登录、接口调用和后台页面检查;有问题就回退。

版本号使用流水线编号而不是 latest,是为了知道线上到底跑的是哪一版,也避免服务器缓存了一个含义不明确的标签。每次发布都应该能从镜像标签追溯到一次构建记录。

线上有人使用时,会不会被踢下线

普通的短请求通常不会明显感觉到切换,因为旧节点在新节点通过检查之前一直在线,Nginx 重载也不会主动停止整个 Web 服务。

但它不是绝对意义上的“零影响发布”。旧容器最终还是会被停止,所以这些情况仍然可能中断:

  • 正在执行很久的请求;
  • 文件上传或下载;
  • SSE、WebSocket 等长连接;
  • 切换瞬间刚好落在旧节点上的少数请求。

当前脚本会在切流后等待几秒再停旧节点。线上有长连接时,我会把这个宽限时间调大,并尽量选低峰期发布。更稳妥的做法是让客户端对幂等请求具备重试能力,同时把数据库连接、会话和缓存放在容器外部或共享服务中。

失败时怎么处理

部署失败不应该靠“再点一次”解决,先看失败发生在哪一层:

失败位置 常见原因 处理方式
构建阶段 Dockerfile、依赖或网络问题 修复构建日志里的具体错误,再重新运行
拉取镜像 镜像仓库权限、网络或标签错误 检查凭证和本次流水线编号
新节点健康检查 环境变量、端口或应用启动失败 查看备用容器日志,线上入口不会被切换
Nginx 检查 配置语法或路径错误 脚本会恢复备份,修复配置后再试
公网检查 域名、证书或反向代理问题 保留旧入口,先恢复网络配置

如果新版本已经切过去,但功能验证发现问题,可以使用服务器上预装脚本的回退动作。示意命令如下,实际路径按自己的服务器配置替换:

1
2
bash /opt/newapi/bin/newapi-blue-green.sh --check
bash /opt/newapi/bin/newapi-blue-green.sh --rollback

--check 只做当前节点和公网健康检查;--rollback 会把备用节点重新启动(如果需要),检查通过后把 Nginx 切回去。回退依赖旧容器和旧镜像仍然存在,所以不要在验证完成前立刻清理所有旧版本。

凭证和脱敏,别等出事才补

这篇文章里的 IP、域名、仓库路径、主机 ID、用户名、密码和 Token 都是脱敏后的占位符。生产环境至少要做到下面几件事:

  • YAML 只引用凭证,不写明文密码。
  • 服务器私钥和数据库密码不提交到代码仓库。
  • 镜像仓库使用最小权限账号,并定期轮换密码。
  • 日志里不要打印完整的 Token、Cookie 或 runtime.env。
  • 线上服务器只开放必要端口,Docker 和 Nginx 及时更新。
  • 回退脚本和 Nginx 配置备份也要限制文件权限。

尤其不要因为“仓库是私有的”就把密码写进去。私有仓库解决的是可见性,不是凭证泄露后的影响范围。

这套方案适合谁

它适合单机、小团队和个人项目:不需要先上 Kubernetes,也不要求服务器必须是某一家云厂商的产品;只要有一台能跑 Docker 的 Linux 服务器,就能把发布过程固定下来。

它也有边界:只有一台服务器时,机器本身宕机仍然没有真正的高可用;蓝绿节点共用同一台机器,资源紧张时构建和启动新容器会争抢 CPU、内存;数据库迁移、定时任务、队列消费者还需要额外处理。

对我来说,这个取舍是可以接受的。它先解决了最烦的那件事——每次更新都要手动停服务——同时保留了清晰的版本号、健康检查和回退入口。等项目规模真的需要多机高可用,再把这套发布逻辑迁移到更完整的平台也不迟。

最后把流程压缩成一句话:代码平时正常提交,发布时手动打开构建开关;新镜像在备用节点完成自检,Nginx 最后切流,出问题就切回旧节点。对于个人项目,这已经是一条足够可靠、也足够容易维护的 CI/CD 起点。